diff --git a/.agents/skills/validate-sas-release/SKILL.md b/.agents/skills/validate-sas-release/SKILL.md new file mode 100644 index 00000000..fac01170 --- /dev/null +++ b/.agents/skills/validate-sas-release/SKILL.md @@ -0,0 +1,12 @@ +--- +name: validate-sas-release +description: Validate Super Agent Skill changes before publication, using the existing local checks without Lovable generation credits. +--- + +Run `npm ci` when locked dependencies changed. Run `npm run check:project` from the repository root. This batches tests, types, dependency audit, build, lint regression checking, content validation and both generated-artifact checks. It writes actual exit codes and logs to `artifacts/validation/`; failures block release. + +Use `npm run check:project -- --security` with the existing Supabase publishable environment to include RLS and anonymous-access checks. Never expose credentials or weaken allowlists to pass. With a local dev server running, add `--ui` to test signup at mobile and desktop sizes with intercepted auth; review its screenshots personally. + +`npm run lint:strict` retains all ESLint errors. The normal lint command permits only the recorded pre-existing explicit-any debt per file; it blocks increases and all other errors. Never increase that baseline to make a change pass. Report outstanding debt explicitly. + +Check version-specific trust evidence, query-preserving auth redirects and MCP outcome semantics when those flows change. No model evaluations, signup creation, agent requests, provisioning, paid operations or production writes are part of this batch. Inspect and validate any additive SQL separately. Publish only after checks pass and publication is user-authorized; verify that Lovable's commit matches the validated code before using direct deployment. Preserve approval, signature and evidence gates. diff --git a/.claude/skills/brand/scripts/extract-colors.cjs b/.claude/skills/brand/scripts/extract-colors.cjs index a2ec2b43..81017007 100755 --- a/.claude/skills/brand/scripts/extract-colors.cjs +++ b/.claude/skills/brand/scripts/extract-colors.cjs @@ -117,9 +117,7 @@ function colorDistance(color1, color2) { if (!rgb1 || !rgb2) return Infinity; return Math.sqrt( - Math.pow(rgb1.r - rgb2.r, 2) + - Math.pow(rgb1.g - rgb2.g, 2) + - Math.pow(rgb1.b - rgb2.b, 2) + Math.pow(rgb1.r - rgb2.r, 2) + Math.pow(rgb1.g - rgb2.g, 2) + Math.pow(rgb1.b - rgb2.b, 2), ); } @@ -235,12 +233,9 @@ function main() { const jsonOutput = args.includes("--json"); const showPalette = args.includes("--palette"); const brandFileIdx = args.indexOf("--brand-file"); - const brandFile = - brandFileIdx !== -1 ? args[brandFileIdx + 1] : DEFAULT_GUIDELINES_PATH; + const brandFile = brandFileIdx !== -1 ? args[brandFileIdx + 1] : DEFAULT_GUIDELINES_PATH; const brandFileValue = brandFileIdx !== -1 ? args[brandFileIdx + 1] : null; - const imagePath = args.find( - (a) => !a.startsWith("--") && a !== brandFileValue - ); + const imagePath = args.find((a) => !a.startsWith("--") && a !== brandFileValue); // Load brand palette const brandPalette = parseBrandColors(brandFile); @@ -269,9 +264,7 @@ function main() { } // Resolve image path - const resolvedPath = path.isAbsolute(imagePath) - ? imagePath - : path.join(process.cwd(), imagePath); + const resolvedPath = path.isAbsolute(imagePath) ? imagePath : path.join(process.cwd(), imagePath); if (!fs.existsSync(resolvedPath)) { console.error(`Image not found: ${resolvedPath}`); @@ -297,8 +290,7 @@ function main() { ], complianceCheck: { threshold: 50, - description: - "Colors within distance 50 (RGB space) are considered brand-compliant", + description: "Colors within distance 50 (RGB space) are considered brand-compliant", brandColors: brandPalette.all, }, }; diff --git a/.claude/skills/brand/scripts/inject-brand-context.cjs b/.claude/skills/brand/scripts/inject-brand-context.cjs index 04073071..920982af 100755 --- a/.claude/skills/brand/scripts/inject-brand-context.cjs +++ b/.claude/skills/brand/scripts/inject-brand-context.cjs @@ -38,18 +38,10 @@ function extractColorsFromTable(content) { }; // Find color tables - const primaryMatch = content.match( - /### Primary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i - ); - const secondaryMatch = content.match( - /### Secondary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i - ); - const neutralMatch = content.match( - /### Neutral[\s\S]*?\|[\s\S]*?(?=###|$)/i - ); - const semanticMatch = content.match( - /### Semantic[\s\S]*?\|[\s\S]*?(?=###|$)/i - ); + const primaryMatch = content.match(/### Primary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i); + const secondaryMatch = content.match(/### Secondary Colors[\s\S]*?\|[\s\S]*?(?=###|$)/i); + const neutralMatch = content.match(/### Neutral[\s\S]*?\|[\s\S]*?(?=###|$)/i); + const semanticMatch = content.match(/### Semantic[\s\S]*?\|[\s\S]*?(?=###|$)/i); if (primaryMatch) colors.primary = extractHexColors(primaryMatch[0]); if (secondaryMatch) colors.secondary = extractHexColors(secondaryMatch[0]); @@ -103,25 +95,21 @@ function extractVoice(content) { }; // Extract personality traits from table - const personalityMatch = content.match( - /### Brand Personality[\s\S]*?\|[\s\S]*?(?=###|##|$)/i - ); + const personalityMatch = content.match(/### Brand Personality[\s\S]*?\|[\s\S]*?(?=###|##|$)/i); if (personalityMatch) { - const traits = personalityMatch[0].match( - /\*\*([^*]+)\*\*\s*\|\s*([^|]+)/g - ); + const traits = personalityMatch[0].match(/\*\*([^*]+)\*\*\s*\|\s*([^|]+)/g); if (traits) { - voice.traits = traits.map((t) => { - const match = t.match(/\*\*([^*]+)\*\*/); - return match ? match[1].trim() : ""; - }).filter(Boolean); + voice.traits = traits + .map((t) => { + const match = t.match(/\*\*([^*]+)\*\*/); + return match ? match[1].trim() : ""; + }) + .filter(Boolean); } } // Extract prohibited terms - const prohibitedMatch = content.match( - /### Prohibited[\s\S]*?(?=###|##|$)/i - ); + const prohibitedMatch = content.match(/### Prohibited[\s\S]*?(?=###|##|$)/i); if (prohibitedMatch) { const terms = prohibitedMatch[0].match(/\|\s*([^|]+)\s*\|/g); if (terms) { @@ -132,15 +120,11 @@ function extractVoice(content) { } // Fallback: look for Forbidden Phrases - const forbiddenMatch = content.match( - /### Forbidden Phrases[\s\S]*?(?=###|##|$)/i - ); + const forbiddenMatch = content.match(/### Forbidden Phrases[\s\S]*?(?=###|##|$)/i); if (forbiddenMatch && voice.prohibited.length === 0) { const items = forbiddenMatch[0].match(/-\s*["']?([^"'\n(]+)/g); if (items) { - voice.prohibited = items - .map((item) => item.replace(/^-\s*["']?/, "").trim()) - .filter(Boolean); + voice.prohibited = items.map((item) => item.replace(/^-\s*["']?/, "").trim()).filter(Boolean); } } @@ -155,13 +139,9 @@ function extractVoice(content) { function extractCoreAttributes(content) { const attributes = []; - const attributesMatch = content.match( - /### Core Attributes[\s\S]*?\|[\s\S]*?(?=###|##|$)/i - ); + const attributesMatch = content.match(/### Core Attributes[\s\S]*?\|[\s\S]*?(?=###|##|$)/i); if (attributesMatch) { - const rows = attributesMatch[0].match( - /\|\s*\*\*([^*]+)\*\*\s*\|\s*([^|]+)\|/g - ); + const rows = attributesMatch[0].match(/\|\s*\*\*([^*]+)\*\*\s*\|\s*([^|]+)\|/g); if (rows) { rows.forEach((row) => { const match = row.match(/\*\*([^*]+)\*\*\s*\|\s*([^|]+)/); @@ -191,24 +171,23 @@ function extractImageStyle(content) { }; // Extract base prompt template (content between ``` blocks after "Base Prompt Template") - const basePromptMatch = content.match( - /### Base Prompt Template[\s\S]*?```\n?([\s\S]*?)```/i - ); + const basePromptMatch = content.match(/### Base Prompt Template[\s\S]*?```\n?([\s\S]*?)```/i); if (basePromptMatch) { imageStyle.basePrompt = basePromptMatch[1].trim().replace(/\n/g, " "); } // Extract style keywords from table - const keywordsMatch = content.match( - /### Style Keywords[\s\S]*?\|[\s\S]*?(?=###|##|$)/i - ); + const keywordsMatch = content.match(/### Style Keywords[\s\S]*?\|[\s\S]*?(?=###|##|$)/i); if (keywordsMatch) { const keywordRows = keywordsMatch[0].match(/\|\s*\*\*[^*]+\*\*\s*\|\s*([^|]+)\|/g); if (keywordRows) { keywordRows.forEach((row) => { const match = row.match(/\|\s*\*\*[^*]+\*\*\s*\|\s*([^|]+)\|/); if (match) { - const keywords = match[1].split(",").map((k) => k.trim()).filter(Boolean); + const keywords = match[1] + .split(",") + .map((k) => k.trim()) + .filter(Boolean); imageStyle.keywords.push(...keywords); } }); @@ -216,9 +195,7 @@ function extractImageStyle(content) { } // Extract visual mood descriptors (bullet points) - const moodMatch = content.match( - /### Visual Mood Descriptors[\s\S]*?(?=###|##|$)/i - ); + const moodMatch = content.match(/### Visual Mood Descriptors[\s\S]*?(?=###|##|$)/i); if (moodMatch) { const moodItems = moodMatch[0].match(/-\s*([^\n]+)/g); if (moodItems) { @@ -227,9 +204,7 @@ function extractImageStyle(content) { } // Extract visual don'ts from table - const dontsMatch = content.match( - /### Visual Don'ts[\s\S]*?\|[\s\S]*?(?=###|##|$)/i - ); + const dontsMatch = content.match(/### Visual Don'ts[\s\S]*?\|[\s\S]*?(?=###|##|$)/i); if (dontsMatch) { const dontRows = dontsMatch[0].match(/\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|/g); if (dontRows) { diff --git a/.claude/skills/brand/scripts/sync-brand-to-tokens.cjs b/.claude/skills/brand/scripts/sync-brand-to-tokens.cjs index 013fa6ff..6e448cf7 100644 --- a/.claude/skills/brand/scripts/sync-brand-to-tokens.cjs +++ b/.claude/skills/brand/scripts/sync-brand-to-tokens.cjs @@ -9,24 +9,24 @@ * node sync-brand-to-tokens.cjs --dry-run */ -const fs = require('fs'); -const path = require('path'); -const { execFileSync } = require('child_process'); +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); // Paths -const BRAND_GUIDELINES = 'docs/brand-guidelines.md'; -const DESIGN_TOKENS_JSON = 'assets/design-tokens.json'; -const DESIGN_TOKENS_CSS = 'assets/design-tokens.css'; -const GENERATE_TOKENS_SCRIPT = '.claude/skills/design-system/scripts/generate-tokens.cjs'; +const BRAND_GUIDELINES = "docs/brand-guidelines.md"; +const DESIGN_TOKENS_JSON = "assets/design-tokens.json"; +const DESIGN_TOKENS_CSS = "assets/design-tokens.css"; +const GENERATE_TOKENS_SCRIPT = ".claude/skills/design-system/scripts/generate-tokens.cjs"; /** * Extract color info from brand guidelines markdown */ function extractColorsFromMarkdown(content) { const colors = { - primary: { name: 'primary', shades: {} }, - secondary: { name: 'secondary', shades: {} }, - accent: { name: 'accent', shades: {} } + primary: { name: "primary", shades: {} }, + secondary: { name: "secondary", shades: {} }, + accent: { name: "accent", shades: {} }, }; // Match a "| Label | #hex |" markdown table row. Bold around the label @@ -38,7 +38,7 @@ function extractColorsFromMarkdown(content) { const quickRef = { primary: /Primary Color\s*\|\s*#([A-Fa-f0-9]{6})/i, secondary: /Secondary Color\s*\|\s*#([A-Fa-f0-9]{6})/i, - accent: /Accent Color\s*\|\s*#([A-Fa-f0-9]{6})/i + accent: /Accent Color\s*\|\s*#([A-Fa-f0-9]{6})/i, }; for (const key of Object.keys(quickRef)) { const m = content.match(quickRef[key]); @@ -48,25 +48,25 @@ function extractColorsFromMarkdown(content) { // 2) Dedicated "### Colors" tables — assign base/dark/light by the // row label keyword. const assignFromSection = (heading, target) => { - const section = content.match(new RegExp(`### ${heading}[\\s\\S]*?(?=\\n###|$)`, 'i')); + const section = content.match(new RegExp(`### ${heading}[\\s\\S]*?(?=\\n###|$)`, "i")); if (!section) return; for (const m of section[0].matchAll(rowRe)) { const label = m[1].trim().toLowerCase(); const hex = `#${m[2]}`; - if (label.includes('dark')) target.dark = hex; - else if (label.includes('light')) target.light = hex; + if (label.includes("dark")) target.dark = hex; + else if (label.includes("light")) target.light = hex; else if (!target.base) target.base = hex; } }; - assignFromSection('Primary Colors', colors.primary); - assignFromSection('Secondary Colors', colors.secondary); - assignFromSection('Accent Colors', colors.accent); + assignFromSection("Primary Colors", colors.primary); + assignFromSection("Secondary Colors", colors.secondary); + assignFromSection("Accent Colors", colors.accent); // 3) Fallback: an accent swatch may live in another table (the starter // lists "Accent Green" under Secondary Colors). if (!colors.accent.base) { for (const m of content.matchAll(rowRe)) { - if (m[1].trim().toLowerCase().includes('accent')) { + if (m[1].trim().toLowerCase().includes("accent")) { colors.accent.base = `#${m[2]}`; break; } @@ -82,16 +82,16 @@ function extractColorsFromMarkdown(content) { function generateColorScale(baseHex, darkHex, lightHex) { // Use provided shades or generate approximations return { - "50": { "$value": lightHex || adjustBrightness(baseHex, 0.9), "$type": "color" }, - "100": { "$value": lightHex || adjustBrightness(baseHex, 0.8), "$type": "color" }, - "200": { "$value": adjustBrightness(baseHex, 0.6), "$type": "color" }, - "300": { "$value": adjustBrightness(baseHex, 0.4), "$type": "color" }, - "400": { "$value": adjustBrightness(baseHex, 0.2), "$type": "color" }, - "500": { "$value": baseHex, "$type": "color" }, - "600": { "$value": darkHex || adjustBrightness(baseHex, -0.15), "$type": "color" }, - "700": { "$value": adjustBrightness(baseHex, -0.3), "$type": "color" }, - "800": { "$value": adjustBrightness(baseHex, -0.45), "$type": "color" }, - "900": { "$value": adjustBrightness(baseHex, -0.6), "$type": "color" } + 50: { $value: lightHex || adjustBrightness(baseHex, 0.9), $type: "color" }, + 100: { $value: lightHex || adjustBrightness(baseHex, 0.8), $type: "color" }, + 200: { $value: adjustBrightness(baseHex, 0.6), $type: "color" }, + 300: { $value: adjustBrightness(baseHex, 0.4), $type: "color" }, + 400: { $value: adjustBrightness(baseHex, 0.2), $type: "color" }, + 500: { $value: baseHex, $type: "color" }, + 600: { $value: darkHex || adjustBrightness(baseHex, -0.15), $type: "color" }, + 700: { $value: adjustBrightness(baseHex, -0.3), $type: "color" }, + 800: { $value: adjustBrightness(baseHex, -0.45), $type: "color" }, + 900: { $value: adjustBrightness(baseHex, -0.6), $type: "color" }, }; } @@ -99,12 +99,12 @@ function generateColorScale(baseHex, darkHex, lightHex) { * Adjust hex color brightness */ function adjustBrightness(hex, percent) { - if (typeof hex !== 'string') return '#000000'; - const num = parseInt(hex.replace('#', ''), 16); + if (typeof hex !== "string") return "#000000"; + const num = parseInt(hex.replace("#", ""), 16); const r = Math.min(255, Math.max(0, (num >> 16) + Math.round(255 * percent))); - const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + Math.round(255 * percent))); - const b = Math.min(255, Math.max(0, (num & 0x0000FF) + Math.round(255 * percent))); - return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0').toUpperCase()}`; + const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + Math.round(255 * percent))); + const b = Math.min(255, Math.max(0, (num & 0x0000ff) + Math.round(255 * percent))); + return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0").toUpperCase()}`; } /** @@ -112,7 +112,10 @@ function adjustBrightness(hex, percent) { */ function updateDesignTokens(tokens, colors) { // Update brand name - const brandName = `ClaudeKit Marketing - ${colors.primary.name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}`; + const brandName = `ClaudeKit Marketing - ${colors.primary.name + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ")}`; tokens.brand = brandName; // Update primitive colors with new names @@ -126,7 +129,7 @@ function updateDesignTokens(tokens, colors) { // Add new named colors. Skip any role with no base hex rather than crashing // on an unexpected guidelines format. - for (const role of ['primary', 'secondary', 'accent']) { + for (const role of ["primary", "secondary", "accent"]) { const c = colors[role]; if (!c.base) { console.warn(`⚠️ No base hex found for ${role} color — skipping its token scale.`); @@ -145,39 +148,39 @@ function updateDesignTokens(tokens, colors) { const a = colors.accent.name; // Primary variants - sem.primary = { "$value": `{primitive.color.${p}.500}`, "$type": "color" }; - sem['primary-hover'] = { "$value": `{primitive.color.${p}.600}`, "$type": "color" }; - sem['primary-active'] = { "$value": `{primitive.color.${p}.700}`, "$type": "color" }; - sem['primary-light'] = { "$value": `{primitive.color.${p}.400}`, "$type": "color" }; - sem['primary-lighter'] = { "$value": `{primitive.color.${p}.100}`, "$type": "color" }; - sem['primary-dark'] = { "$value": `{primitive.color.${p}.600}`, "$type": "color" }; + sem.primary = { $value: `{primitive.color.${p}.500}`, $type: "color" }; + sem["primary-hover"] = { $value: `{primitive.color.${p}.600}`, $type: "color" }; + sem["primary-active"] = { $value: `{primitive.color.${p}.700}`, $type: "color" }; + sem["primary-light"] = { $value: `{primitive.color.${p}.400}`, $type: "color" }; + sem["primary-lighter"] = { $value: `{primitive.color.${p}.100}`, $type: "color" }; + sem["primary-dark"] = { $value: `{primitive.color.${p}.600}`, $type: "color" }; // Secondary variants - sem.secondary = { "$value": `{primitive.color.${s}.500}`, "$type": "color" }; - sem['secondary-hover'] = { "$value": `{primitive.color.${s}.600}`, "$type": "color" }; - sem['secondary-light'] = { "$value": `{primitive.color.${s}.300}`, "$type": "color" }; - sem['secondary-dark'] = { "$value": `{primitive.color.${s}.600}`, "$type": "color" }; + sem.secondary = { $value: `{primitive.color.${s}.500}`, $type: "color" }; + sem["secondary-hover"] = { $value: `{primitive.color.${s}.600}`, $type: "color" }; + sem["secondary-light"] = { $value: `{primitive.color.${s}.300}`, $type: "color" }; + sem["secondary-dark"] = { $value: `{primitive.color.${s}.600}`, $type: "color" }; // Accent variants - sem.accent = { "$value": `{primitive.color.${a}.500}`, "$type": "color" }; - sem['accent-hover'] = { "$value": `{primitive.color.${a}.600}`, "$type": "color" }; - sem['accent-light'] = { "$value": `{primitive.color.${a}.300}`, "$type": "color" }; + sem.accent = { $value: `{primitive.color.${a}.500}`, $type: "color" }; + sem["accent-hover"] = { $value: `{primitive.color.${a}.600}`, $type: "color" }; + sem["accent-light"] = { $value: `{primitive.color.${a}.300}`, $type: "color" }; // Status colors (use accent for success, primary for error/info) - sem.success = { "$value": `{primitive.color.${a}.500}`, "$type": "color" }; - sem['success-light'] = { "$value": `{primitive.color.${a}.300}`, "$type": "color" }; - sem.error = { "$value": `{primitive.color.${p}.500}`, "$type": "color" }; - sem['error-light'] = { "$value": `{primitive.color.${p}.300}`, "$type": "color" }; - sem.info = { "$value": `{primitive.color.${s}.500}`, "$type": "color" }; - sem['info-light'] = { "$value": `{primitive.color.${s}.300}`, "$type": "color" }; + sem.success = { $value: `{primitive.color.${a}.500}`, $type: "color" }; + sem["success-light"] = { $value: `{primitive.color.${a}.300}`, $type: "color" }; + sem.error = { $value: `{primitive.color.${p}.500}`, $type: "color" }; + sem["error-light"] = { $value: `{primitive.color.${p}.300}`, $type: "color" }; + sem.info = { $value: `{primitive.color.${s}.500}`, $type: "color" }; + sem["info-light"] = { $value: `{primitive.color.${s}.300}`, $type: "color" }; } // Update component references (button uses primary color with opacity) if (tokens.component?.button?.secondary && colors.primary.base) { const primaryBase = colors.primary.base; - tokens.component.button.secondary['bg-hover'] = { - "$value": `${primaryBase}1A`, - "$type": "color" + tokens.component.button.secondary["bg-hover"] = { + $value: `${primaryBase}1A`, + $type: "color", }; } @@ -188,9 +191,9 @@ function updateDesignTokens(tokens, colors) { * Main */ function main() { - const dryRun = process.argv.includes('--dry-run'); + const dryRun = process.argv.includes("--dry-run"); - console.log('🔄 Syncing brand guidelines → design tokens\n'); + console.log("🔄 Syncing brand guidelines → design tokens\n"); // Read brand guidelines const guidelinesPath = path.resolve(process.cwd(), BRAND_GUIDELINES); @@ -198,11 +201,11 @@ function main() { console.error(`❌ Brand guidelines not found: ${guidelinesPath}`); process.exit(1); } - const guidelinesContent = fs.readFileSync(guidelinesPath, 'utf-8'); + const guidelinesContent = fs.readFileSync(guidelinesPath, "utf-8"); // Extract colors const colors = extractColorsFromMarkdown(guidelinesContent); - console.log('📊 Extracted colors:'); + console.log("📊 Extracted colors:"); console.log(` Primary: ${colors.primary.name} (${colors.primary.base})`); console.log(` Secondary: ${colors.secondary.name} (${colors.secondary.base})`); console.log(` Accent: ${colors.accent.name} (${colors.accent.base})\n`); @@ -211,16 +214,16 @@ function main() { const tokensPath = path.resolve(process.cwd(), DESIGN_TOKENS_JSON); let tokens = {}; if (fs.existsSync(tokensPath)) { - tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); + tokens = JSON.parse(fs.readFileSync(tokensPath, "utf-8")); } // Update tokens tokens = updateDesignTokens(tokens, colors); if (dryRun) { - console.log('📋 Would update design-tokens.json:'); - console.log(JSON.stringify(tokens.primitive.color, null, 2).slice(0, 500) + '...'); - console.log('\n⏭️ Dry run - no files changed'); + console.log("📋 Would update design-tokens.json:"); + console.log(JSON.stringify(tokens.primitive.color, null, 2).slice(0, 500) + "..."); + console.log("\n⏭️ Dry run - no files changed"); return; } @@ -232,17 +235,21 @@ function main() { const generateScript = path.resolve(process.cwd(), GENERATE_TOKENS_SCRIPT); if (fs.existsSync(generateScript)) { try { - execFileSync('node', [generateScript, '--config', DESIGN_TOKENS_JSON, '-o', DESIGN_TOKENS_CSS], { - cwd: process.cwd(), - stdio: 'inherit' - }); + execFileSync( + "node", + [generateScript, "--config", DESIGN_TOKENS_JSON, "-o", DESIGN_TOKENS_CSS], + { + cwd: process.cwd(), + stdio: "inherit", + }, + ); console.log(`✅ Regenerated: ${DESIGN_TOKENS_CSS}`); } catch (e) { - console.error('⚠️ Failed to regenerate CSS:', e.message); + console.error("⚠️ Failed to regenerate CSS:", e.message); } } - console.log('\n✨ Brand sync complete!'); + console.log("\n✨ Brand sync complete!"); } main(); diff --git a/.claude/skills/brand/scripts/validate-asset.cjs b/.claude/skills/brand/scripts/validate-asset.cjs index 1bd4c929..215f147f 100755 --- a/.claude/skills/brand/scripts/validate-asset.cjs +++ b/.claude/skills/brand/scripts/validate-asset.cjs @@ -20,8 +20,7 @@ const path = require("path"); const RULES = { naming: { pattern: /^[a-z]+_[a-z0-9-]+_[a-z0-9-]+_\d{8}(_[a-z0-9-]+)?\.[a-z]+$/, - description: - "{type}_{campaign}_{description}_{timestamp}_{variant}.{ext}", + description: "{type}_{campaign}_{description}_{timestamp}_{variant}.{ext}", examples: [ "banner_claude-launch_hero-image_20251209.png", "logo_brand-refresh_horizontal_20251209_dark.svg", @@ -99,15 +98,7 @@ function validateFilename(filename) { } // Check valid type - const validTypes = [ - "banner", - "logo", - "design", - "video", - "infographic", - "icon", - "photo", - ]; + const validTypes = ["banner", "logo", "design", "video", "infographic", "icon", "photo"]; if (!validTypes.includes(parsed.type)) { suggestions.push(`Consider using type: ${validTypes.join(", ")}`); } @@ -136,16 +127,10 @@ function validateFileSize(filepath, extension) { } if (size > limits.max) { - issues.push( - `File size (${formatBytes(size)}) exceeds maximum (${formatBytes( - limits.max - )})` - ); + issues.push(`File size (${formatBytes(size)}) exceeds maximum (${formatBytes(limits.max)})`); } else if (size > limits.recommended) { warnings.push( - `File size (${formatBytes(size)}) exceeds recommended (${formatBytes( - limits.recommended - )})` + `File size (${formatBytes(size)}) exceeds recommended (${formatBytes(limits.recommended)})`, ); } @@ -175,8 +160,7 @@ function validateFormat(extension) { if (RULES.formats.image.includes(extension)) info.category = "image"; else if (RULES.formats.vector.includes(extension)) info.category = "vector"; else if (RULES.formats.video.includes(extension)) info.category = "video"; - else if (RULES.formats.document.includes(extension)) - info.category = "document"; + else if (RULES.formats.document.includes(extension)) info.category = "document"; return { valid: true, issues, info }; } @@ -194,9 +178,7 @@ function checkManifest(filepath) { try { const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); const relativePath = path.relative(process.cwd(), filepath); - const found = manifest.assets?.find( - (a) => a.path === relativePath || a.path === filepath - ); + const found = manifest.assets?.find((a) => a.path === relativePath || a.path === filepath); return { registered: !!found, @@ -286,9 +268,7 @@ function validateAsset(assetPath) { results.checks.manifest = manifestResult; if (!manifestResult.registered) { results.warnings.push("Asset not registered in manifest.json"); - results.suggestions.push( - "Register asset in .assets/manifest.json for tracking" - ); + results.suggestions.push("Register asset in .assets/manifest.json for tracking"); } // 5. Suggest corrected filename if needed @@ -330,9 +310,7 @@ function formatOutput(results) { if (results.suggestions.length > 0) { lines.push("\nSUGGESTIONS:"); - results.suggestions.forEach((suggestion) => - lines.push(` - ${suggestion}`) - ); + results.suggestions.forEach((suggestion) => lines.push(` - ${suggestion}`)); } // File size info @@ -357,18 +335,14 @@ function main() { console.error("Usage: node validate-asset.cjs [--json]"); console.error("\nExamples:"); console.error( - " node validate-asset.cjs assets/banners/social-media/banner_launch_hero_20251209.png" - ); - console.error( - " node validate-asset.cjs assets/logos/icon-only/logo-icon.svg --json" + " node validate-asset.cjs assets/banners/social-media/banner_launch_hero_20251209.png", ); + console.error(" node validate-asset.cjs assets/logos/icon-only/logo-icon.svg --json"); process.exit(1); } // Resolve path - const resolvedPath = path.isAbsolute(assetPath) - ? assetPath - : path.join(process.cwd(), assetPath); + const resolvedPath = path.isAbsolute(assetPath) ? assetPath : path.join(process.cwd(), assetPath); // Validate const results = validateAsset(resolvedPath); diff --git a/.claude/skills/create-apple-notes-mockup/generate.js b/.claude/skills/create-apple-notes-mockup/generate.js index f8ac4ff7..d968c8a1 100644 --- a/.claude/skills/create-apple-notes-mockup/generate.js +++ b/.claude/skills/create-apple-notes-mockup/generate.js @@ -21,27 +21,31 @@ // "with_iphone_frame": false // } -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); -const ICONS = require('./templates/icons.js'); +const ICONS = require("./templates/icons.js"); function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } // Mimic iOS Smart Punctuation: convert straight quotes to curly, and triple // dashes to em-dashes. Applied to paragraph and title text before HTML escaping. function smartQuotes(s) { return String(s) - .replace(/---/g, '—') // --- → em-dash - .replace(/--/g, '–') // -- → en-dash - .replace(/\.\.\./g, '…') // ... → ellipsis - .replace(/(^|[\s(\[{<—–])"/g, '$1“') // opening " - .replace(/"/g, '”') // closing " - .replace(/(^|[\s(\[{<—–])'/g, '$1‘') // opening ' - .replace(/'/g, '’'); // closing ' (also handles apostrophes mid-word) + .replace(/---/g, "—") // --- → em-dash + .replace(/--/g, "–") // -- → en-dash + .replace(/\.\.\./g, "…") // ... → ellipsis + .replace(/(^|[\s(\[{<—–])"/g, "$1“") // opening " + .replace(/"/g, "”") // closing " + .replace(/(^|[\s(\[{<—–])'/g, "$1‘") // opening ' + .replace(/'/g, "’"); // closing ' (also handles apostrophes mid-word) } // Wrap any whole-word match of `word` in … @@ -52,14 +56,14 @@ function applySpellMarks(text, words) { const escaped = escapeHtml(smart); let out = escaped; for (const w of words) { - const re = new RegExp(`\\b(${w.replace(/[.*+?^${}()|[\\\]\\\\]/g, '\\$&')})\\b`, 'g'); + const re = new RegExp(`\\b(${w.replace(/[.*+?^${}()|[\\\]\\\\]/g, "\\$&")})\\b`, "g"); out = out.replace(re, '$1'); } return out; } function renderStatusBar(sb = {}) { - const time = sb.time || '9:41'; + const time = sb.time || "9:41"; const batteryPct = sb.battery_pct ?? 87; const batteryLow = !!sb.battery_low; const showFocus = sb.show_focus_glyph ?? false; @@ -67,7 +71,7 @@ function renderStatusBar(sb = {}) {
${escapeHtml(time)} - ${showFocus ? `${ICONS.focusBed}` : ''} + ${showFocus ? `${ICONS.focusBed}` : ""}
${ICONS.signal} @@ -100,38 +104,44 @@ function renderBody(spec) { const cursorMode = spec.cursor || null; // "title" | "end" | null const lastTextBlockIdx = (() => { for (let i = blocks.length - 1; i >= 0; i--) { - if (blocks[i].type === 'paragraph') return i; + if (blocks[i].type === "paragraph") return i; } return -1; })(); - const titleHtml = applySpellMarks(spec.title || '', spellWords) + - (cursorMode === 'title' ? '' : ''); - const blocksHtml = blocks.map((b, i) => { - if (b.type === 'paragraph') { - const isCursorTarget = cursorMode === 'end' && i === lastTextBlockIdx; - const inner = applySpellMarks(b.text || '', spellWords) + - (isCursorTarget ? '' : ''); - return `

${inner}

`; - } - if (b.type === 'image') { - const cap = b.caption - ? `
${escapeHtml(b.caption)}
` - : ''; - return `${cap}`; - } - if (b.type === 'checklist') { - const items = (b.items || []).map(it => { - const cls = it.checked ? 'note-check-box note-check-box--checked' : 'note-check-box'; - const txtCls = it.checked ? 'note-check-text note-check-text--done' : 'note-check-text'; - return `
${escapeHtml(it.text || '')}
`; - }).join(''); - return `
${items}
`; - } - if (b.type === 'divider') { - return `
`; - } - return ''; - }).join('\n'); + const titleHtml = + applySpellMarks(spec.title || "", spellWords) + + (cursorMode === "title" ? '' : ""); + const blocksHtml = blocks + .map((b, i) => { + if (b.type === "paragraph") { + const isCursorTarget = cursorMode === "end" && i === lastTextBlockIdx; + const inner = + applySpellMarks(b.text || "", spellWords) + + (isCursorTarget ? '' : ""); + return `

${inner}

`; + } + if (b.type === "image") { + const cap = b.caption + ? `
${escapeHtml(b.caption)}
` + : ""; + return `${cap}`; + } + if (b.type === "checklist") { + const items = (b.items || []) + .map((it) => { + const cls = it.checked ? "note-check-box note-check-box--checked" : "note-check-box"; + const txtCls = it.checked ? "note-check-text note-check-text--done" : "note-check-text"; + return `
${escapeHtml(it.text || "")}
`; + }) + .join(""); + return `
${items}
`; + } + if (b.type === "divider") { + return `
`; + } + return ""; + }) + .join("\n"); return `

${titleHtml}

@@ -143,14 +153,14 @@ function renderBody(spec) { } function renderKeyboard(spec) { - if (!spec.show_keyboard) return ''; + if (!spec.show_keyboard) return ""; const kb = spec.keyboard_state || {}; - const sug = kb.suggestions || ['see', 'go', 'do']; - const r1 = (kb.letters_row1 || 'qwertyuiop').split(''); - const r2 = (kb.letters_row2 || 'asdfghjkl').split(''); - const r3 = (kb.letters_row3 || 'zxcvbnm').split(''); - const upper = kb.shift === 'upper'; - const ltr = c => upper ? c.toUpperCase() : c; + const sug = kb.suggestions || ["see", "go", "do"]; + const r1 = (kb.letters_row1 || "qwertyuiop").split(""); + const r2 = (kb.letters_row2 || "asdfghjkl").split(""); + const r3 = (kb.letters_row3 || "zxcvbnm").split(""); + const upper = kb.shift === "upper"; + const ltr = (c) => (upper ? c.toUpperCase() : c); return `
@@ -163,17 +173,17 @@ function renderKeyboard(spec) {
- ${sug.map(s => `
${escapeHtml(smartQuotes(s))}
`).join('')} + ${sug.map((s) => `
${escapeHtml(smartQuotes(s))}
`).join("")}
- ${r1.map(c => `
${ltr(c)}
`).join('')} + ${r1.map((c) => `
${ltr(c)}
`).join("")}
- ${r2.map(c => `
${ltr(c)}
`).join('')} + ${r2.map((c) => `
${ltr(c)}
`).join("")}
${ICONS.shift}
- ${r3.map(c => `
${ltr(c)}
`).join('')} + ${r3.map((c) => `
${ltr(c)}
`).join("")}
${ICONS.backspace}
@@ -192,14 +202,14 @@ function renderKeyboard(spec) { } function generateHtml(spec) { - const cssPath = path.join(__dirname, 'templates', 'note.css'); - const css = fs.readFileSync(cssPath, 'utf8'); - const frame = spec.with_iphone_frame ? '
' : ''; + const cssPath = path.join(__dirname, "templates", "note.css"); + const css = fs.readFileSync(cssPath, "utf8"); + const frame = spec.with_iphone_frame ? '
' : ""; return ` -${escapeHtml(spec.title || 'Apple Notes')} +${escapeHtml(spec.title || "Apple Notes")} @@ -219,8 +229,8 @@ module.exports = { generateHtml }; if (require.main === module) { // CLI: node generate.js [out.html] const inPath = process.argv[2]; - const outPath = process.argv[3] || inPath.replace(/\.json$/, '.html'); - const spec = JSON.parse(fs.readFileSync(inPath, 'utf8')); + const outPath = process.argv[3] || inPath.replace(/\.json$/, ".html"); + const spec = JSON.parse(fs.readFileSync(inPath, "utf8")); fs.writeFileSync(outPath, generateHtml(spec)); - console.log('wrote', outPath); + console.log("wrote", outPath); } diff --git a/.claude/skills/create-apple-notes-mockup/render.js b/.claude/skills/create-apple-notes-mockup/render.js index 5fb5e067..caad4816 100755 --- a/.claude/skills/create-apple-notes-mockup/render.js +++ b/.claude/skills/create-apple-notes-mockup/render.js @@ -8,28 +8,27 @@ // // Output goes to ./apple-notes-mockup-exports/-/ unless --output is set. -const fs = require('fs'); -const path = require('path'); -const { generateHtml } = require('./generate.js'); -const { screenshotHtml } = require('./screenshot.js'); +const fs = require("fs"); +const path = require("path"); +const { generateHtml } = require("./generate.js"); +const { screenshotHtml } = require("./screenshot.js"); function parseArgs(argv) { const out = { flags: {}, viewport: { width: 1180, height: 2556 } }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; - if (a === '--note') out.flags.note = argv[++i]; - else if (a === '--with-keyboard') out.flags.with_keyboard = true; - else if (a === '--with-iphone-frame') out.flags.with_iphone_frame = true; - else if (a === '--no-frame') out.flags.no_frame = true; - else if (a === '--output') out.flags.output = argv[++i]; - else if (a === '--name') out.flags.name = argv[++i]; - else if (a === '--out-html') out.flags.outHtml = argv[++i]; - else if (a === '--out-png') out.flags.outPng = argv[++i]; - else if (a === '--viewport') { - const [w, h] = argv[++i].split('x').map(Number); + if (a === "--note") out.flags.note = argv[++i]; + else if (a === "--with-keyboard") out.flags.with_keyboard = true; + else if (a === "--with-iphone-frame") out.flags.with_iphone_frame = true; + else if (a === "--no-frame") out.flags.no_frame = true; + else if (a === "--output") out.flags.output = argv[++i]; + else if (a === "--name") out.flags.name = argv[++i]; + else if (a === "--out-html") out.flags.outHtml = argv[++i]; + else if (a === "--out-png") out.flags.outPng = argv[++i]; + else if (a === "--viewport") { + const [w, h] = argv[++i].split("x").map(Number); out.viewport = { width: w, height: h }; - } - else if (a === '--dpr') out.flags.dpr = Number(argv[++i]); + } else if (a === "--dpr") out.flags.dpr = Number(argv[++i]); } return out; } @@ -41,23 +40,25 @@ function dateSlug() { async function main() { const { flags, viewport } = parseArgs(process.argv); if (!flags.note) { - console.error('error: --note is required'); - console.error('usage: node render.js --note examples/mid.json [--with-keyboard] [--output dir] [--name slug] [--viewport WxH]'); + console.error("error: --note is required"); + console.error( + "usage: node render.js --note examples/mid.json [--with-keyboard] [--output dir] [--name slug] [--viewport WxH]", + ); process.exit(1); } const specPath = path.resolve(flags.note); const specDir = path.dirname(specPath); - const spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); + const spec = JSON.parse(fs.readFileSync(specPath, "utf8")); // Resolve any relative image src in body blocks against the spec file's directory // and convert to data: URIs so the output HTML is self-contained. for (const b of spec.body || []) { - if (b.type === 'image' && b.src && !/^(data:|https?:|file:)/.test(b.src)) { + if (b.type === "image" && b.src && !/^(data:|https?:|file:)/.test(b.src)) { const abs = path.resolve(specDir, b.src); if (fs.existsSync(abs)) { const buf = fs.readFileSync(abs); const ext = path.extname(abs).slice(1).toLowerCase(); - const mime = ext === 'jpg' ? 'image/jpeg' : ext === 'png' ? 'image/png' : `image/${ext}`; - b.src = `data:${mime};base64,${buf.toString('base64')}`; + const mime = ext === "jpg" ? "image/jpeg" : ext === "png" ? "image/png" : `image/${ext}`; + b.src = `data:${mime};base64,${buf.toString("base64")}`; } } } @@ -72,13 +73,13 @@ async function main() { outHtml = flags.outHtml ? path.resolve(flags.outHtml) : null; outPng = flags.outPng ? path.resolve(flags.outPng) : null; } else { - const outRoot = path.resolve(flags.output || './apple-notes-mockup-exports'); - const slug = flags.name || path.basename(flags.note).replace(/\.json$/, ''); + const outRoot = path.resolve(flags.output || "./apple-notes-mockup-exports"); + const slug = flags.name || path.basename(flags.note).replace(/\.json$/, ""); const dir = path.join(outRoot, `${dateSlug()}-${slug}`); fs.mkdirSync(dir, { recursive: true }); - outHtml = path.join(dir, 'index.html'); - outPng = path.join(dir, 'screenshot.png'); - fs.writeFileSync(path.join(dir, 'spec.json'), JSON.stringify(spec, null, 2)); + outHtml = path.join(dir, "index.html"); + outPng = path.join(dir, "screenshot.png"); + fs.writeFileSync(path.join(dir, "spec.json"), JSON.stringify(spec, null, 2)); } if (outPng) { @@ -93,11 +94,11 @@ async function main() { fs.writeFileSync(outHtml, html); } - console.log('html:', outHtml || '(skipped)'); - console.log('png: ', outPng || '(skipped)'); + console.log("html:", outHtml || "(skipped)"); + console.log("png: ", outPng || "(skipped)"); } -main().catch(err => { +main().catch((err) => { console.error(err); process.exit(1); }); diff --git a/.claude/skills/create-apple-notes-mockup/screenshot.js b/.claude/skills/create-apple-notes-mockup/screenshot.js index 9fcf67d5..70a7e4fb 100644 --- a/.claude/skills/create-apple-notes-mockup/screenshot.js +++ b/.claude/skills/create-apple-notes-mockup/screenshot.js @@ -7,12 +7,18 @@ // used to resolve relative URLs (images) in the page. If omitted, the HTML is // loaded via setContent and relative URLs will fail. -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { chromium } = require('playwright'); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { chromium } = require("playwright"); -async function screenshotHtml({ html, outPath, htmlBasePath, viewport = { width: 1180, height: 2556 }, dpr = 2 }) { +async function screenshotHtml({ + html, + outPath, + htmlBasePath, + viewport = { width: 1180, height: 2556 }, + dpr = 2, +}) { const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport, @@ -25,21 +31,23 @@ async function screenshotHtml({ html, outPath, htmlBasePath, viewport = { width: // Write HTML to disk so the browser resolves relative URLs against it. fs.mkdirSync(path.dirname(htmlBasePath), { recursive: true }); fs.writeFileSync(htmlBasePath, html); - await page.goto('file://' + htmlBasePath, { waitUntil: 'networkidle' }); + await page.goto("file://" + htmlBasePath, { waitUntil: "networkidle" }); } else { // Fall back to setContent (relative URLs in HTML will fail). - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'apple-notes-')); - const tmpHtml = path.join(tmpDir, 'index.html'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "apple-notes-")); + const tmpHtml = path.join(tmpDir, "index.html"); fs.writeFileSync(tmpHtml, html); cleanupTmp = tmpDir; - await page.goto('file://' + tmpHtml, { waitUntil: 'networkidle' }); + await page.goto("file://" + tmpHtml, { waitUntil: "networkidle" }); } await page.waitForTimeout(300); fs.mkdirSync(path.dirname(outPath), { recursive: true }); await page.screenshot({ path: outPath, fullPage: false }); await browser.close(); if (cleanupTmp) { - try { fs.rmSync(cleanupTmp, { recursive: true, force: true }); } catch {} + try { + fs.rmSync(cleanupTmp, { recursive: true, force: true }); + } catch {} } } diff --git a/.claude/skills/create-apple-notes-mockup/templates/icons.js b/.claude/skills/create-apple-notes-mockup/templates/icons.js index e1ad0324..80fb02fc 100644 --- a/.claude/skills/create-apple-notes-mockup/templates/icons.js +++ b/.claude/skills/create-apple-notes-mockup/templates/icons.js @@ -24,7 +24,7 @@ const ICONS = { // Status-bar battery — accepts a `pct` and `low` flag to colorize fill. battery: ({ pct = 87, low = false } = {}) => { - const fillColor = low ? '#FF3B30' : '#000'; + const fillColor = low ? "#FF3B30" : "#000"; const innerMaxW = 46; const innerW = Math.max(2, Math.round((pct / 100) * innerMaxW)); return ``; diff --git a/.claude/skills/create-apple-notes-video-ad/scripts/record-master.template.js b/.claude/skills/create-apple-notes-video-ad/scripts/record-master.template.js index 00df4960..5aebbe5b 100644 --- a/.claude/skills/create-apple-notes-video-ad/scripts/record-master.template.js +++ b/.claude/skills/create-apple-notes-video-ad/scripts/record-master.template.js @@ -22,29 +22,30 @@ * node clips/record-master.js */ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const { chromium } = require('playwright'); - +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); +const { chromium } = require("playwright"); // Path to the create-apple-notes-mockup atom's generate.js. // // First try a standard ad layout (/ads/video-NN-/clips/record-master.js // → up 4 to repo root). If that fails, walk up looking for the skills/ tree. function resolveAtomGenerate() { - const rel = '../../../../skills/atoms/messaging/create-apple-notes-mockup/generate.js'; + const rel = "../../../../skills/atoms/messaging/create-apple-notes-mockup/generate.js"; const stdGuess = path.resolve(__dirname, rel); if (fs.existsSync(stdGuess)) return stdGuess; let dir = __dirname; for (let hop = 0; hop < 8; hop++) { - const cand = path.join(dir, 'skills/atoms/messaging/create-apple-notes-mockup/generate.js'); + const cand = path.join(dir, "skills/atoms/messaging/create-apple-notes-mockup/generate.js"); if (fs.existsSync(cand)) return cand; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } - throw new Error('cannot locate create-apple-notes-mockup/generate.js — set the relative path manually in record-master.js'); + throw new Error( + "cannot locate create-apple-notes-mockup/generate.js — set the relative path manually in record-master.js", + ); } const { generateHtml } = require(resolveAtomGenerate()); @@ -61,8 +62,8 @@ const FPS = 30; // ============================================================================= function loadSpec() { - const specPath = path.join(__dirname, '..', 'notes', 'note.json'); - return JSON.parse(fs.readFileSync(specPath, 'utf-8')); + const specPath = path.join(__dirname, "..", "notes", "note.json"); + return JSON.parse(fs.readFileSync(specPath, "utf-8")); } // ============================================================================= @@ -76,7 +77,7 @@ function loadSpec() { // ============================================================================= function seededRng(seed) { - let s = (seed >>> 0) || 1; + let s = seed >>> 0 || 1; return () => { s = (s * 1664525 + 1013904223) >>> 0; return (s & 0x7fffffff) / 0x7fffffff; @@ -86,30 +87,30 @@ function seededRng(seed) { function buildTimingTable(spec) { const rng = seededRng(0xa5b1c2d3); const events = []; - let t = 0.30; // small lead-in before any typing starts (~9 frames) + let t = 0.3; // small lead-in before any typing starts (~9 frames) spec.typed_body.forEach((para, pi) => { t += para.pre_pause_seconds || 0.6; - const text = String(para.text || ''); + const text = String(para.text || ""); const n = Math.max(1, text.length); - const avg = (para.type_seconds || (n * 0.07)) / n; + const avg = (para.type_seconds || n * 0.07) / n; let i = 0; while (i < text.length) { // Jittered per-char step: avg * (0.65 .. 1.35). - const step = avg * (0.65 + rng() * 0.70); + const step = avg * (0.65 + rng() * 0.7); t += step; const ch = text[i]; // Classify the keystroke for SFX selection. - let kind = 'letter'; - if (ch === ' ') kind = 'space'; - else if (ch === '.' || ch === ',' || ch === '?' || ch === '!' || ch === "'") kind = 'punct'; - events.push({ t: +t.toFixed(4), pid: `tp${pi}`, idx: i, ch, kind, kind_evt: 'type' }); + let kind = "letter"; + if (ch === " ") kind = "space"; + else if (ch === "." || ch === "," || ch === "?" || ch === "!" || ch === "'") kind = "punct"; + events.push({ t: +t.toFixed(4), pid: `tp${pi}`, idx: i, ch, kind, kind_evt: "type" }); i += 1; } // After last char of a paragraph, fire a "return" beat unless this is the // last paragraph (cursor stays parked at the end of the final paragraph). if (pi < spec.typed_body.length - 1) { t += 0.18; - events.push({ t: +t.toFixed(4), pid: `tp${pi}`, kind: 'return', kind_evt: 'return' }); + events.push({ t: +t.toFixed(4), pid: `tp${pi}`, kind: "return", kind_evt: "return" }); } }); const total = t + (spec.post_hold_seconds || 1.0); @@ -127,27 +128,27 @@ function buildTimingTable(spec) { function buildInitialSpec(spec) { // Compose the spec passed to the atom's generateHtml(). const body = []; - for (const p of (spec.pre_typed_body || [])) { - body.push({ type: 'paragraph', text: p.text || '' }); + for (const p of spec.pre_typed_body || []) { + body.push({ type: "paragraph", text: p.text || "" }); } // Each typed paragraph starts empty. The driver fills it in. spec.typed_body.forEach((p, i) => { body.push({ - type: 'paragraph', - text: '', + type: "paragraph", + text: "", _typed_pid: `tp${i}`, _typed_target: p.text, _typed_underline: p.autocorrect_underline || [], }); }); return { - title: spec.title || 'Hello', + title: spec.title || "Hello", body, - cursor: 'end', + cursor: "end", autocorrect_underline: [], - status_bar: spec.status_bar || { time: '9:41' }, + status_bar: spec.status_bar || { time: "9:41" }, show_keyboard: true, - keyboard_state: spec.keyboard_state || { suggestions: ['See', 'Let', 'I'], shift: 'upper' }, + keyboard_state: spec.keyboard_state || { suggestions: ["See", "Let", "I"], shift: "upper" }, with_iphone_frame: false, }; } @@ -186,11 +187,9 @@ function tagTypedParagraphs(html, spec) { const pid = `tp${i}`; // Replace the paragraph with one that has data-typed-pid + data-target-text // and starts with an empty span the driver appends to. - const targetEsc = (t.text || '') - .replace(/&/g, '&') - .replace(/"/g, '"'); + const targetEsc = (t.text || "").replace(/&/g, "&").replace(/"/g, """); const replacement = - `

` + + `

` + `` + `` + `

`; @@ -198,7 +197,7 @@ function tagTypedParagraphs(html, spec) { } // Also drop the existing cursor that was added at end of last pre_typed by // the atom (cursor: "end"). The driver will manage cursor visibility. - out = out.replace(/<\/span>/g, ''); + out = out.replace(/<\/span>/g, ""); return out; } @@ -335,11 +334,9 @@ function makeUnderlineMapScript(spec) { // ============================================================================= function buildCueList(timing) { - return timing.events.map(ev => ({ + return timing.events.map((ev) => ({ t: ev.t, - name: ev.kind_evt === 'return' - ? 'kb-return' - : (ev.kind === 'space' ? 'kb-space' : 'kb-tick'), + name: ev.kind_evt === "return" ? "kb-return" : ev.kind === "space" ? "kb-space" : "kb-tick", soft: false, })); } @@ -349,17 +346,17 @@ function buildCueList(timing) { // ============================================================================= async function main() { - const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'apple-notes-master-')); + const tmpDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "apple-notes-master-")); const spec = loadSpec(); const timing = buildTimingTable(spec); const initial = buildInitialSpec(spec); let html = generateHtml(initial); html = tagTypedParagraphs(html, spec); // Inject driver + underline map. - const driverHtml = html - .replace('', - makeUnderlineMapScript(spec) + '\n' + - makeDriverScript(timing) + '\n'); + const driverHtml = html.replace( + "", + makeUnderlineMapScript(spec) + "\n" + makeDriverScript(timing) + "\n", + ); // Per-recording tweaks. The atom's CSS hardcodes html/body/.screen at 2556px // (iPhone's true 19.5:9). We're recording at a shorter 9:16-ish canvas so @@ -375,7 +372,7 @@ async function main() { p.note-paragraph[data-typed-pid] { min-height: 1.38em; } `; - const finalHtml = driverHtml.replace('', styleOverride + '\n'); + const finalHtml = driverHtml.replace("", styleOverride + "\n"); const browser = await chromium.launch(); const ctx = await browser.newContext({ @@ -384,7 +381,7 @@ async function main() { recordVideo: { dir: tmpDir, size: { width: VIEW_W, height: VIEW_H } }, }); const page = await ctx.newPage(); - await page.setContent(finalHtml, { waitUntil: 'load' }); + await page.setContent(finalHtml, { waitUntil: "load" }); await page.waitForFunction(() => window.__driverReady === true, { timeout: 5000 }); await page.evaluate(() => window.__startDriver()); await page.waitForTimeout(timing.total * 1000 + 200); @@ -393,17 +390,23 @@ async function main() { await browser.close(); // Convert webm → mp4 at the chosen fps + native iPhone dimensions. - const outMp4 = path.join(__dirname, 'master-typing.mp4'); + const outMp4 = path.join(__dirname, "master-typing.mp4"); execSync( `ffmpeg -y -i "${videoPath}" -t ${timing.total} -r ${FPS} ` + - `-vf "scale=${VIEW_W}:${VIEW_H}" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "${outMp4}"`, - { stdio: 'pipe' } + `-vf "scale=${VIEW_W}:${VIEW_H}" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "${outMp4}"`, + { stdio: "pipe" }, + ); + fs.writeFileSync( + outMp4.replace(/\.mp4$/, ".sfx.json"), + JSON.stringify(buildCueList(timing), null, 2), ); - fs.writeFileSync(outMp4.replace(/\.mp4$/, '.sfx.json'), JSON.stringify(buildCueList(timing), null, 2)); fs.rmSync(tmpDir, { recursive: true, force: true }); console.log(`mp4 → ${path.relative(process.cwd(), outMp4)}`); console.log(`sfx → ${buildCueList(timing).length} cues`); console.log(`dur → ${timing.total.toFixed(2)}s`); } -main().catch(e => { console.error(e); process.exit(1); }); +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.claude/skills/create-apple-notes-video-ad/scripts/render-end-card.template.js b/.claude/skills/create-apple-notes-video-ad/scripts/render-end-card.template.js index a1679050..cb2f8e63 100644 --- a/.claude/skills/create-apple-notes-video-ad/scripts/render-end-card.template.js +++ b/.claude/skills/create-apple-notes-video-ad/scripts/render-end-card.template.js @@ -26,10 +26,10 @@ * node clips/render-end-card.js */ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const { chromium } = require('playwright'); +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); +const { chromium } = require("playwright"); // Match the recorder's canvas (1180×2100, 9:16-ish) so the typing master // and the end-card MP4 share dimensions — the stitch crossfade requires it. @@ -39,13 +39,17 @@ const HOLD_SEC = 3.5; const FPS = 30; function loadSpec() { - const p = path.join(__dirname, '..', 'notes', 'note.json'); - return JSON.parse(fs.readFileSync(p, 'utf-8')); + const p = path.join(__dirname, "..", "notes", "note.json"); + return JSON.parse(fs.readFileSync(p, "utf-8")); } function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } function loadProductImage(adRoot, specPath) { @@ -55,41 +59,41 @@ function loadProductImage(adRoot, specPath) { if (fs.existsSync(abs)) return abs; } // Auto-detect under assets/. - const candidates = ['product-bottle.png', 'product-bottle.webp', 'product-bottle.jpg']; + const candidates = ["product-bottle.png", "product-bottle.webp", "product-bottle.jpg"]; for (const c of candidates) { - const p = path.join(adRoot, 'assets', c); + const p = path.join(adRoot, "assets", c); if (fs.existsSync(p)) return p; } return null; } function inlineImage(p) { - if (!p) return ''; + if (!p) return ""; const ext = path.extname(p).slice(1).toLowerCase(); - const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`; + const mime = ext === "jpg" ? "image/jpeg" : `image/${ext}`; const buf = fs.readFileSync(p); - return ``; + return ``; } // Wrap the underline_token inside headline_pre with the .underline span. function buildHeadlinePre(headlinePre, underlineToken) { - const esc = escapeHtml(headlinePre || ''); + const esc = escapeHtml(headlinePre || ""); if (!underlineToken) return esc; const tk = escapeHtml(underlineToken); - const re = new RegExp(`\\b(${tk.replace(/[.*+?^${}()|[\\\]\\\\]/g, '\\$&')})\\b`); + const re = new RegExp(`\\b(${tk.replace(/[.*+?^${}()|[\\\]\\\\]/g, "\\$&")})\\b`); return esc.replace(re, '$1'); } // Render headline_main, replacing circled_token with a circled span. function buildHeadlineMain(headlineMain, circledToken) { - const main = String(headlineMain || ''); + const main = String(headlineMain || ""); if (!circledToken) return escapeHtml(main); const tk = String(circledToken); const idx = main.indexOf(tk); if (idx < 0) return escapeHtml(main); const before = main.slice(0, idx); const after = main.slice(idx + tk.length); - let out = ''; + let out = ""; if (before) out += `${escapeHtml(before.trimEnd())}`; out += `${escapeHtml(tk)}`; if (after) out += `${escapeHtml(after.trimStart())}`; @@ -97,36 +101,46 @@ function buildHeadlineMain(headlineMain, circledToken) { } function buildChecklist(items) { - if (!items || !items.length) return ''; - return items.map(it => ` + if (!items || !items.length) return ""; + return items + .map( + (it) => `
-
${escapeHtml(it.label || '')}
-
${escapeHtml(it.value || '')}
+
${escapeHtml(it.label || "")}
+
${escapeHtml(it.value || "")}
✓
- `).join(''); + `, + ) + .join(""); } async function main() { const spec = loadSpec(); const ec = spec.end_card || {}; - const adRoot = path.join(__dirname, '..'); - const tpl = fs.readFileSync(path.join(__dirname, 'end-card.template.html'), 'utf-8'); + const adRoot = path.join(__dirname, ".."); + const tpl = fs.readFileSync(path.join(__dirname, "end-card.template.html"), "utf-8"); const productPath = loadProductImage(adRoot, ec.product_image); const productImg = inlineImage(productPath); - const headlinePre = buildHeadlinePre(ec.headline_pre || 'THE POWER OF', ec.headline_underlined || 'POWER'); - const headlineMain = buildHeadlineMain(ec.headline_main || '1 CAPSULE', ec.headline_circled || null); + const headlinePre = buildHeadlinePre( + ec.headline_pre || "THE POWER OF", + ec.headline_underlined || "POWER", + ); + const headlineMain = buildHeadlineMain( + ec.headline_main || "1 CAPSULE", + ec.headline_circled || null, + ); const checklistRows = buildChecklist(ec.checklist || []); const html = tpl - .replaceAll('{{BG_COLOR}}', ec.background_color || '#eee3d6') - .replaceAll('{{INK_COLOR}}', ec.ink_color || '#1d3d39') - .replace('{{HEADLINE_PRE_HTML}}', headlinePre) - .replace('{{HEADLINE_MAIN_HTML}}', headlineMain) - .replace('{{PRODUCT_IMG}}', productImg) - .replace('{{CHECKLIST_ROWS}}', checklistRows); + .replaceAll("{{BG_COLOR}}", ec.background_color || "#eee3d6") + .replaceAll("{{INK_COLOR}}", ec.ink_color || "#1d3d39") + .replace("{{HEADLINE_PRE_HTML}}", headlinePre) + .replace("{{HEADLINE_MAIN_HTML}}", headlineMain) + .replace("{{PRODUCT_IMG}}", productImg) + .replace("{{CHECKLIST_ROWS}}", checklistRows); const browser = await chromium.launch(); const ctx = await browser.newContext({ @@ -134,24 +148,27 @@ async function main() { deviceScaleFactor: 1, }); const page = await ctx.newPage(); - await page.setContent(html, { waitUntil: 'networkidle' }); + await page.setContent(html, { waitUntil: "networkidle" }); // Give Google Fonts a moment to load + apply. await page.waitForTimeout(600); - const pngPath = path.join(__dirname, 'end-card.png'); + const pngPath = path.join(__dirname, "end-card.png"); await page.screenshot({ path: pngPath, fullPage: false }); await browser.close(); console.log(`png → ${path.relative(process.cwd(), pngPath)}`); // Loop the still into a static MP4 — no Ken-Burns drift, per the imessage // molecule's "End card stays static" learning. - const outMp4 = path.join(__dirname, 'end-card.mp4'); + const outMp4 = path.join(__dirname, "end-card.mp4"); execSync( `ffmpeg -y -loop 1 -i "${pngPath}" -t ${HOLD_SEC} -r ${FPS} ` + - `-vf "scale=${VIEW_W}:${VIEW_H},format=yuv420p" ` + - `-c:v libx264 -pix_fmt yuv420p -movflags +faststart "${outMp4}"`, - { stdio: 'pipe' } + `-vf "scale=${VIEW_W}:${VIEW_H},format=yuv420p" ` + + `-c:v libx264 -pix_fmt yuv420p -movflags +faststart "${outMp4}"`, + { stdio: "pipe" }, ); console.log(`mp4 → ${path.relative(process.cwd(), outMp4)}`); } -main().catch(e => { console.error(e); process.exit(1); }); +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.claude/skills/create-chatgpt-mockup/generate.js b/.claude/skills/create-chatgpt-mockup/generate.js index 1bd2ece2..2b0c9224 100644 --- a/.claude/skills/create-chatgpt-mockup/generate.js +++ b/.claude/skills/create-chatgpt-mockup/generate.js @@ -9,17 +9,17 @@ * node generate.js --thread examples/text-only.json --out /tmp/index.html */ -const fs = require('fs'); -const path = require('path'); -const ICONS = require('./templates/icons'); +const fs = require("fs"); +const path = require("path"); +const ICONS = require("./templates/icons"); function escapeHTML(s) { return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } // Tiny markdown-ish renderer for assistant prose. Intentional scope: @@ -27,8 +27,8 @@ function escapeHTML(s) { // blank-line paragraph breaks, `---` horizontal rule, [[cite:Source]] chip, // [[icon:💡]] inline-icon prefix. function renderMarkdown(src) { - if (!src) return ''; - const lines = src.replace(/\r\n/g, '\n').split('\n'); + if (!src) return ""; + const lines = src.replace(/\r\n/g, "\n").split("\n"); const out = []; let buf = []; let inList = false; @@ -40,26 +40,45 @@ function renderMarkdown(src) { // then join with
so single-newline line breaks (e.g. inside a poem // stanza or under a bold "Section:" header) are preserved. Real ChatGPT // renders single newlines as soft breaks too. - const joined = buf.map(l => formatInline(l)).join('
').replace(/^(
)+|(
)+$/g, ''); + const joined = buf + .map((l) => formatInline(l)) + .join("
") + .replace(/^(
)+|(
)+$/g, ""); if (joined) out.push(`

${joined}

`); buf = []; }; const flushList = () => { - if (inList) { out.push(''); inList = false; } - if (inOrdered) { out.push(''); inOrdered = false; } + if (inList) { + out.push(""); + inList = false; + } + if (inOrdered) { + out.push(""); + inOrdered = false; + } }; for (let raw of lines) { - const line = raw.replace(/\s+$/, ''); - if (!line.trim()) { flushParagraph(); flushList(); continue; } + const line = raw.replace(/\s+$/, ""); + if (!line.trim()) { + flushParagraph(); + flushList(); + continue; + } // Horizontal rule - if (/^---+\s*$/.test(line)) { flushParagraph(); flushList(); out.push('
'); continue; } + if (/^---+\s*$/.test(line)) { + flushParagraph(); + flushList(); + out.push("
"); + continue; + } // Headings const h = /^(#{1,3})\s+(.*)$/.exec(line); if (h) { - flushParagraph(); flushList(); + flushParagraph(); + flushList(); const level = h[1].length; out.push(`${formatInline(h[2])}`); continue; @@ -69,8 +88,14 @@ function renderMarkdown(src) { const li = /^[\*\-]\s+(.*)$/.exec(line); if (li) { flushParagraph(); - if (inOrdered) { out.push(''); inOrdered = false; } - if (!inList) { out.push('
    '); inList = true; } + if (inOrdered) { + out.push(""); + inOrdered = false; + } + if (!inList) { + out.push("
      "); + inList = true; + } out.push(`
    • ${formatInline(li[1])}
    • `); continue; } @@ -79,8 +104,14 @@ function renderMarkdown(src) { const oli = /^(\d+)\.\s+(.*)$/.exec(line); if (oli) { flushParagraph(); - if (inList) { out.push('
    '); inList = false; } - if (!inOrdered) { out.push('
      '); inOrdered = true; } + if (inList) { + out.push("
"); + inList = false; + } + if (!inOrdered) { + out.push("
    "); + inOrdered = true; + } out.push(`
  1. ${formatInline(oli[2])}
  2. `); continue; } @@ -88,15 +119,17 @@ function renderMarkdown(src) { // Bolded "Section title:" pattern → make it visually a section header const sec = /^\*\*(.+?)\*\*:?\s*$/.exec(line); if (sec) { - flushParagraph(); flushList(); + flushParagraph(); + flushList(); out.push(`

    ${formatInline(sec[1])}

    `); continue; } buf.push(line); } - flushParagraph(); flushList(); - return out.join('\n'); + flushParagraph(); + flushList(); + return out.join("\n"); } function formatInline(s) { @@ -106,9 +139,9 @@ function formatInline(s) { // Cite chip out = out.replace(/\[\[cite:(.+?)\]\]/g, (_, c) => `${c}`); // Bold **text** - out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); + out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); // Italic *text* — must come AFTER bold so we don't eat ** markers - out = out.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2'); + out = out.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1$2"); return out; } @@ -118,12 +151,12 @@ function formatInline(s) { function renderStatusBar(thread) { const sb = thread.statusBar || {}; - const time = sb.time || '9:41'; + const time = sb.time || "9:41"; const dnd = sb.dnd === true; // moon glyph next to time // Default: full cluster (signal + wifi + battery) return `
    -
    ${escapeHTML(time)} ${dnd ? ICONS.moonDND : ''}
    +
    ${escapeHTML(time)} ${dnd ? ICONS.moonDND : ""}
    ${ICONS.signal} ${ICONS.wifi} @@ -135,25 +168,26 @@ function renderStatusBar(thread) { function renderHeader(thread) { const h = thread.header || {}; - const style = h.style || 'model-tag'; // model-tag | title-only - const title = h.title || 'ChatGPT'; - const model = h.model || ''; // e.g. "5.1" - const rightIcons = h.rightIcons || (style === 'title-only' ? ['edit'] : ['personPlus', 'dottedCircle']); + const style = h.style || "model-tag"; // model-tag | title-only + const title = h.title || "ChatGPT"; + const model = h.model || ""; // e.g. "5.1" + const rightIcons = + h.rightIcons || (style === "title-only" ? ["edit"] : ["personPlus", "dottedCircle"]); // Optional alternate right-icon set, faded-in by the driver when the chat // transitions from "empty" to "active". Render both sets stacked when both // are provided — the driver toggles `data-active` between "primary"/"alt". const rightIconsAlt = h.rightIconsAlt || null; // Center cluster - let center = ''; - if (style === 'title-only') { + let center = ""; + if (style === "title-only") { // "ChatGPT >" hard-left actually but iOS centers — match reference. center = `
    ${escapeHTML(title)}${ICONS.chevronRight}
    `; - } else if (style === 'plain-title') { + } else if (style === "plain-title") { center = `
    ${escapeHTML(title)}
    `; } else { // model-tag default → "ChatGPT 5.1 v" - center = `
    ${escapeHTML(title)}${model ? `${escapeHTML(model)}` : ''}${ICONS.chevronDown}
    `; + center = `
    ${escapeHTML(title)}${model ? `${escapeHTML(model)}` : ""}${ICONS.chevronDown}
    `; } const iconMap = { @@ -162,16 +196,16 @@ function renderHeader(thread) { edit: ICONS.editPencil, more: ICONS.moreDots, }; - const renderCluster = (keys) => keys.map(k => iconMap[k] || '').join(''); + const renderCluster = (keys) => keys.map((k) => iconMap[k] || "").join(""); const rightHTML = rightIconsAlt ? `
    ${renderCluster(rightIcons)}
    ${renderCluster(rightIconsAlt)}
    ` : renderCluster(rightIcons); - const rightAttrs = rightIconsAlt ? ' data-active="primary"' : ''; + const rightAttrs = rightIconsAlt ? ' data-active="primary"' : ""; return ` -
    +
    ${ICONS.hamburger}
    @@ -188,22 +222,22 @@ function renderHeader(thread) { function animAttrs(msg) { // In animation mode every row gets a stable id (assigned upstream) and may // start in a hidden state. data-pending="1" hides via display:none in CSS. - const id = msg.id ? ` data-anim-id="${escapeHTML(msg.id)}"` : ''; - const pending = msg.popState === 'pending' ? ' data-pending="1"' : ''; - const animClass = msg.popState === 'pending' ? 'pop-pending' - : msg.popState === 'now' ? 'pop-now' : ''; + const id = msg.id ? ` data-anim-id="${escapeHTML(msg.id)}"` : ""; + const pending = msg.popState === "pending" ? ' data-pending="1"' : ""; + const animClass = + msg.popState === "pending" ? "pop-pending" : msg.popState === "now" ? "pop-now" : ""; return { id, pending, animClass }; } function renderUserText(msg) { const a = animAttrs(msg); - return `
    ${escapeHTML(msg.text || '').replace(/\n/g, '
    ')}
    `; + return `
    ${escapeHTML(msg.text || "").replace(/\n/g, "
    ")}
    `; } function renderUserImage(msg) { const a = animAttrs(msg); - const src = msg.src || ''; - const cls = msg.aspect === 'square' ? 'attachment square' : 'attachment'; + const src = msg.src || ""; + const cls = msg.aspect === "square" ? "attachment square" : "attachment"; return `
    `; } @@ -215,26 +249,29 @@ function wrapWordsForStreaming(html) { const out = []; let i = 0; while (i < html.length) { - if (html[i] === '<') { + if (html[i] === "<") { // Pass tags through unchanged - const end = html.indexOf('>', i); - if (end === -1) { out.push(html.slice(i)); break; } + const end = html.indexOf(">", i); + if (end === -1) { + out.push(html.slice(i)); + break; + } out.push(html.slice(i, end + 1)); i = end + 1; continue; } // Read a text run up to the next tag - const nextTag = html.indexOf('<', i); + const nextTag = html.indexOf("<", i); const run = nextTag === -1 ? html.slice(i) : html.slice(i, nextTag); // Tokenize the run into words + whitespace - run.split(/(\s+)/).forEach(tok => { + run.split(/(\s+)/).forEach((tok) => { if (!tok) return; if (/^\s+$/.test(tok)) out.push(tok); else out.push(`${tok}`); }); i = nextTag === -1 ? html.length : nextTag; } - return out.join(''); + return out.join(""); } // Browser-rendered list markers (the bullet glyphs on `
  3. `) can't be @@ -245,11 +282,11 @@ function wrapWordsForStreaming(html) { function inlineListBulletsForStreaming(html) { return html .replace(/]*>/g, '
    ') - .replace(/<\/ul>/g, '
    ') + .replace(/<\/ul>/g, "
  4. ") .replace(/]*>/g, '
    ') - .replace(/<\/ol>/g, '
    ') + .replace(/<\/ol>/g, "
    ") .replace(/
  5. /g, '
    • ') - .replace(/<\/li>/g, '
    '); + .replace(/<\/li>/g, "
  6. "); } function renderAssistant(msg) { @@ -259,17 +296,19 @@ function renderAssistant(msg) { // message.) const titleRaw = msg.title ? `
    ${escapeHTML(msg.title)}
    ` - : ''; - let body = renderMarkdown(msg.text || ''); + : ""; + let body = renderMarkdown(msg.text || ""); let title = titleRaw; if (msg.stream === true) { body = inlineListBulletsForStreaming(body); body = wrapWordsForStreaming(body); if (title) title = wrapWordsForStreaming(title); } - const feedback = msg.feedback === false ? '' : - ``; - const streamCls = msg.stream === true ? ' streaming-body' : ''; + const feedback = + msg.feedback === false + ? "" + : ``; + const streamCls = msg.stream === true ? " streaming-body" : ""; return `
    ${title} @@ -294,16 +333,16 @@ function renderEmptyState() { function renderComposer(thread) { const c = thread.composer || {}; - const placeholder = c.placeholder || 'Ask anything'; - const hasText = c.text != null && c.text !== ''; + const placeholder = c.placeholder || "Ask anything"; + const hasText = c.text != null && c.text !== ""; const streaming = c.streaming === true; const showCaret = c.cursor !== false && hasText; - const sendCls = streaming ? 'streaming' : (hasText ? 'active' : ''); + const sendCls = streaming ? "streaming" : hasText ? "active" : ""; const sendIcon = streaming ? ICONS.stopSquare : ICONS.sendArrow; // Optional Apps-SDK GPT chip if (c.chip) { - const name = c.chip.name || 'GPT'; + const name = c.chip.name || "GPT"; return `
    @@ -313,7 +352,7 @@ function renderComposer(thread) {
    ${ICONS.plus}
    -
    ${hasText ? `${escapeHTML(c.text)}` : `${escapeHTML(placeholder)}`}${showCaret ? '' : ''}
    +
    ${hasText ? `${escapeHTML(c.text)}` : `${escapeHTML(placeholder)}`}${showCaret ? '' : ""}
    ${ICONS.mic}
    ${sendIcon}
    @@ -326,7 +365,7 @@ function renderComposer(thread) {
    ${ICONS.plus}
    -
    ${hasText ? `${escapeHTML(c.text)}` : `${escapeHTML(placeholder)}`}${showCaret ? '' : ''}
    +
    ${hasText ? `${escapeHTML(c.text)}` : `${escapeHTML(placeholder)}`}${showCaret ? '' : ""}
    ${ICONS.mic}
    ${sendIcon}
    @@ -339,17 +378,19 @@ function renderComposer(thread) { // --------------------------------------------------------------------------- function loadCSS() { - return fs.readFileSync(path.join(__dirname, 'templates', 'chat.css'), 'utf-8'); + return fs.readFileSync(path.join(__dirname, "templates", "chat.css"), "utf-8"); } function renderMessages(messages) { - return (messages || []).map(m => { - if (m.type === 'user-text') return renderUserText(m); - if (m.type === 'user-image') return renderUserImage(m); - if (m.type === 'assistant') return renderAssistant(m); - if (m.type === 'loading-dot') return renderLoadingDot(m); - return ''; - }).join('\n'); + return (messages || []) + .map((m) => { + if (m.type === "user-text") return renderUserText(m); + if (m.type === "user-image") return renderUserImage(m); + if (m.type === "assistant") return renderAssistant(m); + if (m.type === "loading-dot") return renderLoadingDot(m); + return ""; + }) + .join("\n"); } function renderHTML(thread) { @@ -363,9 +404,9 @@ function renderHTML(thread) { // Inline keyboard (no external atom). Same structure as the apple-notes // mockup's .kbd block, scaled to fit a 750-wide stage. Driver toggles // data-state and the stage's data-keyboard-shown to swap layouts. - const kbHTML = thread.keyboard ? renderKeyboard(thread.keyboard) : ''; - const stageAttrs = thread.keyboard && thread.keyboard.state === 'shown' - ? ' data-keyboard-shown="1"' : ''; + const kbHTML = thread.keyboard ? renderKeyboard(thread.keyboard) : ""; + const stageAttrs = + thread.keyboard && thread.keyboard.state === "shown" ? ' data-keyboard-shown="1"' : ""; return ` @@ -384,40 +425,40 @@ function renderHTML(thread) { // block. The chatgpt variant has no formatting toolbar (apple-notes' Aa / // checklist / etc.) because the composer pill sits above instead. function renderKeyboard(kb) { - if (!kb) return ''; - const id = kb.id || 'kb'; - const state = kb.state || 'hidden'; - const upper = kb.shift === 'upper'; - const ltr = c => upper ? c.toUpperCase() : c; - const r1 = (kb.letters_row1 || 'qwertyuiop').split(''); - const r2 = (kb.letters_row2 || 'asdfghjkl').split(''); - const r3 = (kb.letters_row3 || 'zxcvbnm').split(''); - const sug = kb.suggestions || ['I', 'The', "I'm"]; + if (!kb) return ""; + const id = kb.id || "kb"; + const state = kb.state || "hidden"; + const upper = kb.shift === "upper"; + const ltr = (c) => (upper ? c.toUpperCase() : c); + const r1 = (kb.letters_row1 || "qwertyuiop").split(""); + const r2 = (kb.letters_row2 || "asdfghjkl").split(""); + const r3 = (kb.letters_row3 || "zxcvbnm").split(""); + const sug = kb.suggestions || ["I", "The", "I'm"]; return `
    - ${sug.map(s => `
    ${escapeHTML(s)}
    `).join('')} + ${sug.map((s) => `
    ${escapeHTML(s)}
    `).join("")}
    - ${r1.map(c => `
    ${escapeHTML(ltr(c))}
    `).join('')} + ${r1.map((c) => `
    ${escapeHTML(ltr(c))}
    `).join("")}
    - ${r2.map(c => `
    ${escapeHTML(ltr(c))}
    `).join('')} + ${r2.map((c) => `
    ${escapeHTML(ltr(c))}
    `).join("")}
    -
    ${ICONS.kbdShift || '⇧'}
    - ${r3.map(c => `
    ${escapeHTML(ltr(c))}
    `).join('')} -
    ${ICONS.kbdBackspace || '⌫'}
    +
    ${ICONS.kbdShift || "⇧"}
    + ${r3.map((c) => `
    ${escapeHTML(ltr(c))}
    `).join("")} +
    ${ICONS.kbdBackspace || "⌫"}
    123
    -
    ${ICONS.kbdEmoji || '☻'}
    +
    ${ICONS.kbdEmoji || "☻"}
    space
    -
    ${ICONS.kbdReturn || '⏎'}
    +
    ${ICONS.kbdReturn || "⏎"}
    `; @@ -426,8 +467,8 @@ function renderKeyboard(kb) { function parseArgs(argv) { const args = {}; for (let i = 2; i < argv.length; i++) { - if (argv[i] === '--thread') args.thread = argv[++i]; - else if (argv[i] === '--out') args.out = argv[++i]; + if (argv[i] === "--thread") args.thread = argv[++i]; + else if (argv[i] === "--out") args.out = argv[++i]; } return args; } @@ -435,10 +476,10 @@ function parseArgs(argv) { function main() { const args = parseArgs(process.argv); if (!args.thread || !args.out) { - console.error('Usage: node generate.js --thread path.json --out path.html'); + console.error("Usage: node generate.js --thread path.json --out path.html"); process.exit(1); } - const thread = JSON.parse(fs.readFileSync(args.thread, 'utf-8')); + const thread = JSON.parse(fs.readFileSync(args.thread, "utf-8")); const html = renderHTML(thread); fs.mkdirSync(path.dirname(args.out), { recursive: true }); fs.writeFileSync(args.out, html); diff --git a/.claude/skills/create-chatgpt-mockup/render.js b/.claude/skills/create-chatgpt-mockup/render.js index 5496db78..2c8deeb0 100644 --- a/.claude/skills/create-chatgpt-mockup/render.js +++ b/.claude/skills/create-chatgpt-mockup/render.js @@ -6,22 +6,31 @@ * node render.js --thread my-thread.json --output ./exports --name my-mockup */ -const fs = require('fs'); -const path = require('path'); -const { renderHTML } = require('./generate'); -const { capture } = require('./screenshot'); +const fs = require("fs"); +const path = require("path"); +const { renderHTML } = require("./generate"); +const { capture } = require("./screenshot"); function parseArgs(argv) { const args = {}; for (let i = 2; i < argv.length; i++) { const a = argv[i]; switch (a) { - case '--thread': args.thread = argv[++i]; break; - case '--output': args.output = argv[++i]; break; - case '--name': args.name = argv[++i]; break; - case '-h': - case '--help': args.help = true; break; - default: break; + case "--thread": + args.thread = argv[++i]; + break; + case "--output": + args.output = argv[++i]; + break; + case "--name": + args.name = argv[++i]; + break; + case "-h": + case "--help": + args.help = true; + break; + default: + break; } } return args; @@ -40,60 +49,68 @@ Default is ./chatgpt-mockup-exports/ in the cwd. `; function slugify(s) { - return (s || 'mockup') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, '') - .slice(0, 40) || 'mockup'; + return ( + (s || "mockup") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .slice(0, 40) || "mockup" + ); } function pickFirstUserText(thread) { - const t = (thread.messages || []).find(m => m.type === 'user-text'); + const t = (thread.messages || []).find((m) => m.type === "user-text"); return t ? t.text : null; } async function main() { const args = parseArgs(process.argv); - if (args.help) { console.log(HELP); return; } - if (!args.thread) { console.error(HELP); process.exit(1); } + if (args.help) { + console.log(HELP); + return; + } + if (!args.thread) { + console.error(HELP); + process.exit(1); + } const threadPath = path.resolve(args.thread); if (!fs.existsSync(threadPath)) { console.error(`Thread file not found: ${threadPath}`); process.exit(1); } - const thread = JSON.parse(fs.readFileSync(threadPath, 'utf-8')); + const thread = JSON.parse(fs.readFileSync(threadPath, "utf-8")); // Resolve any relative image src in messages against the thread.json's dir, // so examples can ship with sibling `assets/` folders. const threadDir = path.dirname(threadPath); - for (const m of (thread.messages || [])) { - if (m.type === 'user-image' && m.src && !/^([a-z]+:|\/)/i.test(m.src)) { + for (const m of thread.messages || []) { + if (m.type === "user-image" && m.src && !/^([a-z]+:|\/)/i.test(m.src)) { m.src = `file://${path.resolve(threadDir, m.src)}`; } } const today = new Date().toISOString().slice(0, 10); - const slug = slugify(args.name || pickFirstUserText(thread) || 'mockup'); + const slug = slugify(args.name || pickFirstUserText(thread) || "mockup"); const outDir = args.output ? path.resolve(args.output, `${today}-${slug}`) - : path.resolve(process.cwd(), 'chatgpt-mockup-exports', `${today}-${slug}`); + : path.resolve(process.cwd(), "chatgpt-mockup-exports", `${today}-${slug}`); fs.mkdirSync(outDir, { recursive: true }); - const htmlPath = path.join(outDir, 'index.html'); - const pngPath = path.join(outDir, 'screenshot.png'); + const htmlPath = path.join(outDir, "index.html"); + const pngPath = path.join(outDir, "screenshot.png"); const html = renderHTML(thread); fs.writeFileSync(htmlPath, html); console.log(`HTML → ${htmlPath}`); await capture({ input: htmlPath, output: pngPath, fontDelay: 600 }); - fs.copyFileSync(threadPath, path.join(outDir, 'thread.json')); + fs.copyFileSync(threadPath, path.join(outDir, "thread.json")); console.log(`\n✓ ${pngPath}`); } -main().catch(err => { - console.error('Render failed:', err.message); +main().catch((err) => { + console.error("Render failed:", err.message); console.error(err.stack); process.exit(1); }); diff --git a/.claude/skills/create-chatgpt-mockup/screenshot.js b/.claude/skills/create-chatgpt-mockup/screenshot.js index 90cc7c3e..d9f113fb 100644 --- a/.claude/skills/create-chatgpt-mockup/screenshot.js +++ b/.claude/skills/create-chatgpt-mockup/screenshot.js @@ -5,9 +5,9 @@ * Fixed 9:16 viewport (750 x 1334), DSF 3 → 2250 x 4002 PNG. */ -const path = require('path'); -const fs = require('fs'); -const { execSync } = require('child_process'); +const path = require("path"); +const fs = require("fs"); +const { execSync } = require("child_process"); const VIEWPORT = { width: 750, height: 1624 }; const DEVICE_SCALE = 3; @@ -15,9 +15,9 @@ const DEVICE_SCALE = 3; function parseArgs(argv) { const args = {}; for (let i = 2; i < argv.length; i++) { - if (argv[i] === '--input' && argv[i + 1]) args.input = argv[++i]; - else if (argv[i] === '--output' && argv[i + 1]) args.output = argv[++i]; - else if (argv[i] === '--font-delay' && argv[i + 1]) args.fontDelay = parseInt(argv[++i], 10); + if (argv[i] === "--input" && argv[i + 1]) args.input = argv[++i]; + else if (argv[i] === "--output" && argv[i + 1]) args.output = argv[++i]; + else if (argv[i] === "--font-delay" && argv[i + 1]) args.fontDelay = parseInt(argv[++i], 10); } return args; } @@ -25,10 +25,10 @@ function parseArgs(argv) { async function ensureChromium() { let chromium; try { - ({ chromium } = require('playwright')); + ({ chromium } = require("playwright")); } catch (err) { - if (err && err.code === 'MODULE_NOT_FOUND') { - console.error('Error: playwright not installed. Run `npm install` in the skill directory.'); + if (err && err.code === "MODULE_NOT_FOUND") { + console.error("Error: playwright not installed. Run `npm install` in the skill directory."); process.exit(1); } throw err; @@ -36,8 +36,8 @@ async function ensureChromium() { try { chromium.executablePath(); } catch { - console.log('Installing chromium via playwright...'); - execSync('npx playwright install chromium', { stdio: 'inherit' }); + console.log("Installing chromium via playwright..."); + execSync("npx playwright install chromium", { stdio: "inherit" }); } return chromium; } @@ -53,12 +53,12 @@ async function capture(args) { fs.mkdirSync(path.dirname(args.output), { recursive: true }); - await page.goto(`file://${args.input}`, { waitUntil: 'networkidle', timeout: 15000 }); + await page.goto(`file://${args.input}`, { waitUntil: "networkidle", timeout: 15000 }); await page.waitForTimeout(args.fontDelay || 600); await page.screenshot({ path: args.output, - type: 'png', + type: "png", fullPage: false, omitBackground: false, }); @@ -70,7 +70,9 @@ async function capture(args) { async function main() { const args = parseArgs(process.argv); if (!args.input || !args.output) { - console.error('Usage: node screenshot.js --input page.html --output out.png [--font-delay 600]'); + console.error( + "Usage: node screenshot.js --input page.html --output out.png [--font-delay 600]", + ); process.exit(1); } args.input = path.resolve(args.input); @@ -81,8 +83,8 @@ async function main() { } if (require.main === module) { - main().catch(err => { - console.error('Screenshot failed:', err.message); + main().catch((err) => { + console.error("Screenshot failed:", err.message); process.exit(1); }); } diff --git a/.claude/skills/create-chatgpt-video-ad/examples/smoke-app-question/clips/record-master.js b/.claude/skills/create-chatgpt-video-ad/examples/smoke-app-question/clips/record-master.js index 686c3f06..58c0e810 100644 --- a/.claude/skills/create-chatgpt-video-ad/examples/smoke-app-question/clips/record-master.js +++ b/.claude/skills/create-chatgpt-video-ad/examples/smoke-app-question/clips/record-master.js @@ -13,9 +13,9 @@ * node clips/record-master.js */ -const fs = require('fs'); -const path = require('path'); -const { chromium } = require('playwright'); +const fs = require("fs"); +const path = require("path"); +const { chromium } = require("playwright"); // Walk up from __dirname until we find skills/atoms/messaging/create-chatgpt-mockup. // Works regardless of whether this file lives in @@ -25,50 +25,56 @@ const { chromium } = require('playwright'); function findAtom(start, atomRel) { let dir = start; for (let i = 0; i < 12; i++) { - const candidate = path.join(dir, 'skills/atoms/messaging', atomRel); - if (fs.existsSync(path.join(candidate, 'generate.js'))) return candidate; + const candidate = path.join(dir, "skills/atoms/messaging", atomRel); + if (fs.existsSync(path.join(candidate, "generate.js"))) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } throw new Error(`Could not locate skills/atoms/messaging/${atomRel} from ${start}`); } -const CHATGPT_MOCKUP = findAtom(__dirname, 'create-chatgpt-mockup'); -const { renderHTML } = require(path.join(CHATGPT_MOCKUP, 'generate')); +const CHATGPT_MOCKUP = findAtom(__dirname, "create-chatgpt-mockup"); +const { renderHTML } = require(path.join(CHATGPT_MOCKUP, "generate")); // --------------------------------------------------------------------------- // Locate the project (this script is copied into /clips/) // --------------------------------------------------------------------------- -const PROJECT_DIR = path.resolve(__dirname, '..'); -const THREAD_PATH = path.join(PROJECT_DIR, 'thread.json'); -const TIMELINE_PATH = path.join(PROJECT_DIR, 'timeline.json'); -const OUT_DIR = path.join(PROJECT_DIR, 'clips'); -const OUT_VIDEO = path.join(OUT_DIR, 'master-chat.mp4'); -const OUT_SFX = path.join(OUT_DIR, 'master-chat.sfx.json'); +const PROJECT_DIR = path.resolve(__dirname, ".."); +const THREAD_PATH = path.join(PROJECT_DIR, "thread.json"); +const TIMELINE_PATH = path.join(PROJECT_DIR, "timeline.json"); +const OUT_DIR = path.join(PROJECT_DIR, "clips"); +const OUT_VIDEO = path.join(OUT_DIR, "master-chat.mp4"); +const OUT_SFX = path.join(OUT_DIR, "master-chat.sfx.json"); -const VIEWPORT = { width: 750, height: 1624 }; // modern iPhone Pro ~9:19.5 -const DEVICE_SCALE = 2; // 1500×3248 PNG would be heavy as video; 2x is enough +const VIEWPORT = { width: 750, height: 1624 }; // modern iPhone Pro ~9:19.5 +const DEVICE_SCALE = 2; // 1500×3248 PNG would be heavy as video; 2x is enough const FPS = 30; // --------------------------------------------------------------------------- // Load thread + timeline // --------------------------------------------------------------------------- -if (!fs.existsSync(THREAD_PATH)) { console.error(`Missing thread.json at ${THREAD_PATH}`); process.exit(1); } -if (!fs.existsSync(TIMELINE_PATH)) { console.error(`Missing timeline.json at ${TIMELINE_PATH}`); process.exit(1); } +if (!fs.existsSync(THREAD_PATH)) { + console.error(`Missing thread.json at ${THREAD_PATH}`); + process.exit(1); +} +if (!fs.existsSync(TIMELINE_PATH)) { + console.error(`Missing timeline.json at ${TIMELINE_PATH}`); + process.exit(1); +} -const thread = JSON.parse(fs.readFileSync(THREAD_PATH, 'utf-8')); -const timeline = JSON.parse(fs.readFileSync(TIMELINE_PATH, 'utf-8')); +const thread = JSON.parse(fs.readFileSync(THREAD_PATH, "utf-8")); +const timeline = JSON.parse(fs.readFileSync(TIMELINE_PATH, "utf-8")); // Force every message that isn't already 'now' into the 'pending' state, so // the page renders with all bubbles hidden. The driver pops them. -for (const m of (thread.messages || [])) { - if (!m.popState) m.popState = 'pending'; +for (const m of thread.messages || []) { + if (!m.popState) m.popState = "pending"; } // Default keyboard state to hidden if a `keyboard` block is present. -if (thread.keyboard && !thread.keyboard.state) thread.keyboard.state = 'hidden'; +if (thread.keyboard && !thread.keyboard.state) thread.keyboard.state = "hidden"; // --------------------------------------------------------------------------- // Derive the deterministic SFX cue list from the timeline @@ -77,30 +83,30 @@ if (thread.keyboard && !thread.keyboard.state) thread.keyboard.state = 'hidden'; function deriveSFX(timeline) { const cues = []; for (const ev of timeline) { - if (ev.kind === 'send-tap') { - cues.push({ t: ev.t, sfx: 'send-tap' }); - } else if (ev.kind === 'composer-type') { + if (ev.kind === "send-tap") { + cues.push({ t: ev.t, sfx: "send-tap" }); + } else if (ev.kind === "composer-type") { // One key-tap per word boundary (space) — quieter than per-char. - const text = ev.text || ''; + const text = ev.text || ""; const dur = ev.dur_sec || 1; const words = text.split(/\s+/).filter(Boolean); const perWord = dur / Math.max(words.length, 1); let consumed = 0; // Tap on the first char of each word for (let i = 0; i < words.length; i++) { - cues.push({ t: +(ev.t + i * perWord).toFixed(3), sfx: 'key-tap' }); + cues.push({ t: +(ev.t + i * perWord).toFixed(3), sfx: "key-tap" }); consumed += words[i].length; } - } else if (ev.kind === 'stream-words') { + } else if (ev.kind === "stream-words") { // Stream-tick every 12 words; response-done at end. const wps = ev.wps || 7; const dur = ev.dur_sec || 6; const totalWords = Math.round(wps * dur); const perWord = 1 / wps; for (let i = 12; i < totalWords; i += 12) { - cues.push({ t: +(ev.t + i * perWord).toFixed(3), sfx: 'stream-tick' }); + cues.push({ t: +(ev.t + i * perWord).toFixed(3), sfx: "stream-tick" }); } - cues.push({ t: +(ev.t + dur + 0.05).toFixed(3), sfx: 'response-done' }); + cues.push({ t: +(ev.t + dur + 0.05).toFixed(3), sfx: "response-done" }); } } cues.sort((a, b) => a.t - b.t); @@ -129,64 +135,64 @@ function driverFnSource() { return function runDriver() { const TL = window.__TIMELINE__ || []; const t0 = performance.now(); - const stage = document.querySelector('.stage'); - const composerInput = document.querySelector('.composer .input'); - const sendBtn = document.querySelector('.composer .send-btn'); - const header = document.querySelector('.gpt-header .right'); - const kb = document.querySelector('.kbd'); + const stage = document.querySelector(".stage"); + const composerInput = document.querySelector(".composer .input"); + const sendBtn = document.querySelector(".composer .send-btn"); + const header = document.querySelector(".gpt-header .right"); + const kb = document.querySelector(".kbd"); function setComposerText(text) { if (!composerInput) return; - const ct = composerInput.querySelector('.composer-text'); - const ph = composerInput.querySelector('.placeholder'); - if (ph) ph.style.display = text ? 'none' : ''; + const ct = composerInput.querySelector(".composer-text"); + const ph = composerInput.querySelector(".placeholder"); + if (ph) ph.style.display = text ? "none" : ""; if (ct) { ct.textContent = text; - ct.style.display = text ? '' : 'none'; + ct.style.display = text ? "" : "none"; } else if (text) { - const span = document.createElement('span'); - span.className = 'composer-text'; + const span = document.createElement("span"); + span.className = "composer-text"; span.textContent = text; composerInput.insertBefore(span, composerInput.firstChild); } // Ensure caret exists when there's text - let caret = composerInput.querySelector('.caret'); + let caret = composerInput.querySelector(".caret"); if (text && !caret) { - caret = document.createElement('span'); - caret.className = 'caret'; + caret = document.createElement("span"); + caret.className = "caret"; composerInput.appendChild(caret); } else if (!text && caret) { caret.remove(); } // Send button activates when there's text if (sendBtn) { - sendBtn.classList.toggle('active', !!text); + sendBtn.classList.toggle("active", !!text); } } function setSendState(state) { if (!sendBtn) return; - sendBtn.classList.remove('active', 'streaming'); - if (state === 'active' || state === 'streaming') { + sendBtn.classList.remove("active", "streaming"); + if (state === "active" || state === "streaming") { sendBtn.classList.add(state); } // Swap icon when streaming/active const arrow = ``; const stop = ``; - sendBtn.innerHTML = state === 'streaming' ? stop : arrow; + sendBtn.innerHTML = state === "streaming" ? stop : arrow; } function popRow(id) { const row = document.querySelector(`[data-anim-id="${id}"]`); if (!row) return; - row.removeAttribute('data-pending'); - row.classList.add('pop-now'); + row.removeAttribute("data-pending"); + row.classList.add("pop-now"); } function hideRow(id) { const row = document.querySelector(`[data-anim-id="${id}"]`); if (!row) return; - row.setAttribute('data-pending', '1'); - row.classList.remove('pop-now'); + row.setAttribute("data-pending", "1"); + row.classList.remove("pop-now"); } function streamWords(id, dur_sec, wps, startedAt) { @@ -230,51 +236,51 @@ function driverFnSource() { while (cursor < TL.length && TL[cursor].t <= t) { const ev = TL[cursor++]; switch (ev.kind) { - case 'composer-type': + case "composer-type": startTyping(ev.text, ev.dur_sec || 2, now); break; - case 'composer-clear': - setComposerText(''); + case "composer-clear": + setComposerText(""); break; - case 'send-tap': { + case "send-tap": { if (sendBtn) { - sendBtn.style.transformOrigin = 'center'; - sendBtn.animate( - [{ transform: 'scale(0.86)' }, { transform: 'scale(1.0)' }], - { duration: 140, easing: 'cubic-bezier(0.2,0.7,0.2,1)' } - ); + sendBtn.style.transformOrigin = "center"; + sendBtn.animate([{ transform: "scale(0.86)" }, { transform: "scale(1.0)" }], { + duration: 140, + easing: "cubic-bezier(0.2,0.7,0.2,1)", + }); } break; } - case 'pop': + case "pop": popRow(ev.target); break; - case 'header-swap': - if (header) header.setAttribute('data-active', ev.value || 'alt'); + case "header-swap": + if (header) header.setAttribute("data-active", ev.value || "alt"); break; - case 'keyboard-show': - if (kb) kb.setAttribute('data-state', 'shown'); - if (stage) stage.setAttribute('data-keyboard-shown', '1'); + case "keyboard-show": + if (kb) kb.setAttribute("data-state", "shown"); + if (stage) stage.setAttribute("data-keyboard-shown", "1"); break; - case 'keyboard-hide': - if (kb) kb.setAttribute('data-state', 'hidden'); - if (stage) stage.removeAttribute('data-keyboard-shown'); + case "keyboard-hide": + if (kb) kb.setAttribute("data-state", "hidden"); + if (stage) stage.removeAttribute("data-keyboard-shown"); break; - case 'loading-dot-show': + case "loading-dot-show": popRow(ev.target); break; - case 'loading-dot-hide': + case "loading-dot-hide": hideRow(ev.target); break; - case 'send-state': + case "send-state": setSendState(ev.value); break; - case 'stream-words': { + case "stream-words": { const j = streamWords(ev.target, ev.dur_sec || 6, ev.wps || 7, now); if (j) streamJobs.push(j); break; } - case 'scroll-to': { + case "scroll-to": { const row = document.querySelector(`[data-anim-id="${ev.target}"]`); if (row) { const rect = row.getBoundingClientRect(); @@ -290,7 +296,10 @@ function driverFnSource() { // Drive any active typing jobs for (const job of typingJobs) { const elapsed = (now - job.startedAt) / 1000; - const targetIdx = Math.min(job.text.length, Math.floor((elapsed / job.dur_sec) * job.text.length)); + const targetIdx = Math.min( + job.text.length, + Math.floor((elapsed / job.dur_sec) * job.text.length), + ); if (targetIdx !== job.lastIdx) { setComposerText(job.text.slice(0, targetIdx)); job.lastIdx = targetIdx; @@ -301,7 +310,7 @@ function driverFnSource() { for (const job of streamJobs) { const elapsed = now - job.startedAt; while (job.done < job.total && job.revealAt[job.done] <= elapsed) { - job.words[job.done].setAttribute('data-stream', '1'); + job.words[job.done].setAttribute("data-stream", "1"); job.done++; } // Once 80% revealed, kick a scroll-to so the rest stays in view @@ -347,15 +356,15 @@ async function main() { const totalDurMs = Math.round((lastT + 1.6) * 1000); const driverFn = `(${driverFnSource().toString()})();`; html = html.replace( - '', + "", ` ` +`, ); - const pagePath = path.join(OUT_DIR, 'master-chat.page.html'); + const pagePath = path.join(OUT_DIR, "master-chat.page.html"); fs.writeFileSync(pagePath, html); console.log(`Page HTML → ${pagePath}`); @@ -370,7 +379,7 @@ window.__START_DRIVER__ = function () { ${driverFn} }; }, }); const page = await ctx.newPage(); - await page.goto(`file://${pagePath}`, { waitUntil: 'load' }); + await page.goto(`file://${pagePath}`, { waitUntil: "load" }); await page.waitForTimeout(300); // 4) Boot the driver from the host side (page-side listener is unreliable @@ -378,29 +387,35 @@ window.__START_DRIVER__ = function () { ${driverFn} }; await page.evaluate(() => window.__START_DRIVER__ && window.__START_DRIVER__()); // 5) Wait for the driver to flag done - await page.waitForFunction(() => window.__DONE__ === true, { timeout: totalDurMs + 5000, polling: 100 }); + await page.waitForFunction(() => window.__DONE__ === true, { + timeout: totalDurMs + 5000, + polling: 100, + }); // 5) Close everything to flush the recording const video = page.video(); await page.close(); await ctx.close(); await browser.close(); - if (!video) { console.error('No video recorded'); process.exit(1); } + if (!video) { + console.error("No video recorded"); + process.exit(1); + } const raw = await video.path(); // 6) Convert to h.264 mp4 at 30fps (Playwright writes webm by default) - const { execSync } = require('child_process'); + const { execSync } = require("child_process"); execSync( `ffmpeg -y -i "${raw}" -r ${FPS} -c:v libx264 -pix_fmt yuv420p -movflags +faststart -an "${OUT_VIDEO}"`, - { stdio: 'inherit' } + { stdio: "inherit" }, ); // Clean up the raw webm fs.unlinkSync(raw); console.log(`\n✓ ${OUT_VIDEO}`); } -main().catch(err => { - console.error('Record failed:', err.message); +main().catch((err) => { + console.error("Record failed:", err.message); console.error(err.stack); process.exit(1); }); diff --git a/.claude/skills/create-chatgpt-video-ad/scripts/record-master.template.js b/.claude/skills/create-chatgpt-video-ad/scripts/record-master.template.js index b1d805df..34914249 100644 --- a/.claude/skills/create-chatgpt-video-ad/scripts/record-master.template.js +++ b/.claude/skills/create-chatgpt-video-ad/scripts/record-master.template.js @@ -13,9 +13,9 @@ * node clips/record-master.js */ -const fs = require('fs'); -const path = require('path'); -const { chromium } = require('playwright'); +const fs = require("fs"); +const path = require("path"); +const { chromium } = require("playwright"); // Walk up from __dirname until we find skills/atoms/messaging/create-chatgpt-mockup. // Works regardless of whether this file lives in @@ -25,22 +25,22 @@ const { chromium } = require('playwright'); function findAtom(start, atomRel) { let dir = start; for (let i = 0; i < 12; i++) { - const candidate = path.join(dir, 'skills/atoms/messaging', atomRel); - if (fs.existsSync(path.join(candidate, 'generate.js'))) return candidate; + const candidate = path.join(dir, "skills/atoms/messaging", atomRel); + if (fs.existsSync(path.join(candidate, "generate.js"))) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } throw new Error(`Could not locate skills/atoms/messaging/${atomRel} from ${start}`); } -const CHATGPT_MOCKUP = findAtom(__dirname, 'create-chatgpt-mockup'); -const { renderHTML } = require(path.join(CHATGPT_MOCKUP, 'generate')); +const CHATGPT_MOCKUP = findAtom(__dirname, "create-chatgpt-mockup"); +const { renderHTML } = require(path.join(CHATGPT_MOCKUP, "generate")); // --------------------------------------------------------------------------- // Locate the project (this script is copied into /clips/) // --------------------------------------------------------------------------- -const PROJECT_DIR = path.resolve(__dirname, '..'); +const PROJECT_DIR = path.resolve(__dirname, ".."); // Look for the spec in the project root first, then in working/. Either // location is canonical — the molecule's HOW_TO uses both conventions. This @@ -48,20 +48,20 @@ const PROJECT_DIR = path.resolve(__dirname, '..'); // root copy still exists, while still letting either layout work. function resolveSpec(name) { const root = path.join(PROJECT_DIR, name); - const working = path.join(PROJECT_DIR, 'working', name); + const working = path.join(PROJECT_DIR, "working", name); if (fs.existsSync(root)) return root; if (fs.existsSync(working)) return working; console.error(`Missing ${name} — expected at ${root} or ${working}`); process.exit(1); } -const THREAD_PATH = resolveSpec('thread.json'); -const TIMELINE_PATH = resolveSpec('timeline.json'); -const OUT_DIR = path.join(PROJECT_DIR, 'clips'); -const OUT_VIDEO = path.join(OUT_DIR, 'master-chat.mp4'); -const OUT_SFX = path.join(OUT_DIR, 'master-chat.sfx.json'); - -const VIEWPORT = { width: 750, height: 1624 }; // modern iPhone Pro ~9:19.5 -const DEVICE_SCALE = 2; // 1500×3248 PNG would be heavy as video; 2x is enough +const THREAD_PATH = resolveSpec("thread.json"); +const TIMELINE_PATH = resolveSpec("timeline.json"); +const OUT_DIR = path.join(PROJECT_DIR, "clips"); +const OUT_VIDEO = path.join(OUT_DIR, "master-chat.mp4"); +const OUT_SFX = path.join(OUT_DIR, "master-chat.sfx.json"); + +const VIEWPORT = { width: 750, height: 1624 }; // modern iPhone Pro ~9:19.5 +const DEVICE_SCALE = 2; // 1500×3248 PNG would be heavy as video; 2x is enough const FPS = 30; // --------------------------------------------------------------------------- @@ -69,17 +69,17 @@ const FPS = 30; // --------------------------------------------------------------------------- // resolveSpec above already errored out if either file was missing. -const thread = JSON.parse(fs.readFileSync(THREAD_PATH, 'utf-8')); -const timeline = JSON.parse(fs.readFileSync(TIMELINE_PATH, 'utf-8')); +const thread = JSON.parse(fs.readFileSync(THREAD_PATH, "utf-8")); +const timeline = JSON.parse(fs.readFileSync(TIMELINE_PATH, "utf-8")); // Force every message that isn't already 'now' into the 'pending' state, so // the page renders with all bubbles hidden. The driver pops them. -for (const m of (thread.messages || [])) { - if (!m.popState) m.popState = 'pending'; +for (const m of thread.messages || []) { + if (!m.popState) m.popState = "pending"; } // Default keyboard state to hidden if a `keyboard` block is present. -if (thread.keyboard && !thread.keyboard.state) thread.keyboard.state = 'hidden'; +if (thread.keyboard && !thread.keyboard.state) thread.keyboard.state = "hidden"; // --------------------------------------------------------------------------- // Derive the deterministic SFX cue list from the timeline @@ -88,30 +88,30 @@ if (thread.keyboard && !thread.keyboard.state) thread.keyboard.state = 'hidden'; function deriveSFX(timeline) { const cues = []; for (const ev of timeline) { - if (ev.kind === 'send-tap') { - cues.push({ t: ev.t, sfx: 'send-tap' }); - } else if (ev.kind === 'composer-type') { + if (ev.kind === "send-tap") { + cues.push({ t: ev.t, sfx: "send-tap" }); + } else if (ev.kind === "composer-type") { // One key-tap per word boundary (space) — quieter than per-char. - const text = ev.text || ''; + const text = ev.text || ""; const dur = ev.dur_sec || 1; const words = text.split(/\s+/).filter(Boolean); const perWord = dur / Math.max(words.length, 1); let consumed = 0; // Tap on the first char of each word for (let i = 0; i < words.length; i++) { - cues.push({ t: +(ev.t + i * perWord).toFixed(3), sfx: 'key-tap' }); + cues.push({ t: +(ev.t + i * perWord).toFixed(3), sfx: "key-tap" }); consumed += words[i].length; } - } else if (ev.kind === 'stream-words') { + } else if (ev.kind === "stream-words") { // Stream-tick every 12 words; response-done at end. const wps = ev.wps || 7; const dur = ev.dur_sec || 6; const totalWords = Math.round(wps * dur); const perWord = 1 / wps; for (let i = 12; i < totalWords; i += 12) { - cues.push({ t: +(ev.t + i * perWord).toFixed(3), sfx: 'stream-tick' }); + cues.push({ t: +(ev.t + i * perWord).toFixed(3), sfx: "stream-tick" }); } - cues.push({ t: +(ev.t + dur + 0.05).toFixed(3), sfx: 'response-done' }); + cues.push({ t: +(ev.t + dur + 0.05).toFixed(3), sfx: "response-done" }); } } cues.sort((a, b) => a.t - b.t); @@ -140,64 +140,64 @@ function driverFnSource() { return function runDriver() { const TL = window.__TIMELINE__ || []; const t0 = performance.now(); - const stage = document.querySelector('.stage'); - const composerInput = document.querySelector('.composer .input'); - const sendBtn = document.querySelector('.composer .send-btn'); - const header = document.querySelector('.gpt-header .right'); - const kb = document.querySelector('.kbd'); + const stage = document.querySelector(".stage"); + const composerInput = document.querySelector(".composer .input"); + const sendBtn = document.querySelector(".composer .send-btn"); + const header = document.querySelector(".gpt-header .right"); + const kb = document.querySelector(".kbd"); function setComposerText(text) { if (!composerInput) return; - const ct = composerInput.querySelector('.composer-text'); - const ph = composerInput.querySelector('.placeholder'); - if (ph) ph.style.display = text ? 'none' : ''; + const ct = composerInput.querySelector(".composer-text"); + const ph = composerInput.querySelector(".placeholder"); + if (ph) ph.style.display = text ? "none" : ""; if (ct) { ct.textContent = text; - ct.style.display = text ? '' : 'none'; + ct.style.display = text ? "" : "none"; } else if (text) { - const span = document.createElement('span'); - span.className = 'composer-text'; + const span = document.createElement("span"); + span.className = "composer-text"; span.textContent = text; composerInput.insertBefore(span, composerInput.firstChild); } // Ensure caret exists when there's text - let caret = composerInput.querySelector('.caret'); + let caret = composerInput.querySelector(".caret"); if (text && !caret) { - caret = document.createElement('span'); - caret.className = 'caret'; + caret = document.createElement("span"); + caret.className = "caret"; composerInput.appendChild(caret); } else if (!text && caret) { caret.remove(); } // Send button activates when there's text if (sendBtn) { - sendBtn.classList.toggle('active', !!text); + sendBtn.classList.toggle("active", !!text); } } function setSendState(state) { if (!sendBtn) return; - sendBtn.classList.remove('active', 'streaming'); - if (state === 'active' || state === 'streaming') { + sendBtn.classList.remove("active", "streaming"); + if (state === "active" || state === "streaming") { sendBtn.classList.add(state); } // Swap icon when streaming/active const arrow = ``; const stop = ``; - sendBtn.innerHTML = state === 'streaming' ? stop : arrow; + sendBtn.innerHTML = state === "streaming" ? stop : arrow; } function popRow(id) { const row = document.querySelector(`[data-anim-id="${id}"]`); if (!row) return; - row.removeAttribute('data-pending'); - row.classList.add('pop-now'); + row.removeAttribute("data-pending"); + row.classList.add("pop-now"); } function hideRow(id) { const row = document.querySelector(`[data-anim-id="${id}"]`); if (!row) return; - row.setAttribute('data-pending', '1'); - row.classList.remove('pop-now'); + row.setAttribute("data-pending", "1"); + row.classList.remove("pop-now"); } function streamWords(id, dur_sec, wps, startedAt) { @@ -241,51 +241,51 @@ function driverFnSource() { while (cursor < TL.length && TL[cursor].t <= t) { const ev = TL[cursor++]; switch (ev.kind) { - case 'composer-type': + case "composer-type": startTyping(ev.text, ev.dur_sec || 2, now); break; - case 'composer-clear': - setComposerText(''); + case "composer-clear": + setComposerText(""); break; - case 'send-tap': { + case "send-tap": { if (sendBtn) { - sendBtn.style.transformOrigin = 'center'; - sendBtn.animate( - [{ transform: 'scale(0.86)' }, { transform: 'scale(1.0)' }], - { duration: 140, easing: 'cubic-bezier(0.2,0.7,0.2,1)' } - ); + sendBtn.style.transformOrigin = "center"; + sendBtn.animate([{ transform: "scale(0.86)" }, { transform: "scale(1.0)" }], { + duration: 140, + easing: "cubic-bezier(0.2,0.7,0.2,1)", + }); } break; } - case 'pop': + case "pop": popRow(ev.target); break; - case 'header-swap': - if (header) header.setAttribute('data-active', ev.value || 'alt'); + case "header-swap": + if (header) header.setAttribute("data-active", ev.value || "alt"); break; - case 'keyboard-show': - if (kb) kb.setAttribute('data-state', 'shown'); - if (stage) stage.setAttribute('data-keyboard-shown', '1'); + case "keyboard-show": + if (kb) kb.setAttribute("data-state", "shown"); + if (stage) stage.setAttribute("data-keyboard-shown", "1"); break; - case 'keyboard-hide': - if (kb) kb.setAttribute('data-state', 'hidden'); - if (stage) stage.removeAttribute('data-keyboard-shown'); + case "keyboard-hide": + if (kb) kb.setAttribute("data-state", "hidden"); + if (stage) stage.removeAttribute("data-keyboard-shown"); break; - case 'loading-dot-show': + case "loading-dot-show": popRow(ev.target); break; - case 'loading-dot-hide': + case "loading-dot-hide": hideRow(ev.target); break; - case 'send-state': + case "send-state": setSendState(ev.value); break; - case 'stream-words': { + case "stream-words": { const j = streamWords(ev.target, ev.dur_sec || 6, ev.wps || 7, now); if (j) streamJobs.push(j); break; } - case 'scroll-to': { + case "scroll-to": { const row = document.querySelector(`[data-anim-id="${ev.target}"]`); if (row) { const rect = row.getBoundingClientRect(); @@ -301,7 +301,10 @@ function driverFnSource() { // Drive any active typing jobs for (const job of typingJobs) { const elapsed = (now - job.startedAt) / 1000; - const targetIdx = Math.min(job.text.length, Math.floor((elapsed / job.dur_sec) * job.text.length)); + const targetIdx = Math.min( + job.text.length, + Math.floor((elapsed / job.dur_sec) * job.text.length), + ); if (targetIdx !== job.lastIdx) { setComposerText(job.text.slice(0, targetIdx)); job.lastIdx = targetIdx; @@ -312,7 +315,7 @@ function driverFnSource() { for (const job of streamJobs) { const elapsed = now - job.startedAt; while (job.done < job.total && job.revealAt[job.done] <= elapsed) { - job.words[job.done].setAttribute('data-stream', '1'); + job.words[job.done].setAttribute("data-stream", "1"); job.done++; } // Once 80% revealed, kick a scroll-to so the rest stays in view @@ -358,15 +361,15 @@ async function main() { const totalDurMs = Math.round((lastT + 1.6) * 1000); const driverFn = `(${driverFnSource().toString()})();`; html = html.replace( - '', + "", ` ` +`, ); - const pagePath = path.join(OUT_DIR, 'master-chat.page.html'); + const pagePath = path.join(OUT_DIR, "master-chat.page.html"); fs.writeFileSync(pagePath, html); console.log(`Page HTML → ${pagePath}`); @@ -381,7 +384,7 @@ window.__START_DRIVER__ = function () { ${driverFn} }; }, }); const page = await ctx.newPage(); - await page.goto(`file://${pagePath}`, { waitUntil: 'load' }); + await page.goto(`file://${pagePath}`, { waitUntil: "load" }); await page.waitForTimeout(300); // 4) Boot the driver from the host side (page-side listener is unreliable @@ -389,29 +392,35 @@ window.__START_DRIVER__ = function () { ${driverFn} }; await page.evaluate(() => window.__START_DRIVER__ && window.__START_DRIVER__()); // 5) Wait for the driver to flag done - await page.waitForFunction(() => window.__DONE__ === true, { timeout: totalDurMs + 5000, polling: 100 }); + await page.waitForFunction(() => window.__DONE__ === true, { + timeout: totalDurMs + 5000, + polling: 100, + }); // 5) Close everything to flush the recording const video = page.video(); await page.close(); await ctx.close(); await browser.close(); - if (!video) { console.error('No video recorded'); process.exit(1); } + if (!video) { + console.error("No video recorded"); + process.exit(1); + } const raw = await video.path(); // 6) Convert to h.264 mp4 at 30fps (Playwright writes webm by default) - const { execSync } = require('child_process'); + const { execSync } = require("child_process"); execSync( `ffmpeg -y -i "${raw}" -r ${FPS} -c:v libx264 -pix_fmt yuv420p -movflags +faststart -an "${OUT_VIDEO}"`, - { stdio: 'inherit' } + { stdio: "inherit" }, ); // Clean up the raw webm fs.unlinkSync(raw); console.log(`\n✓ ${OUT_VIDEO}`); } -main().catch(err => { - console.error('Record failed:', err.message); +main().catch((err) => { + console.error("Record failed:", err.message); console.error(err.stack); process.exit(1); }); diff --git a/.claude/skills/create-html-carousel/screenshot-slides.js b/.claude/skills/create-html-carousel/screenshot-slides.js index fbb6be82..0ee3510b 100755 --- a/.claude/skills/create-html-carousel/screenshot-slides.js +++ b/.claude/skills/create-html-carousel/screenshot-slides.js @@ -2,132 +2,131 @@ /** * LinkedIn Carousel Screenshot Tool - * + * * Automatically captures all HTML slides as 1080x1080px PNG images * for uploading to LinkedIn carousel posts. - * + * * Usage: * node screenshot-slides.js - * + * * Example: * node screenshot-slides.js ai-gtm-workflows */ -const { chromium } = require('playwright'); -const path = require('path'); -const fs = require('fs'); +const { chromium } = require("playwright"); +const path = require("path"); +const fs = require("fs"); // Output directory is alongside the slides async function screenshotSlides(carouselName) { - const carouselDir = path.join(process.cwd(), carouselName); - const slidesDir = path.join(carouselDir, 'slides'); - const outputDir = path.join(carouselDir, 'exports'); - - // Validate slides directory exists - if (!fs.existsSync(slidesDir)) { - console.error(`❌ Error: Slides directory not found: ${slidesDir}`); - console.error('Make sure the carousel HTML files have been generated first.'); - process.exit(1); - } - - // Create exports directory - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - console.log(`📁 Created exports directory: ${outputDir}`); - } - - // Find all HTML files in slides directory - const slideFiles = fs.readdirSync(slidesDir) - .filter(f => f.endsWith('.html')) - .sort(); // Ensure slides are in order - - if (slideFiles.length === 0) { - console.error(`❌ No HTML files found in ${slidesDir}`); - process.exit(1); - } - - console.log(`\n🚀 Starting screenshot capture for ${slideFiles.length} slides...\n`); - - // Launch browser - const browser = await chromium.launch({ - headless: true, - }); - - const context = await browser.newContext({ - viewport: { - width: 1080, - height: 1080, - }, - deviceScaleFactor: 2, // Retina quality for crisp text - }); - - const page = await context.newPage(); - - // Process each slide - for (let i = 0; i < slideFiles.length; i++) { - const slideFile = slideFiles[i]; - const slideNum = String(i + 1).padStart(2, '0'); - const slidePath = path.join(slidesDir, slideFile); - const outputName = `slide-${slideNum}.png`; - const outputPath = path.join(outputDir, outputName); - - process.stdout.write(`📸 Capturing slide ${i + 1}/${slideFiles.length}: ${slideFile}...`); - - try { - // Navigate to slide - await page.goto(`file://${path.resolve(slidePath)}`, { - waitUntil: 'networkidle', // Wait for fonts and assets - timeout: 10000, - }); - - // Extra wait for web fonts to render - await page.waitForTimeout(500); - - // Take screenshot - await page.screenshot({ - path: outputPath, - type: 'png', - fullPage: false, // Exact viewport capture - }); - - // Get file size for feedback - const stats = fs.statSync(outputPath); - const fileSizeKB = Math.round(stats.size / 1024); - - console.log(` ✓ (${fileSizeKB} KB)`); - - } catch (error) { - console.log(` ❌ Failed`); - console.error(` Error: ${error.message}`); - } + const carouselDir = path.join(process.cwd(), carouselName); + const slidesDir = path.join(carouselDir, "slides"); + const outputDir = path.join(carouselDir, "exports"); + + // Validate slides directory exists + if (!fs.existsSync(slidesDir)) { + console.error(`❌ Error: Slides directory not found: ${slidesDir}`); + console.error("Make sure the carousel HTML files have been generated first."); + process.exit(1); + } + + // Create exports directory + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + console.log(`📁 Created exports directory: ${outputDir}`); + } + + // Find all HTML files in slides directory + const slideFiles = fs + .readdirSync(slidesDir) + .filter((f) => f.endsWith(".html")) + .sort(); // Ensure slides are in order + + if (slideFiles.length === 0) { + console.error(`❌ No HTML files found in ${slidesDir}`); + process.exit(1); + } + + console.log(`\n🚀 Starting screenshot capture for ${slideFiles.length} slides...\n`); + + // Launch browser + const browser = await chromium.launch({ + headless: true, + }); + + const context = await browser.newContext({ + viewport: { + width: 1080, + height: 1080, + }, + deviceScaleFactor: 2, // Retina quality for crisp text + }); + + const page = await context.newPage(); + + // Process each slide + for (let i = 0; i < slideFiles.length; i++) { + const slideFile = slideFiles[i]; + const slideNum = String(i + 1).padStart(2, "0"); + const slidePath = path.join(slidesDir, slideFile); + const outputName = `slide-${slideNum}.png`; + const outputPath = path.join(outputDir, outputName); + + process.stdout.write(`📸 Capturing slide ${i + 1}/${slideFiles.length}: ${slideFile}...`); + + try { + // Navigate to slide + await page.goto(`file://${path.resolve(slidePath)}`, { + waitUntil: "networkidle", // Wait for fonts and assets + timeout: 10000, + }); + + // Extra wait for web fonts to render + await page.waitForTimeout(500); + + // Take screenshot + await page.screenshot({ + path: outputPath, + type: "png", + fullPage: false, // Exact viewport capture + }); + + // Get file size for feedback + const stats = fs.statSync(outputPath); + const fileSizeKB = Math.round(stats.size / 1024); + + console.log(` ✓ (${fileSizeKB} KB)`); + } catch (error) { + console.log(` ❌ Failed`); + console.error(` Error: ${error.message}`); } - - await browser.close(); - - console.log(`\n✨ All slides captured!\n`); - console.log(`📁 PNG files saved to: ${outputDir}`); - console.log(`\n📤 Ready to upload to LinkedIn:\n`); - console.log(` 1. Create new LinkedIn post`); - console.log(` 2. Click "Add media" → Upload images`); - console.log(` 3. Select all PNGs from exports/ folder`); - console.log(` 4. Write your post copy`); - console.log(` 5. Publish!\n`); + } + + await browser.close(); + + console.log(`\n✨ All slides captured!\n`); + console.log(`📁 PNG files saved to: ${outputDir}`); + console.log(`\n📤 Ready to upload to LinkedIn:\n`); + console.log(` 1. Create new LinkedIn post`); + console.log(` 2. Click "Add media" → Upload images`); + console.log(` 3. Select all PNGs from exports/ folder`); + console.log(` 4. Write your post copy`); + console.log(` 5. Publish!\n`); } // Parse command line arguments const carouselName = process.argv[2]; if (!carouselName) { - console.error('\n❌ Usage: node screenshot-slides.js \n'); - console.error('Example:'); - console.error(' node screenshot-slides.js ai-gtm-workflows\n'); - process.exit(1); + console.error("\n❌ Usage: node screenshot-slides.js \n"); + console.error("Example:"); + console.error(" node screenshot-slides.js ai-gtm-workflows\n"); + process.exit(1); } // Run screenshot capture -screenshotSlides(carouselName) - .catch(error => { - console.error('\n❌ Screenshot capture failed:', error.message); - process.exit(1); - }); +screenshotSlides(carouselName).catch((error) => { + console.error("\n❌ Screenshot capture failed:", error.message); + process.exit(1); +}); diff --git a/.claude/skills/create-imessage-mockup/generate.js b/.claude/skills/create-imessage-mockup/generate.js index 7c3cbf35..504e7bfc 100755 --- a/.claude/skills/create-imessage-mockup/generate.js +++ b/.claude/skills/create-imessage-mockup/generate.js @@ -10,9 +10,9 @@ * node generate.js --thread examples/dm-with-keyboard.json --mode with-keyboard --out /tmp/index.html */ -const fs = require('fs'); -const path = require('path'); -const ICONS = require('./templates/icons'); +const fs = require("fs"); +const path = require("path"); +const ICONS = require("./templates/icons"); // --------------------------------------------------------------------------- // Helpers @@ -20,11 +20,11 @@ const ICONS = require('./templates/icons'); function escapeHTML(s) { return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } // Trims trailing whitespace; preserves newlines for multi-line bubbles. @@ -32,7 +32,7 @@ function escapeHTML(s) { // link-detector underlines (matches the way iOS underlines auto-detected codes // like FREEPACK in the reference ad). function formatMessageText(s) { - const escaped = escapeHTML(s).replace(/\n/g, '
    '); + const escaped = escapeHTML(s).replace(/\n/g, "
    "); return escaped.replace(/\[\[link:([^\]]+)\]\]/g, '$1'); } @@ -42,7 +42,7 @@ function isEmojiOnly(text) { const stripped = text.trim(); if (!stripped) return false; const emojiRe = /^(\p{Extended_Pictographic}|\p{Emoji_Presentation}|️|‍|\s)+$/u; - return emojiRe.test(stripped) && [...stripped.replace(/\s/g, '')].length <= 6; + return emojiRe.test(stripped) && [...stripped.replace(/\s/g, "")].length <= 6; } // --------------------------------------------------------------------------- @@ -50,12 +50,12 @@ function isEmojiOnly(text) { // --------------------------------------------------------------------------- const DEFAULT_AVATAR_COLORS = [ - '#FF9500', // orange - '#34C759', // green - '#5AC8FA', // sky - '#AF52DE', // purple - '#FF2D55', // pink - '#5856D6', // indigo + "#FF9500", // orange + "#34C759", // green + "#5AC8FA", // sky + "#AF52DE", // purple + "#FF2D55", // pink + "#5856D6", // indigo ]; function buildParticipantMap(participants = []) { @@ -63,7 +63,7 @@ function buildParticipantMap(participants = []) { let colorIdx = 0; for (const p of participants) { const color = p.color || DEFAULT_AVATAR_COLORS[colorIdx++ % DEFAULT_AVATAR_COLORS.length]; - const initials = p.initials || (p.name || '?').trim().slice(0, 1).toUpperCase(); + const initials = p.initials || (p.name || "?").trim().slice(0, 1).toUpperCase(); map.set(p.id, { ...p, color, initials }); } return map; @@ -79,25 +79,25 @@ function renderDMHeader(thread) { // entry, so the standard header is empty in DM mode (matches the reference). // If the thread provides an explicit `header.style: "conversation"`, render // the iOS conversation top bar (back, badge, avatar+name, facetime). - if (thread.header && thread.header.style === 'conversation') { + if (thread.header && thread.header.style === "conversation") { return renderConversationHeader(thread); } - return ''; + return ""; } function renderConversationHeader(thread) { const h = thread.header || {}; - const badge = h.unread != null ? String(h.unread) : ''; - const peer = (thread.participants || []).find(p => !p.self) || {}; - const avatarColor = peer.color || '#6E6E73'; - const avatarInitials = peer.initials || (peer.name || '?').trim().slice(0, 2).toUpperCase(); - const peerName = peer.name || 'Contact'; + const badge = h.unread != null ? String(h.unread) : ""; + const peer = (thread.participants || []).find((p) => !p.self) || {}; + const avatarColor = peer.color || "#6E6E73"; + const avatarInitials = peer.initials || (peer.name || "?").trim().slice(0, 2).toUpperCase(); + const peerName = peer.name || "Contact"; return `
    ${ICONS.backArrow}
    - ${badge ? `
    ${escapeHTML(badge)}
    ` : ''} + ${badge ? `
    ${escapeHTML(badge)}
    ` : ""}
    ${escapeHTML(avatarInitials)}
    @@ -111,16 +111,19 @@ function renderConversationHeader(thread) { } function renderGroupHeader(thread, participantMap) { - const others = (thread.participants || []).filter(p => !p.self); - const tilesHTML = others.slice(0, 4).map(p => { - const meta = participantMap.get(p.id); - return `
    ${escapeHTML(meta.initials)}
    `; - }).join(''); + const others = (thread.participants || []).filter((p) => !p.self); + const tilesHTML = others + .slice(0, 4) + .map((p) => { + const meta = participantMap.get(p.id); + return `
    ${escapeHTML(meta.initials)}
    `; + }) + .join(""); return `
    ${tilesHTML}
    - ${escapeHTML(thread.title || 'Group')} + ${escapeHTML(thread.title || "Group")} ${ICONS.chevron}
    @@ -150,15 +153,15 @@ function renderTimestamp(msg) { // or: { bold: "iMessage", light: "Today 9:41 AM" } let bold = msg.bold; let light = msg.light; - if (msg.label && (!bold && !light)) { - const lines = msg.label.split('\n'); + if (msg.label && !bold && !light) { + const lines = msg.label.split("\n"); bold = lines[0]; - light = lines.slice(1).join(' '); + light = lines.slice(1).join(" "); } return `
    - ${bold ? `${escapeHTML(bold)}` : ''} - ${light ? `
    ${escapeHTML(light)}` : ''} + ${bold ? `${escapeHTML(bold)}` : ""} + ${light ? `
    ${escapeHTML(light)}` : ""}
    `; } @@ -171,16 +174,16 @@ function renderAvatar(participant) { function renderTextBubble(msg, participant, opts) { const { isLastOfRun, mode, showAvatar, showSenderName, isFirstOfRun } = opts; const isSent = participant && participant.self; - const tailClass = isLastOfRun ? 'has-tail' : ''; - const sideClass = isSent ? 'sent' : 'received'; - const emojiClass = isEmojiOnly(msg.text) ? 'emoji-only' : ''; - const rowClass = `row ${sideClass}${isFirstOfRun ? ' first-of-run' : ' tight'}`; - const animClass = msg.popState === 'pending' ? 'pop-pending' - : msg.popState === 'now' ? 'pop-now' : ''; - const dataAnim = msg.id ? ` data-anim-id="${escapeHTML(msg.id)}"` : ''; - const pendingAttr = msg.popState === 'pending' ? ' data-pending="1"' : ''; + const tailClass = isLastOfRun ? "has-tail" : ""; + const sideClass = isSent ? "sent" : "received"; + const emojiClass = isEmojiOnly(msg.text) ? "emoji-only" : ""; + const rowClass = `row ${sideClass}${isFirstOfRun ? " first-of-run" : " tight"}`; + const animClass = + msg.popState === "pending" ? "pop-pending" : msg.popState === "now" ? "pop-now" : ""; + const dataAnim = msg.id ? ` data-anim-id="${escapeHTML(msg.id)}"` : ""; + const pendingAttr = msg.popState === "pending" ? ' data-pending="1"' : ""; - let html = ''; + let html = ""; if (!isSent && showSenderName && isFirstOfRun) { html += `
    ${escapeHTML(participant.name)}
    `; @@ -198,11 +201,11 @@ function renderTextBubble(msg, participant, opts) { html += `
    `; if (isSent && msg.delivered && isLastOfRun) { - const capAnim = msg.popState === 'pending' ? 'pop-pending' - : msg.popState === 'now' ? 'pop-now' : ''; - const capPending = msg.popState === 'pending' ? ' data-pending="1"' : ''; - const capId = msg.id ? ` data-cap-id="${escapeHTML(msg.id)}"` : ''; - html += `
    ${msg.read ? 'Read' : 'Delivered'}
    `; + const capAnim = + msg.popState === "pending" ? "pop-pending" : msg.popState === "now" ? "pop-now" : ""; + const capPending = msg.popState === "pending" ? ' data-pending="1"' : ""; + const capId = msg.id ? ` data-cap-id="${escapeHTML(msg.id)}"` : ""; + html += `
    ${msg.read ? 'Read' : "Delivered"}
    `; } return html; @@ -210,23 +213,27 @@ function renderTextBubble(msg, participant, opts) { function renderAttachment(msg, participant, opts) { const isSent = participant && participant.self; - const sideClass = isSent ? 'sent' : 'received'; - const animClass = msg.popState === 'pending' ? 'pop-pending' - : msg.popState === 'now' ? 'pop-now' : ''; - const dataAnim = msg.id ? ` data-anim-id="${escapeHTML(msg.id)}"` : ''; - const pendingAttr = msg.popState === 'pending' ? ' data-pending="1"' : ''; - const src = msg.src || ''; - const title = msg.title || ''; - const subtitle = msg.subtitle || ''; + const sideClass = isSent ? "sent" : "received"; + const animClass = + msg.popState === "pending" ? "pop-pending" : msg.popState === "now" ? "pop-now" : ""; + const dataAnim = msg.id ? ` data-anim-id="${escapeHTML(msg.id)}"` : ""; + const pendingAttr = msg.popState === "pending" ? ' data-pending="1"' : ""; + const src = msg.src || ""; + const title = msg.title || ""; + const subtitle = msg.subtitle || ""; return `
    - ${src ? `` : ''} + ${src ? `` : ""}
    - ${(title || subtitle) ? `
    - ${title ? `
    ${escapeHTML(title)}
    ` : ''} - ${subtitle ? `
    ${escapeHTML(subtitle)}
    ` : ''} -
    ` : ''} + ${ + title || subtitle + ? `
    + ${title ? `
    ${escapeHTML(title)}
    ` : ""} + ${subtitle ? `
    ${escapeHTML(subtitle)}
    ` : ""} +
    ` + : "" + }
    `; } @@ -234,10 +241,10 @@ function renderAttachment(msg, participant, opts) { function renderTypingBubble(msg, participant, opts) { const { mode, showAvatar } = opts; msg = msg || {}; - const animClass = msg.popState === 'pending' ? 'pop-pending' - : msg.popState === 'now' ? 'pop-now' : ''; - const dataAnim = msg.id ? ` data-anim-id="${escapeHTML(msg.id)}"` : ''; - const pendingAttr = msg.popState === 'pending' ? ' data-pending="1"' : ''; + const animClass = + msg.popState === "pending" ? "pop-pending" : msg.popState === "now" ? "pop-now" : ""; + const dataAnim = msg.id ? ` data-anim-id="${escapeHTML(msg.id)}"` : ""; + const pendingAttr = msg.popState === "pending" ? ' data-pending="1"' : ""; let html = `
    `; if (showAvatar) html += renderAvatar(participant); html += ` @@ -249,45 +256,52 @@ function renderTypingBubble(msg, participant, opts) { } function renderConversation(thread, participantMap, mode) { - const isGroup = thread.mode === 'group'; + const isGroup = thread.mode === "group"; const messages = thread.messages || []; // Pre-compute "last of run" by walking forward. // A "run" is a contiguous sequence of text/typing messages from the same sender. const runFlags = messages.map((m, i) => { - if (m.type !== 'text' && m.type !== 'typing') return { isFirstOfRun: false, isLastOfRun: false }; + if (m.type !== "text" && m.type !== "typing") + return { isFirstOfRun: false, isLastOfRun: false }; const prev = messages[i - 1]; const next = messages[i + 1]; - const samePrev = prev && (prev.type === 'text' || prev.type === 'typing') && prev.from === m.from; - const sameNext = next && (next.type === 'text' || next.type === 'typing') && next.from === m.from; + const samePrev = + prev && (prev.type === "text" || prev.type === "typing") && prev.from === m.from; + const sameNext = + next && (next.type === "text" || next.type === "typing") && next.from === m.from; return { isFirstOfRun: !samePrev, isLastOfRun: !sameNext }; }); const out = []; for (let i = 0; i < messages.length; i++) { const m = messages[i]; - if (m.type === 'timestamp') { + if (m.type === "timestamp") { out.push(renderTimestamp(m)); - } else if (m.type === 'text') { + } else if (m.type === "text") { const participant = participantMap.get(m.from); - out.push(renderTextBubble(m, participant, { - ...runFlags[i], - mode, - showAvatar: isGroup && participant && !participant.self, - showSenderName: isGroup && participant && !participant.self, - })); - } else if (m.type === 'typing') { + out.push( + renderTextBubble(m, participant, { + ...runFlags[i], + mode, + showAvatar: isGroup && participant && !participant.self, + showSenderName: isGroup && participant && !participant.self, + }), + ); + } else if (m.type === "typing") { const participant = participantMap.get(m.from); - out.push(renderTypingBubble(m, participant, { - mode, - showAvatar: isGroup && participant && !participant.self, - })); - } else if (m.type === 'attachment') { + out.push( + renderTypingBubble(m, participant, { + mode, + showAvatar: isGroup && participant && !participant.self, + }), + ); + } else if (m.type === "attachment") { const participant = participantMap.get(m.from); out.push(renderAttachment(m, participant, { mode })); } } - return `
    ${out.join('\n')}
    `; + return `
    ${out.join("\n")}
    `; } // --------------------------------------------------------------------------- @@ -295,14 +309,14 @@ function renderConversation(thread, participantMap, mode) { // --------------------------------------------------------------------------- function renderKeyboard(thread) { - const leftIcon = (thread.keyboard && thread.keyboard.leftIcon) || 'plus'; + const leftIcon = (thread.keyboard && thread.keyboard.leftIcon) || "plus"; const iconSVG = ICONS[leftIcon] || ICONS.plus; - const composer = (thread.composer || {}); - const hasText = composer.text != null && composer.text !== ''; + const composer = thread.composer || {}; + const hasText = composer.text != null && composer.text !== ""; const showCaret = composer.cursor !== false; // default true when there is text - const inputClass = hasText ? 'input has-text' : 'input'; + const inputClass = hasText ? "input has-text" : "input"; const composerInner = hasText - ? `${escapeHTML(composer.text)}${showCaret ? '' : ''}${ICONS.sendArrow}` + ? `${escapeHTML(composer.text)}${showCaret ? '' : ""}${ICONS.sendArrow}` : `iMessage${ICONS.mic}`; return `
    @@ -319,25 +333,23 @@ function renderKeyboard(thread) { // --------------------------------------------------------------------------- function loadCSS() { - return fs.readFileSync(path.join(__dirname, 'templates', 'chat.css'), 'utf-8'); + return fs.readFileSync(path.join(__dirname, "templates", "chat.css"), "utf-8"); } function renderHTML(thread, options = {}) { - const mode = options.mode || 'with-keyboard'; // minimal | with-keyboard | with-iphone-frame + const mode = options.mode || "with-keyboard"; // minimal | with-keyboard | with-iphone-frame const participantMap = buildParticipantMap(thread.participants || []); - const isGroup = thread.mode === 'group'; + const isGroup = thread.mode === "group"; const css = loadCSS(); - const headerHTML = isGroup - ? renderGroupHeader(thread, participantMap) - : renderDMHeader(thread); + const headerHTML = isGroup ? renderGroupHeader(thread, participantMap) : renderDMHeader(thread); const conversationHTML = renderConversation(thread, participantMap, mode); - const keyboardHTML = (mode === 'with-keyboard' || mode === 'with-iphone-frame') ? '' : ''; + const keyboardHTML = mode === "with-keyboard" || mode === "with-iphone-frame" ? "" : ""; // ^ We render the keyboard only for `with-keyboard` mode and as an internal // element in `with-iphone-frame`. Hold for now; assigned per branch below. - if (mode === 'with-iphone-frame') { + if (mode === "with-iphone-frame") { const keyboard = renderKeyboard(thread); return ` @@ -356,10 +368,10 @@ function renderHTML(thread, options = {}) { `; } - if (mode === 'with-keyboard') { + if (mode === "with-keyboard") { return ` - - + +
    ${headerHTML} ${conversationHTML} @@ -371,7 +383,7 @@ function renderHTML(thread, options = {}) { // minimal return ` - +
    ${conversationHTML}
    @@ -385,9 +397,9 @@ function renderHTML(thread, options = {}) { function parseArgs(argv) { const args = {}; for (let i = 2; i < argv.length; i++) { - if (argv[i] === '--thread') args.thread = argv[++i]; - else if (argv[i] === '--mode') args.mode = argv[++i]; - else if (argv[i] === '--out') args.out = argv[++i]; + if (argv[i] === "--thread") args.thread = argv[++i]; + else if (argv[i] === "--mode") args.mode = argv[++i]; + else if (argv[i] === "--out") args.out = argv[++i]; } return args; } @@ -395,11 +407,13 @@ function parseArgs(argv) { function main() { const args = parseArgs(process.argv); if (!args.thread || !args.out) { - console.error('Usage: node generate.js --thread path.json --mode --out path.html'); + console.error( + "Usage: node generate.js --thread path.json --mode --out path.html", + ); process.exit(1); } - const thread = JSON.parse(fs.readFileSync(args.thread, 'utf-8')); - const html = renderHTML(thread, { mode: args.mode || 'with-keyboard' }); + const thread = JSON.parse(fs.readFileSync(args.thread, "utf-8")); + const html = renderHTML(thread, { mode: args.mode || "with-keyboard" }); fs.mkdirSync(path.dirname(args.out), { recursive: true }); fs.writeFileSync(args.out, html); console.log(`Wrote ${args.out}`); diff --git a/.claude/skills/create-imessage-mockup/render.js b/.claude/skills/create-imessage-mockup/render.js index b062d3c5..a8ae4d49 100755 --- a/.claude/skills/create-imessage-mockup/render.js +++ b/.claude/skills/create-imessage-mockup/render.js @@ -11,27 +11,47 @@ * itself. The skill is deliberately deterministic and does not embed an LLM. */ -const fs = require('fs'); -const path = require('path'); -const { renderHTML } = require('./generate'); -const { capture } = require('./screenshot'); +const fs = require("fs"); +const path = require("path"); +const { renderHTML } = require("./generate"); +const { capture } = require("./screenshot"); function parseArgs(argv) { const args = { mode: null, modeFlag: null }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; switch (a) { - case '--thread': args.thread = argv[++i]; break; - case '--prompt': args.prompt = argv[++i]; break; - case '--output': args.output = argv[++i]; break; - case '--name': args.name = argv[++i]; break; - case '--minimal': args.modeFlag = 'minimal'; break; - case '--with-keyboard': args.modeFlag = 'with-keyboard'; break; - case '--with-iphone-frame': args.modeFlag = 'with-iphone-frame'; break; - case '--dm': args.forceMode = 'dm'; break; - case '--group': args.forceMode = 'group'; break; - case '-h': - case '--help': args.help = true; break; + case "--thread": + args.thread = argv[++i]; + break; + case "--prompt": + args.prompt = argv[++i]; + break; + case "--output": + args.output = argv[++i]; + break; + case "--name": + args.name = argv[++i]; + break; + case "--minimal": + args.modeFlag = "minimal"; + break; + case "--with-keyboard": + args.modeFlag = "with-keyboard"; + break; + case "--with-iphone-frame": + args.modeFlag = "with-iphone-frame"; + break; + case "--dm": + args.forceMode = "dm"; + break; + case "--group": + args.forceMode = "group"; + break; + case "-h": + case "--help": + args.help = true; + break; default: // ignore unknown flags rather than failing the orchestration break; @@ -82,15 +102,17 @@ Save it to a path and re-invoke with --thread . `.trimStart(); function slugify(s) { - return (s || 'mockup') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, '') - .slice(0, 40) || 'mockup'; + return ( + (s || "mockup") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .slice(0, 40) || "mockup" + ); } function pickFirstText(thread) { - const t = (thread.messages || []).find(m => m.type === 'text'); + const t = (thread.messages || []).find((m) => m.type === "text"); return t ? t.text : null; } @@ -104,8 +126,12 @@ async function main() { if (args.prompt && !args.thread) { console.error(SCHEMA_DOC); - console.error(`\nReceived --prompt: "${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? '…' : ''}"`); - console.error(`Compose a thread.json matching the schema above, then re-run with --thread .`); + console.error( + `\nReceived --prompt: "${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "…" : ""}"`, + ); + console.error( + `Compose a thread.json matching the schema above, then re-run with --thread .`, + ); process.exit(2); } @@ -119,27 +145,27 @@ async function main() { console.error(`Thread file not found: ${threadPath}`); process.exit(1); } - const thread = JSON.parse(fs.readFileSync(threadPath, 'utf-8')); + const thread = JSON.parse(fs.readFileSync(threadPath, "utf-8")); // Force mode if --dm or --group specified if (args.forceMode) thread.mode = args.forceMode; // Auto-detect mode if missing if (!thread.mode) { - const others = (thread.participants || []).filter(p => !p.self); - thread.mode = others.length > 1 ? 'group' : 'dm'; + const others = (thread.participants || []).filter((p) => !p.self); + thread.mode = others.length > 1 ? "group" : "dm"; } - const mode = args.modeFlag || 'with-keyboard'; + const mode = args.modeFlag || "with-keyboard"; const today = new Date().toISOString().slice(0, 10); - const slug = slugify(args.name || pickFirstText(thread) || 'mockup'); + const slug = slugify(args.name || pickFirstText(thread) || "mockup"); const outDir = args.output ? path.resolve(args.output, `${today}-${slug}`) - : path.resolve(process.cwd(), 'imessage-mockup-exports', `${today}-${slug}`); + : path.resolve(process.cwd(), "imessage-mockup-exports", `${today}-${slug}`); fs.mkdirSync(outDir, { recursive: true }); - const htmlPath = path.join(outDir, 'index.html'); - const pngPath = path.join(outDir, 'screenshot.png'); + const htmlPath = path.join(outDir, "index.html"); + const pngPath = path.join(outDir, "screenshot.png"); const html = renderHTML(thread, { mode }); fs.writeFileSync(htmlPath, html); @@ -148,13 +174,13 @@ async function main() { await capture({ mode, input: htmlPath, output: pngPath, fontDelay: 600 }); // Copy thread for reproducibility - fs.copyFileSync(threadPath, path.join(outDir, 'thread.json')); + fs.copyFileSync(threadPath, path.join(outDir, "thread.json")); console.log(`\n✓ ${pngPath}`); } -main().catch(err => { - console.error('Render failed:', err.message); +main().catch((err) => { + console.error("Render failed:", err.message); console.error(err.stack); process.exit(1); }); diff --git a/.claude/skills/create-imessage-mockup/screenshot.js b/.claude/skills/create-imessage-mockup/screenshot.js index e5a112a6..741ca43d 100755 --- a/.claude/skills/create-imessage-mockup/screenshot.js +++ b/.claude/skills/create-imessage-mockup/screenshot.js @@ -12,26 +12,26 @@ * [--font-delay 500] */ -const path = require('path'); -const fs = require('fs'); -const { execSync } = require('child_process'); +const path = require("path"); +const fs = require("fs"); +const { execSync } = require("child_process"); const MODE_CONFIGS = { - 'minimal': { + minimal: { width: 750, - height: 100, // small initial; fullPage extends to actual content + height: 100, // small initial; fullPage extends to actual content deviceScaleFactor: 3, fullPage: true, }, - 'with-keyboard': { + "with-keyboard": { width: 750, - height: 100, // small initial; fullPage extends to content+keyboard + height: 100, // small initial; fullPage extends to content+keyboard deviceScaleFactor: 3, fullPage: true, }, - 'with-iphone-frame': { - width: 525, // 393 (phone) + ~66px gradient padding each side - height: 980, // 852 (phone) + ~64px padding each side + "with-iphone-frame": { + width: 525, // 393 (phone) + ~66px gradient padding each side + height: 980, // 852 (phone) + ~64px padding each side deviceScaleFactor: 3, fullPage: false, }, @@ -40,17 +40,17 @@ const MODE_CONFIGS = { function parseArgs(argv) { const args = {}; for (let i = 2; i < argv.length; i++) { - if (argv[i] === '--mode' && argv[i + 1]) args.mode = argv[++i]; - else if (argv[i] === '--input' && argv[i + 1]) args.input = argv[++i]; - else if (argv[i] === '--output' && argv[i + 1]) args.output = argv[++i]; - else if (argv[i] === '--font-delay' && argv[i + 1]) args.fontDelay = parseInt(argv[++i], 10); + if (argv[i] === "--mode" && argv[i + 1]) args.mode = argv[++i]; + else if (argv[i] === "--input" && argv[i + 1]) args.input = argv[++i]; + else if (argv[i] === "--output" && argv[i + 1]) args.output = argv[++i]; + else if (argv[i] === "--font-delay" && argv[i + 1]) args.fontDelay = parseInt(argv[++i], 10); } return args; } function validateArgs(args) { if (!args.mode || !MODE_CONFIGS[args.mode]) { - console.error(`Error: --mode must be one of: ${Object.keys(MODE_CONFIGS).join(', ')}`); + console.error(`Error: --mode must be one of: ${Object.keys(MODE_CONFIGS).join(", ")}`); process.exit(1); } if (!args.input || !fs.existsSync(args.input)) { @@ -58,7 +58,7 @@ function validateArgs(args) { process.exit(1); } if (!args.output) { - console.error('Error: --output is required.'); + console.error("Error: --output is required."); process.exit(1); } return { @@ -72,10 +72,10 @@ function validateArgs(args) { async function ensureChromium() { let chromium; try { - ({ chromium } = require('playwright')); + ({ chromium } = require("playwright")); } catch (err) { - if (err && err.code === 'MODULE_NOT_FOUND') { - console.error('Error: playwright not installed. Run `npm install` in the skill directory.'); + if (err && err.code === "MODULE_NOT_FOUND") { + console.error("Error: playwright not installed. Run `npm install` in the skill directory."); process.exit(1); } throw err; @@ -83,8 +83,8 @@ async function ensureChromium() { try { chromium.executablePath(); } catch { - console.log('Installing chromium via playwright...'); - execSync('npx playwright install chromium', { stdio: 'inherit' }); + console.log("Installing chromium via playwright..."); + execSync("npx playwright install chromium", { stdio: "inherit" }); } return chromium; } @@ -101,12 +101,12 @@ async function capture(args) { fs.mkdirSync(path.dirname(args.output), { recursive: true }); - await page.goto(`file://${args.input}`, { waitUntil: 'networkidle', timeout: 15000 }); + await page.goto(`file://${args.input}`, { waitUntil: "networkidle", timeout: 15000 }); await page.waitForTimeout(args.fontDelay); await page.screenshot({ path: args.output, - type: 'png', + type: "png", fullPage: config.fullPage, omitBackground: false, }); @@ -125,8 +125,8 @@ async function main() { } if (require.main === module) { - main().catch(err => { - console.error('Screenshot failed:', err.message); + main().catch((err) => { + console.error("Screenshot failed:", err.message); process.exit(1); }); } diff --git a/.claude/skills/create-imessage-video-ad/scripts/record-master.template.js b/.claude/skills/create-imessage-video-ad/scripts/record-master.template.js index 5ef18644..d8500929 100644 --- a/.claude/skills/create-imessage-video-ad/scripts/record-master.template.js +++ b/.claude/skills/create-imessage-video-ad/scripts/record-master.template.js @@ -16,18 +16,17 @@ * ./master-chat.sfx.json (deterministic cue list) */ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); -const { chromium } = require('playwright'); +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); +const { chromium } = require("playwright"); // The create-imessage-mockup skill provides the framed-chat renderer (generate.js). // In the local/BYOA flow it's a SIBLING skill, not a fixed repo path — resolve it // from MOCKUP_DIR (point it at wherever you fetched/installed create-imessage-mockup; // run with NODE_PATH=$MOCKUP_DIR/node_modules so Playwright resolves too). Falls back // to a sibling `create-imessage-mockup/` dir next to this script. -const MOCKUP_DIR = - process.env.MOCKUP_DIR || path.resolve(__dirname, 'create-imessage-mockup'); -const { renderHTML } = require(path.join(MOCKUP_DIR, 'generate.js')); +const MOCKUP_DIR = process.env.MOCKUP_DIR || path.resolve(__dirname, "create-imessage-mockup"); +const { renderHTML } = require(path.join(MOCKUP_DIR, "generate.js")); const VIEW_W = 720; const VIEW_H = 1280; @@ -35,12 +34,12 @@ const VIEW_H = 1280; // Inline image attachments as data: URIs (same helper as build-scenes.js). function inlineAttachments(thread, baseDir) { for (const m of thread.messages || []) { - if (m.type === 'attachment' && m.src && !m.src.startsWith('data:')) { + if (m.type === "attachment" && m.src && !m.src.startsWith("data:")) { const abs = path.resolve(baseDir, m.src); const buf = fs.readFileSync(abs); const ext = path.extname(abs).slice(1).toLowerCase(); - const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`; - m.src = `data:${mime};base64,${buf.toString('base64')}`; + const mime = ext === "jpg" ? "image/jpeg" : `image/${ext}`; + m.src = `data:${mime};base64,${buf.toString("base64")}`; } } return thread; @@ -60,55 +59,55 @@ function inlineAttachments(thread, baseDir) { // ============================================================================= const TIMELINE = [ - { t: 0.40, kind: 'pop', id: 'm01-attach', sfx: null }, - { t: 1.10, kind: 'pop', id: 'm02-yo', sfx: 'send' }, - { t: 1.20, kind: 'scroll', dur: 350 }, - - { t: 1.95, kind: 'typing-pop', id: 'm03-typ', sfx: null }, - { t: 2.05, kind: 'scroll', dur: 280 }, - { t: 2.85, kind: 'typing-swap', id: 'm03-typ', toId: 'm04-no', sfx: 'receive' }, - { t: 2.95, kind: 'scroll', dur: 250 }, - { t: 3.30, kind: 'pop', id: 'm05-app', sfx: 'receive' }, - { t: 3.40, kind: 'scroll', dur: 220 }, - - { t: 4.05, kind: 'pop', id: 'm06-yeah', sfx: 'send' }, - { t: 4.15, kind: 'scroll', dur: 250 }, - - { t: 4.85, kind: 'typing-pop', id: 'm07-typ', sfx: null }, - { t: 4.95, kind: 'scroll', dur: 250 }, - { t: 5.70, kind: 'typing-swap', id: 'm07-typ', toId: 'm08-what', sfx: 'receive' }, - { t: 5.80, kind: 'scroll', dur: 250 }, - - { t: 6.40, kind: 'composer', text: 'cat', dur: 0.30 }, - { t: 6.85, kind: 'pop', id: 'm09-cb', sfx: 'send' }, - { t: 6.85, kind: 'composer-clear' }, - { t: 6.95, kind: 'scroll', dur: 250 }, - - { t: 7.55, kind: 'composer', text: 'you can build your own', dur: 1.50 }, - { t: 9.20, kind: 'pop', id: 'm10-build', sfx: 'send' }, - { t: 9.20, kind: 'composer-clear' }, - { t: 9.30, kind: 'scroll', dur: 300 }, - - { t: 10.05, kind: 'composer', text: 'then u can', dur: 0.60 }, - { t: 10.85, kind: 'pop', id: 'm11-ship', sfx: 'send' }, - { t: 10.85, kind: 'composer-clear' }, - { t: 10.95, kind: 'scroll', dur: 300 }, - - { t: 11.65, kind: 'typing-pop', id: 'm12-typ', sfx: null }, - { t: 11.75, kind: 'scroll', dur: 250 }, - { t: 12.65, kind: 'typing-swap', id: 'm12-typ', toId: 'm13-ins', sfx: 'receive' }, - { t: 12.75, kind: 'scroll', dur: 250 }, - - { t: 13.55, kind: 'composer', text: 'ya use code FREEPA', dur: 1.40 }, - { t: 15.10, kind: 'pop', id: 'm14-code', sfx: 'send' }, - { t: 15.10, kind: 'composer-clear' }, - { t: 15.20, kind: 'scroll', dur: 350 }, - - { t: 15.95, kind: 'pop', id: 'm15-bet', sfx: 'receive' }, - { t: 16.05, kind: 'scroll', dur: 250 }, + { t: 0.4, kind: "pop", id: "m01-attach", sfx: null }, + { t: 1.1, kind: "pop", id: "m02-yo", sfx: "send" }, + { t: 1.2, kind: "scroll", dur: 350 }, + + { t: 1.95, kind: "typing-pop", id: "m03-typ", sfx: null }, + { t: 2.05, kind: "scroll", dur: 280 }, + { t: 2.85, kind: "typing-swap", id: "m03-typ", toId: "m04-no", sfx: "receive" }, + { t: 2.95, kind: "scroll", dur: 250 }, + { t: 3.3, kind: "pop", id: "m05-app", sfx: "receive" }, + { t: 3.4, kind: "scroll", dur: 220 }, + + { t: 4.05, kind: "pop", id: "m06-yeah", sfx: "send" }, + { t: 4.15, kind: "scroll", dur: 250 }, + + { t: 4.85, kind: "typing-pop", id: "m07-typ", sfx: null }, + { t: 4.95, kind: "scroll", dur: 250 }, + { t: 5.7, kind: "typing-swap", id: "m07-typ", toId: "m08-what", sfx: "receive" }, + { t: 5.8, kind: "scroll", dur: 250 }, + + { t: 6.4, kind: "composer", text: "cat", dur: 0.3 }, + { t: 6.85, kind: "pop", id: "m09-cb", sfx: "send" }, + { t: 6.85, kind: "composer-clear" }, + { t: 6.95, kind: "scroll", dur: 250 }, + + { t: 7.55, kind: "composer", text: "you can build your own", dur: 1.5 }, + { t: 9.2, kind: "pop", id: "m10-build", sfx: "send" }, + { t: 9.2, kind: "composer-clear" }, + { t: 9.3, kind: "scroll", dur: 300 }, + + { t: 10.05, kind: "composer", text: "then u can", dur: 0.6 }, + { t: 10.85, kind: "pop", id: "m11-ship", sfx: "send" }, + { t: 10.85, kind: "composer-clear" }, + { t: 10.95, kind: "scroll", dur: 300 }, + + { t: 11.65, kind: "typing-pop", id: "m12-typ", sfx: null }, + { t: 11.75, kind: "scroll", dur: 250 }, + { t: 12.65, kind: "typing-swap", id: "m12-typ", toId: "m13-ins", sfx: "receive" }, + { t: 12.75, kind: "scroll", dur: 250 }, + + { t: 13.55, kind: "composer", text: "ya use code FREEPA", dur: 1.4 }, + { t: 15.1, kind: "pop", id: "m14-code", sfx: "send" }, + { t: 15.1, kind: "composer-clear" }, + { t: 15.2, kind: "scroll", dur: 350 }, + + { t: 15.95, kind: "pop", id: "m15-bet", sfx: "receive" }, + { t: 16.05, kind: "scroll", dur: 250 }, // Tail: hold for a beat before cutting to the end card. - { t: 17.00, kind: 'noop' }, + { t: 17.0, kind: "noop" }, ]; const TOTAL_DURATION = 17.4; // seconds; end-card is appended downstream @@ -119,18 +118,18 @@ const TOTAL_DURATION = 17.4; // seconds; end-card is appended downstream // ============================================================================= function buildFullThread() { - const fullPath = path.join(__dirname, '..', 'threads', 'full-thread.json'); - const thread = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); + const fullPath = path.join(__dirname, "..", "threads", "full-thread.json"); + const thread = JSON.parse(fs.readFileSync(fullPath, "utf-8")); inlineAttachments(thread, path.dirname(fullPath)); // Mark every message except the first attachment as pop-pending. The driver // unhides them on schedule. The first attachment is also pop-pending (for // the 0.4s entrance fade) but conceptually paired with m02-yo as the hook. for (const m of thread.messages) { - if (m.type === 'text' || m.type === 'typing' || m.type === 'attachment') { - m.popState = 'pending'; + if (m.type === "text" || m.type === "typing" || m.type === "attachment") { + m.popState = "pending"; } } - thread.composer = { text: '' }; + thread.composer = { text: "" }; return thread; } @@ -260,10 +259,10 @@ function buildCueList() { // ============================================================================= async function main() { - const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'imessage-master-')); + const tmpDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "imessage-master-")); const thread = buildFullThread(); - const html = renderHTML(thread, { mode: 'with-keyboard' }); - const driverHtml = html.replace('', makeDriverScript() + '\n'); + const html = renderHTML(thread, { mode: "with-keyboard" }); + const driverHtml = html.replace("", makeDriverScript() + "\n"); // Pin the keyboard so it doesn't scroll out of view as the conversation grows. // Do this with an extra `; - const finalHtml = driverHtml.replace('', styleOverride + '\n'); + const finalHtml = driverHtml.replace("", styleOverride + "\n"); const browser = await chromium.launch(); const ctx = await browser.newContext({ @@ -308,7 +307,7 @@ async function main() { recordVideo: { dir: tmpDir, size: { width: VIEW_W, height: VIEW_H } }, }); const page = await ctx.newPage(); - await page.setContent(finalHtml, { waitUntil: 'load' }); + await page.setContent(finalHtml, { waitUntil: "load" }); await page.waitForFunction(() => window.__driverReady === true, { timeout: 5000 }); // Kick off the timeline NOW; the page-video has already started. await page.evaluate(() => window.__startDriver()); @@ -318,16 +317,19 @@ async function main() { await browser.close(); // Convert webm → mp4 at 30fps yuv420p. - const outMp4 = path.join(__dirname, 'master-chat.mp4'); + const outMp4 = path.join(__dirname, "master-chat.mp4"); execSync( `ffmpeg -y -i "${videoPath}" -t ${TOTAL_DURATION} -r 30 ` + - `-vf "scale=${VIEW_W}:${VIEW_H}" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "${outMp4}"`, - { stdio: 'pipe' } + `-vf "scale=${VIEW_W}:${VIEW_H}" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "${outMp4}"`, + { stdio: "pipe" }, ); - fs.writeFileSync(outMp4.replace(/\.mp4$/, '.sfx.json'), JSON.stringify(buildCueList(), null, 2)); + fs.writeFileSync(outMp4.replace(/\.mp4$/, ".sfx.json"), JSON.stringify(buildCueList(), null, 2)); fs.rmSync(tmpDir, { recursive: true, force: true }); console.log(`mp4 → ${path.relative(process.cwd(), outMp4)}`); console.log(`sfx → ${buildCueList().length} cues`); } -main().catch(e => { console.error(e); process.exit(1); }); +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.claude/skills/create-imessage-video-ad/scripts/render-end-card.template.js b/.claude/skills/create-imessage-video-ad/scripts/render-end-card.template.js index 2a61cc62..5351c20a 100644 --- a/.claude/skills/create-imessage-video-ad/scripts/render-end-card.template.js +++ b/.claude/skills/create-imessage-video-ad/scripts/render-end-card.template.js @@ -12,60 +12,68 @@ * Adapt the LOGO_PATH and HERO_PATH constants below for your ad. No Ken-Burns * on the MP4 output — the brand slate should land hard. */ -const path = require('path'); -const fs = require('fs'); -const { execSync } = require('child_process'); -const { chromium } = require('playwright'); +const path = require("path"); +const fs = require("fs"); +const { execSync } = require("child_process"); +const { chromium } = require("playwright"); // Adjust these for your ad. LOGO_PATH must exist; HERO_PATH is optional. -const LOGO_PATH = path.join(__dirname, '..', 'assets', 'brand-logo.svg'); -const HERO_PATH = path.join(__dirname, '..', 'assets', 'hero.png'); // optional +const LOGO_PATH = path.join(__dirname, "..", "assets", "brand-logo.svg"); +const HERO_PATH = path.join(__dirname, "..", "assets", "hero.png"); // optional function readLogoSvg(p) { if (!fs.existsSync(p)) { - throw new Error(`Missing brand logo SVG at ${p}. Drop the real wordmark there (Wikimedia / brandfetch).`); + throw new Error( + `Missing brand logo SVG at ${p}. Drop the real wordmark there (Wikimedia / brandfetch).`, + ); } - let svg = fs.readFileSync(p, 'utf-8'); + let svg = fs.readFileSync(p, "utf-8"); // Strip XML prolog + doctype so the SVG can be inlined directly. - return svg.replace(/<\?xml[\s\S]*?\?>/, '').replace(//, '').trim(); + return svg + .replace(/<\?xml[\s\S]*?\?>/, "") + .replace(//, "") + .trim(); } function readImageDataUrl(p) { if (!fs.existsSync(p)) return null; const buf = fs.readFileSync(p); const ext = path.extname(p).slice(1).toLowerCase(); - const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`; - return `data:${mime};base64,${buf.toString('base64')}`; + const mime = ext === "jpg" ? "image/jpeg" : `image/${ext}`; + return `data:${mime};base64,${buf.toString("base64")}`; } (async () => { - const html = fs.readFileSync(path.join(__dirname, 'end-card.html'), 'utf-8'); + const html = fs.readFileSync(path.join(__dirname, "end-card.html"), "utf-8"); const logoSvg = readLogoSvg(LOGO_PATH); - let inlinedHtml = html.replace('', logoSvg); + let inlinedHtml = html.replace("", logoSvg); const heroDataUrl = readImageDataUrl(HERO_PATH); if (heroDataUrl) { - inlinedHtml = inlinedHtml.replace('', heroDataUrl); + inlinedHtml = inlinedHtml.replace("", heroDataUrl); } const browser = await chromium.launch(); - const ctx = await browser.newContext({ viewport: { width: 720, height: 1280 }, deviceScaleFactor: 2 }); + const ctx = await browser.newContext({ + viewport: { width: 720, height: 1280 }, + deviceScaleFactor: 2, + }); const page = await ctx.newPage(); - await page.setContent(inlinedHtml, { waitUntil: 'load' }); + await page.setContent(inlinedHtml, { waitUntil: "load" }); await page.waitForTimeout(150); - const outPng = path.join(__dirname, 'end-card.png'); + const outPng = path.join(__dirname, "end-card.png"); await page.screenshot({ path: outPng }); await browser.close(); - console.log(' png →', path.relative(process.cwd(), outPng)); + console.log(" png →", path.relative(process.cwd(), outPng)); // Static still — no Ken-Burns. The brand slate should land hard, not drift. - const outMp4 = path.join(__dirname, 'scene-09-endcard.mp4'); + const outMp4 = path.join(__dirname, "scene-09-endcard.mp4"); execSync( `ffmpeg -y -loop 1 -i "${outPng}" -t 3.5 -r 30 ` + - `-vf "scale=720:1280,format=yuv420p" ` + - `-c:v libx264 -pix_fmt yuv420p -movflags +faststart "${outMp4}"`, - { stdio: 'pipe' } + `-vf "scale=720:1280,format=yuv420p" ` + + `-c:v libx264 -pix_fmt yuv420p -movflags +faststart "${outMp4}"`, + { stdio: "pipe" }, ); - console.log(' mp4 →', path.relative(process.cwd(), outMp4)); + console.log(" mp4 →", path.relative(process.cwd(), outMp4)); })(); diff --git a/.claude/skills/create-workflow-diagram/screenshot-diagram.js b/.claude/skills/create-workflow-diagram/screenshot-diagram.js index f0da9fd4..ab5d9cb3 100644 --- a/.claude/skills/create-workflow-diagram/screenshot-diagram.js +++ b/.claude/skills/create-workflow-diagram/screenshot-diagram.js @@ -12,104 +12,104 @@ * node screenshot-diagram.js lead-gen-pipeline */ -const { chromium } = require('playwright'); -const path = require('path'); -const fs = require('fs'); +const { chromium } = require("playwright"); +const path = require("path"); +const fs = require("fs"); // Default diagram dimensions const SIZES = { - 'landscape': { width: 1920, height: 1080 }, - 'square': { width: 1080, height: 1080 }, - 'wide': { width: 1200, height: 630 }, + landscape: { width: 1920, height: 1080 }, + square: { width: 1080, height: 1080 }, + wide: { width: 1200, height: 630 }, }; -async function screenshotDiagram(diagramName, sizeKey = 'landscape') { - const diagramDir = path.join(process.cwd(), diagramName); - const diagramPath = path.join(diagramDir, 'diagram.html'); - const outputDir = path.join(diagramDir, 'exports'); - - // Validate diagram file exists - if (!fs.existsSync(diagramPath)) { - console.error(`Error: Diagram not found: ${diagramPath}`); - console.error('Make sure the diagram HTML has been generated first.'); - process.exit(1); - } - - // Get dimensions - const size = SIZES[sizeKey] || SIZES['landscape']; - - // Create exports directory - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - console.log(`Created exports directory: ${outputDir}`); - } - - console.log(`\nCapturing diagram at ${size.width}x${size.height}...\n`); - - // Launch browser - const browser = await chromium.launch({ headless: true }); - - const context = await browser.newContext({ - viewport: { - width: size.width, - height: size.height, - }, - deviceScaleFactor: 2, // Retina quality - }); +async function screenshotDiagram(diagramName, sizeKey = "landscape") { + const diagramDir = path.join(process.cwd(), diagramName); + const diagramPath = path.join(diagramDir, "diagram.html"); + const outputDir = path.join(diagramDir, "exports"); + + // Validate diagram file exists + if (!fs.existsSync(diagramPath)) { + console.error(`Error: Diagram not found: ${diagramPath}`); + console.error("Make sure the diagram HTML has been generated first."); + process.exit(1); + } - const page = await context.newPage(); + // Get dimensions + const size = SIZES[sizeKey] || SIZES["landscape"]; - try { - // Navigate to diagram - await page.goto(`file://${path.resolve(diagramPath)}`, { - waitUntil: 'networkidle', - timeout: 15000, - }); + // Create exports directory + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + console.log(`Created exports directory: ${outputDir}`); + } - // Wait for fonts to render - await page.waitForTimeout(800); + console.log(`\nCapturing diagram at ${size.width}x${size.height}...\n`); - const outputPath = path.join(outputDir, 'diagram.png'); + // Launch browser + const browser = await chromium.launch({ headless: true }); - // Take screenshot - await page.screenshot({ - path: outputPath, - type: 'png', - fullPage: false, - }); + const context = await browser.newContext({ + viewport: { + width: size.width, + height: size.height, + }, + deviceScaleFactor: 2, // Retina quality + }); - // Get file size - const stats = fs.statSync(outputPath); - const fileSizeKB = Math.round(stats.size / 1024); + const page = await context.newPage(); - console.log(`Saved: ${outputPath} (${fileSizeKB} KB)`); + try { + // Navigate to diagram + await page.goto(`file://${path.resolve(diagramPath)}`, { + waitUntil: "networkidle", + timeout: 15000, + }); + + // Wait for fonts to render + await page.waitForTimeout(800); - } catch (error) { - console.error(`Failed: ${error.message}`); - } + const outputPath = path.join(outputDir, "diagram.png"); - await browser.close(); + // Take screenshot + await page.screenshot({ + path: outputPath, + type: "png", + fullPage: false, + }); - console.log(`\nDiagram captured!\n`); - console.log(`PNG saved to: ${outputDir}/diagram.png`); + // Get file size + const stats = fs.statSync(outputPath); + const fileSizeKB = Math.round(stats.size / 1024); + + console.log(`Saved: ${outputPath} (${fileSizeKB} KB)`); + } catch (error) { + console.error(`Failed: ${error.message}`); + } + + await browser.close(); + + console.log(`\nDiagram captured!\n`); + console.log(`PNG saved to: ${outputDir}/diagram.png`); } // Parse command line arguments const diagramName = process.argv[2]; -const sizeArg = process.argv[3] || 'landscape'; +const sizeArg = process.argv[3] || "landscape"; if (!diagramName) { - console.error('\nUsage: node screenshot-diagram.js [landscape|square|wide]\n'); - console.error('Examples:'); - console.error(' node screenshot-diagram.js lead-gen-pipeline'); - console.error(' node screenshot-diagram.js lead-gen-pipeline square'); - console.error(' node screenshot-diagram.js lead-gen-pipeline wide\n'); - process.exit(1); + console.error( + "\nUsage: node screenshot-diagram.js [landscape|square|wide]\n", + ); + console.error("Examples:"); + console.error(" node screenshot-diagram.js lead-gen-pipeline"); + console.error(" node screenshot-diagram.js lead-gen-pipeline square"); + console.error(" node screenshot-diagram.js lead-gen-pipeline wide\n"); + process.exit(1); } // Run screenshot -screenshotDiagram(diagramName, sizeArg) - .catch(error => { - console.error('\nScreenshot capture failed:', error.message); - process.exit(1); - }); +screenshotDiagram(diagramName, sizeArg).catch((error) => { + console.error("\nScreenshot capture failed:", error.message); + process.exit(1); +}); diff --git a/.claude/skills/design-system/scripts/embed-tokens.cjs b/.claude/skills/design-system/scripts/embed-tokens.cjs index 419c1047..69531823 100644 --- a/.claude/skills/design-system/scripts/embed-tokens.cjs +++ b/.claude/skills/design-system/scripts/embed-tokens.cjs @@ -10,14 +10,14 @@ * node embed-tokens.cjs --style # Wrap in