diff --git a/plugins/power-pages/scripts/launch-playwright-mcp.js b/plugins/power-pages/scripts/launch-playwright-mcp.js index 403e626ce..dd46c29c6 100644 --- a/plugins/power-pages/scripts/launch-playwright-mcp.js +++ b/plugins/power-pages/scripts/launch-playwright-mcp.js @@ -6,12 +6,31 @@ // Self-contained — no external dependencies required. const { spawn } = require('child_process'); +const path = require('path'); const { detectBrowser } = require('./lib/detect-browser'); -const browser = detectBrowser(); -const child = spawn('npx', ['@playwright/mcp@latest', '--browser', browser, '--viewport-size', '1024,768'], { - stdio: 'inherit', - shell: true, -}); +function buildMcpArgs(browser) { + return [ + '@playwright/mcp@latest', + '--browser', + browser, + '--config', + path.join(__dirname, 'playwright-mcp-fullscreen.config.json'), + ]; +} -child.on('exit', (code) => process.exit(code || 0)); +function launch({ browser = detectBrowser(), spawnFn = spawn, onExit = (code) => process.exit(code || 0) } = {}) { + const child = spawnFn('npx', buildMcpArgs(browser), { + stdio: 'inherit', + shell: true, + }); + + child.on('exit', onExit); + return child; +} + +if (require.main === module) { + launch(); +} + +module.exports = { buildMcpArgs, launch }; diff --git a/plugins/power-pages/scripts/lib/render-template.js b/plugins/power-pages/scripts/lib/render-template.js index 520d908e6..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}`); @@ -44,11 +45,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 `\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, / 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, + SUMMARY: 'Summary with and markup.', + 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.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>/); +}); + +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 ee50f95c6..fe88aa8f3 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`. 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. @@ -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. 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. + +**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 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 and stays visible until this field is cleared. 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,86 @@ 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. - - **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. +### 4.3 Render the HTML Plan -**Output**: Approved implementation plan +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). + +```bash +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 + +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 + +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 +381,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 +451,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 +680,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..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 @@ -110,7 +110,17 @@ 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 { 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); } } `], template: ` <div class="loading-wrapper"> @@ -120,6 +130,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 notification">×</button> + </div> + <div class="center-stage" id="centerStage"> <div class="orbit-system"> <div class="core-glow"></div> @@ -173,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') @@ -180,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 = () => { @@ -230,8 +251,21 @@ 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 dismissedPrompt: string | null = null - const showStatus = (index: number) => { + 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 const existing = statusArea.querySelector('.status-message') if (existing) { @@ -244,15 +278,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) => { + 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]) } const advanceStatus = () => { @@ -266,6 +307,54 @@ 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?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) { + liveOverride = true + if (data.message !== lastLiveMessage) { + lastLiveMessage = 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 + } + const awaiting = !!data.awaitingInput + 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 || dismissedPrompt === prompt + } 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) { + lastPrompt = 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 => { @@ -302,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 2c7f65a63..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 @@ -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 notification">×</button> + </div> + <div class="center-stage" id="centerStage"> <div class="orbit-system"> <div class="core-glow"></div> @@ -166,7 +175,17 @@ 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 { 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); } } </style> <script> @@ -175,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() { @@ -225,8 +245,20 @@ 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 dismissedPrompt: string | null = null - function showStatus(index: number) { + 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 const existing = statusArea.querySelector('.status-message') if (existing) { @@ -239,15 +271,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) { + renderStatusMessage(statuses[index].text) + if (liveOverride) return + const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) } function advanceStatus() { @@ -261,6 +300,54 @@ 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?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) { + liveOverride = true + if (data.message !== lastLiveMessage) { + lastLiveMessage = 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 + } + const awaiting = !!data.awaitingInput + 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 || dismissedPrompt === prompt + } 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) { + lastPrompt = 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..8438af01a --- /dev/null +++ b/plugins/power-pages/skills/create-site/assets/create-site-plan.html @@ -0,0 +1,391 @@ +<!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__

+ +
+ + +
+
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..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 @@ -106,7 +106,17 @@ 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 { 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); } } ` export default function Home() { @@ -119,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() { @@ -169,8 +180,20 @@ export default function Home() { ] let currentStatusIndex = 0 + let liveOverride = false + let lastLiveMessage: string | null = null + let lastAwaiting = false + let lastPrompt: string | null = null + let dismissedPrompt: string | null = null - function showStatus(index: number) { + 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 const existing = statusArea.querySelector('.status-message') if (existing) { @@ -183,15 +206,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) { + renderStatusMessage(statuses[index].text) + if (liveOverride) return + const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) } function advanceStatus() { @@ -205,6 +235,54 @@ 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?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) { + liveOverride = true + if (data.message !== lastLiveMessage) { + lastLiveMessage = 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 + } + const awaiting = !!data.awaitingInput + 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 || dismissedPrompt === prompt + } 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) { + lastPrompt = 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 => { @@ -240,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)) } @@ -255,6 +334,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..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 @@ -6,6 +6,15 @@
+ +
@@ -70,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() { @@ -120,8 +130,20 @@ onMounted(() => { ] let currentStatusIndex = 0 + let liveOverride = false + let lastLiveMessage: string | null = null + let lastAwaiting = false + let lastPrompt: string | null = null + let dismissedPrompt: string | null = null - function showStatus(index: number) { + 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 const existing = statusArea.querySelector('.status-message') if (existing) { @@ -134,15 +156,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) { + renderStatusMessage(statuses[index].text) + if (liveOverride) return + const phaseIndex = Math.min(Math.floor((index / statuses.length) * phaseLabels.length), phaseLabels.length - 1) + updatePhaseLabel(phaseLabels[phaseIndex]) } function advanceStatus() { @@ -156,6 +185,54 @@ 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?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) { + liveOverride = true + if (data.message !== lastLiveMessage) { + lastLiveMessage = 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 + } + const awaiting = !!data.awaitingInput + 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 || dismissedPrompt === prompt + } 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) { + lastPrompt = 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 => { @@ -191,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)) } @@ -307,5 +385,15 @@ 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 { 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); } }