From 1d902674e69ac194ec130e599a2ff82c1472dfe8 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 23 Oct 2026 11:19:38 -0300 Subject: [PATCH 1/3] style: normalize source formatting for existing lint rules --- .../skills/brand/scripts/extract-colors.cjs | 18 +- .../brand/scripts/inject-brand-context.cjs | 75 +- .../brand/scripts/sync-brand-to-tokens.cjs | 149 +- .../skills/brand/scripts/validate-asset.cjs | 48 +- .../create-apple-notes-mockup/generate.js | 134 +- .../create-apple-notes-mockup/render.js | 61 +- .../create-apple-notes-mockup/screenshot.js | 28 +- .../templates/icons.js | 2 +- .../scripts/record-master.template.js | 101 +- .../scripts/render-end-card.template.js | 95 +- .../skills/create-chatgpt-mockup/generate.js | 241 +- .../skills/create-chatgpt-mockup/render.js | 73 +- .../create-chatgpt-mockup/screenshot.js | 34 +- .../smoke-app-question/clips/record-master.js | 189 +- .../scripts/record-master.template.js | 183 +- .../create-html-carousel/screenshot-slides.js | 213 +- .../skills/create-imessage-mockup/generate.js | 232 +- .../skills/create-imessage-mockup/render.js | 94 +- .../create-imessage-mockup/screenshot.js | 50 +- .../scripts/record-master.template.js | 146 +- .../scripts/render-end-card.template.js | 54 +- .../screenshot-diagram.js | 154 +- .../design-system/scripts/embed-tokens.cjs | 60 +- .../design-system/scripts/generate-tokens.cjs | 50 +- .../design-system/scripts/validate-tokens.cjs | 63 +- .../screenshot/fetch-full-tweet.js | 160 +- .../goose-graphics/screenshot/fetch-tweet.js | 298 +- .../goose-graphics/screenshot/screenshot.js | 497 +- .../meeting-brief/scripts/generate_brief.js | 16 +- .../meeting-brief/scripts/research_person.js | 44 +- .../skills/render-ios-keyboard/generate.js | 76 +- .claude/skills/render-ios-keyboard/render.js | 18 +- cli/super-agent.js | 285 +- cli/super-agent.ts | 58 +- packages/sdk-ts/src/index.ts | 18 +- scripts/audit-skills.ts | 245 +- scripts/build-release-bundles.ts | 10 +- scripts/build-skills-sh-mirror.ts | 4 +- scripts/bump-version.ts | 4 +- scripts/capture-extra.ts | 33 +- scripts/capture-screenshots.ts | 48 +- scripts/check-public-access.ts | 16 +- scripts/eval-adversarial.ts | 89 +- scripts/generate-changelog.ts | 20 +- scripts/generate-trust-attestation.ts | 20 +- scripts/run-playbook.ts | 25 +- scripts/seed-gtm-skills.ts | 14 +- scripts/sign-release-bundle.ts | 32 +- scripts/sync-content-to-registry.ts | 22 +- scripts/validate-content.ts | 62 +- scripts/verify-release-bundle.ts | 36 +- scripts/verify-trust-attestation.ts | 20 +- src/components/agents/FactoryProof.tsx | 28 +- src/components/agents/GuidedAgentEditor.tsx | 72 +- .../agents/PreDownloadChecklist.tsx | 22 +- src/components/cloud-skills/ExportBundle.tsx | 11 +- src/components/cloud-skills/SyncAnywhere.tsx | 8 +- src/components/cloud-skills/SyncHistory.tsx | 15 +- src/components/crm/EffectivenessPanel.tsx | 14 +- src/components/crm/ValueSoFarCard.tsx | 4 +- src/components/forge/FeedbackPrompt.tsx | 4 +- src/components/forge/ForgeProgress.tsx | 221 +- src/components/forge/SkillWizard.tsx | 150 +- src/components/lineage/LineageCard.tsx | 17 +- src/components/marketplace/PackageGallery.tsx | 8 +- src/components/match/McpRecommendations.tsx | 17 +- src/components/referrals/ReferralCapture.tsx | 6 +- src/components/reviews/ReviewsSection.tsx | 49 +- src/components/reviews/Stars.tsx | 13 +- src/components/share/ShareMenu.tsx | 35 +- src/components/site/ClientOnly.tsx | 13 +- src/components/site/CopyButton.tsx | 11 +- src/components/site/CreditsPill.tsx | 8 +- src/components/site/Footer.tsx | 130 +- src/components/site/InstallButtons.tsx | 6 +- src/components/site/Logo.tsx | 8 +- src/components/site/McpInstallAnimation.tsx | 8 +- src/components/site/Nav.tsx | 11 +- src/components/site/home/AgentFactory.tsx | 25 +- src/components/site/home/CompatibleAgents.tsx | 4 +- src/components/site/home/GradeImpact.tsx | 4 +- .../site/home/OutcomeComparisonChart.tsx | 69 +- src/components/site/home/University.tsx | 6 +- .../skills/ImpactProjectionReport.tsx | 8 +- src/components/souls/SoulDisclaimer.tsx | 5 +- src/hooks/useStripeCheckout.tsx | 3 +- src/integrations/lovable/index.ts | 5 +- src/integrations/supabase/auth-attacher.ts | 14 +- src/integrations/supabase/auth-middleware.ts | 71 +- src/integrations/supabase/client.server.ts | 12 +- src/integrations/supabase/client.ts | 18 +- src/integrations/supabase/types.ts | 8366 ++++++++--------- .../account/connection-status.functions.ts | 9 +- src/lib/account/packages.functions.ts | 9 +- src/lib/account/tokens.functions.ts | 17 +- src/lib/account/usage.functions.ts | 7 +- src/lib/admin/accounts.functions.ts | 27 +- src/lib/admin/agent-kpis.functions.ts | 56 +- src/lib/admin/author.server.ts | 78 +- src/lib/admin/customers.functions.ts | 59 +- src/lib/admin/imports.functions.ts | 20 +- src/lib/admin/meta-ads-pack.functions.ts | 11 +- src/lib/admin/meta-ads-tools.ts | 46 +- src/lib/admin/packages.functions.ts | 10 +- src/lib/admin/payments-health.functions.ts | 27 +- src/lib/admin/plans.functions.ts | 6 +- src/lib/admin/requests.functions.ts | 22 +- src/lib/admin/review.functions.ts | 49 +- src/lib/adversarial/calibrate.server.ts | 7 +- src/lib/adversarial/judge.server.ts | 8 +- src/lib/adversarial/judge.ts | 28 +- src/lib/adversarial/loader.ts | 13 +- src/lib/adversarial/runner.ts | 9 +- src/lib/adversarial/scorer.ts | 8 +- src/lib/agents/agents.shared.ts | 7 +- src/lib/agents/bundle.ts | 26 +- src/lib/agents/factory-adapt.ts | 15 +- src/lib/agents/factory-runner.server.ts | 35 +- src/lib/agents/factory.functions.ts | 54 +- src/lib/agents/factory.server.ts | 35 +- src/lib/agents/guardrail-library.ts | 38 +- src/lib/ai-gateway.ts | 4 +- src/lib/auth/bearer.server.ts | 10 +- src/lib/auth/paid.server.ts | 3 +- .../cloud-skills/cloud-skills.functions.ts | 89 +- src/lib/cloud-skills/conflicts.ts | 11 +- src/lib/cloud-skills/history.functions.ts | 11 +- src/lib/cloud-skills/subscription-guard.ts | 3 +- src/lib/cloud-skills/sync-log.ts | 3 +- src/lib/credits/credits.functions.ts | 36 +- src/lib/crm/copy.ts | 21 +- src/lib/crm/crm.functions.ts | 53 +- src/lib/crm/guardrails.ts | 65 +- src/lib/crm/learning.server.ts | 45 +- src/lib/crm/learning.ts | 7 +- src/lib/crm/mailer.server.ts | 17 +- src/lib/crm/segments.ts | 7 +- src/lib/crm/snapshot.server.ts | 6 +- src/lib/crm/tool-profile.ts | 14 +- src/lib/earnings/earnings.functions.ts | 11 +- src/lib/email-templates/_brand.ts | 124 +- .../email-templates/action-confirmation.tsx | 62 +- src/lib/email-templates/crm-lifecycle.tsx | 104 +- src/lib/email-templates/email-change.tsx | 30 +- src/lib/email-templates/invite.tsx | 21 +- src/lib/email-templates/magic-link.tsx | 19 +- src/lib/email-templates/reauthentication.tsx | 20 +- src/lib/email-templates/recovery.tsx | 23 +- src/lib/email-templates/registry.ts | 24 +- src/lib/email-templates/signup.tsx | 29 +- src/lib/email-templates/welcome.tsx | 43 +- src/lib/email/send.ts | 32 +- src/lib/experiments/uplift.functions.ts | 32 +- src/lib/experiments/uplift.ts | 18 +- src/lib/favorites/favorites.functions.ts | 9 +- src/lib/growth/bounties.functions.ts | 43 +- src/lib/growth/bounties.ts | 15 +- src/lib/growth/revenue-split.ts | 7 +- src/lib/growth/tips.functions.ts | 3 +- src/lib/i18n/dictionaries.ts | 8 +- src/lib/integrations/registry.ts | 38 +- src/lib/marketplace/detail.functions.ts | 142 +- src/lib/marketplace/glossary.ts | 24 +- src/lib/marketplace/project-profile.ts | 3 +- src/lib/marketplace/telemetry.functions.ts | 20 +- src/lib/match/match.functions.ts | 70 +- src/lib/match/mcp-registry.ts | 64 +- src/lib/mcp/tools/agents.ts | 60 +- src/lib/mcp/tools/cloud-skills.ts | 64 +- src/lib/mcp/tools/cloud-sync.ts | 14 +- src/lib/mcp/tools/onboarding.ts | 40 +- src/lib/mcp/tools/proof.ts | 18 +- src/lib/mcp/tools/returning.ts | 77 +- src/lib/mcp/tools/skills.ts | 27 +- src/lib/mcp/tools/university.ts | 43 +- src/lib/oauth/connections.functions.ts | 27 +- src/lib/oauth/mcp-oauth.functions.ts | 6 +- src/lib/oauth/mcp-oauth.server.ts | 12 +- src/lib/oauth/normalize.ts | 2 +- src/lib/onboarding/checklist.ts | 59 +- src/lib/packs/packs.functions.ts | 84 +- src/lib/packs/pipeline.server.ts | 10 +- src/lib/payments.functions.ts | 12 +- src/lib/plugins/agent-plugins.ts | 13 +- src/lib/plugins/conformance.ts | 67 +- src/lib/plugins/signature.server.ts | 5 +- src/lib/plugins/upload.functions.ts | 7 +- src/lib/plugins/upload.server.ts | 31 +- src/lib/referrals/referrals.functions.ts | 7 +- src/lib/require-auth.ts | 4 +- src/lib/reviews/reports.functions.ts | 41 +- src/lib/reviews/reviews.functions.ts | 183 +- src/lib/runtime/compile.ts | 7 +- src/lib/runtime/expr.ts | 92 +- src/lib/runtime/run.ts | 70 +- src/lib/runtime/types.ts | 7 +- src/lib/seo/canonical.ts | 5 +- src/lib/seo/comparisons.ts | 36 +- src/lib/seo/use-cases.ts | 34 +- src/lib/share/share-promo.functions.ts | 6 +- src/lib/skills/author.functions.ts | 8 +- src/lib/skills/autolearn.functions.ts | 14 +- src/lib/skills/compatibility.functions.ts | 54 +- src/lib/skills/draft-repair.ts | 19 +- src/lib/skills/drift.functions.ts | 5 +- src/lib/skills/evaluator.functions.ts | 19 +- src/lib/skills/feedback.functions.ts | 6 +- src/lib/skills/forge-loop.functions.ts | 62 +- src/lib/skills/fork.functions.ts | 22 +- src/lib/skills/impact-projection.ts | 6 +- src/lib/skills/list.functions.ts | 14 +- src/lib/skills/open-skills.ts | 8 +- src/lib/skills/pipelines.server.ts | 366 +- src/lib/skills/review-history.server.ts | 4 +- src/lib/skills/schemas.ts | 146 +- src/lib/skills/value-proof.ts | 4 +- src/lib/souls/get.functions.ts | 108 +- src/lib/souls/manage.functions.ts | 5 +- src/lib/stripe.server.ts | 12 +- src/lib/telemetry/use-track.ts | 5 +- src/lib/tenant-crypto/envelope.server.ts | 4 +- src/lib/tenant-crypto/keys.functions.ts | 40 +- src/lib/trust/score.ts | 49 +- src/lib/trust/scoring.ts | 6 +- src/lib/trust/signing.ts | 23 +- src/lib/trust/telemetry.ts | 1 - src/lib/university/cases.ts | 2059 +++- src/lib/university/curriculum.ts | 67 +- src/lib/university/diagnose.ts | 106 +- src/lib/university/types.ts | 178 +- src/lib/university/university.functions.ts | 14 +- src/lib/university/university.server.ts | 13 +- src/lib/uploads/queue.server.ts | 2 +- src/lib/uploads/uploads.functions.ts | 20 +- src/lib/uploads/uploads.server.ts | 22 +- src/lib/version.ts | 592 +- src/routes/__root.tsx | 34 +- src/routes/about.tsx | 60 +- src/routes/account.agents.tsx | 7 +- src/routes/account.cloud-skills.tsx | 88 +- src/routes/account.connections.tsx | 8 +- src/routes/account.credits.tsx | 101 +- src/routes/account.earnings.tsx | 36 +- src/routes/account.encryption.tsx | 49 +- src/routes/account.library.tsx | 17 +- src/routes/account.packages.tsx | 65 +- src/routes/account.referrals.tsx | 71 +- src/routes/account.tokens.tsx | 44 +- src/routes/account.usage.tsx | 85 +- src/routes/admin.accounts.tsx | 12 +- src/routes/admin.agent-kpis.tsx | 23 +- src/routes/admin.calibration.tsx | 58 +- src/routes/admin.crm.tsx | 45 +- src/routes/admin.customers.tsx | 253 +- src/routes/admin.import.github.tsx | 9 +- src/routes/admin.import.markdown.tsx | 19 +- src/routes/admin.index.tsx | 10 +- src/routes/admin.meta-ads-pack.tsx | 32 +- src/routes/admin.packages.new.tsx | 49 +- src/routes/admin.packages.tsx | 46 +- src/routes/admin.payments-health.tsx | 18 +- src/routes/admin.plans.tsx | 28 +- src/routes/admin.plugins.tsx | 12 +- src/routes/admin.requests.tsx | 6 +- src/routes/admin.review-audit.tsx | 20 +- src/routes/admin.review-reports.tsx | 23 +- src/routes/admin.review.tsx | 112 +- src/routes/admin.roi.tsx | 616 +- src/routes/admin.tsx | 9 +- src/routes/agents.$slug.tsx | 6 +- src/routes/agents.build.$id.tsx | 54 +- src/routes/agents.index.tsx | 10 +- src/routes/agents.md.ts | 3 +- src/routes/agents.new.tsx | 64 +- src/routes/api/agents.$slug.download.$ext.ts | 11 +- .../api/agents.build.$id.download.$ext.ts | 21 +- src/routes/api/badges.trust.$slug.svg.ts | 9 +- src/routes/api/integrations.$slug.install.ts | 5 +- src/routes/api/og.skill-of-the-week.svg.ts | 52 +- src/routes/api/packages.upload.ts | 21 +- .../packs.customization.$id.download.$ext.ts | 10 +- src/routes/api/public/crm/run.ts | 6 +- src/routes/api/public/curriculum/next.ts | 13 +- src/routes/api/public/diag.ts | 3 +- src/routes/api/public/diagnose/start.ts | 8 +- src/routes/api/public/diagnose/submit.ts | 9 +- .../api/public/install[.]hermes[.]sh.ts | 1 - src/routes/api/public/mcp.health.ts | 2 +- src/routes/api/public/mcp.probe.ts | 3 +- src/routes/api/public/oauth/register.ts | 3 +- src/routes/api/public/oauth/revoke.ts | 4 +- src/routes/api/public/oauth/token.ts | 14 +- src/routes/api/public/onboarding.ts | 65 +- src/routes/api/public/packages.ts | 5 +- src/routes/api/public/payments/webhook.ts | 40 +- .../public/plugins.$slug.signature[.]json.ts | 3 +- src/routes/api/public/review.batch.ts | 70 +- src/routes/api/public/review.ts | 54 +- src/routes/api/public/search.ts | 8 +- src/routes/api/public/telemetry.ts | 5 +- src/routes/api/skills.$slug.export.ts | 19 +- src/routes/api/telemetry.ts | 12 +- src/routes/bounties.$id.tsx | 109 +- src/routes/bounties.new.tsx | 73 +- src/routes/bounties.tsx | 124 +- src/routes/collections.$slug.tsx | 93 +- src/routes/community.tsx | 81 +- src/routes/compare.$pair.tsx | 152 +- src/routes/compare.index.tsx | 13 +- src/routes/contributor-faq.tsx | 107 +- src/routes/curriculum.tsx | 84 +- src/routes/diagnose.tsx | 55 +- src/routes/docs.mcp.tsx | 178 +- src/routes/docs.tsx | 26 +- src/routes/email/unsubscribe.ts | 120 +- src/routes/enterprise.tsx | 73 +- src/routes/evaluation.tsx | 37 +- src/routes/forge.report.$slug.tsx | 154 +- src/routes/forge.tsx | 236 +- src/routes/home.tsx | 11 +- src/routes/how-it-works.tsx | 4 +- src/routes/legal.disclaimers.tsx | 22 +- src/routes/lovable/email/auth/preview.ts | 71 +- src/routes/lovable/email/auth/webhook.ts | 198 +- src/routes/lovable/email/queue/process.ts | 268 +- src/routes/lovable/email/suppression.ts | 168 +- .../lovable/email/transactional/preview.ts | 79 +- .../lovable/email/transactional/send.ts | 288 +- src/routes/marketplace.$packageId.tsx | 231 +- src/routes/marketplace.rankings.tsx | 6 +- src/routes/match.tsx | 45 +- src/routes/nda.tsx | 16 +- src/routes/oauth.authorize.tsx | 24 +- src/routes/packs.$slug.customize.tsx | 173 +- src/routes/packs.$slug.tsx | 52 +- src/routes/packs.index.tsx | 4 +- src/routes/play.tsx | 3 +- src/routes/pricing.tsx | 5 +- src/routes/privacy.tsx | 159 +- src/routes/refunds.tsx | 15 +- src/routes/reset-password.tsx | 4 +- src/routes/sitemap[.]xml.tsx | 8 +- src/routes/skill-of-the-week.tsx | 161 +- src/routes/skillforge.tsx | 8 +- src/routes/souls.$slug.tsx | 34 +- src/routes/status.tsx | 4 +- src/routes/terms.tsx | 339 +- src/routes/u.$handle.tsx | 2 +- src/routes/unsubscribe.tsx | 105 +- src/routes/upload.tsx | 76 +- src/routes/use-case.$vertical.$task.tsx | 170 +- src/routes/use-cases.tsx | 47 +- src/routes/welcome.tsx | 275 +- src/routes/whitepaper.tsx | 143 +- src/server.ts | 3 +- tests/adversarial-harness.test.ts | 15 +- tests/adversarial-holdout.test.ts | 5 +- tests/adversarial-judge.test.ts | 51 +- tests/agent-catalog.test.ts | 50 +- tests/agent-plugins.test.ts | 10 +- tests/bounties.test.ts | 53 +- tests/cli-install.test.ts | 24 +- tests/draft-repair.test.ts | 5 +- tests/growth-revenue-split.test.ts | 39 +- tests/integrations.test.ts | 102 +- tests/package-signature.test.ts | 17 +- tests/plugin-conformance.test.ts | 15 +- tests/release-signing.test.ts | 68 +- tests/responsive-discover.test.ts | 5 +- tests/runtime.test.ts | 104 +- tests/trust-attestation.test.ts | 34 +- tests/trust-badge.test.ts | 3 +- tests/university.test.ts | 75 +- vite.config.ts | 5 +- 374 files changed, 17727 insertions(+), 12215 deletions(-) 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