From cdf9163953a5ee3c0e33fa5b889572719d259a09 Mon Sep 17 00:00:00 2001 From: Priyanshu Agrawal Date: Wed, 22 Apr 2026 14:31:11 +0530 Subject: [PATCH 1/9] Add live scaffold status and HTML plan to create-site - New protocol file public/scaffold-status.json polled by the scaffold loader in all four frameworks (React, Vue, Angular, Astro). Lets the agent narrate what it is actually doing in place of the hardcoded "Installing dependencies..." cycle. - Dismissible "waiting for your input" toast at top-right of the loader, raised before every AskUserQuestion call so a terminal prompt is not missed when the browser loader is full-screen. Dismissal is per-prompt. - Phase 4 plan approval now renders an HTML implementation plan to docs/create-site-plan.html via the new render-createsite-plan.js script and opens it in the default browser, matching the pattern used by integrate-backend, add-server-logic, and add-cloud-flow. - Added node:test coverage for the renderer: data file + data-inline modes, missing-keys, overwrite refusal, bad-JSON handling. - SKILL.md restructured: Live Preview Status Protocol section, Phase 2.1 seed, per-step narration in Phase 5.2, new 4.1-4.7 sub-steps around plan rendering and approval. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scripts/render-createsite-plan.js | 68 ++++ .../tests/render-createsite-plan.test.js | 170 ++++++++ .../power-pages/skills/create-site/SKILL.md | 176 +++++++-- .../angular/src/app/pages/home.component.ts | 104 ++++- .../assets/astro/src/pages/index.astro | 104 ++++- .../create-site/assets/create-site-plan.html | 372 ++++++++++++++++++ .../assets/react/src/pages/Home.tsx | 104 ++++- .../create-site/assets/vue/src/pages/Home.vue | 104 ++++- 8 files changed, 1132 insertions(+), 70 deletions(-) create mode 100644 plugins/power-pages/scripts/render-createsite-plan.js create mode 100644 plugins/power-pages/scripts/tests/render-createsite-plan.test.js create mode 100644 plugins/power-pages/skills/create-site/assets/create-site-plan.html diff --git a/plugins/power-pages/scripts/render-createsite-plan.js b/plugins/power-pages/scripts/render-createsite-plan.js new file mode 100644 index 000000000..cd8339f9c --- /dev/null +++ b/plugins/power-pages/scripts/render-createsite-plan.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * render-createsite-plan.js — Renders the create-site implementation plan HTML. + * + * Usage (inline JSON): + * node render-createsite-plan.js --output --data-inline '' + * + * Usage (file-based): + * node render-createsite-plan.js --output --data + * + * Required keys in the data: + * SITE_NAME, PLAN_TITLE, FRAMEWORK, AESTHETIC, MOOD, SUMMARY, + * TYPOGRAPHY_DATA, PALETTE_DATA, MOTION_DATA, BACKGROUNDS_DATA, + * PAGES_DATA, COMPONENTS_DATA, ROUTES_DATA, REVIEW_DATA, DEPLOYMENT_DATA + */ + +const path = require('path'); +const { renderTemplate, parseArgs } = require('./lib/render-template'); + +const args = parseArgs(process.argv); + +if (!args.output || (!args['data-inline'] && !args.data)) { + console.error( + 'Usage: node render-createsite-plan.js --output --data-inline \'\'\n' + + ' node render-createsite-plan.js --output --data ' + ); + process.exit(1); +} + +const templatePath = path.join( + __dirname, + '..', + 'skills', + 'create-site', + 'assets', + 'create-site-plan.html' +); + +const requiredKeys = [ + 'SITE_NAME', + 'PLAN_TITLE', + 'FRAMEWORK', + 'AESTHETIC', + 'MOOD', + 'SUMMARY', + 'TYPOGRAPHY_DATA', + 'PALETTE_DATA', + 'MOTION_DATA', + 'BACKGROUNDS_DATA', + 'PAGES_DATA', + 'COMPONENTS_DATA', + 'ROUTES_DATA', + 'REVIEW_DATA', + 'DEPLOYMENT_DATA', +]; + +if (args['data-inline']) { + let dataObject; + try { + dataObject = JSON.parse(args['data-inline']); + } catch { + console.error('Error: --data-inline value is not valid JSON'); + process.exit(1); + } + renderTemplate({ templatePath, outputPath: path.resolve(args.output), dataObject, requiredKeys }); +} else { + renderTemplate({ templatePath, outputPath: path.resolve(args.output), dataPath: path.resolve(args.data), requiredKeys }); +} diff --git a/plugins/power-pages/scripts/tests/render-createsite-plan.test.js b/plugins/power-pages/scripts/tests/render-createsite-plan.test.js new file mode 100644 index 000000000..425e76dc1 --- /dev/null +++ b/plugins/power-pages/scripts/tests/render-createsite-plan.test.js @@ -0,0 +1,170 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const scriptPath = path.join(__dirname, '..', 'render-createsite-plan.js'); + +const SAMPLE_DATA = { + SITE_NAME: 'Contoso Portal', + PLAN_TITLE: 'Implementation Plan', + FRAMEWORK: 'React', + AESTHETIC: 'Minimal & Clean', + MOOD: 'Professional & Trustworthy', + SUMMARY: 'An internal portal for Contoso consultants with directory, announcements, and docs.', + TYPOGRAPHY_DATA: { + primary: { name: 'DM Sans', sample: 'Aa Bb Cc', reason: 'Neutral sans for body and UI' }, + secondary: { name: 'Space Grotesk', sample: 'Headings', reason: 'Geometric display for headings' }, + }, + PALETTE_DATA: [ + { var: '--color-primary', hex: '#1E3A5F', description: 'Primary brand' }, + { var: '--color-secondary', hex: '#4A90A4', description: 'Accent' }, + { var: '--color-bg', hex: '#F7F8FA', description: 'Background' }, + ], + MOTION_DATA: [ + { label: 'Page transitions', description: 'Fade-in 300ms on route change' }, + ], + BACKGROUNDS_DATA: [ + { label: 'Hero section', description: 'Gradient overlay on Unsplash photo' }, + ], + PAGES_DATA: [ + { + name: 'Home', + route: '/', + description: 'Landing page for the portal', + content: ['Hero section', 'Quick links', 'Recent announcements'], + components: ['Navbar', 'Hero', 'QuickLinks'], + }, + { + name: 'Directory', + route: '/directory', + description: 'Searchable consultant directory', + content: ['Search bar', 'Consultant cards'], + components: ['Navbar', 'ConsultantCard'], + }, + ], + COMPONENTS_DATA: [ + { name: 'Navbar', purpose: 'Top navigation', usedBy: ['Home', 'Directory'] }, + { name: 'Hero', purpose: 'Landing hero section', usedBy: ['Home'] }, + ], + ROUTES_DATA: [ + { path: '/', page: 'Home' }, + { path: '/directory', page: 'Directory' }, + ], + REVIEW_DATA: [ + 'All pages load without console errors', + 'Navigation links work and highlight the active page', + ], + DEPLOYMENT_DATA: [ + { title: 'Deploy now to Power Pages', description: 'Runs /deploy-site to publish.', recommended: true }, + { title: 'Skip for now', description: 'Continue locally, deploy later.' }, + ], +}; + +test('render-createsite-plan renders HTML from --data file', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'createsite-plan-')); + const dataPath = path.join(tempDir, 'data.json'); + const outputPath = path.join(tempDir, 'plan.html'); + + fs.writeFileSync(dataPath, JSON.stringify(SAMPLE_DATA, null, 2), 'utf8'); + + const result = spawnSync(process.execPath, [scriptPath, '--output', outputPath, '--data', dataPath], { + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(outputPath)); + + const html = fs.readFileSync(outputPath, 'utf8'); + assert.match(html, /Contoso Portal/); + assert.match(html, /Implementation Plan/); + assert.match(html, /React/); + assert.match(html, /Minimal & Clean|Minimal & Clean/); + assert.match(html, /DM Sans/); + assert.match(html, /#1E3A5F/); + assert.match(html, /Directory/); + assert.match(html, /Navbar/); + assert.match(html, /Deploy now to Power Pages/); +}); + +test('render-createsite-plan renders HTML from --data-inline JSON', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'createsite-plan-')); + const outputPath = path.join(tempDir, 'plan-inline.html'); + + const result = spawnSync( + process.execPath, + [scriptPath, '--output', outputPath, '--data-inline', JSON.stringify(SAMPLE_DATA)], + { encoding: 'utf8' } + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(outputPath)); + + const html = fs.readFileSync(outputPath, 'utf8'); + assert.match(html, /Contoso Portal/); + assert.match(html, /Space Grotesk/); +}); + +test('render-createsite-plan fails with no arguments', () => { + const result = spawnSync(process.execPath, [scriptPath], { encoding: 'utf8' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Usage:/); +}); + +test('render-createsite-plan fails with invalid --data-inline JSON', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'createsite-plan-')); + const outputPath = path.join(tempDir, 'plan.html'); + + const result = spawnSync( + process.execPath, + [scriptPath, '--output', outputPath, '--data-inline', '{bad json}'], + { encoding: 'utf8' } + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /not valid JSON/); +}); + +test('render-createsite-plan fails when required keys are missing', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'createsite-plan-')); + const outputPath = path.join(tempDir, 'plan.html'); + + const incomplete = { ...SAMPLE_DATA }; + delete incomplete.PAGES_DATA; + delete incomplete.ROUTES_DATA; + + const result = spawnSync( + process.execPath, + [scriptPath, '--output', outputPath, '--data-inline', JSON.stringify(incomplete)], + { encoding: 'utf8' } + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Missing required keys/); + assert.match(result.stderr, /PAGES_DATA/); + assert.match(result.stderr, /ROUTES_DATA/); +}); + +test('render-createsite-plan refuses to overwrite existing file', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'createsite-plan-')); + const dataPath = path.join(tempDir, 'data.json'); + const outputPath = path.join(tempDir, 'plan.html'); + + fs.writeFileSync(dataPath, JSON.stringify(SAMPLE_DATA, null, 2), 'utf8'); + + const result1 = spawnSync(process.execPath, [scriptPath, '--output', outputPath, '--data', dataPath], { + encoding: 'utf8', + }); + assert.equal(result1.status, 0, result1.stderr || result1.stdout); + + const original = fs.readFileSync(outputPath, 'utf8'); + + const result2 = spawnSync(process.execPath, [scriptPath, '--output', outputPath, '--data', dataPath], { + encoding: 'utf8', + }); + assert.equal(result2.status, 1); + assert.match(result2.stderr, /Output file already exists/); + assert.equal(fs.readFileSync(outputPath, 'utf8'), original); +}); diff --git a/plugins/power-pages/skills/create-site/SKILL.md b/plugins/power-pages/skills/create-site/SKILL.md index 4e2d3438e..e6c19601c 100644 --- a/plugins/power-pages/skills/create-site/SKILL.md +++ b/plugins/power-pages/skills/create-site/SKILL.md @@ -23,6 +23,7 @@ Guide the user through creating a complete, production-quality Power Pages code - **Use TaskCreate/TaskUpdate**: Track all progress throughout all phases — create the todo list upfront with all phases before starting any work. - **Scaffold early, design with intention**: Get the dev server running immediately after discovery so the user has something to look at. Then plan the design and features while the scaffold is live — apply the chosen aesthetic during implementation. - **Live preview feedback loop**: The dev server MUST be running before any customization begins. Browse the site via Playwright (`browser_navigate` + `browser_snapshot`) to verify every significant change. Do NOT take screenshots — only use accessibility snapshots to check page structure and content. +- **Keep the scaffold loader in sync with reality**: The scaffold loader polls `public/scaffold-status.json` and mirrors whatever is written there. Update this file before every `AskUserQuestion` (to raise the "waiting for your input" banner so the user doesn't miss a terminal prompt) and before each implementation step in Phase 5 (so the status line matches what you're actually doing, not the hardcoded placeholder cycle). See [Live Preview Status Protocol](#live-preview-status-protocol). - **Use real images**: Source high-quality photos from Unsplash wherever pages need visual content — hero sections, feature cards, about pages, backgrounds, etc. Use `https://images.unsplash.com/photo-{id}?w={width}&h={height}&fit=crop` URLs with specific photo IDs found via `WebSearch`. Never leave image placeholders or broken `` tags pointing to nonexistent files. - **Git checkpoints**: Commit after every individual page and component — each gets its own commit so breaking changes can be reverted. @@ -32,6 +33,37 @@ Guide the user through creating a complete, production-quality Power Pages code --- +## Live Preview Status Protocol + +While the scaffold loading screen is visible (from Phase 2.6 until the Home page itself is replaced in Phase 5), the loader polls `GET /scaffold-status.json` every 1.5 seconds. Whatever you write into `/public/scaffold-status.json` is what the user sees — the status line and the "waiting for your input" banner. If you do not update this file, the loader falls back to hardcoded placeholder phrases ("Installing dependencies…") which are misleading once the scaffold is done. + +**Why this matters**: When the browser with the loader takes over the user's screen, a prompt in the terminal can sit unanswered for a long time because the user doesn't realize anything is waiting. The banner makes it obvious. + +**File shape** (all fields optional — omit any field you don't want to change): + +```json +{ + "message": "Creating Contact page", + "awaitingInput": false, + "inputPrompt": "Please check your terminal to respond." +} +``` + +- `message` — one short present-participle phrase that replaces the cycling status line. Include the grouping context inline when it helps (e.g., `"Creating Footer component (shared components)"`). +- `awaitingInput` — when `true`, a prominent pulsing banner appears at the top of the loader. Set this **before** every `AskUserQuestion` call and clear it (`false`) **immediately after** the user answers. +- `inputPrompt` — short context for the banner (e.g., `"Choose a framework"`). Optional. + +**When to update the file**: + +1. **After scaffold launches (end of Phase 2)**: write an initial status like `{ "message": "Planning your site", "awaitingInput": false }`. +2. **Before any `AskUserQuestion` that runs while the scaffold is visible** (Phases 3, 4, and any in-scaffold prompt in Phase 5): set `awaitingInput: true` with a short `inputPrompt`. After the user answers, write again with `awaitingInput: false`. +3. **Before each implementation step in Phase 5** — applying design tokens, creating each shared component, creating each page, updating the router, updating navigation — update `message` to the specific action. Examples: `"Applying design tokens"`, `"Creating Navbar component"`, `"Creating Contact page"`. +4. **At the end of Phase 5, after the Home page has been replaced**: delete `public/scaffold-status.json` so it isn't deployed with the site. + +Write the file with the `Write` tool (atomic overwrite). You do not need to read it first. + +--- + ## Phase 1: Discovery **Goal**: Understand what site needs to be built and what problem it solves @@ -105,6 +137,14 @@ Use `Glob` to discover all files in the asset directory, `Read` each file, then `Read` the binary file `${CLAUDE_PLUGIN_ROOT}/skills/create-site/assets/shared/power-pages-icon.png` and `Write` it to `/public/power-pages-icon.png`. (All four supported frameworks serve `public/` at the web root, so the same `/power-pages-icon.png` URL works for every framework.) +**Seed the live status file** so the loader shows a real message the moment it mounts. `Write` `/public/scaffold-status.json`: + +```json +{ "message": "Planning your site", "awaitingInput": false } +``` + +See [Live Preview Status Protocol](#live-preview-status-protocol) for the full contract — from here on, update this file before every `AskUserQuestion` and before each Phase 5 implementation step. + ### 2.2 Replace Placeholders After copying, replace all `__PLACEHOLDER__` tokens in every file. Use `Edit` with `replace_all: true` on each file. @@ -179,7 +219,15 @@ Immediately after the dev server starts, verify the scaffold is working: **Actions**: -1. Use `AskUserQuestion` to collect feature and design requirements: +1. **Raise the "awaiting input" banner** so the user notices the terminal prompt even while the browser loader is full-screen. `Write` `/public/scaffold-status.json`: + + ```json + { "message": "Planning your site", "awaitingInput": true, "inputPrompt": "Features, aesthetic, and mood — please answer in the terminal." } + ``` + + Immediately after the user answers, `Write` the same file again with `"awaitingInput": false` so the banner disappears. + +2. Use `AskUserQuestion` to collect feature and design requirements: | Question | Header | Options | |----------|--------|---------| @@ -194,9 +242,9 @@ Immediately after the dev server starts, verify the scaffold is working: > > Always generate options that make sense for the specific site — never reuse a fixed list. -2. Read the design aesthetics reference: `${CLAUDE_PLUGIN_ROOT}/skills/create-site/references/design-aesthetics.md` -3. **Map aesthetic + mood to design choices** using the Aesthetic x Mood Mapping table from the design reference. Record the chosen font direction, color direction, and motion direction. -4. Analyze requirements and determine needed components. Present component plan to user as a table: +3. Read the design aesthetics reference: `${CLAUDE_PLUGIN_ROOT}/skills/create-site/references/design-aesthetics.md` +4. **Map aesthetic + mood to design choices** using the Aesthetic x Mood Mapping table from the design reference. Record the chosen font direction, color direction, and motion direction. +5. Analyze requirements and determine needed components. Present component plan to user as a table: ``` | Component Type | Count | Details | @@ -207,7 +255,7 @@ Immediately after the dev server starts, verify the scaffold is working: | Routes | 4 | /, /about, /services, /contact | ``` -5. Use best judgement to determine the final color palette based on the chosen aesthetic + mood. These will be written fresh into a new `theme.css` during Implementation (Phase 5) when the scaffold loading screen is completely replaced: +6. Use best judgement to determine the final color palette based on the chosen aesthetic + mood. These will be written fresh into a new `theme.css` during Implementation (Phase 5) when the scaffold loading screen is completely replaced: | CSS Variable | Description | Value | |-------------|-------------|-------| @@ -224,39 +272,90 @@ Immediately after the dev server starts, verify the scaffold is working: ## Phase 4: Plan Approval -**Goal**: Get user approval on the implementation plan +**Goal**: Render the implementation plan as an HTML document, open it in the user's default browser, and get approval before starting implementation. -**Actions**: +> **Why HTML instead of a chat message**: A structured HTML plan (like the ones produced by `/integrate-backend`, `/add-server-logic`, and `/add-cloud-flow`) lets the user skim sections, compare swatches, and preview typography — all impossible in a terminal. The scaffold loader in their browser may also be full-screen, so surfacing the plan in a new tab puts it where they can actually read it. -1. Read the design aesthetics reference: `${CLAUDE_PLUGIN_ROOT}/skills/create-site/references/design-aesthetics.md` -2. Present the implementation plan directly to the user as a formatted message. **The plan MUST have ALL of the following sections:** +### 4.1 Read the Design Reference - **Section A — Design & Pages** - - Pages to create (with content outline for each) - - Components needed for each page - - Routing and navigation structure - - Design decisions (from the chosen design direction): - - Typography: specific Google Fonts chosen - - Color palette: full CSS variable set with hex values (replacing the scaffold defaults) - - Motion/animation plan: page load, hover states, transitions - - Background treatment: gradients, patterns, effects +Read the design aesthetics reference: `${CLAUDE_PLUGIN_ROOT}/skills/create-site/references/design-aesthetics.md`. Every field you populate below should be justified by the chosen aesthetic + mood from Phase 3. - **Section B — Review & Deployment** - - What to verify before handoff - - Deployment options +### 4.2 Build the Plan Data - > **CRITICAL:** The plan is written for the user — do NOT reference internal phase numbers, tool names, or implementation details. Describe what will be built and what it will look like. The scaffold is already running — this plan covers what will be built on top of it. +Assemble a single JSON object with the following keys. The plan template rejects any data that's missing a required key, so include all of them. -3. Use `AskUserQuestion` to get approval: +| Key | Type | Content | +|-----|------|---------| +| `SITE_NAME` | string | Title-case site name from Phase 1 | +| `PLAN_TITLE` | string | Always `"Implementation Plan"` | +| `FRAMEWORK` | string | `React` / `Vue` / `Angular` / `Astro` | +| `AESTHETIC` | string | Chosen aesthetic (e.g., `Minimal & Clean`) | +| `MOOD` | string | Chosen mood (e.g., `Professional & Trustworthy`) | +| `SUMMARY` | string | One paragraph describing what the site is and who it serves | +| `TYPOGRAPHY_DATA` | object | `{ primary: { name, sample, reason }, secondary: { name, sample, reason } }` — `name` must be a real Google Font family | +| `PALETTE_DATA` | array | `[{ var, hex, description }]` — one entry per CSS variable (primary, secondary, bg, surface, text, text-muted) | +| `MOTION_DATA` | array | `[{ label, description }]` — page transitions, hover states, etc. | +| `BACKGROUNDS_DATA` | array | `[{ label, description }]` — hero backgrounds, section treatments, patterns | +| `PAGES_DATA` | array | `[{ name, route, description, content: [...], components: [...] }]` — `content` is an outline of what's on the page, `components` is shared component names used | +| `COMPONENTS_DATA` | array | `[{ name, purpose, usedBy: [...] }]` — shared components with the page names that consume them | +| `ROUTES_DATA` | array | `[{ path, page }]` — every route the router will register | +| `REVIEW_DATA` | array of strings | Verification checklist items (e.g., "All pages load without console errors") | +| `DEPLOYMENT_DATA` | array | `[{ title, description, recommended?: boolean }]` — mark exactly one as `recommended: true` | - | Question | Header | Options | - |----------|--------|---------| - | Does this plan look good? | Plan | Approve and start building (Recommended), I'd like to make changes | +**Write the data for the user**, not for internal tooling — phrase `description` and `reason` fields in plain language. + +### 4.3 Render the HTML Plan - - **If "Approve"**: Proceed to Phase 5. - - **If "I'd like to make changes"**: Ask what they want changed, update the plan, and re-present for approval. +Pick an output path under `/docs/`. Default is `create-site-plan.html`; if that file already exists, pick a descriptive variant like `create-site-plan-v2.html` (the render script refuses to overwrite existing files). -**Output**: Approved implementation plan +```powershell +node "${CLAUDE_PLUGIN_ROOT}/scripts/render-createsite-plan.js" --output "/docs/create-site-plan.html" --data-inline '' +``` + +Use `--data-inline` so no temp JSON file is written. If the JSON is too large for a single shell argument, write it to a temp file and use `--data ` instead, then delete the temp file after the render succeeds. + +The script prints `{"status":"ok","output":""}` on success. Capture and use that actual output path for the next step. + +### 4.4 Open the Plan in the Default Browser + +Use the platform-appropriate command via `Bash`: + +- macOS: `open ""` +- Windows (PowerShell): `Start-Process ""` +- Linux: `xdg-open ""` + +### 4.5 Present a Brief Summary in the Terminal + +Keep the terminal message short — **the full plan lives in the HTML file now**. Include: + +- One sentence confirming the plan was rendered and where (the output path). +- A 3-5 line bullet summary: framework, page count, component count, palette primary + mood. +- A pointer: "See the open browser tab for pages, color swatches, typography samples, and deployment options." + +Do NOT dump the full plan contents into the terminal — that defeats the purpose of the HTML view. + +### 4.6 Raise the "Awaiting Input" Banner + +The user may still be looking at the full-screen scaffold loader when you ask for approval. `Write` `/public/scaffold-status.json`: + +```json +{ "message": "Ready to build", "awaitingInput": true, "inputPrompt": "Plan approval needed — review the plan in your browser and answer in the terminal." } +``` + +Immediately after the user answers, `Write` the same file again with `"awaitingInput": false`. + +### 4.7 Ask for Approval + +Use `AskUserQuestion`: + +| Question | Header | Options | +|----------|--------|---------| +| Does this plan look good? | Plan | Approve and start building (Recommended), I'd like to make changes | + +- **If "Approve"**: Proceed to Phase 5. +- **If "I'd like to make changes"**: Ask what they want changed, update the JSON, and re-render to a new filename (the render script won't overwrite). Re-open that new file in the browser and repeat 4.5–4.7. + +**Output**: Approved implementation plan, with an HTML copy committed alongside the project for the user to reference during and after implementation. --- @@ -286,10 +385,12 @@ Each todo should have a clear `subject`, `activeForm`, and `description` that in The scaffold is a temporary loading screen — it must be **completely replaced** during this phase. Do NOT build on top of it or try to modify the loading animation into a real page. Start fresh with the user's chosen design. -1. **Design foundations** — **Completely rewrite** `theme.css` (or `styles.css` for Angular) from scratch with the chosen color palette as CSS custom properties, Google Fonts, motion/animation utilities, and background treatments. The scaffold's loading screen CSS is discarded entirely. Commit after this step. -2. **Layout** — **Rewrite** the Layout component (and Header/Footer for Astro) with proper navigation, header, and footer that reflect the chosen design. The scaffold's passthrough Layout is replaced with a real layout structure. -3. **Shared components** — Build reusable components (Navbar, Footer, ContactForm, etc.) that pages will use -4. **Pages** — Create route components for each requested page, **replacing** the scaffold Home page and About placeholder entirely. Each page component must update `document.title` on mount to reflect the current page (e.g., `"Contact — Contoso Portal"`). Use the framework's idiomatic lifecycle hook: `useEffect` (React), `onMounted` (Vue), `ngOnInit` (Angular), or a `` tag in the frontmatter (Astro). Format: `"<Page Name> — <Site Name>"`, with the home page using just `"<Site Name>"`. +> **Narrate progress in the loader**: Before each of the steps below, update `<PROJECT_ROOT>/public/scaffold-status.json` so the user — who may still be watching the Home page loader — sees what's actually happening instead of the hardcoded placeholder cycle. Use a short present-participle `message` (e.g., `"Creating Navbar component"`, `"Creating Contact page"`). Include any useful grouping context inline in the message itself. The loader picks up changes within ~1.5 seconds. Updates become no-ops once step 4 replaces the Home page. + +1. **Design foundations** — **Completely rewrite** `theme.css` (or `styles.css` for Angular) from scratch with the chosen color palette as CSS custom properties, Google Fonts, motion/animation utilities, and background treatments. The scaffold's loading screen CSS is discarded entirely. Commit after this step. *Before starting, set the loader status to `{ "message": "Applying design tokens" }`.* +2. **Layout** — **Rewrite** the Layout component (and Header/Footer for Astro) with proper navigation, header, and footer that reflect the chosen design. The scaffold's passthrough Layout is replaced with a real layout structure. *Set status to `{ "message": "Rewriting Layout" }`.* +3. **Shared components** — Build reusable components (Navbar, Footer, ContactForm, etc.) that pages will use. *For each component, set status to `{ "message": "Creating <Component> component" }`.* +4. **Pages** — Create route components for each requested page, **replacing** the scaffold Home page and About placeholder entirely. Each page component must update `document.title` on mount to reflect the current page (e.g., `"Contact — Contoso Portal"`). Use the framework's idiomatic lifecycle hook: `useEffect` (React), `onMounted` (Vue), `ngOnInit` (Angular), or a `<title>` tag in the frontmatter (Astro). Format: `"<Page Name> — <Site Name>"`, with the home page using just `"<Site Name>"`. *For each page, set status to `{ "message": "Creating <Page> page" }` before writing the file. The loader disappears when the Home page itself is replaced — no further status updates are needed after that.* 5. **Router** — Register all new routes (the scaffold only has `/` and `/about` — add all requested routes) 6. **Navigation** — Add links to the new Layout/Header component 7. **Entry HTML** — Update `index.html` (or `Layout.astro` for Astro) to load the chosen Google Fonts instead of the scaffold's DM Sans + Outfit @@ -354,6 +455,10 @@ After each significant change (new page or component), browse the site via Playw The user is previewing in their own browser via the dev server URL shared in Phase 2.7. +### 5.6 Clean Up the Live Status File + +Once the scaffold loader is gone, `public/scaffold-status.json` is just dead weight that would ship with the deployed site. Delete the file from `<PROJECT_ROOT>/public/` and commit the removal alongside the final implementation. + > **GATE: Do NOT proceed to Phase 6 until ALL customization is complete with design applied.** The site must have distinctive typography (Google Fonts — no generic Inter/Roboto/Arial), a cohesive color palette (CSS variables), motion/animations, and all requested pages/features before moving to accessibility verification. **Output**: All pages, components, and design elements implemented and verified @@ -579,7 +684,10 @@ Every site must meet these standards before completion: ### Phase 4: Plan Approval -- Plan presented inline with design & pages + review & deployment sections +- Plan data assembled as a single JSON object +- Rendered to `docs/create-site-plan.html` via `render-createsite-plan.js` +- Opened in the user's default browser +- Brief summary shown in terminal with a pointer to the browser tab - User approved via AskUserQuestion ### Phase 5: Implementation diff --git a/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts b/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts index 7b8d52c2c..4356ae34d 100644 --- a/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts +++ b/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts @@ -110,7 +110,18 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); @keyframes fadeSlideUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } .center-stage.complete .main-heading { animation: completePulse 2s ease-in-out infinite alternate; } @keyframes completePulse { 0% { filter: brightness(1); } 100% { filter: brightness(1.15); } } -@media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } } +.input-banner { position: fixed; top: 24px; right: 24px; z-index: 100; display: flex; align-items: center; gap: 12px; background: linear-gradient(135deg, #FFF4D6 0%, #FFE9B3 100%); border: 1px solid #F5B800; color: #5C3D00; padding: 12px 14px 12px 22px; border-radius: 999px; font-family: 'Outfit', sans-serif; font-size: 14px; font-weight: 500; box-shadow: 0 4px 16px rgba(245, 184, 0, 0.25); max-width: min(360px, calc(100vw - 48px)); animation: bannerEnter 0.4s cubic-bezier(0.22, 1, 0.36, 1); } +.input-banner[hidden] { display: none; } +.input-banner-icon { font-size: 18px; animation: bannerPulse 1.5s ease-in-out infinite; flex-shrink: 0; } +.input-banner-text { line-height: 1.4; flex: 1; min-width: 0; } +.input-banner-text b { font-weight: 600; display: block; letter-spacing: 0.3px; } +.input-banner-text small { display: block; font-size: 12px; font-weight: 400; opacity: 0.8; margin-top: 2px; } +.input-banner-close { flex-shrink: 0; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; color: #5C3D00; cursor: pointer; font-size: 20px; line-height: 1; padding: 0; border-radius: 50%; opacity: 0.55; transition: opacity 0.2s, background 0.2s; font-family: inherit; } +.input-banner-close:hover { opacity: 1; background: rgba(92, 61, 0, 0.08); } +.input-banner-close:focus-visible { outline: 2px solid #F5B800; outline-offset: 2px; opacity: 1; } +@keyframes bannerPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } +@keyframes bannerEnter { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } } +@media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } .input-banner { top: 12px; right: 12px; padding: 10px 12px 10px 16px; font-size: 13px; max-width: calc(100vw - 24px); } } `], template: ` <div class="loading-wrapper"> @@ -120,6 +131,15 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); <div class="particles" id="particles"></div> <div class="connector-lines" id="connectors"></div> + <div class="input-banner" id="inputBanner" hidden> + <span class="input-banner-icon">⚠️</span> + <div class="input-banner-text"> + <b>Waiting for your input</b> + <small id="inputBannerPrompt">Please check your terminal to respond.</small> + </div> + <button type="button" class="input-banner-close" id="inputBannerClose" aria-label="Dismiss">×</button> + </div> + <div class="center-stage" id="centerStage"> <div class="orbit-system"> <div class="core-glow"></div> @@ -230,8 +250,24 @@ export class HomeComponent implements AfterViewInit, OnDestroy { ] let currentStatusIndex = 0 + let liveOverride = false + let lastLiveMessage: string | null = null + let lastAwaiting = false + let lastPrompt: string | null = null + let userDismissed = false + let dismissedPrompt: string | null = null - const showStatus = (index: number) => { + const bannerEl = document.getElementById('inputBanner') as HTMLElement | null + const closeBtn = document.getElementById('inputBannerClose') + if (bannerEl && closeBtn) { + closeBtn.addEventListener('click', () => { + userDismissed = true + dismissedPrompt = lastPrompt + bannerEl.hidden = true + }) + } + + const renderStatusMessage = (text: string) => { if (!statusArea) return const existing = statusArea.querySelector('.status-message') if (existing) { @@ -244,15 +280,22 @@ export class HomeComponent implements AfterViewInit, OnDestroy { const icon = document.createElement('div') icon.classList.add('status-icon', 'working') msg.appendChild(icon) - const text = document.createElement('span') - text.textContent = statuses[index % statuses.length].text - msg.appendChild(text) + const textEl = document.createElement('span') + textEl.textContent = text + msg.appendChild(textEl) statusArea.appendChild(msg) requestAnimationFrame(() => requestAnimationFrame(() => msg.classList.add('active'))) - if (progressLabel) { - const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) - progressLabel.textContent = phaseLabels[phaseIndex] - } + } + + const updatePhaseLabel = (text: string) => { + if (progressLabel) progressLabel.textContent = text + } + + const showStatus = (index: number) => { + if (liveOverride) return + renderStatusMessage(statuses[index % statuses.length].text) + const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) } const advanceStatus = () => { @@ -266,6 +309,49 @@ export class HomeComponent implements AfterViewInit, OnDestroy { this.timeouts.push(window.setTimeout(() => advanceStatus(), 2000)) + const pollStatus = async () => { + const banner = document.getElementById('inputBanner') as HTMLElement | null + const promptEl = document.getElementById('inputBannerPrompt') + try { + const res = await fetch('/scaffold-status.json', { cache: 'no-store' }) + if (!res.ok) throw new Error('no status') + const data = await res.json() as { message?: string; awaitingInput?: boolean; inputPrompt?: string } + if (data.message) { + liveOverride = true + if (data.message !== lastLiveMessage) { + lastLiveMessage = data.message + renderStatusMessage(data.message) + } + } else { + liveOverride = false + lastLiveMessage = null + } + const awaiting = !!data.awaitingInput + const prompt = data.inputPrompt || 'Please check your terminal to respond.' + const awaitingChanged = awaiting !== lastAwaiting + const promptChanged = prompt !== lastPrompt + if (promptChanged && prompt !== dismissedPrompt) userDismissed = false + if (awaitingChanged || promptChanged) { + lastAwaiting = awaiting + lastPrompt = prompt + if (promptEl) promptEl.textContent = prompt + if (banner) banner.hidden = !(awaiting && !userDismissed) + } + } catch { + liveOverride = false + lastLiveMessage = null + if (lastAwaiting) { + lastAwaiting = false + lastPrompt = null + userDismissed = false + dismissedPrompt = null + if (banner) banner.hidden = true + } + } + } + pollStatus() + this.intervals.push(window.setInterval(pollStatus, 1500)) + // Feature cards this.timeouts.push(window.setTimeout(() => { document.querySelectorAll('.feature-card').forEach(card => { diff --git a/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro b/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro index 2c7f65a63..803150e2a 100644 --- a/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro +++ b/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro @@ -10,6 +10,15 @@ import Layout from '../layouts/Layout.astro' <div class="particles" id="particles"></div> <div class="connector-lines" id="connectors"></div> + <div class="input-banner" id="inputBanner" hidden> + <span class="input-banner-icon">⚠️</span> + <div class="input-banner-text"> + <b>Waiting for your input</b> + <small id="inputBannerPrompt">Please check your terminal to respond.</small> + </div> + <button type="button" class="input-banner-close" id="inputBannerClose" aria-label="Dismiss">×</button> + </div> + <div class="center-stage" id="centerStage"> <div class="orbit-system"> <div class="core-glow"></div> @@ -166,7 +175,18 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); @keyframes fadeSlideUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } .center-stage.complete .main-heading { animation: completePulse 2s ease-in-out infinite alternate; } @keyframes completePulse { 0% { filter: brightness(1); } 100% { filter: brightness(1.15); } } -@media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } } +.input-banner { position: fixed; top: 24px; right: 24px; z-index: 100; display: flex; align-items: center; gap: 12px; background: linear-gradient(135deg, #FFF4D6 0%, #FFE9B3 100%); border: 1px solid #F5B800; color: #5C3D00; padding: 12px 14px 12px 22px; border-radius: 999px; font-family: 'Outfit', sans-serif; font-size: 14px; font-weight: 500; box-shadow: 0 4px 16px rgba(245, 184, 0, 0.25); max-width: min(360px, calc(100vw - 48px)); animation: bannerEnter 0.4s cubic-bezier(0.22, 1, 0.36, 1); } +.input-banner[hidden] { display: none; } +.input-banner-icon { font-size: 18px; animation: bannerPulse 1.5s ease-in-out infinite; flex-shrink: 0; } +.input-banner-text { line-height: 1.4; flex: 1; min-width: 0; } +.input-banner-text b { font-weight: 600; display: block; letter-spacing: 0.3px; } +.input-banner-text small { display: block; font-size: 12px; font-weight: 400; opacity: 0.8; margin-top: 2px; } +.input-banner-close { flex-shrink: 0; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; color: #5C3D00; cursor: pointer; font-size: 20px; line-height: 1; padding: 0; border-radius: 50%; opacity: 0.55; transition: opacity 0.2s, background 0.2s; font-family: inherit; } +.input-banner-close:hover { opacity: 1; background: rgba(92, 61, 0, 0.08); } +.input-banner-close:focus-visible { outline: 2px solid #F5B800; outline-offset: 2px; opacity: 1; } +@keyframes bannerPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } +@keyframes bannerEnter { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } } +@media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } .input-banner { top: 12px; right: 12px; padding: 10px 12px 10px 16px; font-size: 13px; max-width: calc(100vw - 24px); } } </style> <script> @@ -225,8 +245,24 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); ] let currentStatusIndex = 0 + let liveOverride = false + let lastLiveMessage: string | null = null + let lastAwaiting = false + let lastPrompt: string | null = null + let userDismissed = false + let dismissedPrompt: string | null = null - function showStatus(index: number) { + const bannerEl = document.getElementById('inputBanner') as HTMLElement | null + const closeBtn = document.getElementById('inputBannerClose') + if (bannerEl && closeBtn) { + closeBtn.addEventListener('click', () => { + userDismissed = true + dismissedPrompt = lastPrompt + bannerEl.hidden = true + }) + } + + function renderStatusMessage(text: string) { if (!statusArea) return const existing = statusArea.querySelector('.status-message') if (existing) { @@ -239,15 +275,22 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); const icon = document.createElement('div') icon.classList.add('status-icon', 'working') msg.appendChild(icon) - const text = document.createElement('span') - text.textContent = statuses[index].text - msg.appendChild(text) + const textEl = document.createElement('span') + textEl.textContent = text + msg.appendChild(textEl) statusArea.appendChild(msg) requestAnimationFrame(() => requestAnimationFrame(() => msg.classList.add('active'))) - if (progressLabel) { - const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) - progressLabel.textContent = phaseLabels[phaseIndex] - } + } + + function updatePhaseLabel(text: string) { + if (progressLabel) progressLabel.textContent = text + } + + function showStatus(index: number) { + if (liveOverride) return + renderStatusMessage(statuses[index].text) + const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) } function advanceStatus() { @@ -261,6 +304,49 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); setTimeout(() => advanceStatus(), 2000) + async function pollStatus() { + const banner = document.getElementById('inputBanner') as HTMLElement | null + const promptEl = document.getElementById('inputBannerPrompt') + try { + const res = await fetch('/scaffold-status.json', { cache: 'no-store' }) + if (!res.ok) throw new Error('no status') + const data = await res.json() as { message?: string; awaitingInput?: boolean; inputPrompt?: string } + if (data.message) { + liveOverride = true + if (data.message !== lastLiveMessage) { + lastLiveMessage = data.message + renderStatusMessage(data.message) + } + } else { + liveOverride = false + lastLiveMessage = null + } + const awaiting = !!data.awaitingInput + const prompt = data.inputPrompt || 'Please check your terminal to respond.' + const awaitingChanged = awaiting !== lastAwaiting + const promptChanged = prompt !== lastPrompt + if (promptChanged && prompt !== dismissedPrompt) userDismissed = false + if (awaitingChanged || promptChanged) { + lastAwaiting = awaiting + lastPrompt = prompt + if (promptEl) promptEl.textContent = prompt + if (banner) banner.hidden = !(awaiting && !userDismissed) + } + } catch { + liveOverride = false + lastLiveMessage = null + if (lastAwaiting) { + lastAwaiting = false + lastPrompt = null + userDismissed = false + dismissedPrompt = null + if (banner) banner.hidden = true + } + } + } + pollStatus() + setInterval(pollStatus, 1500) + // Feature cards setTimeout(() => { document.querySelectorAll('.feature-card').forEach(card => { diff --git a/plugins/power-pages/skills/create-site/assets/create-site-plan.html b/plugins/power-pages/skills/create-site/assets/create-site-plan.html new file mode 100644 index 000000000..343f0e86b --- /dev/null +++ b/plugins/power-pages/skills/create-site/assets/create-site-plan.html @@ -0,0 +1,372 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"/> +<meta name="viewport" content="width=device-width,initial-scale=1.0"/> +<title>__PLAN_TITLE__ - __SITE_NAME__ + + + +
+
+ +
+
__PLAN_TITLE__
+
+ __SITE_NAME__ + · + __FRAMEWORK__ +
+
+
+
+ __AESTHETIC__ + __MOOD__ +
+
+ +
+ + +
+ +
+

Overview

+

Implementation plan for __SITE_NAME__

+ +
__SUMMARY__
+ +
+
0
Pages
+
0
Shared Components
+
0
Routes
+
+ +

What happens next

+
+
+ + +
+

Design

+

Typography, palette, motion, and background treatments

+ +

Typography

+
+ +

Color palette

+
+ +

Motion & animation

+
+ +

Background treatment

+
+
+ + +
+

Pages & Components

+

Pages to build, their content outline, and the shared components they rely on

+ +

Pages

+
+ +

Shared components

+
+ +

Routing

+ + + +
PathPage
+
+ + +
+

Deployment & Review

+

Verification checklist and deployment options

+ +

Before handoff — verify

+
+ +

Deployment options

+
+
+
+
+ + +
AI-generated content may be incorrect
+ + diff --git a/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx b/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx index 38e48cfa8..de412c146 100644 --- a/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx +++ b/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx @@ -106,7 +106,18 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); @keyframes fadeSlideUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } .center-stage.complete .main-heading { animation: completePulse 2s ease-in-out infinite alternate; } @keyframes completePulse { 0% { filter: brightness(1); } 100% { filter: brightness(1.15); } } -@media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } } +.input-banner { position: fixed; top: 24px; right: 24px; z-index: 100; display: flex; align-items: center; gap: 12px; background: linear-gradient(135deg, #FFF4D6 0%, #FFE9B3 100%); border: 1px solid #F5B800; color: #5C3D00; padding: 12px 14px 12px 22px; border-radius: 999px; font-family: 'Outfit', sans-serif; font-size: 14px; font-weight: 500; box-shadow: 0 4px 16px rgba(245, 184, 0, 0.25); max-width: min(360px, calc(100vw - 48px)); animation: bannerEnter 0.4s cubic-bezier(0.22, 1, 0.36, 1); } +.input-banner[hidden] { display: none; } +.input-banner-icon { font-size: 18px; animation: bannerPulse 1.5s ease-in-out infinite; flex-shrink: 0; } +.input-banner-text { line-height: 1.4; flex: 1; min-width: 0; } +.input-banner-text b { font-weight: 600; display: block; letter-spacing: 0.3px; } +.input-banner-text small { display: block; font-size: 12px; font-weight: 400; opacity: 0.8; margin-top: 2px; } +.input-banner-close { flex-shrink: 0; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; color: #5C3D00; cursor: pointer; font-size: 20px; line-height: 1; padding: 0; border-radius: 50%; opacity: 0.55; transition: opacity 0.2s, background 0.2s; font-family: inherit; } +.input-banner-close:hover { opacity: 1; background: rgba(92, 61, 0, 0.08); } +.input-banner-close:focus-visible { outline: 2px solid #F5B800; outline-offset: 2px; opacity: 1; } +@keyframes bannerPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } +@keyframes bannerEnter { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } } +@media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } .input-banner { top: 12px; right: 12px; padding: 10px 12px 10px 16px; font-size: 13px; max-width: calc(100vw - 24px); } } ` export default function Home() { @@ -169,8 +180,24 @@ export default function Home() { ] let currentStatusIndex = 0 + let liveOverride = false + let lastLiveMessage: string | null = null + let lastAwaiting = false + let lastPrompt: string | null = null + let userDismissed = false + let dismissedPrompt: string | null = null - function showStatus(index: number) { + const bannerEl = document.getElementById('inputBanner') as HTMLElement | null + const closeBtn = document.getElementById('inputBannerClose') + if (bannerEl && closeBtn) { + closeBtn.addEventListener('click', () => { + userDismissed = true + dismissedPrompt = lastPrompt + bannerEl.hidden = true + }) + } + + function renderStatusMessage(text: string) { if (!statusArea) return const existing = statusArea.querySelector('.status-message') if (existing) { @@ -183,15 +210,22 @@ export default function Home() { const icon = document.createElement('div') icon.classList.add('status-icon', 'working') msg.appendChild(icon) - const text = document.createElement('span') - text.textContent = statuses[index].text - msg.appendChild(text) + const textEl = document.createElement('span') + textEl.textContent = text + msg.appendChild(textEl) statusArea.appendChild(msg) requestAnimationFrame(() => requestAnimationFrame(() => msg.classList.add('active'))) - if (progressLabel) { - const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) - progressLabel.textContent = phaseLabels[phaseIndex] - } + } + + function updatePhaseLabel(text: string) { + if (progressLabel) progressLabel.textContent = text + } + + function showStatus(index: number) { + if (liveOverride) return + renderStatusMessage(statuses[index].text) + const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) } function advanceStatus() { @@ -205,6 +239,49 @@ export default function Home() { timeouts.push(window.setTimeout(() => advanceStatus(), 2000)) + async function pollStatus() { + const banner = document.getElementById('inputBanner') + const promptEl = document.getElementById('inputBannerPrompt') + try { + const res = await fetch('/scaffold-status.json', { cache: 'no-store' }) + if (!res.ok) throw new Error('no status') + const data = await res.json() as { message?: string; awaitingInput?: boolean; inputPrompt?: string } + if (data.message) { + liveOverride = true + if (data.message !== lastLiveMessage) { + lastLiveMessage = data.message + renderStatusMessage(data.message) + } + } else { + liveOverride = false + lastLiveMessage = null + } + const awaiting = !!data.awaitingInput + const prompt = data.inputPrompt || 'Please check your terminal to respond.' + const awaitingChanged = awaiting !== lastAwaiting + const promptChanged = prompt !== lastPrompt + if (promptChanged && prompt !== dismissedPrompt) userDismissed = false + if (awaitingChanged || promptChanged) { + lastAwaiting = awaiting + lastPrompt = prompt + if (promptEl) promptEl.textContent = prompt + if (banner) banner.hidden = !(awaiting && !userDismissed) + } + } catch { + liveOverride = false + lastLiveMessage = null + if (lastAwaiting) { + lastAwaiting = false + lastPrompt = null + userDismissed = false + dismissedPrompt = null + if (banner) banner.hidden = true + } + } + } + pollStatus() + intervals.push(window.setInterval(pollStatus, 1500)) + // Feature cards timeouts.push(window.setTimeout(() => { document.querySelectorAll('.feature-card').forEach(card => { @@ -255,6 +332,15 @@ export default function Home() {
+ +
diff --git a/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue b/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue index 3e3b5f110..4f50db687 100644 --- a/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue +++ b/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue @@ -6,6 +6,15 @@
+ +
@@ -120,8 +129,24 @@ onMounted(() => { ] let currentStatusIndex = 0 + let liveOverride = false + let lastLiveMessage: string | null = null + let lastAwaiting = false + let lastPrompt: string | null = null + let userDismissed = false + let dismissedPrompt: string | null = null - function showStatus(index: number) { + const bannerEl = document.getElementById('inputBanner') as HTMLElement | null + const closeBtn = document.getElementById('inputBannerClose') + if (bannerEl && closeBtn) { + closeBtn.addEventListener('click', () => { + userDismissed = true + dismissedPrompt = lastPrompt + bannerEl.hidden = true + }) + } + + function renderStatusMessage(text: string) { if (!statusArea) return const existing = statusArea.querySelector('.status-message') if (existing) { @@ -134,15 +159,22 @@ onMounted(() => { const icon = document.createElement('div') icon.classList.add('status-icon', 'working') msg.appendChild(icon) - const text = document.createElement('span') - text.textContent = statuses[index].text - msg.appendChild(text) + const textEl = document.createElement('span') + textEl.textContent = text + msg.appendChild(textEl) statusArea.appendChild(msg) requestAnimationFrame(() => requestAnimationFrame(() => msg.classList.add('active'))) - if (progressLabel) { - const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) - progressLabel.textContent = phaseLabels[phaseIndex] - } + } + + function updatePhaseLabel(text: string) { + if (progressLabel) progressLabel.textContent = text + } + + function showStatus(index: number) { + if (liveOverride) return + renderStatusMessage(statuses[index].text) + const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) } function advanceStatus() { @@ -156,6 +188,49 @@ onMounted(() => { timeouts.push(window.setTimeout(() => advanceStatus(), 2000)) + async function pollStatus() { + const banner = document.getElementById('inputBanner') as HTMLElement | null + const promptEl = document.getElementById('inputBannerPrompt') + try { + const res = await fetch('/scaffold-status.json', { cache: 'no-store' }) + if (!res.ok) throw new Error('no status') + const data = await res.json() as { message?: string; awaitingInput?: boolean; inputPrompt?: string } + if (data.message) { + liveOverride = true + if (data.message !== lastLiveMessage) { + lastLiveMessage = data.message + renderStatusMessage(data.message) + } + } else { + liveOverride = false + lastLiveMessage = null + } + const awaiting = !!data.awaitingInput + const prompt = data.inputPrompt || 'Please check your terminal to respond.' + const awaitingChanged = awaiting !== lastAwaiting + const promptChanged = prompt !== lastPrompt + if (promptChanged && prompt !== dismissedPrompt) userDismissed = false + if (awaitingChanged || promptChanged) { + lastAwaiting = awaiting + lastPrompt = prompt + if (promptEl) promptEl.textContent = prompt + if (banner) banner.hidden = !(awaiting && !userDismissed) + } + } catch { + liveOverride = false + lastLiveMessage = null + if (lastAwaiting) { + lastAwaiting = false + lastPrompt = null + userDismissed = false + dismissedPrompt = null + if (banner) banner.hidden = true + } + } + } + pollStatus() + intervals.push(window.setInterval(pollStatus, 1500)) + // Feature cards timeouts.push(window.setTimeout(() => { document.querySelectorAll('.feature-card').forEach(card => { @@ -307,5 +382,16 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); @keyframes fadeSlideUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } .center-stage.complete .main-heading { animation: completePulse 2s ease-in-out infinite alternate; } @keyframes completePulse { 0% { filter: brightness(1); } 100% { filter: brightness(1.15); } } -@media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } } +.input-banner { position: fixed; top: 24px; right: 24px; z-index: 100; display: flex; align-items: center; gap: 12px; background: linear-gradient(135deg, #FFF4D6 0%, #FFE9B3 100%); border: 1px solid #F5B800; color: #5C3D00; padding: 12px 14px 12px 22px; border-radius: 999px; font-family: 'Outfit', sans-serif; font-size: 14px; font-weight: 500; box-shadow: 0 4px 16px rgba(245, 184, 0, 0.25); max-width: min(360px, calc(100vw - 48px)); animation: bannerEnter 0.4s cubic-bezier(0.22, 1, 0.36, 1); } +.input-banner[hidden] { display: none; } +.input-banner-icon { font-size: 18px; animation: bannerPulse 1.5s ease-in-out infinite; flex-shrink: 0; } +.input-banner-text { line-height: 1.4; flex: 1; min-width: 0; } +.input-banner-text b { font-weight: 600; display: block; letter-spacing: 0.3px; } +.input-banner-text small { display: block; font-size: 12px; font-weight: 400; opacity: 0.8; margin-top: 2px; } +.input-banner-close { flex-shrink: 0; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; color: #5C3D00; cursor: pointer; font-size: 20px; line-height: 1; padding: 0; border-radius: 50%; opacity: 0.55; transition: opacity 0.2s, background 0.2s; font-family: inherit; } +.input-banner-close:hover { opacity: 1; background: rgba(92, 61, 0, 0.08); } +.input-banner-close:focus-visible { outline: 2px solid #F5B800; outline-offset: 2px; opacity: 1; } +@keyframes bannerPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } +@keyframes bannerEnter { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } } +@media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } .input-banner { top: 12px; right: 12px; padding: 10px 12px 10px 16px; font-size: 13px; max-width: calc(100vw - 24px); } } From 2005dafbc275952d1352a8fa51140d23d88b5a2b Mon Sep 17 00:00:00 2001 From: Priyanshu Agrawal Date: Wed, 22 Apr 2026 14:48:33 +0530 Subject: [PATCH 2/9] Address PR review feedback and fix phase label regression - SKILL.md Phase 4.4: fix shell mismatch (Start-Process is PowerShell, not Bash). Split the list into Bash (macOS/Linux) and PowerShell (Windows). Addresses Copilot review comment. - create-site-plan.html: move __SUMMARY__ out of raw-HTML injection into a
0
Pages
@@ -255,6 +256,9 @@

Deployment options

}); }); +// --- Summary (read from text/plain rawtext so &, <, > are safe) --- +document.getElementById('summaryBox').textContent = document.getElementById('summaryRaw').textContent; + // --- Overview stats --- document.getElementById('statPages').textContent = PAGES.length; document.getElementById('statComponents').textContent = COMPONENTS.length; diff --git a/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx b/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx index de412c146..0cb89b18a 100644 --- a/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx +++ b/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx @@ -222,10 +222,10 @@ export default function Home() { } function showStatus(index: number) { - if (liveOverride) return - renderStatusMessage(statuses[index].text) const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) updatePhaseLabel(phaseLabels[phaseIndex]) + if (liveOverride) return + renderStatusMessage(statuses[index].text) } function advanceStatus() { @@ -243,7 +243,7 @@ export default function Home() { const banner = document.getElementById('inputBanner') const promptEl = document.getElementById('inputBannerPrompt') try { - const res = await fetch('/scaffold-status.json', { cache: 'no-store' }) + const res = await fetch('/scaffold-status.json?t=' + Date.now(), { cache: 'no-store' }) if (!res.ok) throw new Error('no status') const data = await res.json() as { message?: string; awaitingInput?: boolean; inputPrompt?: string } if (data.message) { diff --git a/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue b/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue index 4f50db687..f898e88b0 100644 --- a/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue +++ b/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue @@ -171,10 +171,10 @@ onMounted(() => { } function showStatus(index: number) { - if (liveOverride) return - renderStatusMessage(statuses[index].text) const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) updatePhaseLabel(phaseLabels[phaseIndex]) + if (liveOverride) return + renderStatusMessage(statuses[index].text) } function advanceStatus() { @@ -192,7 +192,7 @@ onMounted(() => { const banner = document.getElementById('inputBanner') as HTMLElement | null const promptEl = document.getElementById('inputBannerPrompt') try { - const res = await fetch('/scaffold-status.json', { cache: 'no-store' }) + const res = await fetch('/scaffold-status.json?t=' + Date.now(), { cache: 'no-store' }) if (!res.ok) throw new Error('no status') const data = await res.json() as { message?: string; awaitingInput?: boolean; inputPrompt?: string } if (data.message) { From 070b877e802b6b4ee7c54be4c281b8ae3621deba Mon Sep 17 00:00:00 2001 From: Priyanshu Agrawal Date: Wed, 22 Apr 2026 14:57:04 +0530 Subject: [PATCH 3/9] Route live message to the progress-bar label instead of spinner The spinner area above the progress bar is decorative and keeps cycling the hardcoded "Setting up your workspace..." style phrases. The label *under* the progress bar now reflects the live `message` written to public/scaffold-status.json. - pollStatus() calls updatePhaseLabel(data.message) instead of renderStatusMessage(data.message). - showStatus() inverted: always renders the decorative spinner message, but only updates the phase label when no live override. - When live override clears (no message, or fetch fails), phase label is immediately restored to the current cycling position instead of staying stuck on the last message. - SKILL.md protocol description updated to reflect the new slot. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/power-pages/skills/create-site/SKILL.md | 2 +- .../assets/angular/src/app/pages/home.component.ts | 14 +++++++++++--- .../create-site/assets/astro/src/pages/index.astro | 14 +++++++++++--- .../create-site/assets/react/src/pages/Home.tsx | 14 +++++++++++--- .../create-site/assets/vue/src/pages/Home.vue | 14 +++++++++++--- 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/plugins/power-pages/skills/create-site/SKILL.md b/plugins/power-pages/skills/create-site/SKILL.md index c2c20c19a..9f0dff132 100644 --- a/plugins/power-pages/skills/create-site/SKILL.md +++ b/plugins/power-pages/skills/create-site/SKILL.md @@ -49,7 +49,7 @@ While the scaffold loading screen is visible (from Phase 2.6 until the Home page } ``` -- `message` — one short present-participle phrase that replaces the cycling status line. Include the grouping context inline when it helps (e.g., `"Creating Footer component (shared components)"`). +- `message` — one short present-participle phrase shown as the status line under the progress bar in the loader (replacing the default "Getting started…" / "Setting up infrastructure…" cycle). Include the grouping context inline when it helps (e.g., `"Creating Footer component (shared components)"`). - `awaitingInput` — when `true`, a prominent pulsing banner appears at the top of the loader. Set this **before** every `AskUserQuestion` call and clear it (`false`) **immediately after** the user answers. - `inputPrompt` — short context for the banner (e.g., `"Choose a framework"`). Optional. diff --git a/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts b/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts index 025b8addc..6a3ab1db4 100644 --- a/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts +++ b/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts @@ -292,10 +292,10 @@ export class HomeComponent implements AfterViewInit, OnDestroy { } const showStatus = (index: number) => { + renderStatusMessage(statuses[index % statuses.length].text) + if (liveOverride) return const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) updatePhaseLabel(phaseLabels[phaseIndex]) - if (liveOverride) return - renderStatusMessage(statuses[index % statuses.length].text) } const advanceStatus = () => { @@ -320,9 +320,13 @@ export class HomeComponent implements AfterViewInit, OnDestroy { liveOverride = true if (data.message !== lastLiveMessage) { lastLiveMessage = data.message - renderStatusMessage(data.message) + updatePhaseLabel(data.message) } } else { + if (liveOverride) { + const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) + } liveOverride = false lastLiveMessage = null } @@ -338,6 +342,10 @@ export class HomeComponent implements AfterViewInit, OnDestroy { if (banner) banner.hidden = !(awaiting && !userDismissed) } } catch { + if (liveOverride) { + const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) + } liveOverride = false lastLiveMessage = null if (lastAwaiting) { diff --git a/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro b/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro index ca7e8262a..6955f5cc0 100644 --- a/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro +++ b/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro @@ -287,10 +287,10 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); } function showStatus(index: number) { + renderStatusMessage(statuses[index].text) + if (liveOverride) return const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) updatePhaseLabel(phaseLabels[phaseIndex]) - if (liveOverride) return - renderStatusMessage(statuses[index].text) } function advanceStatus() { @@ -315,9 +315,13 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); liveOverride = true if (data.message !== lastLiveMessage) { lastLiveMessage = data.message - renderStatusMessage(data.message) + updatePhaseLabel(data.message) } } else { + if (liveOverride) { + const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) + } liveOverride = false lastLiveMessage = null } @@ -333,6 +337,10 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); if (banner) banner.hidden = !(awaiting && !userDismissed) } } catch { + if (liveOverride) { + const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) + } liveOverride = false lastLiveMessage = null if (lastAwaiting) { diff --git a/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx b/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx index 0cb89b18a..46750d8da 100644 --- a/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx +++ b/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx @@ -222,10 +222,10 @@ export default function Home() { } function showStatus(index: number) { + renderStatusMessage(statuses[index].text) + if (liveOverride) return const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) updatePhaseLabel(phaseLabels[phaseIndex]) - if (liveOverride) return - renderStatusMessage(statuses[index].text) } function advanceStatus() { @@ -250,9 +250,13 @@ export default function Home() { liveOverride = true if (data.message !== lastLiveMessage) { lastLiveMessage = data.message - renderStatusMessage(data.message) + updatePhaseLabel(data.message) } } else { + if (liveOverride) { + const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) + } liveOverride = false lastLiveMessage = null } @@ -268,6 +272,10 @@ export default function Home() { if (banner) banner.hidden = !(awaiting && !userDismissed) } } catch { + if (liveOverride) { + const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) + } liveOverride = false lastLiveMessage = null if (lastAwaiting) { diff --git a/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue b/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue index f898e88b0..815ab57af 100644 --- a/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue +++ b/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue @@ -171,10 +171,10 @@ onMounted(() => { } function showStatus(index: number) { + renderStatusMessage(statuses[index].text) + if (liveOverride) return const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) updatePhaseLabel(phaseLabels[phaseIndex]) - if (liveOverride) return - renderStatusMessage(statuses[index].text) } function advanceStatus() { @@ -199,9 +199,13 @@ onMounted(() => { liveOverride = true if (data.message !== lastLiveMessage) { lastLiveMessage = data.message - renderStatusMessage(data.message) + updatePhaseLabel(data.message) } } else { + if (liveOverride) { + const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) + } liveOverride = false lastLiveMessage = null } @@ -217,6 +221,10 @@ onMounted(() => { if (banner) banner.hidden = !(awaiting && !userDismissed) } } catch { + if (liveOverride) { + const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) + } liveOverride = false lastLiveMessage = null if (lastAwaiting) { From dc238afd7fc48f3412f2e5aaa98fef2acaf9fe1a Mon Sep 17 00:00:00 2001 From: Priyanshu Agrawal Date: Wed, 22 Apr 2026 15:20:15 +0530 Subject: [PATCH 4/9] Address PR review feedback: harden plan template against script injection - create-site-plan.html: moved every __*_DATA__ placeholder into its own " inside any string cannot close the containing " into PAGES_DATA and asserts the rendered HTML contains no unescaped after the injection point. All 129 plugin tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scripts/lib/render-template.js | 10 ++++-- .../tests/render-createsite-plan.test.js | 34 +++++++++++++++++++ .../create-site/assets/create-site-plan.html | 33 +++++++++++++----- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/plugins/power-pages/scripts/lib/render-template.js b/plugins/power-pages/scripts/lib/render-template.js index f0fabb587..00b7639e2 100644 --- a/plugins/power-pages/scripts/lib/render-template.js +++ b/plugins/power-pages/scripts/lib/render-template.js @@ -44,11 +44,17 @@ function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requir process.exit(1); } - // Replace all __KEY__ placeholders with corresponding values from the data object + // Replace all __KEY__ placeholders with corresponding values from the data object. + // For non-string values (arrays/objects serialized to JSON), escape `<` as `<` + // so a literal `` inside string data cannot close a containing and < inside JSON data to prevent HTML injection', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'createsite-plan-')); + const outputPath = path.join(tempDir, 'plan-xss.html'); + + const malicious = { + ...SAMPLE_DATA, + PAGES_DATA: [ + { + name: '', + route: '/evil', + description: '', + content: ['line with closing tag'], + components: ['OK'], + }, + ], + }; + + const result = spawnSync( + process.execPath, + [scriptPath, '--output', outputPath, '--data-inline', JSON.stringify(malicious)], + { encoding: 'utf8' } + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const html = fs.readFileSync(outputPath, 'utf8'); + // Raw must NOT appear inside any JSON data blob — it would close the script tag. + // The escaped form is safe: JSON.parse decodes it back to the original string at runtime. + assert.ok( + !/<\/script>[^<]*window\.__pwned/i.test(html), + 'rendered HTML leaks a literal inside injected data' + ); + assert.match(html, /\\u003c\/script>/); +}); + test('render-createsite-plan refuses to overwrite existing file', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'createsite-plan-')); const dataPath = path.join(tempDir, 'data.json'); diff --git a/plugins/power-pages/skills/create-site/assets/create-site-plan.html b/plugins/power-pages/skills/create-site/assets/create-site-plan.html index 18ead4b1b..e626b7c0a 100644 --- a/plugins/power-pages/skills/create-site/assets/create-site-plan.html +++ b/plugins/power-pages/skills/create-site/assets/create-site-plan.html @@ -222,16 +222,31 @@

Deployment options

+ + + + + + + + + + cannot break the plan HTML. - Correct the render-template comment to document \u003c escaping. - Clarify scaffold-status.json message updates the progress-bar label while the spinner keeps its built-in cycle. Also keeps the create-site plan logo aligned with the shared Power Pages icon used by other plans. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- plugins/power-pages/scripts/lib/render-template.js | 2 +- .../power-pages/scripts/render-createsite-plan.js | 13 +++++++++++-- .../scripts/tests/render-createsite-plan.test.js | 13 +++++++++++++ plugins/power-pages/skills/create-site/SKILL.md | 4 ++-- .../skills/create-site/assets/create-site-plan.html | 10 +++++----- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/plugins/power-pages/scripts/lib/render-template.js b/plugins/power-pages/scripts/lib/render-template.js index e99f7efe9..1ea5eec85 100644 --- a/plugins/power-pages/scripts/lib/render-template.js +++ b/plugins/power-pages/scripts/lib/render-template.js @@ -45,7 +45,7 @@ function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requir } // Replace all __KEY__ placeholders with corresponding values from the data object. - // For non-string values (arrays/objects serialized to JSON), escape `<` as `<` + // For non-string values (arrays/objects serialized to JSON), escape `<` as `\u003c` // so a literal `` inside string data cannot close a containing and < inside JSON data to prevent const malicious = { ...SAMPLE_DATA, + SUMMARY: 'Summary with and markup.', PAGES_DATA: [ { name: '', @@ -178,6 +186,11 @@ test('render-createsite-plan escapes and < inside JSON data to prevent !/<\/script>[^<]*window\.__pwned/i.test(html), 'rendered HTML leaks a literal inside injected data' ); + assert.ok( + !html.includes(''), + 'rendered HTML leaks a literal from SUMMARY' + ); + assert.match(html, /"text":"Summary with \\u003c\/script>\\u003cscript>window\.__summaryPwned=1;\\u003c\/script>/); assert.match(html, /\\u003c\/script>/); }); diff --git a/plugins/power-pages/skills/create-site/SKILL.md b/plugins/power-pages/skills/create-site/SKILL.md index 7a3fd0f37..62f2df6fc 100644 --- a/plugins/power-pages/skills/create-site/SKILL.md +++ b/plugins/power-pages/skills/create-site/SKILL.md @@ -23,7 +23,7 @@ Guide the user through creating a complete, production-quality Power Pages code - **Use TaskCreate/TaskUpdate**: Track all progress throughout all phases — create the todo list upfront with all phases before starting any work. - **Scaffold early, design with intention**: Get the dev server running immediately after discovery so the user has something to look at. Then plan the design and features while the scaffold is live — apply the chosen aesthetic during implementation. - **Live preview feedback loop**: The dev server MUST be running before any customization begins. Browse the site via Playwright (`browser_navigate` + `browser_snapshot`) to verify every significant change. Do NOT take screenshots — only use accessibility snapshots to check page structure and content. -- **Keep the scaffold loader in sync with reality**: The scaffold loader polls `public/scaffold-status.json` and mirrors whatever is written there. Update this file before every `AskUserQuestion` (to raise the "waiting for your input" banner so the user doesn't miss a terminal prompt) and before each implementation step in Phase 5 (so the status line matches what you're actually doing, not the hardcoded placeholder cycle). See [Live Preview Status Protocol](#live-preview-status-protocol). +- **Keep the scaffold loader in sync with reality**: The scaffold loader polls `public/scaffold-status.json`. Update this file before every `AskUserQuestion` (to raise the "waiting for your input" banner so the user doesn't miss a terminal prompt) and before each implementation step in Phase 5 (so the progress-bar label matches what you're actually doing while the decorative spinner continues its default cycle). See [Live Preview Status Protocol](#live-preview-status-protocol). - **Use real images**: Source high-quality photos from Unsplash wherever pages need visual content — hero sections, feature cards, about pages, backgrounds, etc. Use `https://images.unsplash.com/photo-{id}?w={width}&h={height}&fit=crop` URLs with specific photo IDs found via `WebSearch`. Never leave image placeholders or broken `` tags pointing to nonexistent files. - **Git checkpoints**: Commit after every individual page and component — each gets its own commit so breaking changes can be reverted. @@ -35,7 +35,7 @@ Guide the user through creating a complete, production-quality Power Pages code ## Live Preview Status Protocol -While the scaffold loading screen is visible (from Phase 2.6 until the Home page itself is replaced in Phase 5), the loader polls `GET /scaffold-status.json` every 1.5 seconds. Whatever you write into `/public/scaffold-status.json` is what the user sees — the status line and the "waiting for your input" banner. If you do not update this file, the loader falls back to hardcoded placeholder phrases ("Installing dependencies…") which are misleading once the scaffold is done. +While the scaffold loading screen is visible (from Phase 2.6 until the Home page itself is replaced in Phase 5), the loader polls `GET /scaffold-status.json` every 1.5 seconds. The `message` you write into `/public/scaffold-status.json` appears as the label under the progress bar, and `awaitingInput` controls the "waiting for your input" banner. The decorative spinner above the progress bar continues its built-in phrase cycle; keep the progress-bar label current so the loader still reflects what is actually happening. **Why this matters**: When the browser with the loader takes over the user's screen, a prompt in the terminal can sit unanswered for a long time because the user doesn't realize anything is waiting. The banner makes it obvious. diff --git a/plugins/power-pages/skills/create-site/assets/create-site-plan.html b/plugins/power-pages/skills/create-site/assets/create-site-plan.html index e626b7c0a..8438af01a 100644 --- a/plugins/power-pages/skills/create-site/assets/create-site-plan.html +++ b/plugins/power-pages/skills/create-site/assets/create-site-plan.html @@ -19,7 +19,7 @@ body{font-family:var(--sans);background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;} .topbar{z-index:100;background:var(--surface);box-shadow:var(--shadow-4);padding:14px 28px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;position:sticky;top:0;} .topbar-left{display:flex;align-items:center;gap:14px;} -.logo{width:36px;height:36px;border-radius:var(--radius);background:linear-gradient(135deg,#7b5ea7,#5a9bd5);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:800;color:#fff;font-family:var(--mono);letter-spacing:-0.5px;} +.logo{width:36px;height:36px;object-fit:contain;display:block;flex-shrink:0;} .topbar-title{font-size:16px;font-weight:700;color:var(--text-bright);} .topbar-sub{font-size:11px;color:var(--text-dim);margin-top:1px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;} .topbar-sep{opacity:0.5;} @@ -128,7 +128,7 @@
- +
__PLAN_TITLE__
@@ -160,7 +160,7 @@

Overview

Implementation plan for __SITE_NAME__

- +
0
Pages
@@ -271,8 +271,8 @@

Deployment options

}); }); -// --- Summary (read from text/plain rawtext so &, <, > are safe) --- -document.getElementById('summaryBox').textContent = document.getElementById('summaryRaw').textContent; +// --- Summary --- +document.getElementById('summaryBox').textContent = JSON.parse(document.getElementById('summaryData').textContent).text || ''; // --- Overview stats --- document.getElementById('statPages').textContent = PAGES.length; From 3e02f9168d51dc2bd5c29bc82a80d560c4930beb Mon Sep 17 00:00:00 2001 From: Priyanshu Agrawal Date: Wed, 29 Apr 2026 09:39:50 +0530 Subject: [PATCH 7/9] restore scaffold toast dismiss Restore the close button for create-site scaffold input banners across React, Vue, Angular, and Astro loaders. - Track dismissed prompts so closing the banner hides only the current prompt - Reset dismissal when awaitingInput clears so later prompts show again - Update scaffold loader coverage to require dismissible persistent banners Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/create-site-scaffold-loader.test.js | 11 ++++++++--- .../angular/src/app/pages/home.component.ts | 18 +++++++++++++++++- .../assets/astro/src/pages/index.astro | 15 ++++++++++++++- .../assets/react/src/pages/Home.tsx | 16 +++++++++++++++- .../create-site/assets/vue/src/pages/Home.vue | 16 +++++++++++++++- 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/plugins/power-pages/scripts/tests/create-site-scaffold-loader.test.js b/plugins/power-pages/scripts/tests/create-site-scaffold-loader.test.js index c5f9f411f..201f5d50f 100644 --- a/plugins/power-pages/scripts/tests/create-site-scaffold-loader.test.js +++ b/plugins/power-pages/scripts/tests/create-site-scaffold-loader.test.js @@ -11,12 +11,17 @@ const loaderTemplates = [ 'astro/src/pages/index.astro', ]; -test('create-site loader keeps awaiting-input banner persistent across templates', () => { +test('create-site loader keeps awaiting-input banner persistent and dismissible across templates', () => { for (const template of loaderTemplates) { const content = fs.readFileSync(path.join(createSiteRoot, template), 'utf8'); - assert.match(content, /if \(banner\) banner\.hidden = !awaiting/, template); + assert.match(content, /id="inputBannerClose"/, template); + assert.match(content, /input-banner-close/, template); + assert.match(content, /aria-label="Dismiss notification"/, template); + assert.match(content, /dismissedPrompt/, template); + assert.match(content, /addEventListener\('click', dismissInputBanner\)/, template); + assert.match(content, /if \(banner\) banner\.hidden = !awaiting \|\| dismissedPrompt === prompt/, template); + assert.match(content, /if \(!awaiting\) dismissedPrompt = null/, template); assert.match(content, /if \(!lastAwaiting\)/, template); - assert.doesNotMatch(content, /inputBannerClose|input-banner-close|userDismissed|dismissedPrompt/, template); } }); diff --git a/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts b/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts index 00e039d72..f041cc2af 100644 --- a/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts +++ b/plugins/power-pages/skills/create-site/assets/angular/src/app/pages/home.component.ts @@ -116,6 +116,8 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); .input-banner-text { line-height: 1.4; flex: 1; min-width: 0; } .input-banner-text b { font-weight: 600; display: block; letter-spacing: 0.3px; } .input-banner-text small { display: block; font-size: 12px; font-weight: 400; opacity: 0.8; margin-top: 2px; } +.input-banner-close { width: 24px; height: 24px; border: none; border-radius: 50%; background: rgba(92, 61, 0, 0.12); color: #5C3D00; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; line-height: 1; flex-shrink: 0; transition: background 0.2s ease, transform 0.2s ease; } +.input-banner-close:hover { background: rgba(92, 61, 0, 0.2); transform: scale(1.05); } @keyframes bannerPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } @keyframes bannerEnter { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } } @media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } .input-banner { top: 12px; right: 12px; padding: 10px 12px 10px 16px; font-size: 13px; max-width: calc(100vw - 24px); } } @@ -134,6 +136,7 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); Waiting for your input Please check your terminal to respond.
+
@@ -189,6 +192,7 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); export class HomeComponent implements AfterViewInit, OnDestroy { private intervals: number[] = [] private timeouts: number[] = [] + private inputBannerCloseCleanup: (() => void) | null = null ngAfterViewInit() { const particlesEl = document.getElementById('particles') @@ -196,6 +200,7 @@ export class HomeComponent implements AfterViewInit, OnDestroy { const statusArea = document.getElementById('statusArea') const progressLabel = document.getElementById('progressLabel') const tipTextEl = document.getElementById('tipText') + const inputBannerClose = document.getElementById('inputBannerClose') // Particles const createParticle = () => { @@ -250,6 +255,15 @@ export class HomeComponent implements AfterViewInit, OnDestroy { let lastLiveMessage: string | null = null let lastAwaiting = false let lastPrompt: string | null = null + let dismissedPrompt: string | null = null + + const dismissInputBanner = () => { + const banner = document.getElementById('inputBanner') + dismissedPrompt = lastPrompt || 'Please check your terminal to respond.' + if (banner) banner.hidden = true + } + inputBannerClose?.addEventListener('click', dismissInputBanner) + this.inputBannerCloseCleanup = () => inputBannerClose?.removeEventListener('click', dismissInputBanner) const renderStatusMessage = (text: string) => { if (!statusArea) return @@ -318,12 +332,13 @@ export class HomeComponent implements AfterViewInit, OnDestroy { const prompt = data.inputPrompt || 'Please check your terminal to respond.' const awaitingChanged = awaiting !== lastAwaiting const promptChanged = prompt !== lastPrompt + if (!awaiting) dismissedPrompt = null if (awaitingChanged || promptChanged) { lastAwaiting = awaiting lastPrompt = prompt if (promptEl) promptEl.textContent = prompt } - if (banner) banner.hidden = !awaiting + if (banner) banner.hidden = !awaiting || dismissedPrompt === prompt } catch { if (liveOverride) { const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) @@ -376,6 +391,7 @@ export class HomeComponent implements AfterViewInit, OnDestroy { } ngOnDestroy() { + this.inputBannerCloseCleanup?.() this.intervals.forEach(id => clearInterval(id)) this.timeouts.forEach(id => clearTimeout(id)) } diff --git a/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro b/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro index a1d4cf6bd..6b1d65695 100644 --- a/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro +++ b/plugins/power-pages/skills/create-site/assets/astro/src/pages/index.astro @@ -16,6 +16,7 @@ import Layout from '../layouts/Layout.astro' Waiting for your input Please check your terminal to respond.
+
@@ -180,6 +181,8 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); .input-banner-text { line-height: 1.4; flex: 1; min-width: 0; } .input-banner-text b { font-weight: 600; display: block; letter-spacing: 0.3px; } .input-banner-text small { display: block; font-size: 12px; font-weight: 400; opacity: 0.8; margin-top: 2px; } +.input-banner-close { width: 24px; height: 24px; border: none; border-radius: 50%; background: rgba(92, 61, 0, 0.12); color: #5C3D00; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; line-height: 1; flex-shrink: 0; transition: background 0.2s ease, transform 0.2s ease; } +.input-banner-close:hover { background: rgba(92, 61, 0, 0.2); transform: scale(1.05); } @keyframes bannerPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } @keyframes bannerEnter { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } } @media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } .input-banner { top: 12px; right: 12px; padding: 10px 12px 10px 16px; font-size: 13px; max-width: calc(100vw - 24px); } } @@ -191,6 +194,7 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); const statusArea = document.getElementById('statusArea') const progressLabel = document.getElementById('progressLabel') const tipTextEl = document.getElementById('tipText') + const inputBannerClose = document.getElementById('inputBannerClose') // Particles function createParticle() { @@ -245,6 +249,14 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); let lastLiveMessage: string | null = null let lastAwaiting = false let lastPrompt: string | null = null + let dismissedPrompt: string | null = null + + const dismissInputBanner = () => { + const banner = document.getElementById('inputBanner') + dismissedPrompt = lastPrompt || 'Please check your terminal to respond.' + if (banner) banner.hidden = true + } + inputBannerClose?.addEventListener('click', dismissInputBanner) function renderStatusMessage(text: string) { if (!statusArea) return @@ -313,12 +325,13 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); const prompt = data.inputPrompt || 'Please check your terminal to respond.' const awaitingChanged = awaiting !== lastAwaiting const promptChanged = prompt !== lastPrompt + if (!awaiting) dismissedPrompt = null if (awaitingChanged || promptChanged) { lastAwaiting = awaiting lastPrompt = prompt if (promptEl) promptEl.textContent = prompt } - if (banner) banner.hidden = !awaiting + if (banner) banner.hidden = !awaiting || dismissedPrompt === prompt } catch { if (liveOverride) { const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) diff --git a/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx b/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx index 75ef8ce04..8962e8458 100644 --- a/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx +++ b/plugins/power-pages/skills/create-site/assets/react/src/pages/Home.tsx @@ -112,6 +112,8 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); .input-banner-text { line-height: 1.4; flex: 1; min-width: 0; } .input-banner-text b { font-weight: 600; display: block; letter-spacing: 0.3px; } .input-banner-text small { display: block; font-size: 12px; font-weight: 400; opacity: 0.8; margin-top: 2px; } +.input-banner-close { width: 24px; height: 24px; border: none; border-radius: 50%; background: rgba(92, 61, 0, 0.12); color: #5C3D00; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; line-height: 1; flex-shrink: 0; transition: background 0.2s ease, transform 0.2s ease; } +.input-banner-close:hover { background: rgba(92, 61, 0, 0.2); transform: scale(1.05); } @keyframes bannerPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } @keyframes bannerEnter { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } } @media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } .input-banner { top: 12px; right: 12px; padding: 10px 12px 10px 16px; font-size: 13px; max-width: calc(100vw - 24px); } } @@ -127,6 +129,7 @@ export default function Home() { const statusArea = document.getElementById('statusArea') const progressLabel = document.getElementById('progressLabel') const tipTextEl = document.getElementById('tipText') + const inputBannerClose = document.getElementById('inputBannerClose') // Particles function createParticle() { @@ -181,6 +184,14 @@ export default function Home() { let lastLiveMessage: string | null = null let lastAwaiting = false let lastPrompt: string | null = null + let dismissedPrompt: string | null = null + + const dismissInputBanner = () => { + const banner = document.getElementById('inputBanner') + dismissedPrompt = lastPrompt || 'Please check your terminal to respond.' + if (banner) banner.hidden = true + } + inputBannerClose?.addEventListener('click', dismissInputBanner) function renderStatusMessage(text: string) { if (!statusArea) return @@ -249,12 +260,13 @@ export default function Home() { const prompt = data.inputPrompt || 'Please check your terminal to respond.' const awaitingChanged = awaiting !== lastAwaiting const promptChanged = prompt !== lastPrompt + if (!awaiting) dismissedPrompt = null if (awaitingChanged || promptChanged) { lastAwaiting = awaiting lastPrompt = prompt if (promptEl) promptEl.textContent = prompt } - if (banner) banner.hidden = !awaiting + if (banner) banner.hidden = !awaiting || dismissedPrompt === prompt } catch { if (liveOverride) { const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) @@ -306,6 +318,7 @@ export default function Home() { intervals.push(window.setInterval(showTip, 12000)) return () => { + inputBannerClose?.removeEventListener('click', dismissInputBanner) intervals.forEach(id => clearInterval(id)) timeouts.forEach(id => clearTimeout(id)) } @@ -327,6 +340,7 @@ export default function Home() { Waiting for your input Please check your terminal to respond.
+
diff --git a/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue b/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue index f64d393ad..3a8b12587 100644 --- a/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue +++ b/plugins/power-pages/skills/create-site/assets/vue/src/pages/Home.vue @@ -12,6 +12,7 @@ Waiting for your input Please check your terminal to respond.
+
@@ -78,6 +79,7 @@ onMounted(() => { const statusArea = document.getElementById('statusArea') const progressLabel = document.getElementById('progressLabel') const tipTextEl = document.getElementById('tipText') + const inputBannerClose = document.getElementById('inputBannerClose') // Particles function createParticle() { @@ -132,6 +134,14 @@ onMounted(() => { let lastLiveMessage: string | null = null let lastAwaiting = false let lastPrompt: string | null = null + let dismissedPrompt: string | null = null + + const dismissInputBanner = () => { + const banner = document.getElementById('inputBanner') + dismissedPrompt = lastPrompt || 'Please check your terminal to respond.' + if (banner) banner.hidden = true + } + inputBannerClose?.addEventListener('click', dismissInputBanner) function renderStatusMessage(text: string) { if (!statusArea) return @@ -200,12 +210,13 @@ onMounted(() => { const prompt = data.inputPrompt || 'Please check your terminal to respond.' const awaitingChanged = awaiting !== lastAwaiting const promptChanged = prompt !== lastPrompt + if (!awaiting) dismissedPrompt = null if (awaitingChanged || promptChanged) { lastAwaiting = awaiting lastPrompt = prompt if (promptEl) promptEl.textContent = prompt } - if (banner) banner.hidden = !awaiting + if (banner) banner.hidden = !awaiting || dismissedPrompt === prompt } catch { if (liveOverride) { const phaseIndex = Math.min(Math.floor((currentStatusIndex / statuses.length) * phaseLabels.length), phaseLabels.length - 1) @@ -257,6 +268,7 @@ onMounted(() => { intervals.push(window.setInterval(showTip, 12000)) cleanupFn = () => { + inputBannerClose?.removeEventListener('click', dismissInputBanner) intervals.forEach(id => clearInterval(id)) timeouts.forEach(id => clearTimeout(id)) } @@ -379,6 +391,8 @@ html, body { overflow: hidden; background: var(--pp-bg); color: var(--pp-text); .input-banner-text { line-height: 1.4; flex: 1; min-width: 0; } .input-banner-text b { font-weight: 600; display: block; letter-spacing: 0.3px; } .input-banner-text small { display: block; font-size: 12px; font-weight: 400; opacity: 0.8; margin-top: 2px; } +.input-banner-close { width: 24px; height: 24px; border: none; border-radius: 50%; background: rgba(92, 61, 0, 0.12); color: #5C3D00; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; line-height: 1; flex-shrink: 0; transition: background 0.2s ease, transform 0.2s ease; } +.input-banner-close:hover { background: rgba(92, 61, 0, 0.2); transform: scale(1.05); } @keyframes bannerPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } @keyframes bannerEnter { from { opacity: 0; transform: translateY(-12px); } to { opacity: 1; transform: translateY(0); } } @media (max-width: 600px) { .main-heading { font-size: 24px; } .progress-container { width: 260px; } .feature-cards { flex-direction: column; align-items: center; } .orbit-system { width: 180px; height: 180px; } .input-banner { top: 12px; right: 12px; padding: 10px 12px 10px 16px; font-size: 13px; max-width: calc(100vw - 24px); } } From d3b0e9b598431d32b997934e8abeaeeba97852a4 Mon Sep 17 00:00:00 2001 From: Priyanshu Agrawal Date: Wed, 29 Apr 2026 09:42:20 +0530 Subject: [PATCH 8/9] address PR review feedback Addressed review comments: - Change the create-site plan render command fence from powershell to bash. - Replace the PowerShell-only Start-Process browser-opening example with shell-neutral guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- plugins/power-pages/skills/create-site/SKILL.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugins/power-pages/skills/create-site/SKILL.md b/plugins/power-pages/skills/create-site/SKILL.md index 62f2df6fc..fe88aa8f3 100644 --- a/plugins/power-pages/skills/create-site/SKILL.md +++ b/plugins/power-pages/skills/create-site/SKILL.md @@ -308,7 +308,7 @@ Assemble a single JSON object with the following keys. The plan template rejects Pick an output path under `/docs/`. Default is `create-site-plan.html`; if that file already exists, pick a descriptive variant like `create-site-plan-v2.html` (the render script refuses to overwrite existing files). -```powershell +```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/render-createsite-plan.js" --output "/docs/create-site-plan.html" --data-inline '' ``` @@ -318,11 +318,7 @@ The script prints `{"status":"ok","output":""}` on success. Capture and us ### 4.4 Open the Plan in the Default Browser -Use the platform-appropriate command for the current shell: - -- macOS (`Bash`): `open ""` -- Linux (`Bash`): `xdg-open ""` -- Windows (`PowerShell`): `Start-Process ""` +Open `` in the default browser using the platform-appropriate file opener for the current environment. For example, use `open` on macOS, `xdg-open` on Linux, or the equivalent default-browser opener available on Windows. ### 4.5 Present a Brief Summary in the Terminal From 794c1cc59c36c1a697e9aeda48616ac571f6f676 Mon Sep 17 00:00:00 2001 From: Priyanshu Agrawal Date: Wed, 29 Apr 2026 09:49:22 +0530 Subject: [PATCH 9/9] address PR review feedback Addressed review comments: - Add user-facing invalid JSON handling for render-createsite-plan --data files. - Escape create-site plan string placeholders used in HTML text contexts. - Align new tests with node:assert/strict and node:* import conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/lib/render-template.js | 16 +++++-- .../scripts/render-createsite-plan.js | 31 +++++++++++-- .../tests/create-site-scaffold-loader.test.js | 6 +-- .../tests/launch-playwright-mcp.test.js | 8 ++-- .../tests/render-createsite-plan.test.js | 46 +++++++++++++++++++ 5 files changed, 93 insertions(+), 14 deletions(-) diff --git a/plugins/power-pages/scripts/lib/render-template.js b/plugins/power-pages/scripts/lib/render-template.js index 1ea5eec85..05d393d85 100644 --- a/plugins/power-pages/scripts/lib/render-template.js +++ b/plugins/power-pages/scripts/lib/render-template.js @@ -17,8 +17,9 @@ const path = require('path'); * @param {string} [options.dataPath] - Absolute path to a JSON data file. Ignored if dataObject is provided. * @param {Object} [options.dataObject] - Data object passed directly. If provided, takes precedence over dataPath. * @param {string[]} options.requiredKeys - Keys that must be present in the data + * @param {boolean} [options.escapeStringValues=false] - Escape string values for HTML text contexts */ -function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requiredKeys }) { +function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requiredKeys, escapeStringValues = false }) { // Validate inputs exist if (!fs.existsSync(templatePath)) { console.error(`Template not found: ${templatePath}`); @@ -47,13 +48,13 @@ function renderTemplate({ templatePath, outputPath, dataPath, dataObject, requir // Replace all __KEY__ placeholders with corresponding values from the data object. // For non-string values (arrays/objects serialized to JSON), escape `<` as `\u003c` // so a literal `` inside string data cannot close a containing ', + PLAN_TITLE: 'Plan bold', + FRAMEWORK: 'React ', + AESTHETIC: 'Minimal & Clean ', + MOOD: 'Professional > Casual', + }; + + const result = spawnSync( + process.execPath, + [scriptPath, '--output', outputPath, '--data-inline', JSON.stringify(unsafe)], + { encoding: 'utf8' } + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + const html = fs.readFileSync(outputPath, 'utf8'); + assert.doesNotMatch(html, /