From 7f7c973cdcda5c44a6305c1e64267cc30c587595 Mon Sep 17 00:00:00 2001 From: ClaudiaFang <18342986+ClaudiaFang@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:34:38 +0000 Subject: [PATCH] chore(external): weekly sync of external skills from upstream --- .../subagent-driven-development/SKILL.md | 36 +- .../re-review-prompt.md | 2 +- .../scripts/review-package | 9 +- .../scripts/sdd-workspace | 46 +- .../scripts/task-brief | 4 +- .../task-reviewer-prompt.md | 4 +- external/basic/brainstorming/SKILL.md | 59 +- .../basic/brainstorming/visual-companion.md | 12 +- external/basic/firecrawl-search/SKILL.md | 12 +- .../develop/frontend/ui-animation/SKILL.md | 19 +- .../frontend/ui-animation/evals/evals.json | 14 +- .../references/component-patterns.md | 8 +- .../references/debugging-symptoms.md | 4 +- .../references/decision-framework.md | 6 +- .../ui-animation/references/gesture-drag.md | 73 +- .../ui-animation/references/interface-sfx.md | 41 + .../ui-animation/references/live-tuning.md | 59 + .../ui-animation/references/review-format.md | 4 +- .../references/transition-recipes.md | 2 +- .../openspec/openspec-apply-change/SKILL.md | 18 +- .../openspec/openspec-archive-change/SKILL.md | 35 +- .../openspec-bulk-archive-change/SKILL.md | 41 +- .../openspec-continue-change/SKILL.md | 13 +- .../openspec/openspec-explore/SKILL.md | 27 +- .../openspec/openspec-ff-change/SKILL.md | 13 +- .../openspec/openspec-new-change/SKILL.md | 13 +- .../openspec/openspec-onboard/SKILL.md | 67 +- .../openspec/openspec-propose/SKILL.md | 15 +- .../openspec/openspec-sync-specs/SKILL.md | 30 +- .../openspec/openspec-update-change/SKILL.md | 26 +- .../openspec/openspec-verify-change/SKILL.md | 17 +- .../hyperframes-animation/SKILL.md | 1 + .../hyperframes-animation/adapters/gsap.md | 2 +- .../hyperframes-animation/adapters/three.md | 14 + .../references/motion-blur.md | 133 + .../remotion-best-practices/SKILL.md | 2 +- .../remotion-captions/REFERENCE.md | 2 +- .../remotion-create/REFERENCE.md | 2 +- .../remotion-docs/REFERENCE.md | 2 +- .../remotion-interactivity/REFERENCE.md | 4 +- .../remotion-maps/REFERENCE.md | 2 +- .../remotion-markup/REFERENCE.md | 2 +- .../remotion-maps/REFERENCE.md | 2 +- .../remotion-multimedia/REFERENCE.md | 2 +- .../remotion-render/REFERENCE.md | 2 +- .../remotion-saas/REFERENCE.md | 2 +- .../remotion-studio/REFERENCE.md | 2 +- .../remotion-upgrade/REFERENCE.md | 2 +- .../skills/brainstorming/SKILL.md | 162 +- .../skills/brainstorming/visual-companion.md | 26 +- .../SKILL.md | 12 +- .../skills/firecrawl-search/SKILL.md | 12 +- .../subagent-driven-development/SKILL.md | 642 +- .../implementer-prompt.md | 21 +- .../re-review-prompt.md | 115 + .../scripts/review-package | 27 +- .../scripts/sdd-workspace | 80 +- .../scripts/task-brief | 9 +- .../task-reviewer-prompt.md | 31 +- .../skills/frontend-design/SKILL.md | 56 +- .../skills/react/SKILL.md | 89 +- .../skills/sleek-design-mobile-apps/SKILL.md | 528 +- .../skills/ui-animation/SKILL.md | 102 +- .../skills/ui-animation/evals/evals.json | 51 + .../evaluations/discovery-mode.json | 36 + .../evaluations/fixtures/settings-panel.tsx | 48 + .../references/component-patterns.md | 53 +- .../references/contextual-animations.md | 26 + .../ui-animation/references/curve-fitting.md | 12 +- .../references/debugging-symptoms.md | 80 + .../references/decision-framework.md | 102 +- .../references/discovery-workflow.md | 19 + .../ui-animation/references/gesture-drag.md | 190 +- .../ui-animation/references/interface-sfx.md | 41 + .../ui-animation/references/live-tuning.md | 59 + .../references/performance-deep-dive.md | 57 + .../ui-animation/references/review-format.md | 36 +- .../references/scroll-animations.md | 101 + .../references/spring-animations.md | 62 +- .../ui-animation/references/svg-animation.md | 115 + .../references/transition-recipes.md | 214 +- .../ui-animation/references/vocabulary.md | 159 + .../skills/ui-animation/scripts/fit_curves.py | 2 +- .../vercel-react-best-practices/metadata.json | 15 - .../health-wellness/skills/healthkit/SKILL.md | 32 +- .../references/healthkit-patterns.md | 35 +- .../health-wellness/skills/rem-sleep/LICENSE | 21 + .../skills/rem-sleep/README.md | 136 + .../rem-sleep/scripts/gather-sessions.sh | 97 + .../skills/clean-code/SKILL.md | 15 +- .../skills/devops-engineer/SKILL.md | 8 +- .../devops-engineer/references/gitlab-ci.md | 192 + .../software-delivery/skills/gcloud/SKILL.md | 230 +- .../skills/gcloud/references/cli-usage.md | 153 + .../skills/gcloud/references/mcp-usage.md | 187 + .../skills/playwright-best-practices/SKILL.md | 2 +- .../advanced/authentication-flows.md | 360 + .../advanced/authentication.md | 871 ++ .../advanced/clock-mocking.md | 364 + .../advanced/mobile-testing.md | 409 + .../advanced/multi-context.md | 288 + .../advanced/multi-user.md | 393 + .../advanced/network-advanced.md | 452 + .../advanced/third-party.md | 464 + .../architecture/pom-vs-fixtures.md | 363 + .../architecture/test-architecture.md | 369 + .../architecture/when-to-mock.md | 383 + .../browser-apis/browser-apis.md | 391 + .../browser-apis/iframes.md | 403 + .../browser-apis/service-workers.md | 504 ++ .../browser-apis/websockets.md | 403 + .../core/annotations.md | 424 + .../core/assertions-waiting.md | 361 + .../core/configuration.md | 452 + .../core/fixtures-hooks.md | 417 + .../core/global-setup.md | 434 + .../core/locators.md | 242 + .../core/page-object-model.md | 315 + .../core/projects-dependencies.md | 453 + .../core/test-data.md | 492 ++ .../core/test-suite-structure.md | 361 + .../core/test-tags.md | 298 + .../debugging/console-errors.md | 420 + .../debugging/debugging.md | 504 ++ .../debugging/error-testing.md | 360 + .../debugging/flaky-tests.md | 496 ++ .../frameworks/angular.md | 530 ++ .../frameworks/nextjs.md | 469 + .../frameworks/react.md | 531 ++ .../frameworks/vue.md | 574 ++ .../infrastructure-ci-cd/ci-cd.md | 468 + .../infrastructure-ci-cd/docker.md | 283 + .../infrastructure-ci-cd/github-actions.md | 546 ++ .../infrastructure-ci-cd/gitlab.md | 397 + .../infrastructure-ci-cd/other-providers.md | 521 ++ .../infrastructure-ci-cd/parallel-sharding.md | 371 + .../infrastructure-ci-cd/performance.md | 453 + .../infrastructure-ci-cd/reporting.md | 424 + .../infrastructure-ci-cd/test-coverage.md | 497 ++ .../testing-patterns/accessibility.md | 359 + .../testing-patterns/api-testing.md | 719 ++ .../testing-patterns/browser-extensions.md | 506 ++ .../testing-patterns/canvas-webgl.md | 493 ++ .../testing-patterns/component-testing.md | 500 ++ .../testing-patterns/drag-drop.md | 576 ++ .../testing-patterns/electron.md | 509 ++ .../testing-patterns/file-operations.md | 377 + .../testing-patterns/file-upload-download.md | 562 ++ .../testing-patterns/forms-validation.md | 561 ++ .../testing-patterns/graphql-testing.md | 331 + .../testing-patterns/i18n.md | 508 ++ .../testing-patterns/performance-testing.md | 476 ++ .../testing-patterns/security-testing.md | 430 + .../testing-patterns/visual-regression.md | 634 ++ .../skills/security-scan/SKILL.md | 2 +- .../skills/terraform-engineer/SKILL.md | 3 +- .../skills/using-git-worktrees/SKILL.md | 53 +- .../skills/windmill-rust-backend/SKILL.md | 7 + .../skills/openspec-apply-change/SKILL.md | 18 +- .../skills/openspec-archive-change/SKILL.md | 35 +- .../openspec-bulk-archive-change/SKILL.md | 41 +- .../skills/openspec-continue-change/SKILL.md | 13 +- .../skills/openspec-explore/SKILL.md | 27 +- .../skills/openspec-ff-change/SKILL.md | 13 +- .../skills/openspec-new-change/SKILL.md | 13 +- .../skills/openspec-onboard/SKILL.md | 67 +- .../skills/openspec-propose/SKILL.md | 15 +- .../skills/openspec-sync-specs/SKILL.md | 30 +- .../skills/openspec-update-change/SKILL.md | 26 +- .../skills/openspec-verify-change/SKILL.md | 17 +- .../skills/hyperframes-animation/SKILL.md | 9 +- .../hyperframes-animation/adapters/animejs.md | 82 +- .../adapters/css-animations.md | 23 +- .../adapters/gsap-easing-and-stagger.md | 127 +- .../adapters/gsap-timeline-and-labels.md | 2 +- .../adapters/gsap-transforms-and-perf.md | 39 +- .../hyperframes-animation/adapters/gsap.md | 10 +- .../hyperframes-animation/adapters/lottie.md | 7 +- .../hyperframes-animation/adapters/three.md | 18 +- .../hyperframes-animation/adapters/typegpu.md | 14 +- .../hyperframes-animation/adapters/waapi.md | 9 +- .../hyperframes-animation/blueprints-index.md | 120 +- .../blueprints/agent-progress-theater.md | 76 + .../blueprints/camera-journey.md | 61 + .../blueprints/constellation-hub.md | 27 +- .../blueprints/cta-morph-press.md | 45 +- .../blueprints/cursor-ui-demo.md | 44 +- .../blueprints/dataviz-countup.md | 31 +- .../blueprints/device-surface-showcase.md | 33 +- .../blueprints/fixed-anchor-cycle.md | 46 + .../blueprints/grid-card-assemble.md | 27 +- .../blueprints/kinetic-type-beats.md | 63 +- .../blueprints/logo-assemble-lockup.md | 58 +- .../blueprints/overwhelm-surround.md | 40 +- .../blueprints/panel-edit-live-sync.md | 73 + .../blueprints/prompt-type-submit-generate.md | 85 + .../blueprints/spatial-pan-stations.md | 4 +- .../blueprints/titlecard-reveal.md | 46 +- .../transcript-scroll-artifact-reveal.md | 45 + .../blueprints/zoom-out-workspace-reveal.md | 68 + .../examples/assets/avatars/02.avif | Bin 4473 -> 0 bytes .../examples/assets/brands/github.avif | Bin 2847 -> 0 bytes .../examples/assets/brands/nvidia.avif | Bin 2971 -> 0 bytes .../examples/assets/brands/visa.avif | Bin 2515 -> 0 bytes .../examples/assets/brands/zoominfo.avif | Bin 2901 -> 0 bytes .../references/motion-blur.md | 133 + .../hyperframes-animation/rules-index.md | 31 +- .../rules/3d-camera-flight.md | 180 + .../rules/3d-page-scroll.md | 218 +- .../rules/3d-text-depth-layers.md | 309 +- .../rules/ai-tracking-box.md | 411 +- .../rules/ambient-glow-bloom.md | 354 +- .../rules/anchored-layout-expand.md | 148 + .../rules/asr-keyword-glow.md | 296 +- .../rules/avatar-cloud-network.md | 396 +- .../rules/camera-cursor-tracking.md | 255 +- .../rules/card-morph-anchor.md | 301 +- .../rules/center-outward-expansion.md | 227 +- .../rules/chart-scrub-readout.md | 151 + .../rules/chromatic-glitch.md | 150 + .../rules/context-sensitive-cursor.md | 289 +- .../rules/control-target-sync.md | 131 + .../rules/coordinate-target-zoom.md | 296 +- .../rules/counting-dynamic-scale.md | 288 +- .../rules/css-marker-patterns.md | 227 +- .../rules/cursor-click-ripple.md | 288 +- .../rules/cursor-drag.md | 141 + .../rules/depth-of-field-blur.md | 301 +- .../rules/depth-scatter-assemble.md | 332 +- .../rules/discrete-text-sequence.md | 279 +- .../rules/dynamic-content-sequencing.md | 358 +- .../rules/gradient-text-sweep.md | 136 + .../rules/gsap-effects.md | 185 +- .../rules/hacker-flip-3d.md | 250 +- .../rules/kinetic-beat-slam.md | 200 +- .../rules/motion-blur-streak.md | 362 +- .../rules/multi-cursor-choreography.md | 133 + .../rules/multi-phase-camera.md | 280 +- .../rules/nudge-curve.md | 47 + .../rules/orbit-3d-entry.md | 337 +- .../rules/particle-burst.md | 149 + .../rules/physics-press-reaction.md | 393 +- .../rules/press-release-spring.md | 330 +- .../rules/reactive-displacement.md | 303 +- .../rules/scale-swap-transition.md | 316 +- .../rules/sine-wave-loop.md | 283 +- .../rules/split-tilt-cards.md | 304 +- .../rules/spring-pop-entrance.md | 270 +- .../rules/stat-bars-and-fills.md | 59 +- .../rules/svg-icon-enrichment.md | 404 +- .../rules/svg-path-draw.md | 278 +- .../rules/theme-crossfade-morph.md | 126 + .../rules/vertical-spring-ticker.md | 247 +- .../rules/viewport-change.md | 337 +- .../rules/waterfall-entry.md | 81 + .../scripts/animation-map-sampling.mjs | 40 + .../scripts/animation-map-sampling.test.mjs | 48 + .../scripts/animation-map.mjs | 174 +- .../scripts/animation-map.test.mjs | 444 + .../scripts/package-loader.mjs | 206 +- .../scripts/package-loader.test.mjs | 114 + .../transitions/TRANSITION-REGISTRY.md | 3 +- .../transitions/css-destruction.md | 2 +- .../skills/remotion-best-practices/SKILL.md | 361 +- .../agents/openai.yaml | 7 + .../assets/remotion-icon.svg | 4 + .../remotion-captions/REFERENCE.md | 36 + .../remotion-captions/agents/openai.yaml | 7 + .../assets/remotion-icon.svg | 4 + .../display-captions.md | 12 +- .../import-srt-captions.md | 6 +- .../transcribe-captions.md | 0 .../remotion-create/REFERENCE.md | 85 + .../remotion-create/agents/openai.yaml | 7 + .../remotion-create/assets/remotion-icon.svg | 4 + .../{rules => remotion-create}/tailwind.md | 2 +- .../remotion-create/video-layout.md | 9 + .../remotion-docs/REFERENCE.md | 46 + .../remotion-docs/agents/openai.yaml | 7 + .../remotion-docs/assets/remotion-icon.svg | 4 + .../remotion-interactivity/REFERENCE.md | 249 + .../remotion-interactivity/agents/openai.yaml | 7 + .../assets/remotion-icon.svg | 4 + .../remotion-maps/REFERENCE.md | 36 + .../remotion-maps/agents/openai.yaml | 7 + .../remotion-maps/assets/remotion-icon.svg | 4 + .../techniques/cesium/TECHNIQUE.md | 89 + .../cesium/assets/CesiumFlythrough.tsx | 284 + .../techniques/cesium/assets/cesium-path.json | 299 + .../techniques/cesium/assets/city-path.json | 12 + .../techniques/cesium/assets/example-Root.tsx | 39 + .../techniques/cesium/assets/flight-path.ts | 35 + .../cesium/assets/sample-river.geojson | 215 + .../cesium/references/3d-data-sources.md | 43 + .../references/3d-flyover-architecture.md | 128 + .../cesium/references/3d-troubleshooting.md | 57 + .../cesium/scripts/prep-cesium-path.mjs | 161 + .../techniques/mapbox/TECHNIQUE.md | 449 + .../mapbox/references/render-stability.md | 64 + .../techniques/maplibre/TECHNIQUE.md} | 40 +- .../maplibre/references/render-stability.md | 64 + .../techniques/maptiler/TECHNIQUE.md | 80 + .../maptiler/assets/CountryLabel.tsx | 70 + .../maptiler/assets/MapTilerVectorElement.ts | 80 + .../maptiler/assets/RiverReveal.tsx | 363 + .../maptiler/assets/example-Root.tsx | 22 + .../assets/sample-data/country-meta.json | 7535 +++++++++++++++++ .../assets/sample-data/yarlung-flow.json | 605 ++ .../techniques/maptiler/assets/tokens.ts | 34 + .../maptiler/references/map-data-sources.md | 91 + .../references/map-explainer-architecture.md | 129 + .../maptiler/references/map-geo-prep.md | 85 + .../maptiler/references/render-stability.md | 64 + .../techniques/maptiler/scripts/prep-geo.mjs | 202 + .../techniques/static-map/TECHNIQUE.md | 37 + .../{rules => remotion-markup}/3d.md | 0 .../remotion-markup/REFERENCE.md | 365 + .../remotion-markup/agents/openai.yaml | 7 + .../remotion-markup/assets/remotion-icon.svg | 4 + .../audio-visualization.md | 0 .../{rules => remotion-markup}/audio.md | 6 +- .../calculate-metadata.md | 6 +- .../compositions.md | 110 +- .../remotion-markup/cropping.md | 34 + .../{rules => remotion-markup}/effects.md | 24 +- .../embedding-videos.md} | 10 +- .../{rules => remotion-markup}/ffmpeg.md | 6 +- .../{rules => remotion-markup}/gifs.md | 64 +- .../google-fonts.md | 6 +- .../html-in-canvas.md | 22 +- .../{rules => remotion-markup}/images.md | 4 +- .../remotion-markup/light-leaks.md | 106 + .../{rules => remotion-markup}/local-fonts.md | 6 +- .../{rules => remotion-markup}/lottie.md | 5 +- .../measuring-dom-nodes.md | 6 +- .../measuring-text.md | 10 +- .../remotion-markup/multi-scene-video.md | 81 + .../{rules => remotion-markup}/parameters.md | 4 +- .../remotion-maps/REFERENCE.md | 36 + .../remotion-maps/agents/openai.yaml | 7 + .../remotion-maps/assets/remotion-icon.svg | 4 + .../techniques/cesium/TECHNIQUE.md | 89 + .../cesium/assets/CesiumFlythrough.tsx | 284 + .../techniques/cesium/assets/cesium-path.json | 299 + .../techniques/cesium/assets/city-path.json | 12 + .../techniques/cesium/assets/example-Root.tsx | 39 + .../techniques/cesium/assets/flight-path.ts | 35 + .../cesium/assets/sample-river.geojson | 215 + .../cesium/references/3d-data-sources.md | 43 + .../references/3d-flyover-architecture.md | 128 + .../cesium/references/3d-troubleshooting.md | 57 + .../cesium/scripts/prep-cesium-path.mjs | 161 + .../techniques/mapbox/TECHNIQUE.md | 449 + .../mapbox/references/render-stability.md | 64 + .../techniques/maplibre/TECHNIQUE.md | 484 ++ .../maplibre/references/render-stability.md | 64 + .../techniques/maptiler/TECHNIQUE.md | 80 + .../maptiler/assets/CountryLabel.tsx | 70 + .../maptiler/assets/MapTilerVectorElement.ts | 80 + .../maptiler/assets/RiverReveal.tsx | 363 + .../maptiler/assets/example-Root.tsx | 22 + .../assets/sample-data/country-meta.json | 7535 +++++++++++++++++ .../assets/sample-data/yarlung-flow.json | 605 ++ .../techniques/maptiler/assets/tokens.ts | 34 + .../maptiler/references/map-data-sources.md | 91 + .../references/map-explainer-architecture.md | 129 + .../maptiler/references/map-geo-prep.md | 85 + .../maptiler/references/render-stability.md | 64 + .../techniques/maptiler/scripts/prep-geo.mjs | 202 + .../techniques/static-map/TECHNIQUE.md | 37 + .../{rules => remotion-markup}/sequencing.md | 81 +- .../{rules => remotion-markup}/sfx.md | 0 .../silence-detection.md | 0 .../remotion-markup/text-highlights.md | 56 + .../remotion-markup/timing.md | 111 + .../{rules => remotion-markup}/transitions.md | 56 +- .../remotion-markup/video-editing.md | 90 + .../{rules => remotion-markup}/voiceover.md | 4 +- .../remotion-multimedia/REFERENCE.md | 20 + .../remotion-multimedia/agents/openai.yaml | 7 + .../assets/remotion-icon.svg | 4 + .../get-audio-duration.md | 4 +- .../get-video-dimensions.md | 4 +- .../get-video-duration.md | 4 +- .../remotion-render/REFERENCE.md | 27 + .../remotion-render/agents/openai.yaml | 7 + .../remotion-render/assets/remotion-icon.svg | 4 + .../transparent-videos.md | 0 .../remotion-saas/REFERENCE.md | 33 + .../remotion-saas/agents/openai.yaml | 7 + .../remotion-saas/assets/remotion-icon.svg | 4 + .../remotion-saas/framework.md | 21 + .../remotion-saas/player.md | 33 + .../remotion-saas/rendering.md | 69 + .../remotion-studio/REFERENCE.md | 24 + .../remotion-studio/agents/openai.yaml | 7 + .../remotion-studio/assets/remotion-icon.svg | 4 + .../remotion-upgrade/REFERENCE.md | 31 + .../remotion-upgrade/agents/openai.yaml | 7 + .../remotion-upgrade/assets/remotion-icon.svg | 4 + .../rules/assets/charts-bar-chart.tsx | 173 - .../assets/text-animations-typewriter.tsx | 100 - .../assets/text-animations-word-highlight.tsx | 103 - .../rules/light-leaks.md | 73 - .../rules/subtitles.md | 36 - .../rules/text-animations.md | 20 - .../remotion-best-practices/rules/timing.md | 162 - .../remotion-best-practices/rules/trimming.md | 51 - .../rules/video-layout.md | 68 - .../remotion/scripts/download-stitch-asset.sh | 10 +- skills-lock.json | 82 +- 411 files changed, 62215 insertions(+), 10922 deletions(-) create mode 100644 external/develop/frontend/ui-animation/references/interface-sfx.md create mode 100644 external/develop/frontend/ui-animation/references/live-tuning.md create mode 100644 external/video-design/hyperframes-animation/references/motion-blur.md create mode 100644 plugins/agent-toolkit/skills/subagent-driven-development/re-review-prompt.md create mode 100644 plugins/frontend-product-design/skills/ui-animation/evals/evals.json create mode 100644 plugins/frontend-product-design/skills/ui-animation/evaluations/discovery-mode.json create mode 100644 plugins/frontend-product-design/skills/ui-animation/evaluations/fixtures/settings-panel.tsx create mode 100644 plugins/frontend-product-design/skills/ui-animation/references/debugging-symptoms.md create mode 100644 plugins/frontend-product-design/skills/ui-animation/references/discovery-workflow.md create mode 100644 plugins/frontend-product-design/skills/ui-animation/references/interface-sfx.md create mode 100644 plugins/frontend-product-design/skills/ui-animation/references/live-tuning.md create mode 100644 plugins/frontend-product-design/skills/ui-animation/references/scroll-animations.md create mode 100644 plugins/frontend-product-design/skills/ui-animation/references/svg-animation.md create mode 100644 plugins/frontend-product-design/skills/ui-animation/references/vocabulary.md delete mode 100644 plugins/frontend-product-design/skills/vercel-react-best-practices/metadata.json create mode 100644 plugins/health-wellness/skills/rem-sleep/LICENSE create mode 100644 plugins/health-wellness/skills/rem-sleep/README.md create mode 100755 plugins/health-wellness/skills/rem-sleep/scripts/gather-sessions.sh create mode 100644 plugins/software-delivery/skills/devops-engineer/references/gitlab-ci.md create mode 100644 plugins/software-delivery/skills/gcloud/references/cli-usage.md create mode 100644 plugins/software-delivery/skills/gcloud/references/mcp-usage.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/advanced/authentication-flows.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/advanced/authentication.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/advanced/clock-mocking.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/advanced/mobile-testing.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/advanced/multi-context.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/advanced/multi-user.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/advanced/network-advanced.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/advanced/third-party.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/architecture/pom-vs-fixtures.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/architecture/test-architecture.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/architecture/when-to-mock.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/browser-apis/browser-apis.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/browser-apis/iframes.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/browser-apis/service-workers.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/browser-apis/websockets.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/annotations.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/assertions-waiting.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/configuration.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/fixtures-hooks.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/global-setup.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/locators.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/page-object-model.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/projects-dependencies.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/test-data.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/test-suite-structure.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/core/test-tags.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/debugging/console-errors.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/debugging/debugging.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/debugging/error-testing.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/debugging/flaky-tests.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/frameworks/angular.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/frameworks/nextjs.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/frameworks/react.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/frameworks/vue.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/infrastructure-ci-cd/ci-cd.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/infrastructure-ci-cd/docker.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/infrastructure-ci-cd/github-actions.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/infrastructure-ci-cd/gitlab.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/infrastructure-ci-cd/other-providers.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/infrastructure-ci-cd/performance.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/infrastructure-ci-cd/reporting.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/infrastructure-ci-cd/test-coverage.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/accessibility.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/api-testing.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/browser-extensions.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/canvas-webgl.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/component-testing.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/drag-drop.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/electron.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/file-operations.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/file-upload-download.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/forms-validation.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/graphql-testing.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/i18n.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/performance-testing.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/security-testing.md create mode 100644 plugins/software-delivery/skills/playwright-best-practices/testing-patterns/visual-regression.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/blueprints/agent-progress-theater.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/blueprints/camera-journey.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/blueprints/fixed-anchor-cycle.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/blueprints/panel-edit-live-sync.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/blueprints/prompt-type-submit-generate.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/blueprints/transcript-scroll-artifact-reveal.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/blueprints/zoom-out-workspace-reveal.md delete mode 100644 plugins/visual-content/skills/hyperframes-animation/examples/assets/avatars/02.avif delete mode 100644 plugins/visual-content/skills/hyperframes-animation/examples/assets/brands/github.avif delete mode 100644 plugins/visual-content/skills/hyperframes-animation/examples/assets/brands/nvidia.avif delete mode 100644 plugins/visual-content/skills/hyperframes-animation/examples/assets/brands/visa.avif delete mode 100644 plugins/visual-content/skills/hyperframes-animation/examples/assets/brands/zoominfo.avif create mode 100644 plugins/visual-content/skills/hyperframes-animation/references/motion-blur.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/3d-camera-flight.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/anchored-layout-expand.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/chart-scrub-readout.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/chromatic-glitch.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/control-target-sync.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/cursor-drag.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/gradient-text-sweep.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/multi-cursor-choreography.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/nudge-curve.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/particle-burst.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/theme-crossfade-morph.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/rules/waterfall-entry.md create mode 100644 plugins/visual-content/skills/hyperframes-animation/scripts/animation-map-sampling.mjs create mode 100644 plugins/visual-content/skills/hyperframes-animation/scripts/animation-map-sampling.test.mjs create mode 100644 plugins/visual-content/skills/hyperframes-animation/scripts/animation-map.test.mjs create mode 100644 plugins/visual-content/skills/hyperframes-animation/scripts/package-loader.test.mjs create mode 100644 plugins/visual-content/skills/remotion-best-practices/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/assets/remotion-icon.svg create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-captions/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-captions/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-captions/assets/remotion-icon.svg rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-captions}/display-captions.md (94%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-captions}/import-srt-captions.md (96%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-captions}/transcribe-captions.md (100%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-create/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-create/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-create/assets/remotion-icon.svg rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-create}/tailwind.md (80%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-create/video-layout.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-docs/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-docs/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-docs/assets/remotion-icon.svg create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-interactivity/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-interactivity/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-interactivity/assets/remotion-icon.svg create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/assets/remotion-icon.svg create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/TECHNIQUE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/assets/CesiumFlythrough.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/assets/cesium-path.json create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/assets/city-path.json create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/assets/example-Root.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/assets/flight-path.ts create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/assets/sample-river.geojson create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/references/3d-data-sources.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/references/3d-flyover-architecture.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/references/3d-troubleshooting.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/cesium/scripts/prep-cesium-path.mjs create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/mapbox/TECHNIQUE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/mapbox/references/render-stability.md rename plugins/visual-content/skills/remotion-best-practices/{rules/maplibre.md => remotion-maps/techniques/maplibre/TECHNIQUE.md} (86%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maplibre/references/render-stability.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/TECHNIQUE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/assets/CountryLabel.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/assets/MapTilerVectorElement.ts create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/assets/RiverReveal.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/assets/example-Root.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/assets/sample-data/country-meta.json create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/assets/sample-data/yarlung-flow.json create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/assets/tokens.ts create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/references/map-data-sources.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/references/map-explainer-architecture.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/references/map-geo-prep.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/references/render-stability.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/maptiler/scripts/prep-geo.mjs create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-maps/techniques/static-map/TECHNIQUE.md rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/3d.md (100%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/assets/remotion-icon.svg rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/audio-visualization.md (100%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/audio.md (95%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/calculate-metadata.md (85%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/compositions.md (51%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/cropping.md rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/effects.md (86%) rename plugins/visual-content/skills/remotion-best-practices/{rules/videos.md => remotion-markup/embedding-videos.md} (94%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/ffmpeg.md (91%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/gifs.md (74%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/google-fonts.md (95%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/html-in-canvas.md (89%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/images.md (94%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/light-leaks.md rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/local-fonts.md (94%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/lottie.md (95%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/measuring-dom-nodes.md (92%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/measuring-text.md (95%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/multi-scene-video.md rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/parameters.md (98%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/assets/remotion-icon.svg create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/TECHNIQUE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/assets/CesiumFlythrough.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/assets/cesium-path.json create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/assets/city-path.json create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/assets/example-Root.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/assets/flight-path.ts create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/assets/sample-river.geojson create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/references/3d-data-sources.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/references/3d-flyover-architecture.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/references/3d-troubleshooting.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/cesium/scripts/prep-cesium-path.mjs create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/mapbox/TECHNIQUE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/mapbox/references/render-stability.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maplibre/TECHNIQUE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maplibre/references/render-stability.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/TECHNIQUE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/assets/CountryLabel.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/assets/MapTilerVectorElement.ts create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/assets/RiverReveal.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/assets/example-Root.tsx create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/assets/sample-data/country-meta.json create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/assets/sample-data/yarlung-flow.json create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/assets/tokens.ts create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/references/map-data-sources.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/references/map-explainer-architecture.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/references/map-geo-prep.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/references/render-stability.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/maptiler/scripts/prep-geo.mjs create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/remotion-maps/techniques/static-map/TECHNIQUE.md rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/sequencing.md (65%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/sfx.md (100%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/silence-detection.md (100%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/text-highlights.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/timing.md rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/transitions.md (79%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-markup/video-editing.md rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-markup}/voiceover.md (93%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-multimedia/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-multimedia/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-multimedia/assets/remotion-icon.svg rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-multimedia}/get-audio-duration.md (94%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-multimedia}/get-video-dimensions.md (95%) rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-multimedia}/get-video-duration.md (94%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-render/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-render/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-render/assets/remotion-icon.svg rename plugins/visual-content/skills/remotion-best-practices/{rules => remotion-render}/transparent-videos.md (100%) create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-saas/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-saas/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-saas/assets/remotion-icon.svg create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-saas/framework.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-saas/player.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-saas/rendering.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-studio/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-studio/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-studio/assets/remotion-icon.svg create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-upgrade/REFERENCE.md create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-upgrade/agents/openai.yaml create mode 100644 plugins/visual-content/skills/remotion-best-practices/remotion-upgrade/assets/remotion-icon.svg delete mode 100644 plugins/visual-content/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx delete mode 100644 plugins/visual-content/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx delete mode 100644 plugins/visual-content/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx delete mode 100644 plugins/visual-content/skills/remotion-best-practices/rules/light-leaks.md delete mode 100644 plugins/visual-content/skills/remotion-best-practices/rules/subtitles.md delete mode 100644 plugins/visual-content/skills/remotion-best-practices/rules/text-animations.md delete mode 100644 plugins/visual-content/skills/remotion-best-practices/rules/timing.md delete mode 100644 plugins/visual-content/skills/remotion-best-practices/rules/trimming.md delete mode 100644 plugins/visual-content/skills/remotion-best-practices/rules/video-layout.md diff --git a/external/ai-agents/subagent-driven-development/SKILL.md b/external/ai-agents/subagent-driven-development/SKILL.md index aac35b9..f7bbf6a 100644 --- a/external/ai-agents/subagent-driven-development/SKILL.md +++ b/external/ai-agents/subagent-driven-development/SKILL.md @@ -36,25 +36,25 @@ stop and ask. digraph when_to_use { "Have implementation plan?" [shape=diamond]; "Tasks mostly independent?" [shape=diamond]; - "Stay in this session?" [shape=diamond]; + "Partner chose inline, or no subagent tool?" [shape=diamond]; "subagent-driven-development" [shape=box]; "executing-plans" [shape=box]; "Manual execution or brainstorm first" [shape=box]; "Have implementation plan?" -> "Tasks mostly independent?" [label="yes"]; "Have implementation plan?" -> "Manual execution or brainstorm first" [label="no"]; - "Tasks mostly independent?" -> "Stay in this session?" [label="yes"]; + "Tasks mostly independent?" -> "Partner chose inline, or no subagent tool?" [label="yes"]; "Tasks mostly independent?" -> "Manual execution or brainstorm first" [label="no - tightly coupled"]; - "Stay in this session?" -> "subagent-driven-development" [label="yes"]; - "Stay in this session?" -> "executing-plans" [label="no - parallel session"]; + "Partner chose inline, or no subagent tool?" -> "executing-plans" [label="yes"]; + "Partner chose inline, or no subagent tool?" -> "subagent-driven-development" [label="no"]; } ``` -**vs. Executing Plans (parallel session):** -- Same session (no context switch) -- Fresh subagent per task (no context pollution) -- Review after each task (spec compliance + code quality), broad review at the end -- Faster iteration (no human-in-loop between tasks) +**vs. Executing Plans (inline):** +- Fresh subagent per task (no context pollution) instead of one context doing every task +- Review after each task (spec compliance + code quality) instead of only at the end +- Costs a fresh context per task and per review; inline costs one context plus one final reviewer +- Both run in this session, share the same plan workspace and ledger, and never pause between tasks ## The Process @@ -134,8 +134,8 @@ sequences — the single most expensive failure observed. Track progress in a ledger file, not only in todos. - Each plan owns a workspace: at skill start, run this skill's - `scripts/sdd-workspace PLAN_FILE` — it prints the plan's git-ignored - directory (`/.superpowers/sdd//`), home to + `bash scripts/sdd-workspace PLAN_FILE` — it prints the plan's git-ignored + directory (under `/.superpowers/sdd/`), home to every artifact for THIS plan: ledger, briefs, reports, review packages. Another plan's directory is never yours to read or write. - Check for this plan's ledger at `/progress.md`. If its first @@ -249,7 +249,7 @@ Record BASE (`git rev-parse HEAD`) before dispatching — the review package and fix-round diffs need it. - **Task brief:** before dispatching an implementer, run this skill's - `scripts/task-brief PLAN_FILE N` — it extracts the task's full text to a + `bash scripts/task-brief PLAN_FILE N` — it extracts the task's full text to a uniquely named file and prints the path. Compose the dispatch so the brief stays the single source of requirements. Your dispatch should contain: (1) one line on where this @@ -287,7 +287,7 @@ Template: [implementer-prompt.md](implementer-prompt.md) Implementer subagents report one of four statuses. Handle each appropriately: -**DONE:** Generate the review package (`scripts/review-package PLAN_FILE BASE HEAD`, from this skill's directory — it prints the unique file path it wrote; BASE is the commit you recorded before dispatching the implementer — never `HEAD~1`, which silently drops all but the last commit of a multi-commit task), then dispatch the task reviewer with the printed path. +**DONE:** Generate the review package (`bash scripts/review-package PLAN_FILE BASE HEAD`, from this skill's directory — it prints the unique file path it wrote; BASE is the commit you recorded before dispatching the implementer — never `HEAD~1`, which silently drops all but the last commit of a multi-commit task), then dispatch the task reviewer with the printed path. **DONE_WITH_CONCERNS:** The implementer completed the work but flagged doubts. Read the concerns before proceeding. If the concerns are about correctness or scope, address them before review. If they're observations (e.g., "this file is getting large"), note them and proceed to review. @@ -314,7 +314,7 @@ required. Implementer self-review never replaces the task review; both are needed. - Hand the reviewer its diff as a file: run this skill's - `scripts/review-package PLAN_FILE BASE HEAD` and pass the reviewer the file path + `bash scripts/review-package PLAN_FILE BASE HEAD` and pass the reviewer the file path it prints (or, without bash: `git log --oneline`, `git diff --stat`, and `git diff -U10` for the range, redirected to one uniquely named file). The output never enters your own context, and the reviewer sees @@ -393,7 +393,7 @@ output; dispatch the re-review once all three are present. Name the covering test files in the fix message — a one-line fix does not need the whole suite. -**The re-review is scoped.** Run `scripts/review-package PLAN_FILE FIX_BASE HEAD` +**The re-review is scoped.** Run `bash scripts/review-package PLAN_FILE FIX_BASE HEAD` where FIX_BASE is the head the previous review saw, and dispatch [re-review-prompt.md](re-review-prompt.md) with the findings list, the brief, the report file, and the printed diff path. The re-reviewer verdicts @@ -445,7 +445,7 @@ parked-with-ruling at the cap. ## Final Review The final whole-branch review gets a package too: run -`scripts/review-package PLAN_FILE MERGE_BASE HEAD` (MERGE_BASE = the commit the +`bash scripts/review-package PLAN_FILE MERGE_BASE HEAD` (MERGE_BASE = the commit the branch started from, e.g. `git merge-base main HEAD`) and include the printed path in the final review dispatch, so the final reviewer reads one file instead of re-deriving the branch diff with git commands. Dispatch @@ -460,7 +460,7 @@ with the complete findings list — not one fixer per finding. Per-finding fixers each rebuild context and re-run suites; a real session's final-review fix wave cost more than all its tasks combined. Then run exactly one scoped re-review of the fix wave -(`scripts/review-package PLAN_FILE FIX_BASE HEAD` over the fix range, +(`bash scripts/review-package PLAN_FILE FIX_BASE HEAD` over the fix range, [re-review-prompt.md](re-review-prompt.md)). Adjudicate any residual findings as in the task loop's breaker: park with rulings, or rule on the load-bearing ones and ledger what you decided. Only @@ -507,7 +507,7 @@ You: I'm using Subagent-Driven Development to execute this plan. [Setup: worktree verified] [Read plan file once: docs/superpowers/plans/feature-plan.md] -[Resolve workspace: scripts/sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] +[Resolve workspace: bash scripts/sdd-workspace docs/superpowers/plans/feature-plan.md — no ledger inside, fresh start] [Create todos for all tasks] Task 1: Hook installation script diff --git a/external/ai-agents/subagent-driven-development/re-review-prompt.md b/external/ai-agents/subagent-driven-development/re-review-prompt.md index ad74b10..d49c182 100644 --- a/external/ai-agents/subagent-driven-development/re-review-prompt.md +++ b/external/ai-agents/subagent-driven-development/re-review-prompt.md @@ -109,7 +109,7 @@ Subagent (general-purpose): - `[REPORT_FILE]` — the implementer's report file (fix reports appended) - `[FIX_BASE_SHA]` — the head the previous review saw - `[HEAD_SHA]` — current commit -- `[DIFF_FILE]` — the path `scripts/review-package PLAN_FILE FIX_BASE HEAD` printed +- `[DIFF_FILE]` — the path `bash scripts/review-package PLAN_FILE FIX_BASE HEAD` printed **Re-reviewer returns:** per-finding verdicts (ADDRESSED / NOT ADDRESSED), new breakage in the fix diff, out-of-scope observations, and a round verdict. diff --git a/external/ai-agents/subagent-driven-development/scripts/review-package b/external/ai-agents/subagent-driven-development/scripts/review-package index 31852e2..fa7625f 100755 --- a/external/ai-agents/subagent-driven-development/scripts/review-package +++ b/external/ai-agents/subagent-driven-development/scripts/review-package @@ -22,10 +22,17 @@ head=$3 git rev-parse --verify --quiet "$base" >/dev/null || { echo "bad BASE: $base" >&2; exit 2; } git rev-parse --verify --quiet "$head" >/dev/null || { echo "bad HEAD: $head" >&2; exit 2; } +# Range guards (exit 3): a wrong-branch HEAD yields a range that is empty or +# not rooted at BASE; either would silently produce a bogus review package. +git merge-base --is-ancestor "$base" "$head" || { echo "HEAD is not a descendant of BASE: ${base}..${head}" >&2; exit 3; } +[ "$(git rev-list --count "${base}..${head}")" -gt 0 ] || { echo "empty commit range: ${base}..${head}" >&2; exit 3; } + if [ $# -eq 4 ]; then out=$4 else - dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") + # Invoke via bash rather than direct exec: some extractors (Python zipfile) + # strip Unix exec bits when unpacking marketplace packages (#2040). + dir=$("${BASH:-bash}" "$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") out="$dir/review-$(git rev-parse --short "$base")..$(git rev-parse --short "$head").diff" fi diff --git a/external/ai-agents/subagent-driven-development/scripts/sdd-workspace b/external/ai-agents/subagent-driven-development/scripts/sdd-workspace index 4e2d168..ff6b983 100755 --- a/external/ai-agents/subagent-driven-development/scripts/sdd-workspace +++ b/external/ai-agents/subagent-driven-development/scripts/sdd-workspace @@ -8,6 +8,16 @@ # artifacts. A stale ledger misread as current progress makes controllers # skip whole task sequences — plan-scoping removes that failure structurally. # +# Basename slugs collide when two plans share a filename (docs/alpha/plan.md +# vs docs/beta/plan.md), so each workspace records its owning plan's path in +# a plan-path marker (repo-relative in-repo, absolute outside). A workspace +# owned by a different plan is skipped and the slug disambiguated with the +# plan's parent-directory name, then a counter. A workspace with no marker +# predates the marker scheme and is adopted for the current plan so in-flight +# workspaces keep resolving — which means the first collision on such a +# legacy workspace adopts instead of detecting; acceptable, marker-less +# workspaces age out as plans finish. +# # The workspace lives in the working tree (not under .git/) because Claude Code # treats .git/ as a protected path and denies agent writes there — which blocks # an implementer subagent from writing its report file. A self-ignoring @@ -34,7 +44,39 @@ slug=$(basename "$plan" .md) root=$(git rev-parse --show-toplevel) base="$root/.superpowers/sdd" + +# Normalize the plan path (physical directory, so relative/absolute/../ +# spellings of one plan compare equal) and express it as the marker value: +# repo-relative when the plan lives under the repo root, absolute otherwise. +plan_dir=$(CDPATH= cd -- "$(dirname "$plan")" && pwd -P) +plan_abs="$plan_dir/$(basename "$plan")" +case "$plan_abs" in + "$root"/*) plan_id=${plan_abs#"$root"/} ;; + *) plan_id=$plan_abs ;; +esac + +# True when the workspace at $1 is (or becomes) this plan's: an existing +# marker must name this plan; a missing marker means a new workspace or a +# pre-marker legacy one, and either way the plan claims it by writing one. +owns() { + if [ -e "$1/plan-path" ]; then + [ "$(cat "$1/plan-path")" = "$plan_id" ] + else + mkdir -p "$1" + printf '%s\n' "$plan_id" > "$1/plan-path" + fi +} + dir="$base/$slug" -mkdir -p "$dir" +if ! owns "$dir"; then + parent=$(basename "$plan_dir") + dir="$base/$slug-$parent" + if ! owns "$dir"; then + n=2 + while ! owns "$base/$slug-$parent-$n"; do n=$((n + 1)); done + dir="$base/$slug-$parent-$n" + fi +fi + printf '*\n' > "$base/.gitignore" -cd "$dir" && pwd +CDPATH= cd -- "$dir" && pwd diff --git a/external/ai-agents/subagent-driven-development/scripts/task-brief b/external/ai-agents/subagent-driven-development/scripts/task-brief index 612e14a..b49fc54 100755 --- a/external/ai-agents/subagent-driven-development/scripts/task-brief +++ b/external/ai-agents/subagent-driven-development/scripts/task-brief @@ -21,7 +21,9 @@ n=$2 if [ $# -eq 3 ]; then out=$3 else - dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") + # Invoke via bash rather than direct exec: some extractors (Python zipfile) + # strip Unix exec bits when unpacking marketplace packages (#2040). + dir=$("${BASH:-bash}" "$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan") out="$dir/task-${n}-brief.md" fi diff --git a/external/ai-agents/subagent-driven-development/task-reviewer-prompt.md b/external/ai-agents/subagent-driven-development/task-reviewer-prompt.md index ce79694..5c619bc 100644 --- a/external/ai-agents/subagent-driven-development/task-reviewer-prompt.md +++ b/external/ai-agents/subagent-driven-development/task-reviewer-prompt.md @@ -189,7 +189,7 @@ Subagent (general-purpose): **Placeholders:** - `[MODEL]` — REQUIRED: reviewer model per SKILL.md Model Selection -- `[BRIEF_FILE]` — REQUIRED: the task brief file (`scripts/task-brief PLAN N` +- `[BRIEF_FILE]` — REQUIRED: the task brief file (`bash scripts/task-brief PLAN N` prints the path; same file the implementer worked from) - `[GLOBAL_CONSTRAINTS]` — the binding requirements copied verbatim from the plan's Global Constraints section or the spec: exact values, formats, @@ -200,7 +200,7 @@ Subagent (general-purpose): - `[BASE_SHA]` — commit before this task - `[HEAD_SHA]` — current commit - `[DIFF_FILE]` — REQUIRED: the path the controller wrote the review - package to (`scripts/review-package PLAN_FILE BASE HEAD` prints the unique + package to (`bash scripts/review-package PLAN_FILE BASE HEAD` prints the unique path it wrote; the package never enters the controller's context) **Reviewer returns:** Spec Compliance verdict (✅/❌/⚠️), Strengths, Issues diff --git a/external/basic/brainstorming/SKILL.md b/external/basic/brainstorming/SKILL.md index b56a3b5..e3f1788 100644 --- a/external/basic/brainstorming/SKILL.md +++ b/external/basic/brainstorming/SKILL.md @@ -11,12 +11,48 @@ Start by classifying how much process the request needs, then work through your path: understand the context, refine the idea, present a design, and get your human partner's approval. +## Establish Shared Understanding + +The outcome of brainstorming is an understanding your human partner can +recognize and correct, grounded in what they want to accomplish. + +1. **Discover intent.** Use the request and available context to identify + the intended outcome, who it is for, and what success looks like. When + that information is missing, ask one focused question about purpose or + intended use before proposing features or an approach. Knowing the app + genre does not tell you why your partner wants it. Gathering missing + requirements does not ask them to authorize the task again. +2. **Write back your understanding.** Summarize the intended outcome, + relevant constraints, and success criteria in a short note your partner + can assess. Separate what they said from assumptions. Invite correction + and incorporate their answer before treating this as the design brief. +3. **Carry intent into the design.** Preserve the agreed understanding in + the selected path's design artifact: the written spec for architectural + work, or the in-chat design/probe for bounded work and spikes. Check + proposed features and technical choices against that understanding. + +When the request already supplies the purpose and constraints, reflect +that understanding instead of asking the same questions again. Keep the +note concise; its accuracy and the opportunity to correct it matter. + -Do NOT invoke any implementation skill, write any code, scaffold any -project, or take any implementation action until you have told your -human partner what you intend and they have approved it. This applies -to EVERY task on EVERY path below — the ceremony scales with the task; -the approval gate never does. +Before taking any implementation action, including invoking an +implementation skill, writing product code, scaffolding, installing +product dependencies, or creating an external project, complete the +selected path's prerequisites: + +- Spike: the human partner approves the question and probe. +- Bounded: the human partner approves the short in-chat design. +- Architectural: the human partner reviews and approves the written spec, + then reviews the written implementation plan and selects its execution + method. Conversational design approval only permits writing the spec; + written-spec approval only permits invoking writing-plans. + +A reply approves the stage actually presented. Approval of an idea or +feature scope does not approve artifacts that do not exist yet. Resume +at the earliest incomplete stage; do not turn one approval into permission +to skip the rest of the selected path. Read-only project exploration is +allowed while those prerequisites remain incomplete. ## Three Paths @@ -53,18 +89,17 @@ stop, say so, and step up. Nothing downgrades mid-task. ## Anti-Pattern: "Too Simple To Need Approval" -Every path ends with your human partner approving your intent before -implementation. A todo list, a single-function utility, a config -change — the design may be two sentences in chat, but you MUST present -it and get approval. "Simple" tasks are where unexamined assumptions -cause the most wasted work. What scales with simplicity is the -artifact, never the approval. +Every path ends with your human partner approving the required design +before implementation. A bounded change may need only two sentences in +chat. A new todo-list project is architectural and requires the written +spec and planning handoffs. Scale the artifact to the selected path; +complete that path's reviews before implementation. ## Red Flags | Thought | Reality | |---------|---------| -| "This is too simple to need a design" | Simple means a short design, not no design. Two sentences in chat, then approval. | +| "This is too simple to need a design" | Follow the selected path: a bounded change gets a short chat design; an architectural change gets the written spec and planning handoffs. | | "I'll call it bounded and skip the spec" | Reaching for a label to skip work IS the doubt — take the heavier path. | | "It's bounded and the design is obvious — I'll start while they read it" | The gate is the approval, not the design's length. Present, then stop until you hear yes. | | "I understand this kind of app, so it's bounded" | Bounded measures the repo, not your familiarity. A new project has no existing flow — it is architectural. | diff --git a/external/basic/brainstorming/visual-companion.md b/external/basic/brainstorming/visual-companion.md index c145e64..8dca065 100644 --- a/external/basic/brainstorming/visual-companion.md +++ b/external/basic/brainstorming/visual-companion.md @@ -35,7 +35,7 @@ The server watches a directory for HTML files and serves the newest one to the b ```bash # Start AFTER the user approves the companion. --open auto-opens their browser on # the first screen; --project-dir persists mockups and enables same-port restart. -scripts/start-server.sh --project-dir /path/to/project --open +bash scripts/start-server.sh --project-dir /path/to/project --open # Returns: {"type":"server-started","port":52341, # "url":"http://localhost:52341/?key=ab12…", @@ -62,7 +62,7 @@ without repeating it. **Claude Code:** ```bash # Default mode works — the script backgrounds the server itself. -scripts/start-server.sh --project-dir /path/to/project --open +bash scripts/start-server.sh --project-dir /path/to/project --open ``` On Windows, the script auto-detects and switches to foreground mode (which blocks the tool call). Use `run_in_background: true` on the Bash tool call so the server survives across conversation turns, then read `$STATE_DIR/server-info` on the next turn to get the URL and port. @@ -71,14 +71,14 @@ On Windows, the script auto-detects and switches to foreground mode (which block ```bash # Codex reaps background processes. The script auto-detects CODEX_CI and # switches to foreground mode. Run it normally — no extra flags needed. -scripts/start-server.sh --project-dir /path/to/project --open +bash scripts/start-server.sh --project-dir /path/to/project --open ``` **Gemini CLI:** ```bash # Use --foreground and set is_background: true on your shell tool call # so the process survives across turns -scripts/start-server.sh --project-dir /path/to/project --open --foreground +bash scripts/start-server.sh --project-dir /path/to/project --open --foreground ``` **Copilot CLI:** @@ -95,7 +95,7 @@ bash scripts/start-server.sh --project-dir /path/to/project --open --foreground If the URL is unreachable from your browser (common in remote/containerized setups), bind a non-loopback host: ```bash -scripts/start-server.sh \ +bash scripts/start-server.sh \ --project-dir /path/to/project \ --host 0.0.0.0 \ --url-host localhost @@ -288,7 +288,7 @@ If `$STATE_DIR/events` doesn't exist, the user didn't interact with the browser ## Cleaning Up ```bash -scripts/stop-server.sh $SESSION_DIR +bash scripts/stop-server.sh $SESSION_DIR ``` If the session used `--project-dir`, mockup files persist in `.superpowers/brainstorm/` for later reference. Only `/tmp` sessions get deleted on stop. diff --git a/external/basic/firecrawl-search/SKILL.md b/external/basic/firecrawl-search/SKILL.md index c3b4334..7831635 100644 --- a/external/basic/firecrawl-search/SKILL.md +++ b/external/basic/firecrawl-search/SKILL.md @@ -9,7 +9,7 @@ allowed-tools: # firecrawl search -Web search with optional content scraping. Returns search results as JSON, optionally with full page content. +Search naturally using the user’s actual question. In the Alexandria beta, default search returns web results plus relevant Alexandria tools, with optional web content scraping. ## Quick start @@ -30,6 +30,16 @@ Run `firecrawl search --help` for the full option list. **Done when:** results are saved under `.firecrawl/`, verified non-empty, processed for the request, and one feedback event is sent within the time window (unless opted out). +## Alexandria in normal search + +The beta defaults to `web,alexandria` with domain-tool matching on. Preserve the user's location, marketplace, and constraints in the query; do not turn normal research into an artificial tool-discovery query. Inspect `data.web` and `data.tools` from the same response. + +A tool match is not executed data. If it fits the task, read its inputs, coverage, `creditsCost`/`perRecord`, and access requirements in the JSON. Execute it with `firecrawl scrape --alexandria --options ''`. All provider execution goes through Scrape; `search --scrape` only fetches web result content, not provider tools. + +Use `find-tools` only for an explicitly requested tool set or a missing contract. It runs the `firecrawl/find-tools` meta tool through Scrape and never executes the tools it discovers. It accepts URLs or catalogue selectors; for “tools that can do X,” first use `search "X" --sources alexandria`, then narrow the returned providers with `find-tools --options '{"providers":[""],"level":"tools","limit":100}'`. + +If no returned tool covers the country/market/segment or required inputs, continue with ordinary web results. Do not exhaust the catalogue or pay for adjacent tools just to probe coverage. `--sources web` explicitly opts out of Alexandria; `--sources web --domain-tools` retains domain matches only. + ## Tips - **`--highlights` on by default:** results are query-relevant excerpts, not full-page snippets. Use `--no-highlights` for the original snippets. diff --git a/external/develop/frontend/ui-animation/SKILL.md b/external/develop/frontend/ui-animation/SKILL.md index c4231c6..1c6e56f 100644 --- a/external/develop/frontend/ui-animation/SKILL.md +++ b/external/develop/frontend/ui-animation/SKILL.md @@ -1,11 +1,11 @@ --- name: ui-animation -description: Builds, reviews, and measures UI motion, including springs, gestures, scroll effects, and curve fitting from recordings. Use when asked to "add animation", "match this easing", "reverse engineer this motion", or find animation opportunities. For action semantics use product-design; for visual layout use ui-design. +description: Builds, reviews, and measures UI motion, including springs, gestures, scroll effects, curve fitting from recordings, and sparse interface sound. Use when asked to "add animation", "match this easing", "reverse engineer this motion", "add a click sound", or find animation opportunities. For action semantics use product-design; for visual layout use ui-design. --- # UI Animation -- **IS:** designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, Motion), sweeping an interface for the moments that would genuinely benefit from motion, measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec, and naming a described motion effect (reverse-lookup vocabulary). +- **IS:** designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, Motion), sweeping an interface for the moments that would genuinely benefit from motion, measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec, naming a described motion effect (reverse-lookup vocabulary), and gating sparse interface sound. - **IS NOT:** choosing overall visual direction, palettes, or typography (use `ui-design` Direction mode), auditing a whole page's UI quality (use `ui-design` Audit mode), or named text-effect specs (use the external `animate-text` skill where installed). ## Routing boundary @@ -34,7 +34,9 @@ description: Builds, reviews, and measures UI motion, including springs, gesture | [references/curve-fitting.md](references/curve-fitting.md) | Reverse-engineer: reading `fit_curves.py` output, spring vs bezier, judging fit error, asymmetric open/close | | [references/code-output.md](references/code-output.md) | Reverse-engineer: emitting code for CSS, Motion/Framer Motion, SwiftUI, React Native, UIKit | | [references/choreography.md](references/choreography.md) | Reverse-engineer: multi-element/multi-phase motion: staggers, blur-before-move, per-edge settling | +| [references/live-tuning.md](references/live-tuning.md) | Dialling a curve in live when there is no reference to fit against: the DevTools bezier editor, retiming in the Animations panel, when a control-panel library earns a dependency | | [references/vocabulary.md](references/vocabulary.md) | Naming a motion effect the user describes vaguely ("what's it called when...") | +| [references/interface-sfx.md](references/interface-sfx.md) | Click sounds, interface audio, UI SFX, haptic-plus-sound, or "why is the web afraid of sound" | ## Core rules @@ -66,7 +68,7 @@ description: Builds, reviews, and measures UI motion, including springs, gesture - Avoid `filter` animation for core interactions; if unavoidable keep blur ≤ 20px (heavy blur is expensive, especially in Safari). - SVG: apply transforms on a `` wrapper with `transform-box: fill-box; transform-origin: center`; without it they rotate/scale around the canvas origin. Line drawing, path morphing, and the Motion SVG origin override live in [references/svg-animation.md](references/svg-animation.md). - `transform: scale()` also scales children (icons, text, borders scale proportionally), unlike `width`/`height`: a feature for press feedback, but account for it when an inner element must stay fixed-size. -- Disable transitions during theme switches (`[data-theme-switching] * { transition: none !important }`), or every themed property animates at once. +- Disable transitions during theme switches (`[data-theme-switching] * { transition: none !important }`), or every themed property animates at once. Force a reflow (`void document.body.offsetHeight`) after the flip and remove the override on the next frame, or use `next-themes` `disableTransitionOnChange`. ## Easing defaults @@ -120,13 +122,14 @@ Prefer lower-overhead transitions (CSS-only) unless the design requires JS orche ## Spatial and sequencing -- Popover `transform-origin` at the trigger (modals stay `center`), dialog/menu entrances from `scale(0.85-0.9)` not `scale(0)`, and 30-50ms staggers (total under 300ms, most important element leading). Full rules and code in [references/component-patterns.md](references/component-patterns.md) and [references/contextual-animations.md](references/contextual-animations.md). +- Popover `transform-origin` at the trigger (modals stay `center`), dialog/menu entrances from `scale(0.9-0.96)` not `scale(0)` (small popovers at the low end, full dialogs at the high end: a large surface already travels far in absolute pixels), and 30-50ms staggers (total under 300ms, most important element leading). Full rules and code in [references/component-patterns.md](references/component-patterns.md) and [references/contextual-animations.md](references/contextual-animations.md). - **Paired elements rule:** elements that animate together (modal + overlay, tooltip + arrow, FAB + label) must share easing and duration. Mismatched timing is the usual cause of "something feels off". ## Accessibility - Gate hover (motion and paint) behind `@media (hover: hover) and (pointer: fine)`, or touch devices replay hover on tap. Inspect the generated CSS before adding a gate; Tailwind v4 already wraps `hover:` in `@media (hover: hover)`. - During direct manipulation, keep the element locked to the pointer with no easing; add easing only after release. +- Optional interface SFX: sparse, gesture-unlocked, additive confirmation only. See [references/interface-sfx.md](references/interface-sfx.md). ## Performance @@ -163,7 +166,7 @@ Animation progress: ``` 1. Answer the four questions in [references/decision-framework.md](references/decision-framework.md): animate? purpose? easing? speed? -2. Pick duration from the easing defaults table above. +2. Pick duration from the easing defaults table above. If the value is contested or the component is hard to reach, dial it live in the DevTools bezier editor rather than guessing, then bake the result into source ([references/live-tuning.md](references/live-tuning.md)). 3. Choose implementation: CSS transition > WAAPI > spring > keyframe > JS. 4. Load the reference for your component or technique. 5. When reviewing, apply the strict posture in [references/review-format.md](references/review-format.md): measure against the ten standards, output the Before/After/Why table, then a tiered verdict ending in a Block/Approve decision. @@ -177,7 +180,7 @@ Produce evidence for each check (DevTools observations, not "looks fine"): - Slow to 10% in the DevTools Animations panel to catch timing and `transform-origin` issues invisible at full speed. - Confirm `will-change` is toggled around animations, not permanently set, and looping animations pause off-screen. - Test touch interactions on real devices; simulators under-report gesture and hover-on-tap issues. -- Honor `prefers-reduced-motion`: replace spatial travel and looping effects with immediate state changes or restrained fades, then exercise the same task in that mode. +- Honor `prefers-reduced-motion`: replace spatial travel with immediate state changes or restrained fades. Pause looping decorations with `animation-play-state: paused` (do not yank them with `display: none`). Keep explicit user-triggered feedback. Exercise the same task in that mode. ## Discovery workflow @@ -219,6 +222,10 @@ Reverse-engineer progress: Maintenance only: when changing Discovery routing or the gate, run the scenarios in `evaluations/` as a regression rubric. They never load during a user task. +## Sources + +Interface SFX gating taken from Craft (gustavo-fior) and Raphael Salaja's web-sound writing. Novelty 90/10 split, one-shot intro gating, and `animation-play-state` on loops taken from Rauno Freiberg. Rejected vendoring emilkowalski/skills and gustavo-fior/craft: trigger collision with this skill. Clip-path and proportional scale already lived here. + ## Related skills - `product-design`: which states exist, what an action affects, and whether it is reversible. Route here first when a gesture replaces a control, since swipe-to-delete and hold-to-confirm change what the user can do before they change how it moves. diff --git a/external/develop/frontend/ui-animation/evals/evals.json b/external/develop/frontend/ui-animation/evals/evals.json index 0b88fae..9ebeff3 100644 --- a/external/develop/frontend/ui-animation/evals/evals.json +++ b/external/develop/frontend/ui-animation/evals/evals.json @@ -22,12 +22,24 @@ "Does not run scripts relative to the application by accident", "Reports fitted error rather than claiming an exact visual match" ] + }, + { + "id": 3, + "prompt": "Add a click sound to every button on the dashboard, including list-row hovers.", + "expected_output": "Refuse high-frequency SFX; if any sound ships, it is rare, gesture-unlocked, and additive to visual feedback.", + "files": [], + "assertions": [ + "Loads interface-sfx.md", + "Keeps typing, hover, and list navigation silent", + "Does not create AudioContext on page load" + ] } ], "routing": { "should_trigger": [ "Review a Tailwind v4 modal animation. Generated hover CSS already includes @media (hover: hover). Keyboard focus moves immediately; a 120ms opacity transition continues afterward. Reduced motion uses an immediate state change.", - "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps." + "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps.", + "Add a click sound to every button on the dashboard, including list-row hovers." ], "near_miss": [ { diff --git a/external/develop/frontend/ui-animation/references/component-patterns.md b/external/develop/frontend/ui-animation/references/component-patterns.md index 7278ff4..fc643cc 100644 --- a/external/develop/frontend/ui-animation/references/component-patterns.md +++ b/external/develop/frontend/ui-animation/references/component-patterns.md @@ -47,9 +47,9 @@ Blur under 20px; heavy blur is expensive, especially in Safari. Scale in from the trigger point, not from center; the default `transform-origin: center` is wrong for popovers. ```css -/* Radix UI */ +/* Base UI. Radix exposes the same thing as --radix-popover-content-transform-origin */ .popover { - transform-origin: var(--radix-popover-content-transform-origin); + transform-origin: var(--transform-origin); } /* Data attribute fallback */ @@ -59,11 +59,11 @@ Scale in from the trigger point, not from center; the default `transform-origin: .popover[data-side="right"] { transform-origin: center left; } ``` -Start at `scale(0.88)`, never `scale(0)`: nothing appears from nothing. +Start at `scale(0.92)`, never `scale(0)`: nothing appears from nothing. ```css .menu { - transform: scale(0.88); + transform: scale(0.92); opacity: 0; transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1), opacity 200ms cubic-bezier(0.22, 1, 0.36, 1); diff --git a/external/develop/frontend/ui-animation/references/debugging-symptoms.md b/external/develop/frontend/ui-animation/references/debugging-symptoms.md index 59b0581..e50a129 100644 --- a/external/develop/frontend/ui-animation/references/debugging-symptoms.md +++ b/external/develop/frontend/ui-animation/references/debugging-symptoms.md @@ -43,8 +43,8 @@ Turn "this feels off" into a named cause, then make the smallest fix that addres | Check, in order | Fix | | --- | --- | -| Entrance from `scale(0)` or a bare fade | Start from `scale(0.9-0.95)` plus opacity; nothing real appears from nothing, and a near-full start reads as "it was almost already there". | -| Wrong `transform-origin` | Popovers, dropdowns, and tooltips scale from their trigger, not center (use the library's origin variable, e.g. `--radix-popover-content-transform-origin`). Slowed playback makes a wrong origin unmistakable. | +| Entrance from `scale(0)` or a bare fade | Start from `scale(0.9-0.96)` plus opacity; nothing real appears from nothing, and a near-full start reads as "it was almost already there". | +| Wrong `transform-origin` | Popovers, dropdowns, and tooltips scale from their trigger, not center (use the library's origin variable: `--transform-origin` in Base UI, `--radix-popover-content-transform-origin` in Radix). Slowed playback makes a wrong origin unmistakable. | | Crossfade shows two distinct overlapping states | Add `filter: blur(2px)` during the transition; blur bridges the gap so the eye reads one transforming object instead of two swapped ones. | | Sub-animations on different clocks | Unify the timing family so the component reads as one entity; one slow sub-animation breaks the whole thing. | | Enter and exit mismatched | Exit in the direction of entry, roughly 20% faster and simpler than the entrance; the user already decided, get out of the way. | diff --git a/external/develop/frontend/ui-animation/references/decision-framework.md b/external/develop/frontend/ui-animation/references/decision-framework.md index ec45851..71c040f 100644 --- a/external/develop/frontend/ui-animation/references/decision-framework.md +++ b/external/develop/frontend/ui-animation/references/decision-framework.md @@ -20,6 +20,10 @@ Answer these four questions in order before writing animation code. SKILL.md car | Occasional | Modals, drawers, toasts | Standard animation | | Rare / first-time | Onboarding, feedback forms, celebrations | Can add delight | +**Novelty budget.** Keep most of a surface familiar: about 90% expected motion (or none) and 10% novel treatment. Do not stack high-novelty beats in consecutive sections; put quiet structure between them. + +**One-shot only.** First-run staggers, intro morphs, and login flourishes must not replay on every visit. Gate them with a cookie, local flag, or rewrite so a reload is instant. + ## 2. What is the purpose? Answer "why does this animate?" before writing code. @@ -65,7 +69,7 @@ Sweep these seam classes. The skill is done sweeping when each has either yielde | Feedback gap | A pressable control with no press state | `onClick` / `onPress` on elements with no `:active`, `active:`, or transition | | Teleporting state | Content that swaps, appears, or vanishes with no bridge | `{isOpen &&`, `{show`, `display: none` toggles, accordions and collapses with no height or opacity transition | | Missing spatial story | A surface with no connection to what opened it | Popovers, menus, and panels with no `transform-origin` at the trigger; dismissable surfaces that exit by a different path than they entered | -| Group entrance | An occasionally-viewed grid or list that pops in whole | `.map(` renders on first-load surfaces, where a 30-80ms stagger would help | +| Group entrance | An occasionally-viewed grid or list that pops in whole | `.map(` renders on first-load surfaces, where a 30-50ms stagger would help | | Gesture seam | Draggable or swipeable elements that snap with no physics | Drag and pointer handlers with no spring, no velocity-based dismissal, no rubber-banding at boundaries | | Flat delight moment | Rare, high-emotion states rendered without any motion | First-run, empty, success, and completion components | diff --git a/external/develop/frontend/ui-animation/references/gesture-drag.md b/external/develop/frontend/ui-animation/references/gesture-drag.md index 4438f71..5c61d91 100644 --- a/external/develop/frontend/ui-animation/references/gesture-drag.md +++ b/external/develop/frontend/ui-animation/references/gesture-drag.md @@ -8,6 +8,8 @@ Drag, swipe, and gesture patterns where the user directly manipulates elements. - [Momentum projection](#momentum-projection) - [Boundary damping](#boundary-damping) - [Pointer capture](#pointer-capture) +- [Grab offset](#grab-offset) +- [Axis commitment](#axis-commitment) - [Multi-touch protection](#multi-touch-protection) - [Friction vs hard stops](#friction-vs-hard-stops) - [Rotary drag](#rotary-drag) @@ -113,6 +115,49 @@ function onPointerUp(e: PointerEvent) { Always use `setPointerCapture`; without it, fast swipes escape the element and the drag breaks. +## Grab offset + +Record where inside the element the pointer landed, and hold that offset for the whole drag: + +```ts +let grabY = 0; + +function onPointerDown(e: PointerEvent) { + const r = el.getBoundingClientRect(); + grabY = e.clientY - r.top; // where in the element the finger actually is +} + +function onPointerMove(e: PointerEvent) { + setY(e.clientY - grabY); // not e.clientY, and not a centred element +} +``` + +Positioning from `e.clientY` alone snaps the element's top (or its centre, with a `-50%` translate) to the pointer the instant the drag begins. The element jumps under the finger before it has moved, which breaks 1:1 tracking at the only moment the user is watching for it. Grab a sheet by its handle and it should stay gripped by the handle. + +## Axis commitment + +Track from `pointerdown`, but do not claim an axis until the pointer has travelled about 10px: + +```ts +let axis: "x" | "y" | null = null; + +function onPointerMove(e: PointerEvent) { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + if (!axis) { + if (Math.hypot(dx, dy) < 10) return; // too early to tell + axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; + } + if (axis !== "x") return; // this handler owns horizontal only + // drag... +} +``` + +Deciding on the first `pointermove` reads noise: the first few pixels of a vertical scroll usually carry some horizontal drift, so a swipe-to-dismiss row inside a scrolling list steals the gesture and the list stops scrolling. Once committed, hold the axis until `pointerup`; re-deciding mid-drag makes the element stutter between behaviours. + +This is the custom-handler counterpart to the declarative fix under Carousel axis. `touch-action` tells the browser which axis it may keep, which settles native scrolling; it does nothing for a handler resolving the ambiguity itself. + ## Multi-touch protection Ignore extra touch points after the drag begins; without this, switching fingers mid-drag makes the element jump. @@ -199,23 +244,29 @@ The tick marks themselves carry the feedback during the drag. Scale or darken th ## Swipe-to-dismiss pattern -Combine velocity, distance, and direction for a complete swipe gesture: +Velocity decides; distance is only the tie-breaker. Sign both against the dismissal direction, so "toward dismissal" is positive on each. ```ts -function handleSwipeEnd(direction: "left" | "right", distance: number, velocity: number) { - const shouldDismiss = distance > THRESHOLD || velocity > 0.11; - - if (shouldDismiss) { - // Animate out in swipe direction, handing off the release velocity (see Velocity handoff) - animateOut(direction, velocity); - } else { - // Spring back to origin - springBack(); +const FLICK = 0.11; // px/ms, matches Sonner + +// offset and velocity are both signed along the drag axis: +// positive = moving toward dismissal, negative = back toward rest. +function handleSwipeEnd(offset: number, velocity: number) { + if (Math.abs(velocity) > FLICK) { + // A flick decides on its own, in whichever direction it points. + if (velocity > 0) animateOut(velocity); + else springBack(velocity); + return; } + // Released slowly: position is all the intent there is. + if (offset > THRESHOLD) animateOut(velocity); + else springBack(velocity); } ``` -The exit should continue in the swipe direction with momentum; snapping elsewhere feels wrong. Feed `velocity` into the exit spring's `velocity` option so drag and animation share no seam. +The common bug is `distance > THRESHOLD || velocity > 0.11` against an unsigned velocity. A sheet dragged 80% closed and then flicked back toward open passes the distance test and dismisses anyway, which is the user's cancel gesture doing the opposite of what they asked. Checking magnitude first and sign second is what makes a reversal cancel. + +The exit continues in the swipe direction with momentum; snapping elsewhere feels wrong. Feed `velocity` into the exit spring's `velocity` option so drag and animation share no seam, and into `springBack` too: a cancelled flick that starts from zero reads as a bounce the user did not cause. ## Carousel axis diff --git a/external/develop/frontend/ui-animation/references/interface-sfx.md b/external/develop/frontend/ui-animation/references/interface-sfx.md new file mode 100644 index 0000000..19b6736 --- /dev/null +++ b/external/develop/frontend/ui-animation/references/interface-sfx.md @@ -0,0 +1,41 @@ +# Interface SFX + +Sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions. + +## Scope + +- **IS:** sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions (toggle lock, payment confirm, drag release, success moment). +- **IS NOT:** background music, autoplay, looping UI beds, or replacing visual feedback. + +## Rules + +1. **Unlock from a user gesture.** Create or resume `AudioContext` only inside a click, tap, or keydown handler. Never on page load or in `useEffect` without a gesture. +2. **Stay quiet.** Keep volume well below content audio. Respect system mute and tab mute; if the tab is muted, do not play. +3. **Additive only.** Pair every sound with visual feedback (scale, color, icon swap). Sound confirms what the user already sees; it never carries the message alone. +4. **Same frequency rule as motion.** High-frequency actions stay silent: typing, hover, scrolling, list navigation, repeated toggles. If the user does it dozens of times per session, no sound. +5. **Honor `prefers-reduced-motion`.** Treat it as a signal to skip optional SFX unless the user explicitly enabled sounds in settings. +6. **Keep clips tiny.** Tens of milliseconds, soft attack, no peak that clips. One-shot, non-looping. +7. **One owner.** Route all playback through a tiny `play(id)` helper (preload, volume, mute checks, reduced-motion gate). No ad-hoc `new Audio()` at call sites. + +## Implementation sketch + +```javascript +let ctx; + +function unlockAudio() { + if (!ctx) ctx = new AudioContext(); + if (ctx.state === 'suspended') ctx.resume(); +} + +function playSfx(id) { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + if (!ctx || ctx.state !== 'running') return; + // fetch decoded buffer for id, set gain ~0.1-0.2, play once +} +``` + +Wire `unlockAudio` to the first meaningful interaction on the surface that uses SFX. + +## Sources + +Informed by Craft (gustavo-fior Interface SFX) and Raphael Salaja's writing on web sound. Original prose; not copied. diff --git a/external/develop/frontend/ui-animation/references/live-tuning.md b/external/develop/frontend/ui-animation/references/live-tuning.md new file mode 100644 index 0000000..48cb14a --- /dev/null +++ b/external/develop/frontend/ui-animation/references/live-tuning.md @@ -0,0 +1,59 @@ +# Live tuning + +The reverse-engineer workflow runs backwards: record a motion you admire, then fit a curve to it. This is the forward version, for when there is no reference to copy and the table value is contested. Tune against the running component instead of guessing, reloading, and guessing again. + +Start in DevTools. It is already open, it costs nothing, and it covers every bezier in the easing defaults table. + +## Contents + +- [When this is worth it](#when-this-is-worth-it) +- [The bezier editor](#the-bezier-editor) +- [Retiming in the Animations panel](#retiming-in-the-animations-panel) +- [What DevTools cannot do](#what-devtools-cannot-do) +- [Baking the value back](#baking-the-value-back) + +## When this is worth it + +- **The value is contested.** Two people disagree on whether a drawer should be 300ms or 400ms and neither can win the argument from a table. +- **The component is hard to reach.** A toast that needs a form submitted, a sheet three navigations deep. Each rebuild round trip costs more than the setup does once, and an HMR reload loses the state that got you there. +- **The motion is multi-phase.** Stagger offset, blur ramp, and settle interact, so three numbers guessed one reload at a time converge slowly. + +Not for picking a button press duration. The easing defaults table answers that in one line. + +## The bezier editor + +Chrome, Edge, and Firefox render a small curve swatch next to any `transition-timing-function` or `animation-timing-function` in the Styles (or Rules) pane. Click it for a draggable cubic-bezier editor. + +Edits apply live with no rebuild, so retrigger the interaction and watch it under the new curve. The editor emits the literal (`cubic-bezier(0.22, 1, 0.36, 1)`), which is what goes back into source. + +Two things that waste time otherwise: + +- The swatch only exists once the property is valid. On an element with no timing function yet, add the declaration in the `element.style` pane first and the swatch appears. +- Start from the table value, not a built-in preset. Opening on `cubic-bezier(0.22, 1, 0.36, 1)` gives you something to judge against; opening on `ease` means finding the table value by hand. + +Safari has no bezier editor. Tune in Chrome, verify in Safari. + +## Retiming in the Animations panel + +The panel's slow-motion playback is a debugging tool and belongs to the Validation workflow. Two of its controls are tuning tools: + +- **Drag a bar's edges** to change a duration or delay live, then replay. Faster than editing per-item delays for a stagger you are trying to feel out. +- **Read the captured group** to see every element's delay and duration side by side. This is the quickest way to recover the timing of a stagger you did not write, including one a library is generating. + +## What DevTools cannot do + +- **Springs.** No spring editor exists. Reach for the presets and the `visualDuration`/`bounce` framing in `spring-animations.md`: they are perceptual, so they land close on the first try, and a wrong spring usually needs one parameter moved rather than a search. +- **Composing multi-phase choreography.** The panel retimes what already fired; it will not let you build the phases against a shared playhead. + +If a project hits those two often enough to matter, a control-panel library (DialKit, Leva, Tweakpane) earns a dev dependency: a spring control returns a Motion `TransitionConfig` that drops straight into `animate()`, and a timeline dock composes phases. That is a standing decision about the project, not something to install mid-task for one curve. + +## Baking the value back + +A tuning surface is a measuring instrument, not a delivery mechanism. + +- A DevTools edit lives only in that tab and dies on navigation. Paste the literal into source before you believe it. +- Put it next to the other timing constants, so the next person sees it beside the values it has to agree with. +- A control panel leaves more behind than the dock: replace every sampled binding with the real animation, then remove the panel, its root, and the dependency. Framework roots hide themselves in production builds, but a vanilla root does not, and a forgotten one ships a control panel to users. +- Re-check the result against the ten standards. What felt right after ten iterations on a fast laptop still has to clear no layout-property transitions, `prefers-reduced-motion` handled, and interruption retargeting rather than restarting. + +Tune on the real surface. A curve dialled on an isolated demo reads differently against the distance, size, and neighbours of the actual component, and how often the user sees it moves the answer more than any parameter does. diff --git a/external/develop/frontend/ui-animation/references/review-format.md b/external/develop/frontend/ui-animation/references/review-format.md index a87da2b..5fb866b 100644 --- a/external/develop/frontend/ui-animation/references/review-format.md +++ b/external/develop/frontend/ui-animation/references/review-format.md @@ -20,7 +20,7 @@ Measure every animation in the diff against these; a violation is a finding. For 2. **Frequency-appropriate.** Keyboard focus and repeated actions must respond immediately. Flag motion that delays task completion or creates distracting repeated travel; a brief nonblocking transition is not automatically a defect. 3. **Responsive easing.** Entering/exiting elements use `ease-out` or a strong custom curve; built-in CSS easings are too weak for deliberate animation. Flag on sight: `ease-in` on any UI interaction, or weak built-in easing on a deliberate animation (it delays the moment the user watches most). 4. **Sub-300ms UI.** UI animations stay under 300ms; scale duration with distance traveled. Flag on sight: UI duration > 300ms with no stated reason. -5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.85-0.97)` plus opacity). +5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.9-0.96)` plus opacity). 6. **Interruptibility.** Rapidly-triggered or gesture-driven motion (toasts, toggles, drags) must retarget from its current state; prefer CSS transitions or springs over keyframes, which restart from zero. Flag on sight: keyframes on toasts, toggles, or anything added/triggered rapidly. 7. **GPU-only properties.** Animate `transform` and `opacity` only. Flag on sight: animating `width`/`height`/`margin`/`padding`/`top`/`left`; `transition: all` (unbounded property animation); Framer Motion `x`/`y`/`scale` props on motion that runs while the page is busy; updating a CSS variable on a parent to drive a child transform (style recalc storm). 8. **Accessibility.** Inspect generated hover gating, including Tailwind v4's built-in media query. Exercise reduced-motion behavior and the same keyboard/touch task. Flag spatial motion without an appropriate reduced-motion alternative. @@ -51,7 +51,7 @@ Required first part of every review. Markdown table, one row per issue; never a | `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing | | `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback | | No `:active` state on button | `transform: scale(0.97)` on `:active` with `transition-duration: 0s` | Buttons must feel responsive to press | -| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers scale from trigger (modals stay centered) | +| `transform-origin: center` on popover | `transform-origin: var(--transform-origin)` | Popovers scale from trigger (modals stay centered) | ## Review checklist diff --git a/external/develop/frontend/ui-animation/references/transition-recipes.md b/external/develop/frontend/ui-animation/references/transition-recipes.md index 73961d1..9906774 100644 --- a/external/develop/frontend/ui-animation/references/transition-recipes.md +++ b/external/develop/frontend/ui-animation/references/transition-recipes.md @@ -356,7 +356,7 @@ See also: `contextual-animations.md` § Contextual icon swaps for the Motion/Ani Origin-aware dropdown with open/close animations. JS handles close-state cleanup. -See also: `component-patterns.md` § Popovers and dropdowns for Radix UI transform-origin and scale patterns. +See also: `component-patterns.md` § Popovers and dropdowns for library transform-origin and scale patterns. ```html
diff --git a/external/develop/openspec/openspec-apply-change/SKILL.md b/external/develop/openspec/openspec-apply-change/SKILL.md index 098f63f..ed033ba 100644 --- a/external/develop/openspec/openspec-apply-change/SKILL.md +++ b/external/develop/openspec/openspec-apply-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-apply-change -description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. +description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Implement tasks from an OpenSpec change. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name (e.g., `/openspec-apply-change add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -48,9 +59,12 @@ Implement tasks from an OpenSpec change. - Dynamic instruction based on current state - Optional `context`: current required project instruction input from the selected root - Optional `operationGuidance`: current advisory guidance for apply + - `missingArtifacts` (when present): required artifact ids with no output **Handle states:** - - If `state: "blocked"` (missing artifacts): show message, suggest using `/openspec-continue-change` (if it is not installed, run `openspec status --change "" --json` to see the next artifact and `openspec instructions --change "" --json` for how to create it) + - If `state: "blocked"`: show the message and pause implementation. + - If `missingArtifacts` is non-empty: suggest using `/openspec-continue-change` to create them. + - Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked. - If `state: "all_done"`: congratulate, suggest archive - Otherwise: proceed to implementation diff --git a/external/develop/openspec/openspec-archive-change/SKILL.md b/external/develop/openspec/openspec-archive-change/SKILL.md index 5f34ed5..dd98c44 100644 --- a/external/develop/openspec/openspec-archive-change/SKILL.md +++ b/external/develop/openspec/openspec-archive-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-archive-change -description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. +description: Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Archive a completed change in the experimental workflow. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. @@ -76,7 +87,11 @@ Archive a completed change in the experimental workflow. Read the tasks file (typically `tasks.md`) to check for incomplete tasks. - Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete). + A checkbox is complete when its only content is `x` or `X`; spacing inside + the brackets does not matter, so `- [ x]` counts as complete too. Every + other marker is incomplete - `- [ ]`, an empty `- []`, and markers OpenSpec + assigns no meaning to such as `- [~]` or `- [-]`. Never read an unfamiliar + marker as complete. **If incomplete tasks found:** - Display warning showing count of incomplete tasks @@ -94,17 +109,23 @@ Archive a completed change in the experimental workflow. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `/openspec/specs//spec.md` (use the store-aware `planningHome.root` from step 2, not a hardcoded repo path) + - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input: + - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version. + - Otherwise, if the delta has only REMOVED requirements and the change's `.openspec.yaml` declares `retire_capabilities: true`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync. + - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. `openspec archive` refuses the unmarked REMOVED-only case with `Spec must have at least one requirement`. + - Otherwise, count the capability as needing sync and name it in the summary (`: new main spec will be created`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as `openspec archive` does. - Determine what changes would be applied (adds, modifications, removals, renames) - - Show a combined summary before prompting + - Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting. **Prompt options:** - - If changes needed: "Sync now (recommended)", "Archive without syncing" - - If already synced: "Archive now", "Sync anyway", "Cancel" + - If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel" + - Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing" + - Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel" Route on the answer: - "Cancel" — stop, do not archive - "Archive without syncing" or "Archive now" — proceed to archive - - "Sync now" or "Sync anyway" — sync, then verify (below) + - "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices. - Anything else — ask again rather than archiving Before a selected sync writes any main spec, run @@ -118,7 +139,7 @@ Archive a completed change in the experimental workflow. Then run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge) for change '', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching `specs` instructions again. Do not delegate it to a background task — step 5 would move `changeRoot` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result. - Then re-run the comparison from the top of this step against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: + Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in `artifactPaths.specs.existingOutputPaths` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced: - ADDED requirements present - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving `## Requirements` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match diff --git a/external/develop/openspec/openspec-bulk-archive-change/SKILL.md b/external/develop/openspec/openspec-bulk-archive-change/SKILL.md index 252e1dd..b388f0b 100644 --- a/external/develop/openspec/openspec-bulk-archive-change/SKILL.md +++ b/external/develop/openspec/openspec-bulk-archive-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-bulk-archive-change -description: Archive multiple completed changes at once. Use when archiving several parallel changes. +description: Archive multiple completed OpenSpec changes at once. Use when archiving several parallel changes. Also use for a plural archive request - "openspec bulk-archive", "opsx bulk-archive", "openspec archive all", or "openspec archive these changes". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -15,6 +15,17 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: None required (prompts for selection) @@ -70,7 +81,9 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig - Note which artifacts are `done` vs other states b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON - - Count `- [ ]` (incomplete) vs `- [x]` (complete) + - Complete means the checkbox holds only `x`/`X`, ignoring spacing + (`- [ x]` is complete); every other marker is incomplete (`- [ ]`, + `- []`, and unfamiliar ones such as `- [~]` or `- [-]`) - If no tasks file exists, note as "No tasks" c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON @@ -81,6 +94,14 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig lookup for that change; do not infer deltas from unrelated artifacts. - Evaluate this independently for every change, including mixed-schema batches where some schemas have no `specs` artifact. + + d. **Archive target** - Compute each change's target name once and record it as that change's `` + - Use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-` (same rule as `openspec archive`) + - Check whether `/archive/` already exists + - If it exists, or another selected change resolves to the same target name, mark every such change `Blocked` with `Archive directory already exists` + - A blocked change is never synced or moved: show it as `Blocked` in the step 6 table, leave it out of conflict resolution (resolve its conflicts using only the other changes), and record it as Failed in step 8d + - Checking here, before any main spec is written, matches `openspec archive`: a collision found after sync would leave main specs rewritten for an archive that never happened + 4. **Detect spec conflicts** Build a map keyed by ``, the exact path relative to `specs/`: @@ -153,8 +174,8 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig Route on the answer by intent, not by exact label — you wrote these labels, so match what the user picked rather than the wording above: - "Cancel" — stop, do not archive. Report that nothing was archived and skip the remaining steps. - - The archive-everything option — proceed with every selected change - - The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived. + - The archive-everything option — proceed with every selected change that is not `Blocked` + - The ready-only option — proceed with only the changes the step 6 table marks `Ready` or `Ready*`, and record the rest as Skipped in step 8d, except `Blocked` changes, which stay Failed with `Archive directory already exists`. If a `Ready*` change's conflict partner is skipped, re-derive that conflict's resolution using only the changes being archived. - Anything else — ask again rather than archiving Before step 8 writes the first main spec or moves any change, fetch every @@ -199,13 +220,20 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig c. **Perform the archive**: - Target name: use the change name as-is when it already starts with a `YYYY-MM-DD-` prefix; otherwise prepend the current date as `YYYY-MM-DD-` (same rule as `openspec archive`). + Target name: use the `` recorded for this change in step 3d, unchanged. Never recompute it here: a batch that runs past midnight would check one date in step 3 and move to another. + + **Check if target already exists:** + - Check again immediately before the move, even though step 3 already checked: the target can appear mid-batch + - If yes: record this change as Failed with `Archive directory already exists`, leave `changeRoot` where it is, report any main specs step 8a already synced for it, and continue with the remaining changes + - If no: move `changeRoot` to the archive directory ```bash mkdir -p "/archive" mv "" "/archive/" ``` + **Confirm the move did not nest:** `mv` exits 0 even when the target appeared after the check, moving the change *inside* it. If `/archive//` now exists (the last path segment of `changeRoot`), move that directory back to `changeRoot` and record this change as Failed with `Archive directory already exists`. Never report it as archived. + d. **Track outcome** for each change: - Success: archived successfully - Failed: error during archive or spec verification (record error) @@ -320,8 +348,9 @@ No active changes found. Create a new change to get started. - Never archive after the user cancels the confirmation — a cancelled batch archives nothing - Track and report all outcomes (success/skip/fail) - Preserve .openspec.yaml when moving to archive -- Archive directory target uses current date: YYYY-MM-DD-; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date) +- Archive directory target uses the current date, computed once in step 3d and reused at the move: YYYY-MM-DD-; a name that already starts with a `YYYY-MM-DD-` prefix is used as-is (never stack a second date) - If archive target exists, fail that change but continue with others +- Check every archive target in step 3, before the first main-spec write; a change whose target exists is never synced or moved - If sync is requested, run the `openspec-sync-specs` workflow inline (agent-driven) for each change with included delta specs - Carry the per-delta `includedDeltas` and `excludedDeltas` decisions into execution; sync and verify only included deltas - Report every excluded delta as `sync skipped` without treating the archive itself as skipped diff --git a/external/develop/openspec/openspec-continue-change/SKILL.md b/external/develop/openspec/openspec-continue-change/SKILL.md index 5991b06..1693da7 100644 --- a/external/develop/openspec/openspec-continue-change/SKILL.md +++ b/external/develop/openspec/openspec-continue-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-continue-change -description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. +description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow. Also use when the user says "openspec continue" or "opsx continue". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Continue working on a change by creating the next artifact. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** diff --git a/external/develop/openspec/openspec-explore/SKILL.md b/external/develop/openspec/openspec-explore/SKILL.md index 2ff15bf..5b4534e 100644 --- a/external/develop/openspec/openspec-explore/SKILL.md +++ b/external/develop/openspec/openspec-explore/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-explore -description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. +description: Enter OpenSpec explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements in a project that uses OpenSpec. Use when the user wants to think through something before or during an OpenSpec change. Also use when the user says "openspec explore" or "opsx explore". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -11,12 +11,23 @@ metadata: Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. For a new change, scaffold it first as described below. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, investigate the codebase, and run read-only commands or tools without confirmation, but you must NEVER write code or implement features. If the user asks you to implement something, do not start it here: say that explore mode does not implement, and point them at `/openspec-propose`, which turns the discussion into a change. The work happens from that change, never from explore mode. You MAY create or update OpenSpec change artifacts (proposals, designs, specs) within a confirmed scope—that's capturing thinking, not implementing. Answering design or clarifying questions is never consent to write. Before the first write-capable action, name the artifacts or files you would change and what you would do, ask a direct yes/no question, and wait for the user's confirmation in a separate message. Confirmation covers only the scope you described; ask again before expanding it. An explicit request from the user to capture the exploration as a new change is itself that confirmation, covering the change and the change artifacts the request names; scaffold it first as described below. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + --- ## The Stance @@ -141,14 +152,14 @@ Think freely. When insights crystallize, you might offer: - "This feels solid enough to start a change. Want me to create a proposal?" - Or keep exploring - no pressure to formalize -If the user asks you to capture the exploration as a new change, transition seamlessly into the requested capture: +If the user asks you to capture the exploration as a new change, that request is the confirmation required above. It covers scaffolding that change and creating the change artifacts the request names, and nothing else. This holds only when the request is theirs: a yes to an offer you made confirms only the scope your offer itself named, so name the change and the artifacts in the offer. Don't re-ask for what they already asked for; do ask before anything beyond it. Transition seamlessly into the requested capture: 1. Run `openspec new change ""` (with `--store ` when applicable) before creating any artifacts. Never create a new change directory under `openspec/changes/` by hand; the CLI scaffold creates required metadata such as `.openspec.yaml`. Keep the selected `--store ` on every applicable follow-up `status` and `instructions` command. 2. Run `openspec status --change "" --json` (append the confirmed `--store ""` only for a registered standalone store), then process the requested artifacts in dependency order. For each requested artifact that is `ready`, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store). Before creating a requested artifact, evaluate any condition in its own `instruction` against the explored change; record a deliberate skip instead when the condition does not apply. If a requested artifact is blocked by a direct prerequisite the user did not request, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) for that prerequisite whether it is `ready` or `blocked`. If its own `instruction` states a condition, evaluate that condition against the explored change and record a deliberate skip only when the condition does not apply. If the condition applies, or the prerequisite is not conditional, treat it as a normal prerequisite and ask before expanding the capture. Do not create an unrequested prerequisite unless the user approves. 3. Follow the returned `template` and `instruction` fields. Read completed dependency files listed in `dependencies`, and apply `context` and `rules` as constraints without copying them into the artifact. If the instruction delegates creation to a specific skill or command, invoke it; otherwise write the artifact to `resolvedOutputPath`, using the instruction to choose a concrete path when it is a glob. Verify that the selected concrete output exists. 4. After creating each artifact, re-run `openspec status --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) and continue until every requested artifact is `done`, `skipped`, or was deliberately skipped because its own `instruction` stated a condition that did not apply. Tell the user about a deliberate conditional skip, remember it, and do not reconsider it. Dependencies are enablers, not gates: if a requested artifact is still `blocked` only because you deliberately skipped a conditional prerequisite, run `openspec instructions "" --change "" --json` (append the confirmed `--store ""` only for a registered standalone store) despite the blocked status, then create it using step 3 only when those recorded conditional skips are its sole missing dependencies. If a requested artifact is blocked by a prerequisite the user did not ask to capture and cannot be conditionally skipped, explain that dependency and ask before expanding the capture. -Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. +Capture the artifact(s) the user requested without asking them to invoke another workflow command. If they asked only to start a change, stop after scaffolding and show its status. When the requested capture is done, stop there and name where the work continues: `/openspec-propose` writes the remaining planning artifacts, and `/openspec-apply-change` implements the change once tasks exist. Capturing artifacts never starts implementing them. ### When a change exists @@ -304,7 +315,7 @@ You: That changes everything. There's no required ending. Discovery might: -- **Flow into a proposal**: "Ready to start? I can create a change proposal." +- **Flow into a proposal**: "Ready to start? Run `/openspec-propose` and this becomes a change." - **Result in artifact updates**: "Updated design.md with these decisions" - **Just provide clarity**: User has what they need, moves on - **Continue later**: "We can pick this up anytime" @@ -321,7 +332,7 @@ When it feels like things are crystallizing, you might summarize: **Open questions**: [if any remain] **Next steps** (if ready): -- Create a change proposal +- Turn this into a change: `/openspec-propose` - Keep exploring: just keep talking ``` @@ -331,11 +342,11 @@ But this summary is optional. Sometimes the thinking IS the value. ## Guardrails -- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. +- **Don't implement** - Never write code or implement features. Workflow configuration counts too: creating or editing schemas, templates, or `openspec/config.yaml` is a change, not thinking. Creating or updating OpenSpec change artifacts within the confirmed scope is fine, writing anything else is not. When the user is ready to build, name the handoff rather than starting: `/openspec-propose` turns the discussion into a change, and the work happens there. - **Don't fake understanding** - If something is unclear, dig deeper - **Don't rush** - Discovery is thinking time, not task time - **Don't force structure** - Let patterns emerge naturally -- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write. +- **Don't auto-capture** - Offer to save insights, don't just do it. Read-only commands and tools need no confirmation. Before the first write-capable action—including `openspec new change` or another command that writes files—name the artifacts or files and proposed changes, ask a direct yes/no question, and wait for explicit confirmation in a separate user message. That confirmation covers only the described scope; ask again before expanding it. Answers to design or clarifying questions are never consent to write. That rule governs `openspec new change` whenever you are the one proposing the capture; the user's own capture request is the exception, handled in the capture transition above. - **Don't manually scaffold changes** - Never create a new change directory under `openspec/changes/` by hand. Always use `openspec new change ""` (with `--store ` when applicable) so required metadata such as `.openspec.yaml` is created before writing artifacts. - **Do visualize** - A good diagram is worth many paragraphs - **Do explore the codebase** - Ground discussions in reality diff --git a/external/develop/openspec/openspec-ff-change/SKILL.md b/external/develop/openspec/openspec-ff-change/SKILL.md index 72a9562..f7b4b69 100644 --- a/external/develop/openspec/openspec-ff-change/SKILL.md +++ b/external/develop/openspec/openspec-ff-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-ff-change -description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. +description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually. Also use when the user says "openspec ff" or "opsx ff". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Fast-forward through artifact creation - generate everything needed to start imp **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** diff --git a/external/develop/openspec/openspec-new-change/SKILL.md b/external/develop/openspec/openspec-new-change/SKILL.md index 9aea11d..432cc45 100644 --- a/external/develop/openspec/openspec-new-change/SKILL.md +++ b/external/develop/openspec/openspec-new-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-new-change -description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. +description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach. Also use when the user says "openspec new change" or "opsx new". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Start a new change using the experimental artifact-driven approach. **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** diff --git a/external/develop/openspec/openspec-onboard/SKILL.md b/external/develop/openspec/openspec-onboard/SKILL.md index fb3f13b..66de72f 100644 --- a/external/develop/openspec/openspec-onboard/SKILL.md +++ b/external/develop/openspec/openspec-onboard/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-onboard -description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. +description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. Also use when the user says "openspec onboard" or "opsx onboard". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Guide the user through their first complete OpenSpec workflow cycle. This is a t **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + --- ## Preflight @@ -220,6 +231,8 @@ Here's a draft proposal: --- +# Proposal + ## Why [1-2 sentences explaining the problem/opportunity] @@ -287,6 +300,8 @@ Here's the spec: --- +# Spec Delta + ## ADDED Requirements ### Requirement: @@ -326,6 +341,8 @@ Here's the design: --- +# Design + ## Context [Brief context about the current state] @@ -371,6 +388,8 @@ Here are the implementation tasks: --- +# Tasks + ## 1. [Category or file] - [ ] 1.1 [Specific task] — verify: [test, command, observable behavior, or delivered artifact] @@ -472,23 +491,18 @@ This same rhythm works for any size change—a small fix or a major feature. ## Command Reference -**Core workflow:** +**The commands you have installed:** - | Command | What it does | - |-------------------|--------------------------------------------| + | Command | What it does | + |------------------|--------------------------------------------| | `/openspec-propose` | Create a change and generate all artifacts | | `/openspec-explore` | Think through problems before/during work | | `/openspec-apply-change` | Implement tasks from a change | | `/openspec-archive-change` | Archive a completed change | - -**Additional commands** (only if installed - availability depends on your profile): - - | Command | What it does | - |--------------------|----------------------------------------------------------| - | `/openspec-new-change` | Start a new change, step through artifacts one at a time | - | `/openspec-continue-change` | Continue working on an existing change | - | `/openspec-ff-change` | Fast-forward: create all artifacts at once | - | `/openspec-verify-change` | Verify implementation matches artifacts | + | `/openspec-new-change` | Start a new change, one artifact at a time | + | `/openspec-continue-change` | Continue working on an existing change | + | `/openspec-ff-change` | Fast-forward: create all artifacts at once | + | `/openspec-verify-change` | Verify implementation matches artifacts | --- @@ -508,8 +522,8 @@ If the user says they need to stop, want to pause, or seem disengaged: ``` No problem! Your change is saved at the `changeRoot` reported by `openspec status --change "" --json`. -To pick up where we left off later: -- `/openspec-continue-change ` - Resume artifact creation (if installed; otherwise `openspec status --change "" --json` shows the next artifact) +To pick up where we left off later, `openspec status --change "" --json` shows exactly where the change stands. +- `/openspec-continue-change ` - Resume artifact creation - `/openspec-apply-change ` - Jump to implementation (if tasks exist) The work won't be lost. Come back whenever you're ready. @@ -524,23 +538,18 @@ If the user says they just want to see the commands or skip the tutorial: ``` ## OpenSpec Quick Reference -**Core workflow:** +**The commands you have installed:** | Command | What it does | |--------------------------|--------------------------------------------| - | `/openspec-propose ` | Create a change and generate all artifacts | - | `/openspec-explore` | Think through problems (no code changes) | - | `/openspec-apply-change ` | Implement tasks | - | `/openspec-archive-change ` | Archive when done | - -**Additional commands** (only if installed - availability depends on your profile): - - | Command | What it does | - |---------------------------|-------------------------------------| - | `/openspec-new-change ` | Start a new change, step by step | - | `/openspec-continue-change ` | Continue an existing change | - | `/openspec-ff-change ` | Fast-forward: all artifacts at once | - | `/openspec-verify-change ` | Verify implementation | + | `/openspec-propose ` | Create a change and generate all artifacts | + | `/openspec-explore` | Think through problems (no code changes) | + | `/openspec-apply-change ` | Implement tasks | + | `/openspec-archive-change ` | Archive when done | + | `/openspec-new-change ` | Start a new change, step by step | + | `/openspec-continue-change ` | Continue an existing change | + | `/openspec-ff-change ` | Fast-forward: all artifacts at once | + | `/openspec-verify-change ` | Verify implementation | Try `/openspec-propose` to start your first change. ``` diff --git a/external/develop/openspec/openspec-propose/SKILL.md b/external/develop/openspec/openspec-propose/SKILL.md index 49454cd..7f41c4b 100644 --- a/external/develop/openspec/openspec-propose/SKILL.md +++ b/external/develop/openspec/openspec-propose/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-propose -description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. +description: Propose a new OpenSpec change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. Also use when the user says "openspec propose" or "opsx propose". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -27,6 +27,17 @@ When the user is ready to implement, they must start the apply workflow explicit **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** @@ -44,7 +55,7 @@ When the user is ready to implement, they must start the apply workflow explicit 2. **Load project context** - Run `openspec context --json` from the current working directory (or `openspec context --json --store ""` when a registered store was explicitly selected). Use the returned `root.path` as the authoritative OpenSpec root. If context reports `no_openspec_root`, stop without creating or changing any files. Offer `openspec init` and wait for the user to request initialization. Do not initialize automatically or run `openspec new change`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. + Run `openspec context --json` from the current working directory (or `openspec context --json --store ""` when a registered store was explicitly selected). Use the returned `root.path` as the authoritative OpenSpec root. If context reports `no_openspec_root`, stop without creating or changing any files and follow the **Project check** above for how this workflow was reached. Offer `openspec init` only for an explicit OpenSpec request, and wait for the user to request initialization. Do not initialize automatically or run `openspec new change`. After initialization, rerun this context check before continuing. For any other context failure, stop and report the error; do not fall back to the current directory or run later OpenSpec commands without the selected store. Only when context returns a resolved `root.path`, read `/openspec/config.yaml`. Use `config.yml` only when `config.yaml` does not exist. If neither file exists, continue without project context. Do not fall back to `config.yml` if `config.yaml` is unreadable or invalid. diff --git a/external/develop/openspec/openspec-sync-specs/SKILL.md b/external/develop/openspec/openspec-sync-specs/SKILL.md index d12d56b..5683f84 100644 --- a/external/develop/openspec/openspec-sync-specs/SKILL.md +++ b/external/develop/openspec/openspec-sync-specs/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-sync-specs -description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. +description: Sync delta specs from an OpenSpec change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change. Also use when the user says "openspec sync" or "opsx sync". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -15,6 +15,17 @@ This is an **agent-driven** operation - you will read delta specs and directly e **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + `` is the spec directory relative to `specs/` (for example, `user-auth` or `identity/user-auth`). Preserve the full path from each delta spec when resolving its main spec. **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. @@ -95,6 +106,13 @@ This is an **agent-driven** operation - you will read delta specs and directly e b. **Read the main spec** at `/openspec/specs//spec.md` (may not exist yet) + **If it does not exist yet** (a new capability), match what `openspec archive` does: + only ADDED requirements may be applied - step d creates the spec from them. + MODIFIED and RENAMED have no requirement to act on, so stop the sync for that + capability and report that its main spec does not exist and only ADDED is allowed + for a new spec; never invent the missing requirement. REMOVED has nothing to + remove - skip it and warn. + c. **Apply changes intelligently**: **ADDED Requirements:** @@ -142,6 +160,14 @@ This is an **agent-driven** operation - you will read delta specs and directly e (this is what `openspec archive` does; it warns and moves on) d. **Create new main spec** if capability doesn't exist yet: + - Only when the delta has ADDED requirements to put in it and no MODIFIED or + RENAMED requirements blocked this capability in step b. Otherwise create nothing + and leave the specs directory untouched. For a REMOVED-only delta, if the change's + `.openspec.yaml` declares `retire_capabilities: true`, report it as already retired + and continue without recreating the spec. Without that marker, report the sync as blocked: + `openspec archive` rejects it with `Spec must have at least one requirement`. + An empty delta has no operations to sync; report it as blocked too. + Never write an empty `## Requirements` section. - Create `/openspec/specs//spec.md` - Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one (this is what `openspec archive` does); only write a brief TBD placeholder when it does not @@ -166,6 +192,8 @@ This is an **agent-driven** operation - you will read delta specs and directly e **Delta Spec Format Reference** ```markdown +# Spec Delta + ## Purpose Only on a delta that introduces a brand-new capability. Seeds the new main spec. diff --git a/external/develop/openspec/openspec-update-change/SKILL.md b/external/develop/openspec/openspec-update-change/SKILL.md index 24c9f88..9aae524 100644 --- a/external/develop/openspec/openspec-update-change/SKILL.md +++ b/external/develop/openspec/openspec-update-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-update-change -description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Never edits code. +description: Update an OpenSpec change by revising its existing planning artifacts and keeping them coherent with one another. Use when the user wants to revise a change's plan, fold new decisions into it, or reconcile its artifacts after an edit. Also use when the user says "openspec update change" or "opsx update". If the user means the openspec update CLI command, which refreshes generated files, run that command instead. Never edits code. allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,9 +13,20 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. -`/openspec-continue-change` is an optional workflow and may not be installed. Before suggesting it anywhere below, verify that it is available. If it is unavailable, `openspec status --change "" --json` shows the next artifact and `openspec instructions "" --change "" --json` explains how to create it. +This workflow revises artifacts that already exist; `/openspec-continue-change` is what creates the ones that do not. **Steps** @@ -56,13 +67,14 @@ Revise a change's existing planning artifacts and keep them coherent. Never edit 4. **Read and reconcile** - Read the artifact(s) the request touches and the change's other existing artifacts. - - Apply the requested edit. Then check every other existing artifact against it - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. + - Draft the requested edit in the conversation, not in files. Work out exactly what it changes; step 5 owns every write. Then check every other existing artifact against the drafted edit - in ANY direction: an edit to a later artifact may require revising an earlier one, not only the other way around. Build order is a useful reading order, not a constraint on which artifacts may be revised. - Note everything that is now inconsistent, missing, or contradictory. - - Revise only files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them. - - If the change is already coherent, say so and make no edits. + - Propose revisions only to files that already exist (`existingOutputPaths`). Do NOT create artifacts that don't exist yet, and do NOT invent new files under a glob artifact - note them and point the user to `/openspec-continue-change` to create them. + - If the change is already coherent, say so and propose no revisions. 5. **Confirm and apply, one artifact at a time** - - Show each proposed revision and why. Write only after the user confirms. + - This step performs every artifact write in this workflow; no earlier step edits an artifact. + - Show each proposed revision and why - including the requested edit drafted in step 4. Write only after the user confirms. - If the user rejects a revision, do not write it - leave that artifact unchanged. - When a substantial rewrite is needed, get that artifact's rules and template first: ```bash @@ -87,4 +99,4 @@ After each invocation, show: - Edit only the concrete files in `existingOutputPaths`; never write to a glob `resolvedOutputPath`. - Do not advance the build frontier: no new artifacts, no new files under glob artifacts - that is `/openspec-continue-change`'s job. - Confirm every edit with the user before writing. -- If the request changes the change's *intent* rather than refining it, first verify whether the optional `/openspec-new-change` workflow is available. If it is, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). If it is unavailable, ask for a distinct unused change name and recommend `openspec new change ""` instead. +- If the request changes the change's *intent* rather than refining it, recommend starting fresh with `/openspec-new-change` (the "Update vs. Start Fresh" heuristic). diff --git a/external/develop/openspec/openspec-verify-change/SKILL.md b/external/develop/openspec/openspec-verify-change/SKILL.md index 2165a6a..355febc 100644 --- a/external/develop/openspec/openspec-verify-change/SKILL.md +++ b/external/develop/openspec/openspec-verify-change/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec-verify-change -description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. +description: Verify implementation matches OpenSpec change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving. Also use when the user says "openspec verify" or "opsx verify". allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. @@ -13,6 +13,17 @@ Verify that an implementation matches the change artifacts (specs, tasks, design **Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`, `schemas`, `view`). Once selected, treat `--store ` as sticky for the rest of the workflow. Every unscoped example of those commands below is shorthand: before running it, append the flag. For example, run `openspec status --change "" --json --store ""`, not the unscoped form shown below. Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. +**Project check:** These steps expect a project that already uses OpenSpec. Before the first step that writes anything (`new change`, `archive`, `sync specs`, or authoring an artifact file), confirm the project has a root: run `openspec list --json` (with `--store ` when a store is selected, since the store is then the root) and read `root`. A root object means the project is set up. `"root": null` means it is not - there is no `openspec/` directory here, and a write such as `openspec new change` would create one as a side effect. The command also exits non-zero, which is that answer rather than a broken CLI, so read the JSON instead of retrying or working around it. + +One `"root": null` is not about setup: when a `status` error message starts with `Declared in` or `Invalid store declaration in` and names this project's `openspec/config.yaml` (or `config.yml`), the project does use OpenSpec through a store it declares, which this machine cannot resolve (the store is not registered, or the `store:` line is malformed). Do not treat it as uninitialized and skip the branches below: stop before writing and show the user that error's `message` and `fix`. + +Otherwise, with no root, what happens next depends on how this workflow was reached: + +- **Auto-selected**: you chose this workflow yourself, without the user naming OpenSpec, naming this skill, or running its slash command. Stop using OpenSpec and answer the request normally, as you would with no OpenSpec installed. Do not ask them to set anything up and do not mention OpenSpec setup. +- **Explicit OpenSpec request**: the user named OpenSpec, named this skill, or ran its slash command. Stop before writing and ask how to proceed: set this project up (`openspec init`), target a store they already have (`--store `), or continue without OpenSpec for this request. Wait for their answer. + +In both branches, never create the root as a side effect: do not run `openspec init` until the user asks for it, do not hand-create `openspec/` files, and do not let a command create it. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -60,7 +71,9 @@ Verify that an implementation matches the change artifacts (specs, tasks, design **Task Completion**: - If `contextFiles.tasks` exists, read every file path in it - - Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete) + - Parse checkboxes: complete means the box holds only `x`/`X`, ignoring + spacing (`- [ x]` is complete); every other marker is incomplete + (`- [ ]`, `- []`, and unfamiliar ones such as `- [~]` or `- [-]`) - Count complete vs total tasks - If incomplete tasks exist: - Add CRITICAL issue for each incomplete task diff --git a/external/video-design/hyperframes-animation/SKILL.md b/external/video-design/hyperframes-animation/SKILL.md index 00aaaee..8571c10 100644 --- a/external/video-design/hyperframes-animation/SKILL.md +++ b/external/video-design/hyperframes-animation/SKILL.md @@ -30,6 +30,7 @@ Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/. | Read one blueprint's full recipe | `blueprints/.md` | | Author a scene transition (CSS-driven, between two clips) | `transitions/overview.md`, `transitions/catalog.md` | | Look up a broader motion-design technique | `techniques.md` | +| Motion blur — shutter smear on an element, and when not to use it | `references/motion-blur.md` | | Analyze an existing composition's animation map | `scripts/animation-map.mjs` | | GSAP API — timeline / tweens / position parameters | `adapters/gsap.md` | | GSAP — drop-in effect recipes | `rules/gsap-effects.md` | diff --git a/external/video-design/hyperframes-animation/adapters/gsap.md b/external/video-design/hyperframes-animation/adapters/gsap.md index 6638706..53dddf9 100644 --- a/external/video-design/hyperframes-animation/adapters/gsap.md +++ b/external/video-design/hyperframes-animation/adapters/gsap.md @@ -61,7 +61,7 @@ HyperFrames is stricter than vanilla GSAP. Animate only: - **Compositor-cheap**: `opacity`, `x`, `y`, `scale`, `scaleX`, `scaleY`, `rotation`, `rotationX`, `rotationY`, `skewX`, `skewY`, `transformOrigin` - **Visual fills**: `color`, `backgroundColor`, `borderColor`, `borderRadius` - **CSS variables**: `"--hue": 180` etc. -- **Media `volume`** (on `
), + Layout: ({ children, slots }) => ( +
+
{slots?.header}
+
{children}
+
{slots?.footer}
+
+ ), }, }); ``` @@ -74,25 +87,41 @@ The React schema uses an element tree format: "root": { "type": "Card", "props": { "title": "Hello" }, - "children": [ - { "type": "Button", "props": { "label": "Click me" } } - ] + "children": [{ "type": "Button", "props": { "label": "Click me" } }] + } +} +``` + +## Named Slots + +Use `children` for the `"default"` slot. Use the element's top-level `slots` object for other slot names declared by the catalog: + +```json +{ + "type": "Layout", + "props": {}, + "children": ["main"], + "slots": { + "header": ["heading"], + "footer": ["actions"] } } ``` +Registry components receive named content as `slots?.header`, `slots?.footer`, and so on. Do not use `slots.default`. + ## Visibility Conditions Use `visible` on elements to show/hide based on state. New syntax: `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`, `{ "$state": "/path", "not": true }`, `{ "$and": [cond1, cond2] }` for AND, `{ "$or": [cond1, cond2] }` for OR. Helpers: `visibility.when("/path")`, `visibility.unless("/path")`, `visibility.eq("/path", val)`, `visibility.and(cond1, cond2)`, `visibility.or(cond1, cond2)`. ## Providers -| Provider | Purpose | -|----------|---------| -| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. | -| `ActionProvider` | Handle actions dispatched via the event system | -| `VisibilityProvider` | Enable conditional rendering based on state | -| `ValidationProvider` | Form field validation | +| Provider | Purpose | +| -------------------- | ------------------------------------------------------------------------------------------------------ | +| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. | +| `ActionProvider` | Handle actions dispatched via the event system | +| `VisibilityProvider` | Enable conditional rendering based on state | +| `ValidationProvider` | Form field validation | ### External Store (Controlled Mode) @@ -103,7 +132,7 @@ import { createStateStore, type StateStore } from "@json-render/react"; const store = createStateStore({ count: 0 }); -{children} +{children}; // Mutate from anywhere — React re-renders automatically: store.set("/count", 1); @@ -119,6 +148,7 @@ Any prop value can be a data-driven expression resolved by the renderer before c - **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components. - **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes. - **Filtered lists**: `repeat` plus an `$item` visible condition on the same container renders only matching items: `{ "repeat": { "statePath": "/tasks", "key": "id" }, "visible": { "$item": "status", "eq": "todo" }, "children": ["task-card"] }`. AND-composed `$state` conjuncts gate the container shell; `$item`/`$index` conjuncts filter items. +- **Nested lists**: inside a repeat, use `{ "repeat": { "statePath": { "$item": "comments" }, "key": "id" } }` to iterate an array on the enclosing item. - **`{ "$cond": , "$then": , "$else": }`** - conditional value - **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings - **`{ "$computed": "fn", "args": { ... } }`** - calls registered functions with resolved args @@ -184,7 +214,10 @@ Elements can declare a `watch` field (top-level, sibling of type/props/children) ```json { "type": "Select", - "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] }, + "props": { + "value": { "$bindState": "/form/country" }, + "options": ["US", "Canada"] + }, "watch": { "/form/country": { "action": "loadCities" } }, "children": [] } @@ -246,20 +279,20 @@ const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => ( ## Key Exports -| Export | Purpose | -|--------|---------| -| `defineRegistry` | Create a type-safe component registry from a catalog | -| `Renderer` | Render a spec using a registry | -| `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) | -| `useStateStore` | Access state context | -| `useStateValue` | Get single value from state | -| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | -| `useActions` | Access actions context | -| `useAction` | Get a single action dispatch function | -| `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) | -| `useUIStream` | Stream specs from an API endpoint | -| `createStateStore` | Create a framework-agnostic in-memory `StateStore` | -| `StateStore` | Interface for plugging in external state management | -| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | -| `EventHandle` | Event handle type (`emit`, `shouldPreventDefault`, `bound`) | -| `ComponentContext` | Typed component context (catalog-aware) | +| Export | Purpose | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | +| `defineRegistry` | Create a type-safe component registry from a catalog | +| `Renderer` | Render a spec using a registry | +| `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) | +| `useStateStore` | Access state context | +| `useStateValue` | Get single value from state | +| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | +| `useActions` | Access actions context | +| `useAction` | Get a single action dispatch function | +| `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) | +| `useUIStream` | Stream specs from an API endpoint | +| `createStateStore` | Create a framework-agnostic in-memory `StateStore` | +| `StateStore` | Interface for plugging in external state management | +| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | +| `EventHandle` | Event handle type (`emit`, `shouldPreventDefault`, `bound`) | +| `ComponentContext` | Typed component context (catalog-aware) | diff --git a/plugins/frontend-product-design/skills/sleek-design-mobile-apps/SKILL.md b/plugins/frontend-product-design/skills/sleek-design-mobile-apps/SKILL.md index 0a5aed8..11824b3 100644 --- a/plugins/frontend-product-design/skills/sleek-design-mobile-apps/SKILL.md +++ b/plugins/frontend-product-design/skills/sleek-design-mobile-apps/SKILL.md @@ -1,6 +1,6 @@ --- name: sleek-design-mobile-apps -description: Use when the user wants to design a mobile app, create screens, build UI, or interact with their Sleek projects. Covers high-level requests ("design an app that does X") and specific ones ("list my projects", "create a new project", "screenshot that screen"). +description: Use when the user wants to design a mobile app or UI screens, when they mention their Sleek (sleek.design) projects, or when implementing Sleek designs in code (HTML, React Native, SwiftUI). compatibility: Requires SLEEK_API_KEY environment variable. Network access limited to https://sleek.design only. metadata: requires-env: SLEEK_API_KEY @@ -19,14 +19,22 @@ metadata: **Auth**: `Authorization: Bearer $SLEEK_API_KEY` on every `/api/v1/*` request **Content-Type**: `application/json` (requests and responses) **CORS**: Enabled on all `/api/v1/*` endpoints +**Parsing responses**: write the body to a file (`curl -o run.json`) and parse the file. Don't pipe JSON through `echo`: in zsh it expands the escaped `\n` inside string values into real newlines, which makes the body invalid JSON. +**API docs**: OpenAPI spec at `https://sleek.design/api/v1/spec.json`; browsable docs at `https://sleek.design/api/v1/docs`. Fetch the spec for any contract detail not covered here. --- ## Prerequisites: API Key -Create API keys at **https://sleek.design/dashboard/api-keys**. The full key value is shown only once at creation — store it in the `SLEEK_API_KEY` environment variable. +If `SLEEK_API_KEY` is not set, use the device flow so the user never handles the raw key: -**Required plan**: Pro or higher (API access is gated) +1. `POST https://sleek.design/api/v1/device/start` (no auth) with body `{"source": "your-tool-slug"}`. The response contains a `verificationUrl`, a human-checkable `userCode`, a secret `deviceCode`, and a poll `interval` in seconds. +2. Show the user the `verificationUrl` and the `userCode`, and tell them to confirm the code matches before approving. +3. Poll `POST https://sleek.design/api/v1/device/poll` with `{"deviceCode": "..."}` every `interval` seconds. When the user approves, the poll returns `{"status": "approved", "key": "sk_..."}` exactly once: store it as `SLEEK_API_KEY`. Codes expire after 15 minutes; on `expired`, start over. + +Fallback: send the user to **https://sleek.design/agents/setup**, which handles sign-in, plan upgrade, and key creation in one place, and ask them to paste the key back to you. Keys can also be managed at **https://sleek.design/dashboard/api-keys**. The full key value is shown only once at creation. + +**Plans**: free accounts can try the API with their one-time trial credits (about one design run), so a new user can see their first design before any payment decision. Sustained use requires the Pro plan or higher ($49.99/month, or $30/month billed yearly at $360/year; includes 20,000 monthly AI credits, roughly 650 screens). When cost becomes relevant (the user asks, an upgrade is needed to continue, or you're about to send them to a payment page), state this pricing plainly, including the yearly option. Never let a payment step come as a surprise. ### Key scopes @@ -52,21 +60,162 @@ Create a key with only the scopes needed for the task. --- -## Quick Reference — All Endpoints +## Designing + +The full request/response shapes for every endpoint used below are in the [API reference](#quick-reference-all-endpoints). + +### 1. Create a project + +Create a project with `POST /api/v1/projects` if one doesn't exist yet. Derive a name from the request. + +Each project has its own theme, style, and design system. If the user wants multiple design variations, create a separate project for each variation. + +### 2. Send a chat message + +Send the request with `POST /api/v1/projects/:id/chat/messages`. Sleek plans screen content and layout from your message, and will invent a visual style if you don't give it one. Don't decompose the request into screens and don't add product details the user didn't ask for; send the full intent as a single message. If the user described specific screens, include those. Sleek produces richer designs when given room to plan. + +**Author a style direction**: write one whenever the user has given you anything to ground it in — reference images, apps they like, vibe adjectives, things to avoid — or whenever you're producing variations, one direction per variation. Pass the request through unchanged only when it's bare. A style direction is a single comprehensive paragraph, included in the message, covering mood (2–3 adjectives), color strategy (the logic, not hex codes), typography feel, layout philosophy, component style (radii, borders vs shadows, nav treatment), imagery and illustration style, and one or two distinctive details. Commit to a palette, a type direction, and an overall feel — anything that only sets a mood reads as a hint, not a direction. Be opinionated; don't hedge. Put the personality in color, type, and imagery rather than in unusual layout or navigation. + +Extend what the user gave you and never contradict it. When they point at reference images or apps they like, study each one and carry what you take into the direction — Sleek only sees images passed as `imageUrls`, so for anything local the direction is how those references reach it. Borrow patterns, never the source's branding, content, or name. + +Use a style direction or a `referenceId`, not both — a reference already carries a full style guide of its own. + +**Seed a style with a reference**: Sleek curates a catalog of design references. When the user wants a specific look or asks for style options, list them with `GET /api/v1/references` (each has a `name` and `previewImageUrls` you can show) and pass the chosen id as `referenceId` on the first message to a project, so its style guide seeds the whole design. + +**Identify your tool**: always send `source`, the slug of the tool making the request. The Sleek editor uses it to show the user who is designing while the run streams. Recognized values: `claude-code`, `claude`, `codex`, `chatgpt`, `cursor`, `openclaw`, `grok`. If your tool isn't listed, send a short kebab-case slug for it anyway (max 64 chars). Unrecognized values are fine and get a generic label. -| Method | Path | Scope | Description | -| -------- | --------------------------------------- | ----------------- | ----------------- | -| `GET` | `/api/v1/projects` | `projects:read` | List projects | -| `POST` | `/api/v1/projects` | `projects:write` | Create project | -| `GET` | `/api/v1/projects/:id` | `projects:read` | Get project | -| `DELETE` | `/api/v1/projects/:id` | `projects:write` | Delete project | -| `GET` | `/api/v1/projects/:id/components` | `components:read` | List components | -| `GET` | `/api/v1/projects/:id/components/:componentId` | `components:read` | Get component | -| `POST` | `/api/v1/projects/:id/chat/messages` | `chats:write` | Send chat message | -| `GET` | `/api/v1/projects/:id/chat/runs/:runId` | `chats:read` | Poll run status | -| `POST` | `/api/v1/screenshots` | `screenshots` | Render screenshot | +**Watch it live**: runs render in the Sleek editor in real time. After sending the first message to a project, tell the user they can watch their screens being designed live in Sleek, and share the editor link: `https://sleek.design/project/:projectId`. Don't open a browser yourself unless the user asks. -All IDs are stable string identifiers. +**Polling**: chat messages are async by default: you get a `runId` and poll `GET /api/v1/projects/:id/chat/runs/:runId`. Start at 2s interval, back off to 5s after 10s, give up after 5 minutes. Exit on `completed` or `failed`; if you can't read the status, stop and report it rather than counting it as "not done yet". You can also use `?wait=true` for a blocking call (up to 300s; falls back to polling if it times out with `202`). + +**Editing a specific screen**: use `target.screenId` to direct changes to the right screen. The `screenId` comes from the run's `result.operations` or from the `screenId` field on each component returned by `GET /api/v1/projects/:id/components`; it is not the component ID. + +**One run at a time**: only one active run is allowed per project. If you get `409 CONFLICT`, wait for the current run to complete before sending the next message. If the user changed their mind or a stale run is blocking the project, cancel it (see [Cancel Run](#chat-cancel-run)). Messages to different projects can run in parallel; use async polling (not `?wait=true`) when running multiple projects concurrently. + +**Safe retries**: add an `idempotency-key` header (≤255 chars) to replay-safe re-sends. The server returns the existing run rather than creating a duplicate. + +### 3. Show the results + +After every chat run that produces `screen_created` or `screen_updated` operations, **take screenshots and show them to the user** using `POST /api/v1/screenshots`. The step is done only when the user has seen a screenshot of every screen the run created or updated; never complete a run silently. + +- **New screens**: one screenshot per screen + one combined screenshot of all screens in the project. +- **Updated screens**: one screenshot per affected screen. + +Use `background: "transparent"` unless the user explicitly requests a specific background color. + +Save screenshots in the project directory (not a temporary folder) so the user can easily view them. + +**Showing vs reviewing**: the defaults capture only the viewport, which is the right framing for the user — screens look like phone screens. They are the wrong framing for judging your own work, because everything below the fold is cropped away. When you're reviewing what a run produced, re-shoot the screen with `fullHeight: true` (one screen per request) to see the whole scrollable page. + +Screenshot requests are independent, so issue them in parallel — the user-facing shot and your `fullHeight` review shot go out together, as do the shots for different screens. "One screen per request" governs what goes into each image, not how fast you send them; it is not a reason to wait for one response before starting the next. Back off only if you actually get a `429`. + +**Never call a screen incomplete from a viewport screenshot.** Content that looks missing is almost always just below the fold. Before telling the user something is absent, or sending a follow-up message asking Sleek to add it, confirm it against the whole screen: a `fullHeight: true` screenshot, or the component HTML from `GET /api/v1/projects/:id/components/:componentId`, which is the ground truth for what's on the screen. The screenshot is the default and answers most review questions on its own — don't go to the code to double-check something it already shows. Reach for the code only when you're about to claim something is missing: a render can omit what's really there (past the height cap, in a collapsed section, on a later carousel slide), so a negative conclusion is the one worth a second source. Note the reverse too — an element present in the HTML may still not be visible to the user. + +--- + +## Implementing Designs + +When the user wants to implement the designs in code (not just preview them), **always fetch the component HTML code**. Do not rely on screenshots alone. + +Use `GET /api/v1/projects/:id/components/:componentId` to fetch each screen's code. The `componentId` comes from the chat run's `result.operations`. + +Component code can be large. When saving it to files, avoid writing the content through your text output: it's slow and wastes tokens. Instead, use shell commands to fetch the API response and write it directly to disk (e.g., pipe the response body into a file). + +### Which version to use + +Each component carries a `versions[]` array and an `activeVersion: number`. **By default, use the entry where `versions[i].version === activeVersion`**: that's the code currently shown in Sleek. + +If the user's prompt pins specific versions, follow those instead (see [Pinned versions](#pinned-versions) below). + +### Pinned versions + +The user's prompt may include a pin block telling you to implement specific historical versions instead of the current ones, like this: + +``` +... at this exact state instead of the project's current version: +- component cmp_abc: version ver_001 +- component cmp_def: version ver_002 +- theme thm_ghi: version ver_003 +``` + +When you see a pin block, implement those exact versions instead of `activeVersion`. Components not named in the pin block continue to use their active version. Theme IDs surface only inside pin blocks; this skill exposes no separate endpoint to enumerate them. + +#### Fetching the right code + +For each pinned component, find the entry in `versions[]` where `versions[i].id` matches the given version id (e.g. `ver_001`) and use its `code`. Do **not** fall back to `activeVersion` for pinned components. + +#### Screenshots of pinned versions + +Pass `componentVersionOverrides` and `themeVersionOverrides` to `POST /api/v1/screenshots`: + +```json +{ + "componentIds": ["cmp_abc"], + "projectId": "proj_xyz", + "componentVersionOverrides": { "cmp_abc": "ver_001" }, + "themeVersionOverrides": { "thm_ghi": "ver_003" } +} +``` + +Keys are component / theme public ids; values are the corresponding `versions[i].id`. Entities missing from a map fall back to their active version. Include the override maps whenever the prompt specified pinned versions. + +### HTML prototypes + +The component `code` is a complete HTML document. Save it directly to a `.html` file. No build step needed. + +### Native frameworks (React Native, SwiftUI, etc.) + +Use both the HTML code and the screenshots together: + +- **HTML code** is the implementation reference: it contains the exact structure, layout, styling, colors, spacing, content, image URLs, and icon names. +- **Screenshots** are the visual target: use them to verify your implementation matches the intended look. + +The HTML tells you _how_ to build it; the screenshot tells you _what_ it should look like. + +#### Icons + +Sleek uses [Iconify](https://iconify.design) icons in the format `prefix:name` (e.g., `solar:heart-bold`, `material-symbols:search-rounded`, `lucide:settings`). The most common sets are **Solar**, **Hugeicons**, **Material Symbols** and **MDI**. + +**Use the exact icons from the HTML code**. Do not substitute with a different icon set. Matching icons is important for design fidelity. + +When implementing icons: + +1. **Check if the project already has an icon system** that supports the same sets Sleek uses (Solar, Hugeicons, Material Symbols, MDI). If so, use it. Note: `@expo/vector-icons` does **not** support these sets, so do not use it as a substitute. +2. **Otherwise, fetch the SVGs from the Iconify API and embed them in the code:** + + ``` + GET https://api.iconify.design/{prefix}/{name}.svg + ``` + + Example: `https://api.iconify.design/solar/heart-bold.svg` + + Collect all icon names from the HTML, fetch their SVGs, and save them as static assets or string constants in the codebase. For **React Native / Expo**, render them with `react-native-svg`'s `SvgXml` component, which works in Expo Go with no additional native dependencies. + +#### Fonts + +The HTML includes Google Fonts via `` tags in the ``. Use the same fonts and weights when implementing in a native framework. Extract the font family names and weights from the `` tags. + +#### Navigation + +The designs may include navigation elements like tab bars and headers. Update the project's navigation styling and structure to match the designs. Don't just implement the screen content while leaving the default navigation untouched. + +--- + +## Quick Reference: All Endpoints + +| Method | Path | Scope | Description | +| -------- | ---------------------------------------------- | ----------------- | ----------------- | +| `GET` | `/api/v1/projects` | `projects:read` | List projects | +| `POST` | `/api/v1/projects` | `projects:write` | Create project | +| `GET` | `/api/v1/projects/:id` | `projects:read` | Get project | +| `DELETE` | `/api/v1/projects/:id` | `projects:write` | Delete project | +| `GET` | `/api/v1/projects/:id/components` | `components:read` | List components | +| `GET` | `/api/v1/projects/:id/components/:componentId` | `components:read` | Get component | +| `GET` | `/api/v1/references` | any valid key | List references | +| `POST` | `/api/v1/projects/:id/chat/messages` | `chats:write` | Send chat message | +| `GET` | `/api/v1/projects/:id/chat/runs/:runId` | `chats:read` | Poll run status | +| `POST` | `/api/v1/projects/:id/chat/runs/:runId/cancel` | `chats:write` | Cancel run | +| `POST` | `/api/v1/screenshots` | `screenshots` | Render screenshot | --- @@ -108,7 +257,7 @@ Content-Type: application/json { "name": "My New App" } ``` -Response `201` — same shape as a single project. +Response `201`: same shape as a single project. #### Get / Delete project @@ -128,7 +277,7 @@ GET /api/v1/projects/:projectId/components?limit=50&offset=0 Authorization: Bearer $SLEEK_API_KEY ``` -Both list and get accept an optional `inlineIcons` query param (default `false`). When omitted, icons render as `` web components and the HTML pulls in the Iconify script — leave it off by default. Pass `?inlineIcons=true` only when the consumer needs self-contained SVGs in the HTML (for example, importing into tools that don't run scripts). +Both list and get accept an optional `inlineIcons` query param (default `false`). When omitted, icons render as `` web components and the HTML pulls in the Iconify script, so leave it off by default. Pass `?inlineIcons=true` only when the consumer needs self-contained SVGs in the HTML (for example, importing into tools that don't run scripts). Response `200`: @@ -137,9 +286,17 @@ Response `200`: "data": [ { "id": "cmp_xyz", + "screenId": "scr_xyz", "name": "Hero Section", "activeVersion": 3, - "versions": [{ "id": "ver_001", "version": 1, "code": "...", "createdAt": "..." }], + "versions": [ + { + "id": "ver_001", + "version": 1, + "code": "...", + "createdAt": "..." + } + ], "createdAt": "...", "updatedAt": "..." } @@ -157,24 +314,39 @@ GET /api/v1/projects/:projectId/components/:componentId Authorization: Bearer $SLEEK_API_KEY ``` -Response `200` — same shape as a single item from the list endpoint: +Response `200`: `{ "data": ... }` with a single component in the same shape as a list item. + +--- + +### References + +References are curated design styles from featured Sleek projects. They are world-readable: any valid API key can list them, no scope needed. + +```http +GET /api/v1/references?limit=50&offset=0 +Authorization: Bearer $SLEEK_API_KEY +``` + +Response `200`: ```json { - "data": { - "id": "cmp_xyz", - "name": "Hero Section", - "activeVersion": 3, - "versions": [{ "id": "ver_001", "version": 1, "code": "...", "createdAt": "..." }], - "createdAt": "...", - "updatedAt": "..." - } + "data": [ + { + "id": "proj_ref1", + "name": "Ember Fitness", + "previewImageUrls": ["https://.../screenshot.png"] + } + ], + "pagination": { "total": 44, "limit": 50, "offset": 0 } } ``` +To use one, pass its `id` as `referenceId` on [Send Message](#chat-send-message). + --- -### Chat — Send Message +### Chat: Send Message This is the core action: describe what you want in `message.text` and the AI creates or modifies screens. @@ -186,20 +358,24 @@ idempotency-key: { "message": { "text": "Add a pricing section with three tiers" }, + "source": "claude-code", "imageUrls": ["https://example.com/ref.png"], - "target": { "screenId": "scr_abc" } + "target": { "screenId": "scr_abc" }, + "referenceId": "proj_ref1" } ``` -| Field | Required | Notes | -| ------------------------ | -------- | --------------------------------------------- | -| `message.text` | Yes | 1+ chars, trimmed | -| `imageUrls` | No | HTTPS URLs only; included as visual context | -| `target.screenId` | No | Edit a specific screen using its `screenId` (not `componentId`); omit to let AI decide | -| `?wait=true/false` | No | Sync wait mode (default: false) | -| `idempotency-key` header | No | Replay-safe re-sends | +| Field | Required | Notes | +| ------------------------ | -------- | ---------------------------------------------------------------------------------------- | +| `message.text` | Yes | 1+ chars, trimmed | +| `source` | Treat as required | Slug of the tool sending the request (see [step 2 of Designing](#2-send-a-chat-message)) | +| `imageUrls` | No | HTTPS URLs only; included as visual context | +| `target.screenId` | No | Edit a specific screen using its `screenId` (from run operations or the components list; not `componentId`); omit to let AI decide | +| `referenceId` | No | Seed the design style from a reference (see [References](#references)); invalid id → `400` | +| `?wait=true/false` | No | Sync wait mode (default: false) | +| `idempotency-key` header | No | Replay-safe re-sends | -#### Response — async (default, `wait=false`) +#### Response: async (default, `wait=false`) Status `202 Accepted`. `result` and `error` are absent until the run reaches a terminal state. @@ -213,7 +389,7 @@ Status `202 Accepted`. `result` and `error` are absent until the run reaches a t } ``` -#### Response — sync (`wait=true`) +#### Response: sync (`wait=true`) Blocks up to **300 seconds**. Returns `200` when completed, `202` if timed out. @@ -226,8 +402,17 @@ Blocks up to **300 seconds**. Returns `200` when completed, `202` if timed out. "result": { "assistantText": "I added a pricing section with...", "operations": [ - { "type": "screen_created", "screenId": "scr_xyz", "screenName": "Pricing", "componentId": "cmp_xyz" }, - { "type": "screen_updated", "screenId": "scr_abc", "componentId": "cmp_abc" }, + { + "type": "screen_created", + "screenId": "scr_xyz", + "screenName": "Pricing", + "componentId": "cmp_xyz" + }, + { + "type": "screen_updated", + "screenId": "scr_abc", + "componentId": "cmp_abc" + }, { "type": "theme_updated" } ] } @@ -237,7 +422,7 @@ Blocks up to **300 seconds**. Returns `200` when completed, `202` if timed out. --- -### Chat — Poll Run Status +### Chat: Poll Run Status Use this after async send to check progress. @@ -246,48 +431,31 @@ GET /api/v1/projects/:projectId/chat/runs/:runId Authorization: Bearer $SLEEK_API_KEY ``` -Response — same shape as send message `data` object: +The response has the same `data` shape as send message: `result` is present when `completed`, `error` when `failed`: ```json { "data": { "runId": "run_111", - "status": "queued", - "statusUrl": "..." + "status": "failed", + "statusUrl": "...", + "error": { "code": "execution_failed", "message": "..." } } } ``` -When completed successfully, `result` is present: +**Run status lifecycle**: `queued` → `running` → `completed | failed` -```json -{ - "data": { - "runId": "run_111", - "status": "completed", - "statusUrl": "...", - "result": { - "assistantText": "...", - "operations": [...] - } - } -} -``` +--- -When failed, `error` is present: +### Chat: Cancel Run -```json -{ - "data": { - "runId": "run_111", - "status": "failed", - "statusUrl": "...", - "error": { "code": "execution_failed", "message": "..." } - } -} +```http +POST /api/v1/projects/:projectId/chat/runs/:runId/cancel +Authorization: Bearer $SLEEK_API_KEY ``` -**Run status lifecycle**: `queued` → `running` → `completed | failed` +Marks a `queued` or `running` run as `failed` with error code `cancelled` and returns the updated run; already-finished runs are returned unchanged. Use it when the user changes their mind mid-run or a stale run is blocking the project with `409 CONFLICT`. --- @@ -311,29 +479,32 @@ Content-Type: application/json } ``` -| Field | Default | Notes | -| ------------ | ------------- | --------------------------------------------------------------------- | -| `format` | `png` | `png` or `webp` | -| `scale` | `2` | 1–3 (device pixel ratio) | -| `gap` | `40` | Pixels between components | -| `padding` | `40` | Uniform padding on all sides | -| `paddingX` | _(optional)_ | Horizontal padding; overrides `padding` for left/right when provided | -| `paddingY` | _(optional)_ | Vertical padding; overrides `padding` for top/bottom when provided | -| `paddingTop` | _(optional)_ | Top padding; overrides `paddingY` when provided | -| `paddingRight` | _(optional)_ | Right padding; overrides `paddingX` when provided | -| `paddingBottom` | _(optional)_ | Bottom padding; overrides `paddingY` when provided | -| `paddingLeft` | _(optional)_ | Left padding; overrides `paddingX` when provided | -| `background` | `transparent` | Any CSS color (hex, named, `transparent`) | -| `showDots` | `false` | Overlay a subtle dot grid on the background | -| `radius` | `48` | Squircle corner radius per component in pixels (integer ≥ 0); pass `0` for sharp corners | +| Field | Default | Notes | +| --------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `format` | `png` | `png` or `webp` | +| `scale` | `2` | 1–3 (device pixel ratio) | +| `gap` | `40` | Pixels between components | +| `padding` | `40` | Uniform padding on all sides | +| `paddingX` | _(optional)_ | Horizontal padding; overrides `padding` for left/right when provided | +| `paddingY` | _(optional)_ | Vertical padding; overrides `padding` for top/bottom when provided | +| `paddingTop` | _(optional)_ | Top padding; overrides `paddingY` when provided | +| `paddingRight` | _(optional)_ | Right padding; overrides `paddingX` when provided | +| `paddingBottom` | _(optional)_ | Bottom padding; overrides `paddingY` when provided | +| `paddingLeft` | _(optional)_ | Left padding; overrides `paddingX` when provided | +| `background` | `transparent` | Any CSS color (hex, named, `transparent`) | +| `showDots` | `false` | Overlay a subtle dot grid on the background | +| `fullHeight` | `false` | Capture the entire scrollable screen instead of just the viewport (see below) | +| `radius` | `48` | Squircle corner radius per component in pixels (integer ≥ 0); pass `0` for sharp corners | | `componentVersionOverrides` | _(optional)_ | Map of `componentId` → `versions[i].id` to render at a pinned version instead of `activeVersion` (see [Pinned versions](#pinned-versions)) | -| `themeVersionOverrides` | _(optional)_ | Map of `themeId` → `versions[i].id` to render with a pinned theme version (see [Pinned versions](#pinned-versions)) | +| `themeVersionOverrides` | _(optional)_ | Map of `themeId` → `versions[i].id` to render with a pinned theme version (see [Pinned versions](#pinned-versions)) | Padding resolves with a cascade: per-side → axis → uniform. For example, `paddingTop` falls back to `paddingY`, which falls back to `padding`. So `{ "padding": 20, "paddingX": 10, "paddingLeft": 5 }` gives top/bottom 20px, right 10px, left 5px. -When `showDots` is `true`, a dot pattern is drawn over the background color. The dots automatically adapt to the background: dark backgrounds get light dots, light backgrounds get dark dots. This has no effect when `background` is `"transparent"`. +By default a component is captured at frame height, so anything the user would reach by scrolling is cut off. `fullHeight: true` expands each frame to the height of its own content before capturing. Use it when you're reviewing your own work; leave it off for the screenshots you show the user, where the phone-shaped framing is the point. -Always use `"background": "transparent"` unless the user explicitly requests a specific background color. +Frames are capped at **4× the default frame height**, so a screen longer than that is still cut off at the bottom even with `fullHeight: true`. On a very long screen, treat the component HTML as the authority for what's below the cap. Expanded frames make for tall images; prefer one component per request so each screen keeps its detail — and send those requests in parallel rather than one after another. + +When `showDots` is `true`, a dot pattern is drawn over the background color. The dots automatically adapt to the background: dark backgrounds get light dots, light backgrounds get dark dots. This has no effect when `background` is `"transparent"`. Response: raw binary `image/png` or `image/webp` with `Content-Disposition: attachment`. @@ -345,146 +516,27 @@ Response: raw binary `image/png` or `image/webp` with `Content-Disposition: atta { "code": "UNAUTHORIZED", "message": "..." } ``` -| HTTP | Code | When | -| ---- | ----------------------- | -------------------------------------- | -| 401 | `UNAUTHORIZED` | Missing/invalid/expired API key | -| 403 | `FORBIDDEN` | Valid key, wrong scope or plan | -| 404 | `NOT_FOUND` | Resource doesn't exist | -| 400 | `BAD_REQUEST` | Validation failure | -| 409 | `CONFLICT` | Another run is active for this project | -| 500 | `INTERNAL_SERVER_ERROR` | Server error | +| HTTP | Code | When | +| ---- | ----------------------- | ------------------------------------------------------- | +| 401 | `UNAUTHORIZED` | Missing/invalid/expired API key | +| 403 | `FORBIDDEN` | Valid key, wrong scope or plan | +| 404 | `NOT_FOUND` | Resource doesn't exist | +| 400 | `BAD_REQUEST` | Validation failure | +| 409 | `CONFLICT` | Another run is active for this project | +| 429 | `TOO_MANY_REQUESTS` | Too many requests; back off and retry later | +| 500 | `INTERNAL_SERVER_ERROR` | Server error | -Chat run-level errors (inside `data.error`): - -| Code | Meaning | -| ------------------ | -------------------------------- | -| `out_of_credits` | Organization has no credits left | -| `execution_failed` | AI execution error | - ---- - -## Prompting Sleek - -Sleek has its own AI that plans screen content, visual style, and layout. Pass the user's request to Sleek as-is — don't add details the user didn't ask for. If the user described specific screens and styling, include those. If they just said "build me a running app," send that and let Sleek decide the rest. Sleek produces richer designs when given room to plan, so avoid inventing screen content or layout details that the user didn't specify. - ---- +`401`, `403`, and `429` bodies may include `data.url`: a page where the user can fix the condition (create a key, upgrade the plan). When present, share that URL with the user instead of improvising one. -## Designing - -### 1. Create a project - -Create a project with `POST /api/v1/projects` if one doesn't exist yet. Ask the user for a name, or derive one from the request. - -Each project has its own theme, style, and design system. If the user wants multiple design variations, create a separate project for each variation. - -### 2. Send a chat message - -Describe what to build using `POST /api/v1/projects/:id/chat/messages`. You can use the user's words directly — Sleek's AI interprets natural language. You do not need to decompose the request into screens; send the full intent as a single message and let Sleek decide what screens to create. - -Chat messages are async by default — you get a `runId` and poll for completion with `GET /api/v1/projects/:id/chat/runs/:runId`. You can also use `?wait=true` for a blocking call (up to 300s; falls back to polling if it times out with `202`). - -**Polling**: start at 2s interval, back off to 5s after 10s, give up after 5 minutes. - -**Editing a specific screen**: use `target.screenId` to direct changes to the right screen (uses the screen ID from operations, not the component ID). - -**One run at a time**: only one active run is allowed per project. If you get `409 CONFLICT`, wait for the current run to complete before sending the next message. Messages to different projects can run in parallel — use async polling (not `?wait=true`) when running multiple projects concurrently. - -**Safe retries**: add an `idempotency-key` header (≤255 chars) to replay-safe re-sends. The server returns the existing run rather than creating a duplicate. - -### 3. Show the results - -After every chat run that produces `screen_created` or `screen_updated` operations, **always take screenshots and show them to the user** using `POST /api/v1/screenshots`. Never silently complete a chat run without delivering the visuals. - -- **New screens**: one screenshot per screen + one combined screenshot of all screens in the project. -- **Updated screens**: one screenshot per affected screen. - -Use `background: "transparent"` for all screenshots unless the user explicitly requests otherwise. - -Save screenshots in the project directory (not a temporary folder) so the user can easily view them. - ---- - -## Implementing Designs - -When the user wants to implement the designs in code (not just preview them), **always fetch the component HTML code** — do not rely on screenshots alone. - -Use `GET /api/v1/projects/:id/components/:componentId` to fetch each screen's code. The `componentId` comes from the chat run's `result.operations`. - -### Which version to use - -Each component carries a `versions[]` array and an `activeVersion: number`. **By default, use the entry where `versions[i].version === activeVersion`** — that's the code currently shown in Sleek. - -If the user's prompt pins specific versions, follow those instead (see [Pinned versions](#pinned-versions) below). - -### Pinned versions - -The user's prompt may include a pin block telling you to implement specific historical versions instead of the current ones, like this: - -``` -... at this exact state instead of the project's current version: -- component cmp_abc: version ver_001 -- component cmp_def: version ver_002 -- theme thm_ghi: version ver_003 -``` - -When you see a pin block, implement those exact versions instead of `activeVersion`. Components not named in the pin block continue to use their active version. Theme IDs surface only inside pin blocks — this skill exposes no separate endpoint to enumerate them. - -#### Fetching the right code - -For each pinned component, find the entry in `versions[]` where `versions[i].id` matches the given version id (e.g. `ver_001`) and use its `code`. Do **not** fall back to `activeVersion` for pinned components. - -#### Screenshots of pinned versions - -Pass `componentVersionOverrides` and `themeVersionOverrides` to `POST /api/v1/screenshots`: - -```json -{ - "componentIds": ["cmp_abc"], - "projectId": "proj_xyz", - "componentVersionOverrides": { "cmp_abc": "ver_001" }, - "themeVersionOverrides": { "thm_ghi": "ver_003" } -} -``` - -Keys are component / theme public ids; values are the corresponding `versions[i].id`. Entities missing from a map fall back to their active version. Include the override maps whenever the prompt specified pinned versions. - -### HTML prototypes - -The component `code` is a complete HTML document — save it directly to a `.html` file. No build step needed. - -### Native frameworks (React Native, SwiftUI, etc.) - -Use both the HTML code and the screenshots together: - -- **HTML code** is the implementation reference — it contains the exact structure, layout, styling, colors, spacing, content, image URLs, and icon names. -- **Screenshots** are the visual target — use them to verify your implementation matches the intended look. - -The HTML tells you *how* to build it; the screenshot tells you *what* it should look like. - -#### Icons - -Sleek uses [Iconify](https://iconify.design) icons in the format `prefix:name` (e.g., `solar:heart-bold`, `material-symbols:search-rounded`, `lucide:settings`). The most common sets are **Solar**, **Hugeicons**, **Material Symbols** and **MDI**. - -**Use the exact icons from the HTML code** — do not substitute with a different icon set. Matching icons is important for design fidelity. - -When implementing icons: - -1. **Check if the project already has an icon system** that supports the same sets Sleek uses (Solar, Hugeicons, Material Symbols, MDI). If so, use it. Note: `@expo/vector-icons` does **not** support these sets — do not use it as a substitute. -2. **Otherwise, fetch the SVGs from the Iconify API and embed them in the code:** - ``` - GET https://api.iconify.design/{prefix}/{name}.svg - ``` - Example: `https://api.iconify.design/solar/heart-bold.svg` - - Collect all icon names from the HTML, fetch their SVGs, and save them as static assets or string constants in the codebase. For **React Native / Expo**, render them with `react-native-svg`'s `SvgXml` component — this works in Expo Go with no additional native dependencies. - -#### Fonts - -The HTML includes Google Fonts via `` tags in the ``. Use the same fonts and weights when implementing in a native framework — extract the font family names and weights from the `` tags. +Chat run-level errors (inside `data.error`): -#### Navigation +| Code | Meaning | +| ------------------ | ------------------------------------- | +| `out_of_credits` | Organization has no credits left | +| `execution_failed` | AI execution error | +| `cancelled` | Run cancelled via the cancel endpoint | -The designs may include navigation elements like tab bars and headers. Update the project's navigation styling and structure to match the designs — don't just implement the screen content while leaving the default navigation untouched. +An `out_of_credits` error includes `error.url`, the page where the user can top up credits. Relay it to the user; don't retry the run until they have. --- @@ -498,23 +550,15 @@ GET /api/v1/projects?limit=10&offset=20 --- -## Tips - -### Saving component HTML to files - -Component code can be large. When saving it to `.html` files, avoid writing the content through your text output — this is slow and wastes tokens. Instead, use shell commands to fetch the API response and write it directly to disk (e.g., pipe the response body into a file). This applies to both single and multiple components. - ---- - ## Common Mistakes -| Mistake | Fix | -| --------------------------------------------------- | ------------------------------------------------------------------------------- | -| Sending to `/api/v1` without `Authorization` header | Add `Authorization: Bearer $SLEEK_API_KEY` to every request | -| Using wrong scope | Check key's scopes match the endpoint (e.g. `chats:write` for sending messages) | -| Sending next message before run completes | Poll until `completed`/`failed` before next send | -| Using `wait=true` on long generations | It blocks 300s max; have a fallback to polling for `202` response | -| HTTP URLs in `imageUrls` | Only HTTPS URLs are accepted | -| Assuming `result` is present on `202` | `result` is absent until status is `completed` | -| Using `screenId` as `componentIds` in screenshots | `screenId` and `componentId` are different; always use `componentId` from operations for screenshots | -| Confusing `versions[i].version` (number) with `versions[i].id` (string) | When resolving pinned versions, match by `id` (e.g. `ver_001`); `version` is the numeric index | +| Mistake | Fix | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Omitting `source` on chat messages | Always send `source` so the run is attributed in the Sleek editor | +| Using `wait=true` on long generations | It blocks 300s max; have a fallback to polling for `202` response | +| Assuming `result` is present on `202` | `result` is absent until status is `completed` | +| Piping a JSON response through `echo` to parse it | zsh expands the `\n` in `assistantText` and breaks the JSON; parse from a file instead | +| Treating an unreadable run status as "not done yet" | The loop then spins to its cap long after the run finished; stop and report instead | +| Calling a screen incomplete based on a viewport screenshot | The content is usually below the fold; re-shoot with `fullHeight: true` or check the component HTML before reporting anything missing | +| Using `screenId` as `componentIds` in screenshots | `screenId` and `componentId` are different: every screen has both (run operations and the components list return the pair). the chat message `target.screenId` takes `screenId`; screenshots and component reads take `componentId` | +| Confusing `versions[i].version` (number) with `versions[i].id` (string) | When resolving pinned versions, match by `id` (e.g. `ver_001`); `version` is the numeric index | diff --git a/plugins/frontend-product-design/skills/ui-animation/SKILL.md b/plugins/frontend-product-design/skills/ui-animation/SKILL.md index dfb5449..1c6e56f 100644 --- a/plugins/frontend-product-design/skills/ui-animation/SKILL.md +++ b/plugins/frontend-product-design/skills/ui-animation/SKILL.md @@ -1,47 +1,51 @@ --- name: ui-animation -description: >- - Designs, implements, reviews, debugs, and reverse-engineers UI motion: CSS - transitions, keyframes, springs, gestures, drag, easing, timing, - framer-motion, and animation curves from screen recordings. Use when asked to - "add animations", "make this feel smooth", "review my animations", "add a - swipe gesture", "match this easing", "reverse engineer this animation", or - "extract the animation curve". For visual direction use ui-design; for - page-level UI audit use ui-audit. +description: Builds, reviews, and measures UI motion, including springs, gestures, scroll effects, curve fitting from recordings, and sparse interface sound. Use when asked to "add animation", "match this easing", "reverse engineer this motion", "add a click sound", or find animation opportunities. For action semantics use product-design; for visual layout use ui-design. --- # UI Animation -- **IS:** designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, framer-motion), and measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec. -- **IS NOT:** choosing overall visual direction, palettes, or typography (use `ui-design`), auditing a whole page's UI quality (use `ui-audit`), or named text-effect specs (use the external `animate-text` skill where installed). +- **IS:** designing, implementing, reviewing, debugging UI motion (springs, gestures, drag, easing, CSS transitions, keyframes, Motion), sweeping an interface for the moments that would genuinely benefit from motion, measuring motion from a recording (extract frames, track, fit curves) to emit code plus a handoff spec, naming a described motion effect (reverse-lookup vocabulary), and gating sparse interface sound. +- **IS NOT:** choosing overall visual direction, palettes, or typography (use `ui-design` Direction mode), auditing a whole page's UI quality (use `ui-design` Audit mode), or named text-effect specs (use the external `animate-text` skill where installed). + +## Routing boundary + +`product-design` owns action semantics, scope, reversibility, and contested state choices. `ui-design` builds and styles those states. `ui-animation` owns timing, gestures, and measured motion. A routine missing loading or error state stays with the UI build; a gesture replacing a control needs a product decision and an accessible alternative before its physics. -Canonical home for reverse-engineering motion from a recording: route "reverse engineer this animation" and "match this easing" here, not to a separate skill. If the input is a screen recording or video, you are MEASURING motion: follow the Reverse-engineer workflow. Otherwise (designing, implementing, reviewing) use the rules and Workflow below. ## Reference files | File | Read when | | --- | --- | -| [references/decision-framework.md](references/decision-framework.md) | Default: deciding whether/why to animate, picking easing character | -| [references/spring-animations.md](references/spring-animations.md) | Spring physics, framer-motion useSpring, configuring spring params | +| [references/discovery-workflow.md](references/discovery-workflow.md) | Finding worthwhile opportunities for motion in an existing interface | +| [references/decision-framework.md](references/decision-framework.md) | Default: deciding whether/why to animate, picking easing character; also the seam list for a Discovery sweep | +| [references/spring-animations.md](references/spring-animations.md) | Spring physics, Motion `useSpring`, configuring spring params, Apple damping/response values, asymmetric open/close character, interruption mechanics | | [references/component-patterns.md](references/component-patterns.md) | Buttons, popovers, tooltips, drawers, modals, toasts with animation | | [references/clip-path-techniques.md](references/clip-path-techniques.md) | clip-path for reveals, tabs, hold-to-delete, comparison sliders | -| [references/gesture-drag.md](references/gesture-drag.md) | Drag, swipe-to-dismiss, momentum, pointer capture | +| [references/gesture-drag.md](references/gesture-drag.md) | Drag, swipe-to-dismiss, momentum, pointer capture, velocity handoff, momentum projection, rotary/knob drag, detents, carousel `touch-action` | +| [references/scroll-animations.md](references/scroll-animations.md) | Scroll-triggered reveals, scrubbed/scroll-driven animation (`animation-timeline`, `useScroll`), parallax, sticky scrollytelling, and when a scroll animation shouldn't exist | | [references/performance-deep-dive.md](references/performance-deep-dive.md) | Jank, CSS vs JS, WAAPI, CSS variables trap, Framer Motion caveats | +| [references/debugging-symptoms.md](references/debugging-symptoms.md) | An animation feels off and the cause isn't named: symptom-indexed tables for sluggish, robotic, cheap, jumpy, and misfiring motion | +| [references/svg-animation.md](references/svg-animation.md) | Animating vector art: line drawing (`stroke-dashoffset`), SVG transform-origin traps, path morphing, shakes, ambient life | | [references/review-format.md](references/review-format.md) | Reviewing animation code: ten standards (each with flag-on-sight triggers), Before/After/Why table, Block/Approve verdict | -| [references/contextual-animations.md](references/contextual-animations.md) | Contextual icon swaps, word-level stagger entrances, fixed-offset exits | -| [references/transition-recipes.md](references/transition-recipes.md) | Installing a CSS transition: card resize, badge, dropdown, modal, panel, page slide, icon swap, number pop-in, text swap, success, avatar hover, error shake | +| [references/contextual-animations.md](references/contextual-animations.md) | Contextual icon swaps, word-level stagger entrances, peripheral de-emphasis, fixed-offset exits | +| [references/transition-recipes.md](references/transition-recipes.md) | Installing a CSS transition: container morph, card resize, badge, dropdown, modal, panel, page slide, icon swap, number pop-in, odometer roll, text swap, success, avatar hover, error shake | | [references/measurement-guide.md](references/measurement-guide.md) | Reverse-engineer: what to measure, eye vs script, reading `metrics.json`, choosing an ROI | | [references/curve-fitting.md](references/curve-fitting.md) | Reverse-engineer: reading `fit_curves.py` output, spring vs bezier, judging fit error, asymmetric open/close | | [references/code-output.md](references/code-output.md) | Reverse-engineer: emitting code for CSS, Motion/Framer Motion, SwiftUI, React Native, UIKit | | [references/choreography.md](references/choreography.md) | Reverse-engineer: multi-element/multi-phase motion: staggers, blur-before-move, per-edge settling | +| [references/live-tuning.md](references/live-tuning.md) | Dialling a curve in live when there is no reference to fit against: the DevTools bezier editor, retiming in the Animations panel, when a control-panel library earns a dependency | +| [references/vocabulary.md](references/vocabulary.md) | Naming a motion effect the user describes vaguely ("what's it called when...") | +| [references/interface-sfx.md](references/interface-sfx.md) | Click sounds, interface audio, UI SFX, haptic-plus-sound, or "why is the web afraid of sound" | ## Core rules - Animate for feedback, orientation, continuity, or deliberate delight. If it's just "it looks cool" and the user sees it often, don't. -- Never animate keyboard-initiated actions (shortcuts, arrow navigation, tab/focus); they repeat constantly and animation makes them feel slow. +- Keep keyboard focus and repeated navigation immediate. A state transition may animate if focus and task completion do not wait for it. - Prefer CSS transitions for interruptible UI: keyframes restart from zero on interruption, transitions retarget. Use keyframes only for predetermined sequences. - Implementation priority: CSS transitions > WAAPI > CSS keyframes > JS (`requestAnimationFrame`); under load CSS stays smooth while JS drops frames. - Asymmetric timing: occasional interactions can enter slightly slower, exit fast. High-frequency ephemeral UI (hover highlights, popovers, panel toggles) inverts this: enter instantly (0ms), exit with a brief fade (100-150ms) so the action feels immediate. +- Tappable controls press on `:active` at 0ms and set `touch-action: manipulation`. - Use `@starting-style` for DOM entry; fall back to a `data-mounted` attribute where unsupported. - A small `filter: blur(2px)` hides rough crossfades between swapped content. @@ -49,7 +53,8 @@ Canonical home for reverse-engineering motion from a recording: route "reverse e - **Continuity over teleportation.** Elements visible in both states transition in place; expand from where elements sit rather than fading in a new instance. Never duplicate a persistent element or hard-cut between views that share components; hard cuts lose spatial context. - **Directional motion matches position.** Tab and carousel transitions animate in the direction matching spatial layout (left-to-right forward, right-to-left back). -- **Emerge from the trigger.** Overlays, trays, and panels animate outward from the element that opened them; generic centre-screen entrances break spatial orientation. +- **Emerge from the trigger.** Overlays, trays, and panels animate outward from the element that opened them; generic centre-screen entrances break spatial orientation. Better still where the shapes allow: let the trigger *become* the surface (see the container-morph recipe). +- **Confirm in place, not in a corner.** An action's result belongs on the control that caused it: the button becomes "Copied", holds, and reverts. A toast in the far corner makes the user's eye leave the thing they just touched to find out whether it worked. Reserve corner toasts for results with no on-screen origin (a background job finishing, an incoming message). - **Animate paired states together.** If open animates, close animates. If hover has motion, focus and pressed states get equivalent feedback. Do not polish only one half of a repeated interaction. - **Delight scales inversely with frequency.** Rarer interactions get more personality; high-frequency actions must be invisible. - **Motion enhances perceived speed.** Smooth transitions feel faster than hard cuts, even at identical load times. @@ -58,12 +63,12 @@ Canonical home for reverse-engineering motion from a recording: route "reverse e - Movement: `transform` and `opacity` only; they skip layout and paint. - State feedback: `color`, `background-color`, and `opacity` are acceptable. -- Never animate layout properties (`width`, `height`, `top`, `left`); they trigger layout recalc every frame. (Exception: a deliberate container resize tween, see the card-resize recipe.) +- Never animate layout properties (`width`, `height`, `top`, `left`); they trigger layout recalc every frame. (Exception: a deliberate container tween, see the card-resize and container-morph recipes.) - Never use `transition: all`; it animates unintended properties and silently adopts future ones. List them explicitly. - Avoid `filter` animation for core interactions; if unavoidable keep blur ≤ 20px (heavy blur is expensive, especially in Safari). -- SVG: apply transforms on a `` wrapper with `transform-box: fill-box; transform-origin: center`; without it they rotate/scale around the canvas origin. +- SVG: apply transforms on a `` wrapper with `transform-box: fill-box; transform-origin: center`; without it they rotate/scale around the canvas origin. Line drawing, path morphing, and the Motion SVG origin override live in [references/svg-animation.md](references/svg-animation.md). - `transform: scale()` also scales children (icons, text, borders scale proportionally), unlike `width`/`height`: a feature for press feedback, but account for it when an inner element must stay fixed-size. -- Disable transitions during theme switches (`[data-theme-switching] * { transition: none !important }`), or every themed property animates at once. +- Disable transitions during theme switches (`[data-theme-switching] * { transition: none !important }`), or every themed property animates at once. Force a reflow (`void document.body.offsetHeight`) after the flip and remove the override on the next frame, or use `next-themes` `disableTransitionOnChange`. ## Easing defaults @@ -75,7 +80,8 @@ Canonical home for reverse-engineering motion from a recording: route "reverse e | Modals, drawers | 200-350ms | `cubic-bezier(0.22, 1, 0.36, 1)` | | Move/slide on screen | 200-300ms | `cubic-bezier(0.25, 1, 0.5, 1)` | | Page transitions | 250-400ms | enter or move curve | -| Simple hover (colour/opacity) | 200ms | `ease` | +| Hover (colour/opacity) | 200ms | `ease` | +| Hover (transform/scale) | 100-150ms | enter curve | | Illustrative/marketing | Up to 1000ms | Spring or custom | Keep routine UI under 300ms; scale duration with distance (a full-screen slide can exceed 300ms, a 6px tooltip shift stays under 150ms). @@ -84,7 +90,10 @@ Keep routine UI under 300ms; scale duration with distance (a full-screen slide c - **Enter:** `cubic-bezier(0.22, 1, 0.36, 1)` for entrances and transform-based hover - **Move:** `cubic-bezier(0.25, 1, 0.5, 1)` for slides, drawers, panels -- **Drawer (iOS-like):** `cubic-bezier(0.32, 0.72, 0, 1)` +- **Drawer (iOS-like):** `cubic-bezier(0.32, 0.72, 0, 1)` (extremely steep start; the reason its 500ms doesn't read as slow) +- **Expo out:** `cubic-bezier(0.19, 1, 0.22, 1)` for dramatic reveals, card hovers, text reveals +- **Press:** `cubic-bezier(0.25, 0.46, 0.45, 0.94)` for button press feedback +- **On-screen move:** `cubic-bezier(0.645, 0.045, 0.355, 1)` for back-and-forth movement that stays on screen Avoid `ease-in` for UI: it starts slow, so the element lags the user's action and feels sluggish. Prefer custom curves from [easing.dev](https://easing.dev/) over built-in `ease`/`ease-out`, whose gentle acceleration reads soft, not decisive. @@ -95,6 +104,7 @@ Match the UI element first, then pick the recipe from [references/transition-rec | UI pattern | Recipe | |---|---| | Trigger + floating dot/count | Notification badge | +| Trigger grows into the surface it opens | Container morph | | Trigger + anchored surface | Menu dropdown | | Centred surface on top of page | Modal dialog | | Panel sliding into existing container | Panel reveal | @@ -102,7 +112,8 @@ Match the UI element first, then pick the recipe from [references/transition-rec | Element dimension changes | Card resize | | Text updating in place | Text state swap | | Two icons in same slot | Icon swap | -| Number updating | Number pop-in | +| Number arriving on its own | Number pop-in | +| Number the user is driving | Odometer digit roll | | Confirmation / success moment | Success celebration | | Hovering item in horizontal stack | Avatar group hover | | Form validation error | Error state shake | @@ -111,24 +122,23 @@ Prefer lower-overhead transitions (CSS-only) unless the design requires JS orche ## Spatial and sequencing -- Set `transform-origin` at the trigger point for popovers; keep `center` for modals (app-level state, not an anchored trigger). -- For dialogs/menus, start around `scale(0.85-0.9)`. Never `scale(0)`: nothing appears from nothing. -- Stagger reveals at 30-50ms per item; total stagger under 300ms. Vary timing by visual importance, most important element leads; uniform stagger removes hierarchy and feels mechanical. +- Popover `transform-origin` at the trigger (modals stay `center`), dialog/menu entrances from `scale(0.9-0.96)` not `scale(0)` (small popovers at the low end, full dialogs at the high end: a large surface already travels far in absolute pixels), and 30-50ms staggers (total under 300ms, most important element leading). Full rules and code in [references/component-patterns.md](references/component-patterns.md) and [references/contextual-animations.md](references/contextual-animations.md). - **Paired elements rule:** elements that animate together (modal + overlay, tooltip + arrow, FAB + label) must share easing and duration. Mismatched timing is the usual cause of "something feels off". ## Accessibility -- Every animation needs a `prefers-reduced-motion: reduce` path: disable transform/keyframe motion, keep instant state changes or opacity-only fades. All recipes include the guard. -- Gate hover animations behind `@media (hover: hover) and (pointer: fine)`, or touch devices replay hover on tap. Tailwind v4 `hover:` utilities apply this automatically; skip the manual query there. +- Gate hover (motion and paint) behind `@media (hover: hover) and (pointer: fine)`, or touch devices replay hover on tap. Inspect the generated CSS before adding a gate; Tailwind v4 already wraps `hover:` in `@media (hover: hover)`. - During direct manipulation, keep the element locked to the pointer with no easing; add easing only after release. +- Optional interface SFX: sparse, gesture-unlocked, additive confirmation only. See [references/interface-sfx.md](references/interface-sfx.md). ## Performance - Pause looping animations off-screen with `IntersectionObserver`; they burn GPU even when invisible. - Toggle `will-change` only during heavy motion and only for `transform`/`opacity`; remove it after. Each promotion costs compositor memory; permanent promotion across many elements is worse than none. - Do not animate drag via CSS variables on a container; every update recalculates styles for all children. Set `transform` directly on the moving element. -- Motion `x`/`y` values are the default for axis movement and drag (they bypass React re-renders). Use a full `transform` string only when one owner must combine multiple transform functions or interop with non-Motion code. -- See [references/performance-deep-dive.md](references/performance-deep-dive.md) for WAAPI, compositing layers, and the CSS vs JS comparison table. +- Motion `x`/`y` values are the default for axis movement and drag (they bypass React re-renders). Use a full `transform` string when one owner must combine multiple transform functions, interop with non-Motion code, or survive a busy main thread: the shorthands run on `requestAnimationFrame` and drop frames when motion coincides with navigation, data loading, or hydration; CSS/WAAPI stay smooth there. +- Motion that janks only sometimes (on open, during navigation, while data lands) is usually a long task sharing the tick, not a costly animation. Don't start an animation and expensive work in the same tick: start the motion, let a frame land, then do the work, or defer it to `transitionend`. +- See [references/performance-deep-dive.md](references/performance-deep-dive.md) for WAAPI, compositing layers, long tasks during animation, and the CSS vs JS comparison table. ## Anti-patterns @@ -136,10 +146,11 @@ High-signal failures not covered above: - Animating on mount without a user trigger: unexpected motion disorients; the user did nothing to cause it. - Hard stops on drag boundaries feel broken; apply friction/damping so movement diminishes past it (see gesture-drag reference). -- Mixing Motion `x`/`y` with a handwritten `transform` on one element: both write `transform`, so one clobbers the other. Pick one transform owner. - Animating both a container and staggering its children: pick one entrance per container. If the panel slides in, its content should already be visible on arrival. -- Keyframes on rapidly-triggered elements (toasts, list items): interruption restarts from zero; use CSS transitions, which retarget. - Tooltip animation after the first is open: subsequent tooltips in the group open instantly, or the toolbar feels laggy. +- Scroll-revealing product UI, above-the-fold content, or every section of a page: scroll reveals belong to a few chosen moments on marketing surfaces, run once, and never re-animate on scroll-up (see [references/scroll-animations.md](references/scroll-animations.md)). +- Easing or duration on scrubbed (scroll-driven) motion: scroll position is the clock, so any curve or duration makes it lag the scrollbar. `linear` and no duration is correct there, and only there. +- Installing `framer-motion` for new work: the package is now `motion` and React imports come from `motion/react`. The old package still resolves, so a mixed codebase compiles while shipping two copies of the library. ## Workflow @@ -155,7 +166,7 @@ Animation progress: ``` 1. Answer the four questions in [references/decision-framework.md](references/decision-framework.md): animate? purpose? easing? speed? -2. Pick duration from the easing defaults table above. +2. Pick duration from the easing defaults table above. If the value is contested or the component is hard to reach, dial it live in the DevTools bezier editor rather than guessing, then bake the result into source ([references/live-tuning.md](references/live-tuning.md)). 3. Choose implementation: CSS transition > WAAPI > spring > keyframe > JS. 4. Load the reference for your component or technique. 5. When reviewing, apply the strict posture in [references/review-format.md](references/review-format.md): measure against the ten standards, output the Before/After/Why table, then a tiered verdict ending in a Block/Approve decision. @@ -167,14 +178,20 @@ Produce evidence for each check (DevTools observations, not "looks fine"): - Grep the diff for layout property transitions (`width`, `height`, `top`, `left`) and `transition: all`. - Retoggle components rapidly; confirm transitions retarget instead of restarting from zero. - Slow to 10% in the DevTools Animations panel to catch timing and `transform-origin` issues invisible at full speed. -- Emulate `prefers-reduced-motion: reduce` (DevTools Rendering panel) and confirm every animation has a reduced path. - Confirm `will-change` is toggled around animations, not permanently set, and looping animations pause off-screen. - Test touch interactions on real devices; simulators under-report gesture and hover-on-tap issues. +- Honor `prefers-reduced-motion`: replace spatial travel with immediate state changes or restrained fades. Pause looping decorations with `animation-play-state: paused` (do not yank them with `display: none`). Keep explicit user-triggered feedback. Exercise the same task in that mode. + +## Discovery workflow + +For "where should this animate", load `references/discovery-workflow.md` and `references/decision-framework.md`. Report opportunities supported by purpose and usage frequency. Implement a suggestion only when implementation is in scope. ## Reverse-engineer workflow Use this branch to measure an existing animation from a screen recording, then emit code and a handoff spec that reproduce it. The scripts under `scripts/` are the canonical, deterministic path; run them rather than reconstructing their logic. +Resolve every `scripts/` command below relative to the installed skill directory, not the application working directory. + **Dependencies:** `ffmpeg` for frame extraction (`brew install ffmpeg`); Python with `pip install opencv-python numpy scipy` for tracking and curve fitting. Degrades gracefully: with only ffmpeg you can extract frames and reason visually; tracking and fitting need the Python packages. ```text @@ -201,10 +218,19 @@ Reverse-engineer progress: - `fit_curves.py` defaults to `--fps 30`: extract at 60 but fit at the default and every `duration_ms` doubles while fitted stiffness drops to a quarter. Always pass the extraction fps to the fit. - Sampling above the source rate duplicates frames: a 24 fps GIF extracted at 60 inflates fit error with plateaued runs in `metrics.json`. Probe and match the source rate. - Screen recordings drop frames and iOS/QuickTime captures are variable-frame-rate; consecutive identical rows are duplicated frames, not a pause. Re-record at a steadier rate if plateaus dominate. -- Open and close are never mirror images; measure each direction as its own clip. Treat a fit `error` above 0.08 as suspect. +- Measure open and close as separate clips and report two curves; never fit one and reuse it reversed (see `references/choreography.md`). Treat a fit `error` above 0.08 as suspect. + +Maintenance only: when changing Discovery routing or the gate, run the scenarios in `evaluations/` as a regression rubric. They never load during a user task. + +## Sources + +Interface SFX gating taken from Craft (gustavo-fior) and Raphael Salaja's web-sound writing. Novelty 90/10 split, one-shot intro gating, and `animation-play-state` on loops taken from Rauno Freiberg. Rejected vendoring emilkowalski/skills and gustavo-fior/craft: trigger collision with this skill. Clip-path and proportional scale already lived here. ## Related skills -- `ui-design`: visual direction, palettes, typography; settle the visual system before tuning motion. -- `ui-audit`: page/feature-level UI quality audit; its motion findings route back here for fixes. +- `product-design`: which states exist, what an action affects, and whether it is reversible. Route here first when a gesture replaces a control, since swipe-to-delete and hold-to-confirm change what the user can do before they change how it moves. +- `ui-design` Direction mode: visual direction, palettes, typography; settle the visual system before tuning motion. +- `ui-design` Audit mode: page/feature-level UI quality audit. Motion craft and fixes belong here. - Optional external `animate-text` skill where installed: curated named text effects (typewriter, line reveal, stagger builds) with exact JSON specs. + +Maintenance only: `evals/evals.json` contains regression scenarios for changes to this skill; it does not load during a user task. diff --git a/plugins/frontend-product-design/skills/ui-animation/evals/evals.json b/plugins/frontend-product-design/skills/ui-animation/evals/evals.json new file mode 100644 index 0000000..9ebeff3 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/evals/evals.json @@ -0,0 +1,51 @@ +{ + "skill_name": "ui-animation", + "evals": [ + { + "id": 1, + "prompt": "Review a Tailwind v4 modal animation. Generated hover CSS already includes @media (hover: hover). Keyboard focus moves immediately; a 120ms opacity transition continues afterward. Reduced motion uses an immediate state change.", + "expected_output": "Avoid false positives for already-gated hover and nonblocking keyboard feedback.", + "files": [], + "assertions": [ + "Inspects generated hover CSS", + "Does not ban the transition solely because a keyboard opened it", + "Checks reduced-motion task completion" + ] + }, + { + "id": 2, + "prompt": "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps.", + "expected_output": "Resolve installed script paths and carry the actual frame rate through fitting.", + "files": [], + "assertions": [ + "Passes 60fps to fitting", + "Does not run scripts relative to the application by accident", + "Reports fitted error rather than claiming an exact visual match" + ] + }, + { + "id": 3, + "prompt": "Add a click sound to every button on the dashboard, including list-row hovers.", + "expected_output": "Refuse high-frequency SFX; if any sound ships, it is rare, gesture-unlocked, and additive to visual feedback.", + "files": [], + "assertions": [ + "Loads interface-sfx.md", + "Keeps typing, hover, and list navigation silent", + "Does not create AudioContext on page load" + ] + } + ], + "routing": { + "should_trigger": [ + "Review a Tailwind v4 modal animation. Generated hover CSS already includes @media (hover: hover). Keyboard focus moves immediately; a 120ms opacity transition continues afterward. Reduced motion uses an immediate state change.", + "Match a recording extracted at 60fps using the bundled fitting scripts. The fitting default is 30fps.", + "Add a click sound to every button on the dashboard, including list-row hovers." + ], + "near_miss": [ + { + "prompt": "Should swipe-to-delete be undoable?", + "expected": "product-design" + } + ] + } +} diff --git a/plugins/frontend-product-design/skills/ui-animation/evaluations/discovery-mode.json b/plugins/frontend-product-design/skills/ui-animation/evaluations/discovery-mode.json new file mode 100644 index 0000000..158fa30 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/evaluations/discovery-mode.json @@ -0,0 +1,36 @@ +[ + { + "skills": [ + "ui-animation" + ], + "query": "Where should this animate? Nothing moves right now and it feels dead.", + "files": [ + "fixtures/settings-panel.tsx" + ], + "expected_behavior": [ + "Selects the Discovery workflow, not the main build workflow: reports opportunities with file:line evidence instead of editing the fixture", + "Flags the Advanced toggle as a feedback gap: onClick with no :active or transition", + "Flags the {expanded && ...} conditional as teleporting state, and proposes an opacity plus transform entrance rather than an animated height", + "Flags the saved confirmation as a rare high-emotion moment where the delight budget applies", + "REJECTS CommandMenu explicitly: keyboard-initiated and opened 100+ times a day, so it never animates", + "REJECTS the requests table: functional data the user is reading, where motion hinders", + "Includes the rejected-candidates section, each naming the gate question that killed it", + "Names a purpose (feedback, orientation, continuity, delight) for every surviving suggestion, and gives exact property, duration, and curve values drawn from the easing defaults table", + "Caps the list at seven suggestions and does not implement any of them" + ] + }, + { + "skills": [ + "ui-animation" + ], + "query": "The Advanced panel should slide open instead of popping.", + "files": [ + "fixtures/settings-panel.tsx" + ], + "expected_behavior": [ + "Selects the main workflow, not Discovery: the user named the interaction, so there is nothing to sweep for", + "Implements the transition rather than returning a report with a rejected-candidates section", + "Animates transform and opacity, never height" + ] + } +] diff --git a/plugins/frontend-product-design/skills/ui-animation/evaluations/fixtures/settings-panel.tsx b/plugins/frontend-product-design/skills/ui-animation/evaluations/fixtures/settings-panel.tsx new file mode 100644 index 0000000..5fe656a --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/evaluations/fixtures/settings-panel.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; + +// Opened with Cmd+K, used constantly throughout the day. +export function CommandMenu({ open }: { open: boolean }) { + if (!open) return null; + return ( +
+ +
+ ); +} + +export function SettingsPanel() { + const [expanded, setExpanded] = useState(false); + const [saved, setSaved] = useState(false); + + return ( +
+ {/* No :active or transition on a pressable control */} + + + {/* Teleporting state: appears and vanishes with no bridge */} + {expanded && ( +
+ + +
+ )} + + {/* Rare, high-emotion moment rendered flat */} + {saved &&

Everything is up to date.

} + + + + {/* Functional data the user is reading */} + + + + + + + +
Requests18,204
+
+ ); +} diff --git a/plugins/frontend-product-design/skills/ui-animation/references/component-patterns.md b/plugins/frontend-product-design/skills/ui-animation/references/component-patterns.md index 8276972..fc643cc 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/component-patterns.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/component-patterns.md @@ -11,18 +11,21 @@ - [Lists and stagger](#lists-and-stagger) - [Hover effects](#hover-effects) - [Step form navigation](#step-form-navigation) +- [Layout morphs and auto height (Motion)](#layout-morphs-and-auto-height-motion) - [3D transforms](#3d-transforms) ## Buttons -Add `transform: scale(0.97)` on `:active` for instant press feedback. +Add `transform: scale(0.97)` on `:active` for instant press feedback. Press is 0ms; release may ease. `touch-action: manipulation` on the control drops the double-tap-zoom delay. Do not put it on `html`, a map, or a pinch-zoom lightbox. ```css .button { + touch-action: manipulation; transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1); } .button:active { transform: scale(0.97); + transition-duration: 0s; } ``` @@ -44,9 +47,9 @@ Blur under 20px; heavy blur is expensive, especially in Safari. Scale in from the trigger point, not from center; the default `transform-origin: center` is wrong for popovers. ```css -/* Radix UI */ +/* Base UI. Radix exposes the same thing as --radix-popover-content-transform-origin */ .popover { - transform-origin: var(--radix-popover-content-transform-origin); + transform-origin: var(--transform-origin); } /* Data attribute fallback */ @@ -56,11 +59,11 @@ Scale in from the trigger point, not from center; the default `transform-origin: .popover[data-side="right"] { transform-origin: center left; } ``` -Start at `scale(0.88)`, never `scale(0)`: nothing appears from nothing. +Start at `scale(0.92)`, never `scale(0)`: nothing appears from nothing. ```css .menu { - transform: scale(0.88); + transform: scale(0.92); opacity: 0; transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1), opacity 200ms cubic-bezier(0.22, 1, 0.36, 1); @@ -133,7 +136,7 @@ Use `@starting-style` for entry animations without JavaScript: } ``` -Fall back to the `data-mounted` attribute pattern when `@starting-style` browser support is insufficient. +`@starting-style` has been Baseline since August 2024, so the `data-mounted` attribute pattern is a fallback for browsers older than that, not the default. Ship the CSS above and add the attribute path only when the support matrix actually includes those browsers. ## Toasts @@ -219,7 +222,7 @@ When removing items, use `AnimatePresence mode="popLayout"` so the exiting eleme ## Hover effects -Gate hover animations behind a media query to avoid false positives on touch. +Gate hover animations behind a media query to avoid false positives on touch. Tailwind `hover:` is not gated unless the project set `hoverOnlyWhenSupported` or a custom variant. ```css @media (hover: hover) and (pointer: fine) { @@ -239,11 +242,11 @@ Fix hover flicker: apply hover on the parent, animate the child. `translateY` on transform: translateY(-20%); } .box-inner { - transition: transform 200ms ease; + transition: transform 150ms ease; } ``` -For scale-based hover, use `scale(1.01)` to `scale(1.02)`; `scale(1.05)` is visibly inflated. Hover transitions should be 100-150ms; 300ms feels laggy because the user's eye is already on the element. +For scale-based hover, use `scale(1.01)` to `scale(1.02)`; `scale(1.05)` is visibly inflated. Transform hovers run 100-150ms, faster than the 200ms colour/opacity hover above: the user's eye is already on the element, so movement past 150ms reads as lag. ```css @media (hover: hover) and (pointer: fine) { @@ -286,6 +289,38 @@ const variants = { ``` +## Layout morphs and auto height (Motion) + +The `layout` and `layoutId` props cover what CSS can't animate, and each carries a gotcha that presents as a visual bug: + +- **`layout`** animates any layout change, including CSS-unanimatable properties like `flex-direction`. Change the element's *actual styles* (className or inline), not the `animate` prop; Motion measures before and after and interpolates. Add `layout` to neighbouring elements too, or they jump while the animating one glides. +- **`layoutId`** morphs one element into another across mount/unmount: tab indicators, card-to-detail expansions, a button becoming a popover. You can't steer *how* a shared-layout morph moves; to add motion on top, animate the **parent** and let the children follow. +- **Border radius distorts during layout animation** because the morph is transform-based scaling. Motion corrects the radius only when it's an inline pixel value: always `style={{ borderRadius: 12 }}`, never a className or `rem` radius, on anything with `layout`/`layoutId`. +- **No `key`, no exit.** An `AnimatePresence` child without a `key` never unmounts, so the exit animation silently never fires (and `AnimatePresence` must wrap the conditional, not sit inside it). When an exit does nothing, check the key first. +- **Exiting elements have stale props.** An `AnimatePresence` child that is animating out has already left the tree, so it can't see new state. Pass `custom` to both `AnimatePresence` and the `motion` element (as in the step-form pattern above), or direction-aware exits always leave the same way. + +**Auto height:** Motion can't animate `auto` to `auto`. Measure the content and animate to the pixel value: + +```jsx +import useMeasure from "react-use-measure"; + +const [ref, bounds] = useMeasure(); + + +
{content}
{/* padding lives here */} +
+``` + +The `ref` and the animated height must be on *different* elements; on the same one, the element freezes at its animated height and stops reacting to content changes. Put the padding on the inner element so the measurement includes it, and fall back to `null` (meaning `auto`) while `bounds.height` is `0` on first render to avoid a layout shift. `useMeasure` wraps `ResizeObserver`; hand-rolling it is a few lines if the dependency isn't wanted. + +When the same surface swaps content at different sizes, make the crossfade duration proportional to how much the height changed, so small changes don't over-animate: + +```js +const MIN = 0.15, MAX = 0.27; +const delta = Math.abs(bounds.height - previousHeightRef.current); +const duration = Math.min(Math.max(delta / 500, MIN), MAX); +``` + ## 3D transforms For depth effects (card flips, coin spins, orbits), use `rotateX()`/`rotateY()` with `transform-style: preserve-3d` on the wrapper: stays on the GPU, needs no JavaScript. Reserve it for illustrative or delight moments, not high-frequency UI. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/contextual-animations.md b/plugins/frontend-product-design/skills/ui-animation/references/contextual-animations.md index fa492b5..afe059f 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/contextual-animations.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/contextual-animations.md @@ -5,6 +5,7 @@ Patterns for icon swaps, word-level stagger entrances, and subtle exits. ## Contents - [Contextual icon swaps](#contextual-icon-swaps) - [Word-level stagger entrances](#word-level-stagger-entrances) +- [Peripheral de-emphasis](#peripheral-de-emphasis) - [Subtle exit animations](#subtle-exit-animations) --- @@ -137,6 +138,31 @@ These differ from the general-purpose 30-50ms item stagger in `component-pattern --- +## Peripheral de-emphasis + +To focus attention on one item in a set, animate the *siblings*, not the item. Blurring and fading the neighbours pushes them behind the focal plane, which reads as depth. A scrim over the whole page reads as a mode change, which is a much heavier claim than "this one is active". + +Use it for hover previews in a dense grid of chips or thumbnails, and for a picker whose options stay visible behind it. Do not use it as a substitute for a modal backdrop: a dialog that traps focus needs the scrim, because the dim is communicating that the rest of the page is inert, not merely secondary. + +```css +.chip { + transition: opacity 200ms ease, filter 200ms ease, scale 150ms cubic-bezier(0.22, 1, 0.36, 1); +} + +/* Blur the siblings of whatever is hovered, not the hovered chip. */ +@media (hover: hover) and (pointer: fine) { + .chip-grid:has(.chip:hover) .chip:not(:hover) { + opacity: 0.5; + filter: blur(2px); + } + .chip:hover { scale: 1.04; } +} +``` + +Keep the blur at 2-3px. Past about 4px the neighbours stop reading as content and the grid looks broken rather than defocused. Fade to roughly 0.5 opacity, never to invisible: the point is that the set is still there. + +--- + ## Subtle exit animations Exits should be directional (signal where content goes) but quieter than enters. Use a small fixed offset, not the computed element height. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/curve-fitting.md b/plugins/frontend-product-design/skills/ui-animation/references/curve-fitting.md index 0ee38ba..f0fb5f1 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/curve-fitting.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/curve-fitting.md @@ -87,14 +87,7 @@ Two more error inflators to rule out before splitting phases: ## Asymmetric open/close -Open and close are almost never mirror images: open tends to be slower and springier, close -faster and flatter. - -- Record (or trim) open and close as **separate clips** and run the full pipeline on each; - don't fit one curve and reuse it reversed. -- Report two curves. In code, give the enter and exit transitions different `duration`/easing - (and different spring configs) rather than a single shared one. -- See `references/choreography.md` for expressing asymmetry per target. +Open and close are almost never mirror images: fit each direction as its own clip and report two curves, never one curve reused reversed. Full treatment (why, and expressing it per target) in `references/choreography.md`. ## Converting spring params across APIs @@ -107,4 +100,5 @@ The fit fixes `mass = 1`. From `stiffness` (k), `damping` (c), `mass` (m): `dampingFraction = c / (2·√(k·m))` (that's `zeta`). - **Reanimated**: `withSpring(to, { stiffness, damping, mass })`. - **CSS**: no native spring. Use the fitted `bezier.css`, or generate a `linear()` easing by - sampling the spring response (more faithful for overshoot). Read `references/code-output.md`. + sampling the spring response (more faithful for overshoot). The per-target templates come from + the Emit step of SKILL.md's reverse-engineer workflow. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/debugging-symptoms.md b/plugins/frontend-product-design/skills/ui-animation/references/debugging-symptoms.md new file mode 100644 index 0000000..e50a129 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/debugging-symptoms.md @@ -0,0 +1,80 @@ +# Debugging Symptoms + +Turn "this feels off" into a named cause, then make the smallest fix that addresses it. Never tweak values blindly: randomly nudging durations produces a different animation, not a better one, and destroys the ability to tell what actually helped. + +## Contents +- [The loop](#the-loop) +- ["It feels slow / sluggish"](#it-feels-slow--sluggish) +- ["It feels robotic / lifeless / flat"](#it-feels-robotic--lifeless--flat) +- ["It feels cheap, but I can't say why"](#it-feels-cheap-but-i-cant-say-why) +- ["It's janky / drops frames"](#its-janky--drops-frames) +- ["It jumps / snaps / shifts"](#it-jumps--snaps--shifts) +- ["It fires when it shouldn't / flickers"](#it-fires-when-it-shouldnt--flickers) +- [When no row matches](#when-no-row-matches) + +## The loop + +1. **Reproduce it on the environment where it feels wrong.** A gesture that's fine on a laptop can stutter on a phone; an opacity crossfade that's fine at 120Hz looks rough at 60Hz. +2. **Slow it down.** Record and scrub frame by frame, or set the DevTools Animations panel to 10-25% playback. This is the single highest-leverage step: the flaw invisible at full speed (a late fade, a wrong origin, two states reading as separate objects) is obvious at quarter speed. +3. **Classify the symptom** with the tables below; causes are ordered by likelihood. +4. **Change one variable, re-record, compare.** Easing first, then duration: duration depends on the easing (a steep curve affords a longer duration), so tuning duration before the curve is settled is wasted work. +5. **Verify at full speed, then with fresh eyes.** An animation approved only in slow motion hasn't been approved. + +## "It feels slow / sluggish" + +| Check, in order | Fix | +| --- | --- | +| `ease-in` on the animation | Swap to a strong ease-out; `ease-in` starts slow, delaying the exact moment the user is watching. The same duration instantly feels faster. | +| Built-in named easing (`ease-out`, `ease-in-out`) | Replace with a custom curve; built-ins accelerate too weakly, so motion feels flat and slow at any duration. | +| Duration over ~300ms on product UI | Cut it. A 180ms dropdown feels more responsive than a 400ms one. Only a very steep curve earns a long duration. | +| Animation on a high-frequency action (keyboard nav, shortcut toggle, constant hover) | Delete the animation. At 100+ uses a day any duration reads as lag; the fix is removal, not tuning. | +| A `delay` in the chain | Remove or shrink it; delays on interactive responses read as the UI hesitating. | + +## "It feels robotic / lifeless / flat" + +| Check, in order | Fix | +| --- | --- | +| `linear` easing on non-constant motion | Nothing physical moves at constant speed. Ease-out for enter/exit, ease-in-out for on-screen movement. `linear` only for marquees, spinners, time-visualizing holds, and scrubbed scroll motion. | +| Curve too weak | Steepen it; when an animation feels flat, the curve is usually the problem, not the duration. | +| A duration-based ease on something that should feel alive (drag release, morphing pill) | Use a spring; fixed durations can't carry velocity or an organic settle. A weird-feeling spring is usually fixed by raising damping. | +| Uniform stagger (identical delay and distance per item) | Vary delay and distance by importance; the metronome effect is what feels mechanical. | + +## "It feels cheap, but I can't say why" + +| Check, in order | Fix | +| --- | --- | +| Entrance from `scale(0)` or a bare fade | Start from `scale(0.9-0.96)` plus opacity; nothing real appears from nothing, and a near-full start reads as "it was almost already there". | +| Wrong `transform-origin` | Popovers, dropdowns, and tooltips scale from their trigger, not center (use the library's origin variable: `--transform-origin` in Base UI, `--radix-popover-content-transform-origin` in Radix). Slowed playback makes a wrong origin unmistakable. | +| Crossfade shows two distinct overlapping states | Add `filter: blur(2px)` during the transition; blur bridges the gap so the eye reads one transforming object instead of two swapped ones. | +| Sub-animations on different clocks | Unify the timing family so the component reads as one entity; one slow sub-animation breaks the whole thing. | +| Enter and exit mismatched | Exit in the direction of entry, roughly 20% faster and simpler than the entrance; the user already decided, get out of the way. | +| Motion mismatched to personality | A playful app can bounce; a dashboard stays crisp. Feel can overrule the blueprint, but deliberately. | + +## "It's janky / drops frames" + +Work the diagnosis checklist in `performance-deep-dive.md`; the short order is: non-`transform`/`opacity` properties first, then motion coinciding with a busy main thread (move to CSS/WAAPI, or stop co-scheduling the work), then per-frame React state updates, then an inherited CSS variable driving transforms, then animated `blur()` over 20px. Only after those, `will-change: transform`. + +If it janks only sometimes (on open, on the first run, during navigation, while data lands), the animation is fine and a long task is sharing the tick. Record a performance trace over the interaction and look for a task over 50ms; fix the scheduling, not the motion (see Long tasks during animation in `performance-deep-dive.md`). + +## "It jumps / snaps / shifts" + +| Check, in order | Fix | +| --- | --- | +| Element jumps when retriggered quickly (new toast, rapid toggle) | `@keyframes` restart from zero; they aren't interruptible. Use CSS transitions or springs, which retarget from the current state with velocity. | +| Exit animation never plays | The `AnimatePresence` child is missing a `key` (or `AnimatePresence` sits inside the conditional instead of around it). No key, no exit; check this first. | +| Height snaps instead of animating | `height: auto` isn't animatable; measure it and animate the pixel value (see the auto-height pattern in `component-patterns.md`). | +| 1px shift at animation start or end | `will-change: transform`; the browser is handing the element between CPU and GPU, which render slightly differently. | +| Content flashes to its final state before animating | The initial state arrives after first paint. Set it in CSS (or `@starting-style`) so the element is born hidden. | + +## "It fires when it shouldn't / flickers" + +| Check, in order | Fix | +| --- | --- | +| Hover element oscillates between states | The hover animation moves the element out from under the cursor, ending the hover, dropping it back in. Move the transform to an inner child; the parent stays put under the cursor. | +| Hover states firing on phones | Touch taps trigger phantom hovers. Gate with `@media (hover: hover) and (pointer: fine)`. | +| Every tooltip in a row animates as the cursor sweeps | Once one tooltip is open, siblings open with no delay and no animation (Base UI exposes `data-instant`; set `transition-duration: 0ms` on it). | +| Animation replays every time it scrolls into view or on back-navigation | Intro and reveal animations run once. Unobserve after firing or persist a has-played flag. | + +## When no row matches + +The animation may be correct and wrong anyway: built to spec but the spec is off. Re-derive the basics in order: should this animate at all (frequency)? Right easing family for the motion type? Duration matched to that easing and the element's size? If a reference exists (an app whose version feels right), record the reference and scrub both side by side; matching reality beats theorizing. When a crossfade resists all tuning, a 2px blur is the sanctioned last resort. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/decision-framework.md b/plugins/frontend-product-design/skills/ui-animation/references/decision-framework.md index a28ebd1..71c040f 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/decision-framework.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/decision-framework.md @@ -5,8 +5,9 @@ - [2. What is the purpose?](#2-what-is-the-purpose) - [3. What easing should it use?](#3-what-easing-should-it-use) - [4. How fast should it be?](#4-how-fast-should-it-be) +- [Finding opportunities: where motion is missing](#finding-opportunities-where-motion-is-missing) -Answer these four questions in order before writing animation code. +Answer these four questions in order before writing animation code. SKILL.md carries the duration table, the named curves, and the pattern-to-recipe map; this file is the reasoning that picks between them. ## 1. Should this animate at all? @@ -19,7 +20,9 @@ Answer these four questions in order before writing animation code. | Occasional | Modals, drawers, toasts | Standard animation | | Rare / first-time | Onboarding, feedback forms, celebrations | Can add delight | -Never animate keyboard-initiated actions; they repeat hundreds of times daily and animation makes them feel slow and disconnected. +**Novelty budget.** Keep most of a surface familiar: about 90% expected motion (or none) and 10% novel treatment. Do not stack high-novelty beats in consecutive sections; put quiet structure between them. + +**One-shot only.** First-run staggers, intro morphs, and login flourishes must not replay on every visit. Gate them with a cookie, local flag, or rewrite so a reload is instant. ## 2. What is the purpose? @@ -32,97 +35,44 @@ Answer "why does this animate?" before writing code. | **Continuity** | Preserves context across state changes | Page transitions, layout shifts | | **Delight** | Adds personality (use sparingly) | Stagger reveals, spring overshoot | -If the purpose is just "it looks cool" and users see it often, don't animate. - ## 3. What easing should it use? -Follow this decision tree: +Two cases the named curves in SKILL.md do not cover: -- **Entering the viewport?** → enter curve: `cubic-bezier(0.22, 1, 0.36, 1)` -- **Exiting the viewport?** → same curve, shorter duration -- **Moving/sliding on screen?** → move curve: `cubic-bezier(0.25, 1, 0.5, 1)` -- **Simple hover (color/opacity)?** → `200ms ease` -- **Needs physics feel?** → spring -- **Direct manipulation (drag)?** → no easing, follow the pointer +- **Needs physics feel?** → spring ([spring-animations.md](spring-animations.md)) - **Constant motion (marquee, spinner)?** → `linear` -Avoid `ease-in` for UI; it starts slow and feels sluggish. Built-in `ease-out`/`ease` have gentle acceleration that reads soft rather than decisive. Custom curves like `cubic-bezier(0.22, 1, 0.36, 1)` accelerate steeply (the element covers most of its distance in the first third), so the same 200ms feels significantly faster. - -**Easing resources:** [easing.dev](https://easing.dev/) and [easings.co](https://easings.co/) for stronger custom variants. - -### Extended easing reference - -| Name | Curve | Character | -|---|---|---| -| ease-out-quad | `cubic-bezier(0.25, 0.46, 0.45, 0.94)` | Gentle deceleration | -| ease-out-cubic | `cubic-bezier(0.22, 0.61, 0.36, 1)` | Standard deceleration | -| ease-out-quart | `cubic-bezier(0.165, 0.84, 0.44, 1)` | Strong deceleration | -| ease-out-quint | `cubic-bezier(0.23, 1, 0.32, 1)` | Very strong deceleration | -| ease-out-expo | `cubic-bezier(0.19, 1, 0.22, 1)` | Explosive start, soft land | -| ease-out-circ | `cubic-bezier(0.075, 0.82, 0.165, 1)` | Circular deceleration | -| ease-in-out-quad | `cubic-bezier(0.455, 0.03, 0.515, 0.955)` | Gentle symmetric | -| ease-in-out-cubic | `cubic-bezier(0.645, 0.045, 0.355, 1)` | Standard symmetric | -| ease-in-out-quart | `cubic-bezier(0.77, 0, 0.175, 1)` | Strong symmetric | - -Use weaker curves (quad, cubic) for small or frequent elements; stronger curves (quint, expo) for large or rare transitions. +Match curve strength to size and frequency: weaker curves (quad, cubic) for small or frequent elements, stronger curves (quint, expo) for large or rare transitions. Full named catalogue at [easing.dev](https://easing.dev/), stronger custom variants at [easings.co](https://easings.co/). ### Asymmetric vs symmetric curves -Symmetric ease-in-out starts slow: a noticeable lag between the user's action and the element beginning to move. For interactive elements (drawers, panels, menus), use asymmetric curves, steep at the start and settling slowly, to preserve responsiveness while the slow deceleration adds quality. +Symmetric ease-in-out starts slow: a noticeable lag between the user's action and the element beginning to move. For interactive elements (drawers, panels, menus), use asymmetric curves, steep at the start and settling slowly, to preserve responsiveness while the slow deceleration adds quality. A steep curve covers most of its distance in the first third, so the same 200ms reads as significantly faster. Duration and easing are inseparable: a steep curve affords a longer duration because the movement is front-loaded. Vaul's drawer uses 500ms with `cubic-bezier(0.32, 0.72, 0, 1)` but doesn't feel slow, covering most of its distance in the first 200ms. ## 4. How fast should it be? -Pick duration from the easing defaults table in SKILL.md. Keep routine UI under 300ms; scale with distance: a full-screen menu can exceed 300ms, a 6px tooltip shift under 150ms. - -### Perceived performance +Duration changes perceived performance independently of actual speed: -Animation speed changes perceived performance: +- A fast-spinning spinner makes loading feel faster (same elapsed time, different perception) +- `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms: the user sees immediate movement +- Instant tooltips after the first opens (skip delay and animation) make the whole toolbar feel faster -- Fast-spinning spinner makes loading feel faster (same time, different perception) -- `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms: user sees immediate movement -- Instant tooltips after the first opens (skip delay and animation) make the toolbar feel faster +## Finding opportunities: where motion is missing -### Asymmetric timing +Questions 1 and 2 above judge a candidate someone already proposed. This section is the sweep that produces candidates in the first place: given an interface, where would motion genuinely help? Run every hit back through questions 1 and 2, and expect to reject most of them. A short list of high-conviction opportunities beats a long wishlist, and an opportunity finder that suggests motion everywhere produces exactly the sluggish, over-animated interfaces the rest of this skill exists to prevent. -Enter can be slightly slower than exit. Hold-to-delete: 2s linear on press, 200ms ease-out on release. +Sweep these seam classes. The skill is done sweeping when each has either yielded candidates with `file:line` evidence or been explicitly cleared. -```css -/* Release: fast */ -.overlay { - transition: clip-path 200ms ease-out; -} - -/* Press: slow and deliberate */ -.button:active .overlay { - transition: clip-path 2s linear; -} -``` - -### Instant enter, animated exit (productivity tools) - -Canonical statement: SKILL.md core rule on asymmetric timing. For high-frequency ephemeral UI, invert the standard rule: enter instantly (0ms), exit with a brief fade (100-150ms). - -```css -/* Hover highlight: instant appear, soft dismiss */ -.highlight { - transition: opacity 0.15s ease-out; - opacity: 0; -} -.item:hover .highlight { - transition-duration: 0s; - opacity: 1; -} -``` - -This applies when: -- Interaction happens tens to hundreds of times per day -- User initiates the action (hover, click, keyboard) -- Element is ephemeral (highlight, popover, tooltip after first open) +| Seam | What it looks like | Where to grep | +|---|---|---| +| Feedback gap | A pressable control with no press state | `onClick` / `onPress` on elements with no `:active`, `active:`, or transition | +| Teleporting state | Content that swaps, appears, or vanishes with no bridge | `{isOpen &&`, `{show`, `display: none` toggles, accordions and collapses with no height or opacity transition | +| Missing spatial story | A surface with no connection to what opened it | Popovers, menus, and panels with no `transform-origin` at the trigger; dismissable surfaces that exit by a different path than they entered | +| Group entrance | An occasionally-viewed grid or list that pops in whole | `.map(` renders on first-load surfaces, where a 30-50ms stagger would help | +| Gesture seam | Draggable or swipeable elements that snap with no physics | Drag and pointer handlers with no spring, no velocity-based dismissal, no rubber-banding at boundaries | +| Flat delight moment | Rare, high-emotion states rendered without any motion | First-run, empty, success, and completion components | -It does not apply to: -- Rare interactions (modals, onboarding): use standard asymmetric timing -- Content needing orientation (drawers with nav): enter animation provides spatial context +The last row is where the delight budget lives, and it is the only tier where bounce, generous stagger, or a longer beat are welcome. -Once the element should animate, match the UI pattern to a recipe via the "Transition decision rules" table in SKILL.md. +**Report both halves.** A discovery pass caps at five to seven suggestions ordered by leverage, and it must also list two to five places deliberately *not* suggested, each naming the question that killed it ("command palette open/close: keyboard-initiated, 100+/day, never animate"). The rejected list is what separates a discovery pass from an animation wishlist. Where the interface is already close to right, saying so is the correct result, not a failure. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/discovery-workflow.md b/plugins/frontend-product-design/skills/ui-animation/references/discovery-workflow.md new file mode 100644 index 0000000..352e4ea --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/discovery-workflow.md @@ -0,0 +1,19 @@ +# Discovery workflow + +Use this branch when the request is "where should this animate", not "animate this". Every other mode starts from motion that exists; this one starts from its absence. It reports and never implements: hand a surviving suggestion back to the implementation workflow in SKILL.md to build it. + +```text +Discovery progress: +- [ ] Step 1: Recon the stack, existing motion tokens, and product personality +- [ ] Step 2: Sweep every seam class +- [ ] Step 3: Gate each candidate +- [ ] Step 4: Report survivors and rejections +``` + +1. **Recon.** Identify the motion library (if any), the easing and duration tokens already in use, and how often each surface is visited. Suggestions extend the existing vocabulary rather than introducing a parallel one, and a dense dashboard earns fewer and subtler suggestions than a playful consumer app. +2. **Sweep.** Walk the seam table in the decision framework loaded by SKILL.md, which carries the grep signature for each. Clear a seam explicitly rather than skipping it silently. +3. **Gate.** Run each candidate through questions 1 and 2 of the same file: frequency, then purpose. "It looks cool" is not a purpose. Most candidates die here, which is the point. +4. **Report.** Order suggestions by impact, each with `file:line`, what happens today, the named purpose, the frequency tier, and exact values (property, duration, curve) drawn from the core easing and transition tables in SKILL.md. Include rejected candidates only when the reason clarifies a likely alternative. Close with which single suggestion has the highest leverage. + +Where the interface already carries the right amount of motion, say so. That is the correct result for a well-built UI, not an empty report. + diff --git a/plugins/frontend-product-design/skills/ui-animation/references/gesture-drag.md b/plugins/frontend-product-design/skills/ui-animation/references/gesture-drag.md index efa0de5..5c61d91 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/gesture-drag.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/gesture-drag.md @@ -4,11 +4,18 @@ Drag, swipe, and gesture patterns where the user directly manipulates elements. ## Contents - [Momentum-based dismissal](#momentum-based-dismissal) +- [Velocity handoff](#velocity-handoff) +- [Momentum projection](#momentum-projection) - [Boundary damping](#boundary-damping) - [Pointer capture](#pointer-capture) +- [Grab offset](#grab-offset) +- [Axis commitment](#axis-commitment) - [Multi-touch protection](#multi-touch-protection) - [Friction vs hard stops](#friction-vs-hard-stops) +- [Rotary drag](#rotary-drag) +- [Detents and snapping](#detents-and-snapping) - [Swipe-to-dismiss pattern](#swipe-to-dismiss-pattern) +- [Carousel axis](#carousel-axis) ## Momentum-based dismissal @@ -29,6 +36,43 @@ function onPointerUp(e: PointerEvent) { Default threshold: velocity > 0.11. Combine with a minimum distance (e.g. 20px) to prevent accidental dismissals. +## Velocity handoff + +When a gesture ends, the animation must continue at the finger's exact velocity so there is no visible seam between dragging and animating. This is the detail that most separates "fluid" from "fine". Pass the pointer's release velocity as the spring's initial velocity. + +Motion and Framer Motion take absolute px/s velocity directly via the `velocity` option, so hand them the raw release velocity: + +```ts +// releaseVelocity in px/s, measured over the last few pointermove events +animate(el, { y: target }, { type: "spring", velocity: releaseVelocity, bounce: 0, duration: 0.4 }); +``` + +Some spring APIs want relative velocity: normalize by the remaining distance to the target. + +```ts +const relativeVelocity = gestureVelocity / (targetValue - currentValue); +// element at y=50, target y=150 (100px to go), finger at 50px/s -> 50 / 100 = 0.5 +``` + +To have velocity ready at release, track a short position and timestamp history (last few `pointermove` events), not just the current point. + +## Momentum projection + +Don't snap to the nearest boundary from the release point. Use velocity to project where the gesture is heading, then snap to the target nearest that projected point. This is what makes a flick feel like it throws the element, exactly like scroll deceleration. Good bottom sheets and carousels (Vaul, Embla) work this way. + +```ts +// decelerationRate ~ 0.998 for a normal scroll feel; 0.99 for snappier +function project(initialVelocity: number, decelerationRate = 0.998): number { + return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate); +} + +const projectedEndpoint = currentPosition + project(releaseVelocity); +const target = nearestSnapPoint(projectedEndpoint); // choose target from the projection +animateSpringTo(target, { velocity: releaseVelocity }); // then hand off velocity (previous section) +``` + +Use this exponential-decay form, not the physics-textbook `v^2 / (2 * decel)`; the decay form is what Apple ships in the *Designing Fluid Interfaces* sample code. + ## Boundary damping Past the natural boundary (e.g. pulling a drawer up when already at top), apply damping: the more they drag, the less it moves. @@ -42,6 +86,15 @@ function applyDamping(offset: number, max: number): number { const dampedOffset = applyDamping(rawOffset, 200); ``` +Apple's canonical rubber-band function (from *Designing Fluid Interfaces*) is a good drop-in alternative, tuned to feel like iOS overscroll: + +```ts +// the further past the bound, the less the element follows +function rubberband(overshoot: number, dimension: number, constant = 0.55): number { + return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot)); +} +``` + Real things slow before stopping; friction beats hard stops. ## Pointer capture @@ -62,6 +115,49 @@ function onPointerUp(e: PointerEvent) { Always use `setPointerCapture`; without it, fast swipes escape the element and the drag breaks. +## Grab offset + +Record where inside the element the pointer landed, and hold that offset for the whole drag: + +```ts +let grabY = 0; + +function onPointerDown(e: PointerEvent) { + const r = el.getBoundingClientRect(); + grabY = e.clientY - r.top; // where in the element the finger actually is +} + +function onPointerMove(e: PointerEvent) { + setY(e.clientY - grabY); // not e.clientY, and not a centred element +} +``` + +Positioning from `e.clientY` alone snaps the element's top (or its centre, with a `-50%` translate) to the pointer the instant the drag begins. The element jumps under the finger before it has moved, which breaks 1:1 tracking at the only moment the user is watching for it. Grab a sheet by its handle and it should stay gripped by the handle. + +## Axis commitment + +Track from `pointerdown`, but do not claim an axis until the pointer has travelled about 10px: + +```ts +let axis: "x" | "y" | null = null; + +function onPointerMove(e: PointerEvent) { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + if (!axis) { + if (Math.hypot(dx, dy) < 10) return; // too early to tell + axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; + } + if (axis !== "x") return; // this handler owns horizontal only + // drag... +} +``` + +Deciding on the first `pointermove` reads noise: the first few pixels of a vertical scroll usually carry some horizontal drift, so a swipe-to-dismiss row inside a scrolling list steals the gesture and the list stops scrolling. Once committed, hold the axis until `pointerup`; re-deciding mid-drag makes the element stutter between behaviours. + +This is the custom-handler counterpart to the declarative fix under Carousel axis. `touch-action` tells the browser which axis it may keep, which settles native scrolling; it does nothing for a handler resolving the ambiguity itself. + ## Multi-touch protection Ignore extra touch points after the drag begins; without this, switching fingers mid-drag makes the element jump. @@ -95,22 +191,96 @@ function applyFriction(delta: number, isAtBoundary: boolean): number { Hard stops feel broken; users expect physics. Apply friction for scroll containers, sliders, and drawers. +## Rotary drag + +For knobs and dials, track the *angle* from the control's centre, not the pointer delta. Reading `dx`/`dy` makes the knob respond to how far the pointer moved rather than where it moved to, so the grip slides off the moment the user circles wide. + +The trap is the wrap at ±180°. `atan2` jumps from `π` to `-π` in one frame, and an unguarded subtraction sends the value flying a full turn. Normalise every delta into `(-π, π]` before accumulating: + +```ts +const TWO_PI = Math.PI * 2; + +function angleFrom(el: HTMLElement, e: PointerEvent): number { + const r = el.getBoundingClientRect(); + return Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2)); +} + +let last = 0; +let turns = 0; // accumulated rotation in radians, unbounded + +function onPointerDown(e: PointerEvent) { + el.setPointerCapture(e.pointerId); // see Pointer capture + last = angleFrom(el, e); +} + +function onPointerMove(e: PointerEvent) { + const now = angleFrom(el, e); + // Shortest way round, so the ±180 seam never registers as a full turn. + const delta = ((now - last + Math.PI * 3) % TWO_PI) - Math.PI; + turns += delta; + last = now; + setValue(clamp(turns / TWO_PI)); +} +``` + +Two things fall out of this. A knob with a limited range (a volume dial, not an endless encoder) needs the *accumulated* value clamped, never the per-frame angle, or the knob detaches from the pointer at the limit and jumps back when the user reverses. And a knob whose travel is under one full turn should apply Boundary damping at each end, exactly as a linear slider does. + +## Detents and snapping + +A ruler picker, tick slider, or segment scrubber has discrete stops. Do not snap during the drag: the value should follow the pointer continuously, and settle to the nearest detent only on release. Snapping live makes the control feel like it is fighting the finger. + +```ts +function onRelease(value: number, velocity: number) { + // Project where momentum would carry it, then snap that (see Momentum projection). + const projected = value + velocity * 0.15; + const target = Math.round(projected / STEP) * STEP; + animate(value, target, { type: "spring", stiffness: 500, damping: 40 }); +} +``` + +Snapping the *projected* landing point rather than the release position is what makes a flick feel like it threw the control several notches, instead of dropping it at the nearest tick. + +The tick marks themselves carry the feedback during the drag. Scale or darken the tick under the indicator as it passes, on `transform` and `color` only. This is the visual stand-in for the haptic click a physical detent would give, and without it a continuous drag over a ruler reads as a smooth slider that happens to be drawn with lines. On a device that supports it, pair the passing tick with `navigator.vibrate(1)`. + ## Swipe-to-dismiss pattern -Combine velocity, distance, and direction for a complete swipe gesture: +Velocity decides; distance is only the tie-breaker. Sign both against the dismissal direction, so "toward dismissal" is positive on each. ```ts -function handleSwipeEnd(direction: "left" | "right", distance: number, velocity: number) { - const shouldDismiss = distance > THRESHOLD || velocity > 0.11; +const FLICK = 0.11; // px/ms, matches Sonner - if (shouldDismiss) { - // Animate out in swipe direction with remaining momentum - animateOut(direction, velocity); - } else { - // Spring back to origin - springBack(); +// offset and velocity are both signed along the drag axis: +// positive = moving toward dismissal, negative = back toward rest. +function handleSwipeEnd(offset: number, velocity: number) { + if (Math.abs(velocity) > FLICK) { + // A flick decides on its own, in whichever direction it points. + if (velocity > 0) animateOut(velocity); + else springBack(velocity); + return; } + // Released slowly: position is all the intent there is. + if (offset > THRESHOLD) animateOut(velocity); + else springBack(velocity); } ``` -The exit should continue in the swipe direction with momentum; snapping elsewhere feels wrong. +The common bug is `distance > THRESHOLD || velocity > 0.11` against an unsigned velocity. A sheet dragged 80% closed and then flicked back toward open passes the distance test and dismisses anyway, which is the user's cancel gesture doing the opposite of what they asked. Checking magnitude first and sign second is what makes a reversal cancel. + +The exit continues in the swipe direction with momentum; snapping elsewhere feels wrong. Feed `velocity` into the exit spring's `velocity` option so drag and animation share no seam, and into `springBack` too: a cancelled flick that starts from zero reads as a bounce the user did not cause. + +## Carousel axis + +A horizontal scroller that also moves the page is an axis fight. + +**CSS `overflow-x` / scroll-snap:** let the browser pan horizontally, and stop horizontal overscroll from triggering Back: + +```css +.carousel { + overflow-x: auto; + touch-action: pan-x; + overscroll-behavior-x: contain; +} +``` + +**JS-driven** (Embla, Swiper, Keen): those libraries set `touch-action: pan-y` so the page still scrolls vertically while they handle the horizontal drag. Do not override to `pan-x`. + diff --git a/plugins/frontend-product-design/skills/ui-animation/references/interface-sfx.md b/plugins/frontend-product-design/skills/ui-animation/references/interface-sfx.md new file mode 100644 index 0000000..19b6736 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/interface-sfx.md @@ -0,0 +1,41 @@ +# Interface SFX + +Sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions. + +## Scope + +- **IS:** sparse confirmation sounds for rare, high-stakes, or physical-feeling interactions (toggle lock, payment confirm, drag release, success moment). +- **IS NOT:** background music, autoplay, looping UI beds, or replacing visual feedback. + +## Rules + +1. **Unlock from a user gesture.** Create or resume `AudioContext` only inside a click, tap, or keydown handler. Never on page load or in `useEffect` without a gesture. +2. **Stay quiet.** Keep volume well below content audio. Respect system mute and tab mute; if the tab is muted, do not play. +3. **Additive only.** Pair every sound with visual feedback (scale, color, icon swap). Sound confirms what the user already sees; it never carries the message alone. +4. **Same frequency rule as motion.** High-frequency actions stay silent: typing, hover, scrolling, list navigation, repeated toggles. If the user does it dozens of times per session, no sound. +5. **Honor `prefers-reduced-motion`.** Treat it as a signal to skip optional SFX unless the user explicitly enabled sounds in settings. +6. **Keep clips tiny.** Tens of milliseconds, soft attack, no peak that clips. One-shot, non-looping. +7. **One owner.** Route all playback through a tiny `play(id)` helper (preload, volume, mute checks, reduced-motion gate). No ad-hoc `new Audio()` at call sites. + +## Implementation sketch + +```javascript +let ctx; + +function unlockAudio() { + if (!ctx) ctx = new AudioContext(); + if (ctx.state === 'suspended') ctx.resume(); +} + +function playSfx(id) { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + if (!ctx || ctx.state !== 'running') return; + // fetch decoded buffer for id, set gain ~0.1-0.2, play once +} +``` + +Wire `unlockAudio` to the first meaningful interaction on the surface that uses SFX. + +## Sources + +Informed by Craft (gustavo-fior Interface SFX) and Raphael Salaja's writing on web sound. Original prose; not copied. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/live-tuning.md b/plugins/frontend-product-design/skills/ui-animation/references/live-tuning.md new file mode 100644 index 0000000..48cb14a --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/live-tuning.md @@ -0,0 +1,59 @@ +# Live tuning + +The reverse-engineer workflow runs backwards: record a motion you admire, then fit a curve to it. This is the forward version, for when there is no reference to copy and the table value is contested. Tune against the running component instead of guessing, reloading, and guessing again. + +Start in DevTools. It is already open, it costs nothing, and it covers every bezier in the easing defaults table. + +## Contents + +- [When this is worth it](#when-this-is-worth-it) +- [The bezier editor](#the-bezier-editor) +- [Retiming in the Animations panel](#retiming-in-the-animations-panel) +- [What DevTools cannot do](#what-devtools-cannot-do) +- [Baking the value back](#baking-the-value-back) + +## When this is worth it + +- **The value is contested.** Two people disagree on whether a drawer should be 300ms or 400ms and neither can win the argument from a table. +- **The component is hard to reach.** A toast that needs a form submitted, a sheet three navigations deep. Each rebuild round trip costs more than the setup does once, and an HMR reload loses the state that got you there. +- **The motion is multi-phase.** Stagger offset, blur ramp, and settle interact, so three numbers guessed one reload at a time converge slowly. + +Not for picking a button press duration. The easing defaults table answers that in one line. + +## The bezier editor + +Chrome, Edge, and Firefox render a small curve swatch next to any `transition-timing-function` or `animation-timing-function` in the Styles (or Rules) pane. Click it for a draggable cubic-bezier editor. + +Edits apply live with no rebuild, so retrigger the interaction and watch it under the new curve. The editor emits the literal (`cubic-bezier(0.22, 1, 0.36, 1)`), which is what goes back into source. + +Two things that waste time otherwise: + +- The swatch only exists once the property is valid. On an element with no timing function yet, add the declaration in the `element.style` pane first and the swatch appears. +- Start from the table value, not a built-in preset. Opening on `cubic-bezier(0.22, 1, 0.36, 1)` gives you something to judge against; opening on `ease` means finding the table value by hand. + +Safari has no bezier editor. Tune in Chrome, verify in Safari. + +## Retiming in the Animations panel + +The panel's slow-motion playback is a debugging tool and belongs to the Validation workflow. Two of its controls are tuning tools: + +- **Drag a bar's edges** to change a duration or delay live, then replay. Faster than editing per-item delays for a stagger you are trying to feel out. +- **Read the captured group** to see every element's delay and duration side by side. This is the quickest way to recover the timing of a stagger you did not write, including one a library is generating. + +## What DevTools cannot do + +- **Springs.** No spring editor exists. Reach for the presets and the `visualDuration`/`bounce` framing in `spring-animations.md`: they are perceptual, so they land close on the first try, and a wrong spring usually needs one parameter moved rather than a search. +- **Composing multi-phase choreography.** The panel retimes what already fired; it will not let you build the phases against a shared playhead. + +If a project hits those two often enough to matter, a control-panel library (DialKit, Leva, Tweakpane) earns a dev dependency: a spring control returns a Motion `TransitionConfig` that drops straight into `animate()`, and a timeline dock composes phases. That is a standing decision about the project, not something to install mid-task for one curve. + +## Baking the value back + +A tuning surface is a measuring instrument, not a delivery mechanism. + +- A DevTools edit lives only in that tab and dies on navigation. Paste the literal into source before you believe it. +- Put it next to the other timing constants, so the next person sees it beside the values it has to agree with. +- A control panel leaves more behind than the dock: replace every sampled binding with the real animation, then remove the panel, its root, and the dependency. Framework roots hide themselves in production builds, but a vanilla root does not, and a forgotten one ships a control panel to users. +- Re-check the result against the ten standards. What felt right after ten iterations on a fast laptop still has to clear no layout-property transitions, `prefers-reduced-motion` handled, and interruption retargeting rather than restarting. + +Tune on the real surface. A curve dialled on an isolated demo reads differently against the distance, size, and neighbours of the actual component, and how often the user sees it moves the answer more than any parameter does. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/performance-deep-dive.md b/plugins/frontend-product-design/skills/ui-animation/references/performance-deep-dive.md index 0cf5bfb..76b9263 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/performance-deep-dive.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/performance-deep-dive.md @@ -3,7 +3,9 @@ Advanced performance guidance beyond the quick rules in SKILL.md. ## Contents +- [Property cost tiers](#property-cost-tiers) - [CSS vs JS animations](#css-vs-js-animations) +- [Long tasks during animation](#long-tasks-during-animation) - [Web Animations API (WAAPI)](#web-animations-api-waapi) - [CSS variables inheritance trap](#css-variables-inheritance-trap) - [Motion transform ownership](#motion-transform-ownership) @@ -11,6 +13,27 @@ Advanced performance guidance beyond the quick rules in SKILL.md. - [Compositing layers and will-change](#compositing-layers-and-will-change) - [Fix shaky 1px shifts](#fix-shaky-1px-shifts) +## Property cost tiers + +Every animatable property enters the browser's Layout, Paint, Composite pipeline at one of three points, and the cost differs by an order of magnitude: + +| Tier | Properties | Cost | +|---|---|---| +| Composite only | `transform`, `opacity` (plus `filter`, `clip-path`, `background-color` in current Chrome/Firefox) | Cheapest; the browser promotes these to their own layer | +| Paint + Composite | `box-shadow`, `border-radius`, `color` | No re-measuring, but an expensive redraw every frame | +| Layout + Paint + Composite | `width`, `height`, `padding`, `margin`, `top`, `left`, `border-width` | Most expensive; layout recalculates every frame | + +The paint tier is the one people miss because it doesn't look like layout. Swap down a tier: + +| Instead of animating | Animate | +|---|---| +| `width`/`height`/`padding` to grow or shrink | `scale()` | +| `margin`/`top`/`left` to move | `translate()` (percentages are relative to the element's own size) | +| `box-shadow` | `filter: drop-shadow(...)` | +| `border-radius` | `clip-path: inset(0 round 50px)` | + +A layout property may not visibly drop frames on an element with `position: absolute` or few children, but the `scale()` version looks identical and cannot regress on a slower device; take the one with no downside. + ## CSS vs JS animations | Approach | Driver | Interruptible | Best for | @@ -23,6 +46,38 @@ Advanced performance guidance beyond the quick rules in SKILL.md. **Rule: CSS transitions > WAAPI > CSS keyframes > JS.** Under load (page navigation, heavy rendering), CSS stays smooth while JS drops frames. +## Long tasks during animation + +The rule above holds because `transform` and `opacity` animate on the compositor thread, which keeps running while the main thread is blocked. Everything else shares one thread: style recalculation, layout, paint, and every line of JS including `requestAnimationFrame` callbacks and Motion's `x`/`y`. That thread is also the one your application code runs on. The budget there is roughly 10ms of the 16.6ms frame at 60Hz, and half that at 120Hz. A task over 50ms is a long task: any concurrent main-thread animation visibly stutters and input goes unanswered for its duration. + +So when motion janks *only sometimes* (on open, on first run, during navigation, while data lands), suspect the work sharing the tick, not the animation code. Moving to CSS/WAAPI is the fix when the animation can be expressed that way; when it can't (drag, springs, physics, choreography), fix the scheduling instead. + +**1. Don't co-schedule.** Starting an animation and expensive work in the same tick makes the entrance pay for the work: a modal that mounts a large tree, a drawer that parses its contents, a tab that fetches on click. Start the motion, let a frame land, then do the work, or defer the work to `transitionend`/`onAnimationComplete` so it runs after the motion finishes. + +**2. Chunk what can't be deferred,** against a time budget rather than a fixed item count, so the cost tracks the device instead of your laptop: + +```ts +const yieldToBrowser = (): Promise => + typeof scheduler !== "undefined" && "yield" in scheduler + ? scheduler.yield() + : new Promise((resolve) => setTimeout(resolve, 0)); + +async function inChunks(items: T[], work: (item: T) => void) { + let start = performance.now(); + for (const item of items) { + work(item); + if (performance.now() - start > 5) { // leave the rest of the frame to the animation + await yieldToBrowser(); + start = performance.now(); + } + } +} +``` + +`scheduler.yield()` resumes ahead of other pending tasks rather than behind them, but it is Chromium-only today, hence the `setTimeout` fallback. Use `await new Promise(requestAnimationFrame)` instead when the chunked work feeds the animation itself and must resume in step with frames. + +Yielding does not make the work faster; the total is unchanged. It lets frames paint and input dispatch between the pieces, which is the entire perceived difference. If the work genuinely cannot be split (one large parse, one synchronous layout of a huge tree), it belongs in a worker or on the server; no amount of animation tuning hides it. + ## Web Animations API (WAAPI) JavaScript control with CSS performance. Hardware-accelerated, interruptible, promise-based. @@ -76,6 +131,8 @@ const x = useMotionValue(0); Don't mix Motion `x`/`y` props with a handwritten `transform` string on one element; pick one transform owner. +One more reason to reach for the string form: the individual shorthands (`x`, `y`, `scale`, `rotate`) are implemented with CSS variables and driven from `requestAnimationFrame`, so they are not hardware-accelerated. That's harmless normally, but motion that runs *while* the main thread is busy (page navigation, tab switches during data loading, hydration) drops frames exactly then. Vercel's dashboard hit this with a shared-layout tab highlight that janked during navigation; the fix was moving it to CSS. When an animation must survive a busy main thread, animate the full `transform` string, or move it to CSS/WAAPI. + ## Pause looping animations off-screen Looping animations consume GPU resources even when not visible. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/review-format.md b/plugins/frontend-product-design/skills/ui-animation/references/review-format.md index 92cb65c..5fb866b 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/review-format.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/review-format.md @@ -7,8 +7,6 @@ - [Before/After/Why table](#beforeafterwhy-table) - [Review checklist](#review-checklist) - [Verdict output](#verdict-output) -- [Component design principles](#component-design-principles) -- [Debugging animations](#debugging-animations) ## Operating posture @@ -19,13 +17,13 @@ Senior motion reviewer with a brutal eye for craft. Bias toward motion that feel Measure every animation in the diff against these; a violation is a finding. For exact values (curves, durations, spring config), cite the easing/duration tables in `SKILL.md` rather than approximating. Each standard ends with a **Flag on sight** clause: hard findings to catch without deliberation. 1. **Justified motion.** Every animation answers "why animate this?": feedback, orientation, continuity, state, or deliberate delight. "Looks cool" on a frequently-seen element is a block. -2. **Frequency-appropriate.** Keyboard-initiated and 100+/day actions get no animation; tens/day gets reduced motion; occasional gets standard; rare or first-time can carry delight. Flag on sight: animation on a keyboard shortcut, command-palette toggle, or 100+/day action. +2. **Frequency-appropriate.** Keyboard focus and repeated actions must respond immediately. Flag motion that delays task completion or creates distracting repeated travel; a brief nonblocking transition is not automatically a defect. 3. **Responsive easing.** Entering/exiting elements use `ease-out` or a strong custom curve; built-in CSS easings are too weak for deliberate animation. Flag on sight: `ease-in` on any UI interaction, or weak built-in easing on a deliberate animation (it delays the moment the user watches most). 4. **Sub-300ms UI.** UI animations stay under 300ms; scale duration with distance traveled. Flag on sight: UI duration > 300ms with no stated reason. -5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.85-0.97)` plus opacity). +5. **Origin and physical correctness.** Popovers, dropdowns, and tooltips scale from their trigger (`transform-origin`), not center; modals stay centered. Flag on sight: `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip, or `scale(0)`/pure-fade entrances with no initial transform (start at `scale(0.9-0.96)` plus opacity). 6. **Interruptibility.** Rapidly-triggered or gesture-driven motion (toasts, toggles, drags) must retarget from its current state; prefer CSS transitions or springs over keyframes, which restart from zero. Flag on sight: keyframes on toasts, toggles, or anything added/triggered rapidly. 7. **GPU-only properties.** Animate `transform` and `opacity` only. Flag on sight: animating `width`/`height`/`margin`/`padding`/`top`/`left`; `transition: all` (unbounded property animation); Framer Motion `x`/`y`/`scale` props on motion that runs while the page is busy; updating a CSS variable on a parent to drive a child transform (style recalc storm). -8. **Accessibility.** `prefers-reduced-motion` is honored (gentler, not zero: keep opacity/color, drop movement); hover animations gated behind `@media (hover: hover) and (pointer: fine)`. Flag on sight: missing reduced-motion handling on movement, or ungated `:hover` motion. +8. **Accessibility.** Inspect generated hover gating, including Tailwind v4's built-in media query. Exercise reduced-motion behavior and the same keyboard/touch task. Flag spatial motion without an appropriate reduced-motion alternative. 9. **Asymmetric enter/exit.** Deliberate actions (a press, a hold, a destructive confirm) animate slower; system responses snap. Flag on sight: symmetric enter/exit timing on a press-and-release or hold interaction. 10. **Cohesion.** Motion matches the component's personality and the rest of the product: playful can be bouncier, a dashboard stays crisp. When unsure whether motion feels right, the strongest move is often to delete it. Flag on sight: mismatched personality, a jarring crossfade where a subtle blur would bridge two states, or an everything-at-once entrance where a 30-50ms stagger belongs. @@ -33,7 +31,7 @@ Measure every animation in the diff against these; a violation is a finding. For Prefer earlier moves over later ones: -1. **Delete the animation** (high-frequency, no purpose, or keyboard-triggered). +1. **Delete the animation** (disruptively repeated or without a purpose). 2. **Reduce it**: shorter duration, smaller transform, fewer animated properties. 3. **Fix the easing**: swap `ease-in` to `ease-out` or a strong custom curve. 4. **Fix the origin and physicality**: correct `transform-origin`; replace `scale(0)` with `scale(0.95)` plus opacity. @@ -41,7 +39,7 @@ Prefer earlier moves over later ones: 6. **Move it to the GPU**: layout props to `transform`/`opacity`; shorthand to a full `transform` string; WAAPI for programmatic CSS. 7. **Asymmetric timing**: slow the deliberate phase, snap the response. 8. **Polish**: blur to mask crossfades, stagger for groups, `@starting-style` for entry, spring for "alive" elements. -9. **Accessibility and cohesion**: add reduced-motion and hover gating; tune to match the component's personality. +9. **Accessibility and cohesion**: add hover gating; tune to match the component's personality. ## Before/After/Why table @@ -52,8 +50,8 @@ Required first part of every review. Markdown table, one row per issue; never a | `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; `all` animates unintended properties off-GPU | | `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing | | `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback | -| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press | -| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers scale from trigger (modals stay centered) | +| No `:active` state on button | `transform: scale(0.97)` on `:active` with `transition-duration: 0s` | Buttons must feel responsive to press | +| `transform-origin: center` on popover | `transform-origin: var(--transform-origin)` | Popovers scale from trigger (modals stay centered) | ## Review checklist @@ -73,6 +71,9 @@ Rows add recipe-specific signal beyond the ten standards; for the standard viola | Missing close-state cleanup after `setTimeout` | Add `is-closing` class, remove after transition duration | | Missing reflow (`void el.offsetWidth`) between class changes | Force reflow before re-adding classes to restart transitions | | Animating container instead of inner pieces | Apply transitions to child elements, not the wrapper | +| Same bouncy spring on open and close | Bounce the open only; damp the close and roughly halve its duration | +| Value snapped to its detent during the drag | Follow the pointer continuously; snap the projected landing point on release | +| CSS carousel scrolls the page | `touch-action: pan-x` and `overscroll-behavior-x: contain`; leave JS libraries on `pan-y` | | Hardcoded `stroke-dasharray` on SVG success path | Use `path.getTotalLength()` to measure the path | | `.is-error` and `.is-shaking` merged into one class | Keep them separate: `.is-shaking` controls animation only, `.is-error` controls visual state | @@ -85,20 +86,13 @@ Required second part of every review. Group remaining commentary by impact tier, 3. **Performance**: non-GPU properties, dropped-frame risks, recalc storms. 4. **Interruptibility and timing**: keyframes where transitions/springs belong; symmetric timing that should be asymmetric. 5. **Origin, physicality, and cohesion**: wrong origin, mismatched personality, jarring crossfades. -6. **Accessibility**: reduced-motion and pointer/hover gating. +6. **Accessibility**: pointer/hover gating. Close with a decision, citing `file:line`: -- **Block**: any feel-breaking regression, animation on a keyboard or high-frequency action, `scale(0)` or `ease-in` on UI, or a non-GPU animation with an easy GPU fix. -- **Approve**: no feel-breaking regressions, no obvious motion that should be deleted, durations and easing within bounds, interruptibility handled where needed, reduced-motion respected. +- **Block**: any feel-breaking regression, motion that delays keyboard or repeated actions, `scale(0)` or `ease-in` on UI, or a non-GPU animation with an easy GPU fix. +- **Approve**: no feel-breaking regressions, no obvious motion that should be deleted, durations and easing within bounds, interruptibility handled where needed. -## Component design principles +Reusable-component library DX (defaults over options, drop-in ergonomics, naming, docs site) is authoring, not review; see the `ui-design` skill. -Authoring-adjacent, not review. For reusable components the polish that earns adoption lives mostly outside the motion: excellent defaults over options, drop-in DX (Sonner: insert `` once, call `toast()` anywhere), transitions over keyframes for dynamic UI, personality-matched cohesion, invisible edge cases (pause timers on hidden tabs, fill gaps with pseudo-elements for hover, capture pointer on drag), memorable naming over descriptive, and a touchable docs site with copyable snippets. - -## Debugging animations - -- **Slow motion:** Increase duration 2-5x or use the browser animation inspector; check colour timing, easing, and transform-origin. -- **Frame-by-frame:** Step through the Chrome DevTools Animations panel to reveal timing issues between coordinated properties. -- **Real devices:** Test touch interactions (drawers, swipe gestures) on physical hardware; the Xcode Simulator works but real hardware is better for gestures. -- **Review next day:** Fresh eyes catch imperfections you missed during development. +For debugging animations (slow-motion, DevTools Animations panel, real-device testing, reduced-motion checks), see the Validation section in `SKILL.md`. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/scroll-animations.md b/plugins/frontend-product-design/skills/ui-animation/references/scroll-animations.md new file mode 100644 index 0000000..e559c1e --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/scroll-animations.md @@ -0,0 +1,101 @@ +# Scroll Animations + +Scroll-triggered reveals and scroll-driven (scrubbed) motion. Scroll is the most abused trigger in web motion, so this reference is half restraint, half implementation, in that order. The scrollbar belongs to the user: motion may respond to scrolling, but must never take it over or make content wait. + +## Contents +- [Gate: should this scroll animation exist?](#gate-should-this-scroll-animation-exist) +- [Two kinds: triggered vs scrubbed](#two-kinds-triggered-vs-scrubbed) +- [Triggered reveals](#triggered-reveals) +- [Scrubbed animation](#scrubbed-animation) +- [Parallax](#parallax) +- [Sticky and scrollytelling sections](#sticky-and-scrollytelling-sections) +- [Never hijack scroll](#never-hijack-scroll) +- [Performance](#performance) + +## Gate: should this scroll animation exist? + +Walk this before writing any code: + +``` +Is this inside a product (dashboard, app, tool)? +├── Yes → No scroll animation. Users scroll product UI dozens of times a +│ session; content appearing late reads as lag, not delight. +└── No, it's a marketing surface (landing page, blog, docs) + ├── Is the element in the initial viewport (above the fold)? + │ └── Yes → Don't scroll-reveal it. Use a one-time intro animation + │ or nothing. The hero must never wait for a scroll event. + ├── Are you about to reveal EVERY section? + │ └── Yes → Cut it to 2-4 moments. If everything animates, nothing + │ stands out; each reveal devalues the next. + └── Does it explain, pace, or emphasize something specific? + ├── Yes → Build it (rules below). + └── No ("it looks cool") → The best animation is no animation. +``` + +Marketing pages are the packaging of the product: they have earned slower, more expressive motion because they are seen rarely. That freedom is the reason to be selective, not a license to animate everything. + +## Two kinds: triggered vs scrubbed + +Every scroll animation is one of these; the wrong choice is unfixable by tuning: + +- **Triggered reveal.** Crossing a threshold *starts* a normal animation that then runs on its own clock (easing plus duration). For "fade in as it enters the viewport". +- **Scrubbed.** Scroll position *is* the clock; progress maps directly to animation progress and reverses when the user scrolls back. For progress bars, parallax, sticky sequences. + +A scrubbed animation has **no duration and no easing**: the user's hand is both. Adding a duration to scrubbed motion makes it lag behind the scrollbar, the same disconnected feeling as a spring during a drag. + +## Triggered reveals + +- **Reveal once. Never re-animate on scroll-up.** Intro animations run one time; replaying on every pass turns delight into a tic and makes content flicker during normal reading. Unobserve after firing (or `once: true` in Motion's `useInView`). +- **The recipe:** `opacity: 0` plus `translateY(10-16px)` settling to rest, with a strong ease-out (entering elements always ease out; the fast start reads as responsive). 400-600ms is right for marketing; product-speed 200ms reveals look nervous on a landing page. +- **Trigger early.** Start the animation when the element is roughly 10-20% into the viewport (`rootMargin: "0px 0px -10% 0px"`), so it plays *as* the user arrives, not after they have stopped and stared at a blank slot. +- **Stagger like a wave, not a metronome.** Sibling reveals offset by roughly 80-120ms, with delay and distance varied by importance: the heading leads, supporting text follows, the least important item can just fade with no movement. Uniform stagger kills hierarchy. +- **Content survives without JS.** The un-animated state is *visible*; JS adds the hidden initial state right before animating. A page of `opacity: 0` sections behind a broken script is the worst failure mode a marketing page has. +- **One entrance per container.** Don't reveal a section *and* stagger its children; pick one. + +Implementation: `IntersectionObserver` (or Motion's `useInView`) toggling a class. Never a scroll listener; it fires per frame on the main thread for work a threshold check does once. + +## Scrubbed animation + +Preference order, and why: + +1. **CSS scroll-driven animations:** `animation-timeline: view()` (the element's own viewport progress) or `scroll()` (container progress). They run off the main thread, stay hooked to the scrollbar even while the page is busy loading images, and cost no JS. Progressive-enhance: wrap in `@supports (animation-timeline: view())` with the no-animation state as fallback. +2. **Motion's `useScroll` plus `useTransform`:** when progress must feed React logic or compose with springs and gestures. This runs on the main thread via `requestAnimationFrame`: fine normally, drops frames under load. +3. **A raw scroll listener writing React state: never.** A re-render per scrolled pixel. + +```css +@supports (animation-timeline: view()) { + .figure { + animation: reveal linear both; + animation-timeline: view(); + animation-range: entry 0% cover 40%; + } +} +``` + +`linear` is correct here and only here: the scrubbed timeline's pacing comes from the user's hand, and any curve would distort the 1:1 mapping. + +The `@supports` wrapper is not optional: `animation-timeline` is still not Baseline, so without it the element sits at its keyframe start forever in browsers that ignore the property. The unwrapped rule must leave the element in its final, readable state. + +## Parallax + +Parallax is depth seasoning, and heavy-handed parallax is the fastest way to make a page feel dated: + +- **Keep the differential at or under roughly 15%** of scroll distance between layers. Enough to read as depth; more reads as content swimming. +- **Transform only**, scrubbed (no duration), decorative elements only: never body text, never anything the user needs to read while it moves. +- Skip it on mobile: short viewports and momentum scrolling turn subtle parallax into jitter. + +## Sticky and scrollytelling sections + +A section that pins while scroll drives a sequence is an **explanation** device; it earns its scroll length only if each increment reveals a step of a story. Rules: progress maps monotonically to the sequence (scrolling back rewinds it); keep the pinned length at or under roughly 2-3 viewport heights, because trapped-feeling sticky sections are where users close tabs; and the section must be skippable by simply continuing to scroll. Never block or slow the scrollbar to force the story. + +## Never hijack scroll + +No scroll-jacking, no rewriting wheel deltas, no "one wheel tick = one full-screen slide". Smooth-scroll libraries that re-implement scrolling on the main thread trade native responsiveness for a float many users read as lag. If you add `scroll-behavior: smooth` for anchor links, keep it to that: + +```css +html { scroll-behavior: smooth; } +``` + +## Performance + +The golden rule holds: **animate only `transform` and `opacity`**. A scrolling page is the worst place for layout-triggering properties, since Layout and Paint work stacks on top of the scroll itself. Add `will-change: transform` on scrubbed elements only (they animate for the whole scroll, so the dedicated layer pays for itself; on one-shot reveals it's wasted memory). Keep any animated `blur()` at or under 20px. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/spring-animations.md b/plugins/frontend-product-design/skills/ui-animation/references/spring-animations.md index a0ada4d..ec3b15c 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/spring-animations.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/spring-animations.md @@ -2,6 +2,16 @@ Springs simulate physics, so they feel more natural than duration-based animations: no fixed duration, they settle by physical parameters. +## Contents +- [When to use springs](#when-to-use-springs) +- [Spring parameters](#spring-parameters) +- [Configuration presets](#configuration-presets) +- [Apple's damping and response framing](#apples-damping-and-response-framing) +- [Asymmetric spring character](#asymmetric-spring-character) +- [Interruptibility advantage](#interruptibility-advantage) +- [Spring-based mouse interactions](#spring-based-mouse-interactions) +- [Snap instead of spring](#snap-instead-of-spring) + ## When to use springs - Drag with momentum (release, let physics take over) @@ -39,6 +49,50 @@ Springs simulate physics, so they feel more natural than duration-based animatio Bounce signals brand personality. Default to zero (the safe choice): a finance dashboard should never bounce; a learning app or creative tool can use subtle bounce (0.1-0.2) to feel friendlier. The question isn't "does it look better with bounce?" but "does it match the brand?" +## Apple's damping and response framing + +Apple deliberately replaced the physics triplet (mass/stiffness/damping) with two designer-friendly parameters. Reason in these: + +- **Damping ratio** controls overshoot. `1.0` = critically damped, no bounce, smooth settle; `< 1.0` overshoots and oscillates; lower = bouncier. +- **Response** is how quickly the value reaches the target, in seconds. Lower = snappier. This is not a duration: a spring has no fixed duration, its settle time emerges from the parameters. + +Default most UI to **damping 1.0** (critically damped): graceful and non-distracting. Add bounce (**damping ~0.8**) only when the gesture itself carried momentum (a flick, a throw, a drag release). Overshoot on a menu that just faded in feels wrong; overshoot on a card you flicked feels right. + +Values Apple ships: + +| Interaction | Damping | Response | +|---|---|---| +| Move / reposition (e.g. PiP) | `1.0` | `0.4` | +| Rotation | `0.8` | `0.4` | +| Drawer / sheet | `0.8` | `0.3` | + +**Web mapping:** Motion's `bounce` + `duration` spring API maps closely to Apple's damping + response. A safe house style is critically damped springs everywhere by default; reserve bounce for momentum-driven, physical interactions. + +```js +// Critically damped default (no overshoot) +animate(el, { y: 0 }, { type: "spring", bounce: 0, duration: 0.4 }); + +// Momentum interaction: a little bounce, only because a flick preceded it +animate(el, { y: target }, { type: "spring", bounce: 0.2, duration: 0.4 }); +``` + +## Asymmetric spring character + +Open and close differ in **stiffness, not just duration**: when an element earns bounce, the bounce belongs to the open and the close stays critically damped. Bouncing both directions is the most common reason a well-built morph still feels cheap. + +Measured on a production container morph (frame-by-frame at 60fps): + +| Direction | Time to extreme | Overshoot | Fitted spring | At rest | +|---|---|---|---|---| +| Open | 284ms | 121% of travel | `stiffness: 155, damping: 11` (ζ 0.44) | 584ms | +| Close | 185ms | ~102% of travel | `stiffness: 620, damping: 36` (ζ ~0.75) | 300ms | + +The close is twice as fast *and* nearly four times as stiff. Its 2% undershoot is below the perceptual threshold, so a plain `cubic-bezier(0.32, 0.72, 0, 1)` substitutes for it cleanly. + +This widens the "bounce only after momentum" default above rather than replacing it. A menu that merely faded in still should not bounce. A container the user watched push outwards has enough implied mass to justify a settle, and that is the one case where the default reads as too conservative. + +For measuring asymmetry off a recording rather than choosing it, `choreography.md` covers reading the two directions out of the frame timeline. + ## Interruptibility advantage Springs keep velocity when interrupted; CSS keyframes restart from zero. Ideal for gestures users might change mid-motion. @@ -51,12 +105,18 @@ Springs keep velocity when interrupted; CSS keyframes restart from zero. Ideal f /> ``` +Three rules make interruption feel seamless: + +- **Animate from the presentation value, never the logical target.** On interrupt, read the element's live on-screen transform and start the new animation from there. Starting from the target value causes a visible jump. (A closing modal the user grabs again should follow the finger, not finish closing first and then reopen.) Springs do this by default; CSS transitions and keyframes cannot be grabbed and reversed mid-flight, so avoid them for gesture-driven motion. +- **Carry velocity through a retarget.** Replacing one animation with another at a reversal creates a velocity discontinuity, a "brick wall". Pick a spring library that re-targets from the current velocity (iOS does this natively with additive animations). +- **Decompose 2D motion into independent X and Y springs.** A single spring on a 2D distance desyncs when X and Y have different velocities. + ## Spring-based mouse interactions Tying values directly to mouse position feels artificial. Use `useSpring` to interpolate instead of updating immediately. ```tsx -import { useSpring } from "framer-motion"; +import { useSpring } from "motion/react"; // Without spring: instant, feels artificial const rotation = mouseX * 0.1; diff --git a/plugins/frontend-product-design/skills/ui-animation/references/svg-animation.md b/plugins/frontend-product-design/skills/ui-animation/references/svg-animation.md new file mode 100644 index 0000000..aed8422 --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/svg-animation.md @@ -0,0 +1,115 @@ +# SVG Animation + +Recipes for animating vector art: line drawing, rotation, path morphing, shakes, and ambient life. SVG has its own coordinate system and its own transform-origin rules, so HTML habits produce wrong results here. + +## Contents +- [Fundamentals](#fundamentals) +- [Line drawing (self-drawing stroke)](#line-drawing-self-drawing-stroke) +- [Rotation and transform-origin (the SVG trap)](#rotation-and-transform-origin-the-svg-trap) +- [Path morphing](#path-morphing) +- [Shakes and multi-step motion](#shakes-and-multi-step-motion) +- [Ambient life](#ambient-life) +- [Performance for busy SVG scenes](#performance-for-busy-svg-scenes) + +## Fundamentals + +- SVG is coordinate-based with no document flow; unpositioned elements stack at `(0,0)`. +- `viewBox="minX minY width height"` is the camera: it enables responsive scaling and keeps animation values consistent at any display size. +- Path commands: `M` move (no draw), `L` line, `Z` close; uppercase is absolute, lowercase relative. Close paths with `Z` or the point where start meets end shows an awkward corner. +- Degenerate shapes don't render at all: `width="0"`, `r="0"`, or a line whose start equals its end vanish entirely (unlike `opacity: 0`, where the shape still exists). +- Put `overflow: visible` on the `` so overshoot and scale don't clip. Nest `` groups to layer independent transforms on one element. + +## Line drawing (self-drawing stroke) + +Reveal a stroke as if it's being drawn by animating `stroke-dashoffset`: + +1. Set `stroke-dasharray` so the dash equals the full path length and the gap is large (only one dash shows). +2. Offset by the path length to hide it. +3. Animate the offset back to `0` to draw it in. + +```css +path { + stroke-dasharray: 1px 1.1px; + stroke-dashoffset: 1px; + animation: draw 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} +@keyframes draw { to { stroke-dashoffset: 0; } } +``` + +- `pathLength="100"` on the path normalizes its length so you work in round numbers and can share values across paths of different real lengths. +- `animation-fill-mode: forwards` is required or the shape snaps back to hidden when the animation ends. +- Stagger multiple strokes with `animation-delay` (a checkmark waits for its box to finish drawing). +- `stroke-linecap: round` gotcha: rounded caps extend past the mathematical dash, so make the gap slightly larger than the dash (`1px` dash, `1.1px` gap) or the caps peek through while the line should be hidden. + +## Rotation and transform-origin (the SVG trap) + +`transform-origin` in SVG defaults to the viewBox `(0,0)`, and `center` means the center of the viewBox, not the element. Fix it one of two ways: + +```css +/* Preferred: make origin relative to the element's own box (HTML-like) */ +.el { transform-box: fill-box; transform-origin: center; } + +/* Or: keep viewBox coordinates and rotate around a specific point */ +.hand { transform-origin: 50px 50px; } /* clock center of a 100x100 viewBox */ +``` + +For a zero-thickness line's bounding box, `transform-origin: 0% 100%` hits the start point (the zero dimension ignores its percentage). + +**Motion for React overrides a `transformOrigin` set in `style` on SVG elements back to `50% 50%`.** Set it in the `initial` prop instead: + +```jsx + +``` + +Use `transform-box: view-box` plus a pixel `transformOrigin` to rotate a group around a distant point (e.g. decorations orbiting a clock's center). + +## Path morphing + +Animate a path's `d` between two shapes; this only works when both paths share point structure: + +```jsx +const progress = useMotionValue(0); +const d = useTransform(progress, [0, 1], [openPath, closedPath]); +// +``` + +If the two paths differ in structure, interpolate with the `flubber` library instead. + +## Shakes and multi-step motion + +Keyframe arrays fit shakes, pulses, and press feedback: decaying, alternating-sign values. + +```jsx +// bell shake: rotate keyframes, large to small, alternating +animate={{ rotate: [0, 20, -15, 12.5, -10, 10, -7.5, 7.5, -5, 5, 0] }} +// press feedback: compress, overshoot, settle +animate={{ transform: ["scale(1)", "scale(0.97)", "scale(1.01)", "scale(1)"] }} +``` + +Put the rotate on a wrapping `` so nested decorations shake for free. + +## Ambient life + +Make idle scenes feel alive with barely perceptible looping motion, and use **non-syncing durations** so layers never line up; that's what makes it read organic instead of mechanical: + +```jsx +// float: translateY 0 to 1.5px over 3s; rotate: 0 to 2deg over 4s +transition={{ ease: "easeInOut", repeat: Infinity, repeatType: "reverse" }} +``` + +Give idle and attention loops an initial delay (~2s) so users discover interactions first, and a `repeatDelay` between plays. Pause the loops off-screen (see the IntersectionObserver hook in `performance-deep-dive.md`). + +## Performance for busy SVG scenes + +Many simultaneously animating SVG elements, especially with filters, can drop frames. Promote only the animated ones, after you see jank, not preemptively: + +```css +svg [data-animate] { will-change: transform, opacity, stroke-dashoffset; contain: layout style paint; } +svg .filter-animated { will-change: transform; transform: translateZ(0); } +``` + +`contain: layout style paint` isolates an element's rendering so it doesn't repaint siblings; `translateZ(0)` forces a GPU layer for expensive filtered elements. Target `[data-animate]`, not every node; too many GPU layers cost memory. diff --git a/plugins/frontend-product-design/skills/ui-animation/references/transition-recipes.md b/plugins/frontend-product-design/skills/ui-animation/references/transition-recipes.md index e4cd19a..9906774 100644 --- a/plugins/frontend-product-design/skills/ui-animation/references/transition-recipes.md +++ b/plugins/frontend-product-design/skills/ui-animation/references/transition-recipes.md @@ -1,10 +1,11 @@ # CSS Transition Recipes -12 CSS transition patterns. Each includes CSS, HTML hooks, JS orchestration where needed, and a `prefers-reduced-motion` guard. All read from a shared `:root` custom properties block. +14 CSS transition patterns. Each includes CSS, HTML hooks, and JS orchestration where needed. All read from a shared `:root` custom properties block. ## Contents - [Custom properties](#custom-properties) +- [Container morph](#container-morph) - [Card resize](#card-resize) - [Panel reveal](#panel-reveal) - [Notification badge](#notification-badge) @@ -14,6 +15,7 @@ - [Text state swap](#text-state-swap) - [Page side-by-side slides](#page-side-by-side-slides) - [Number pop-in](#number-pop-in) +- [Odometer digit roll](#odometer-digit-roll) - [Avatar group hover](#avatar-group-hover) - [Success celebration](#success-celebration) - [Error state shake](#error-state-shake) @@ -26,10 +28,23 @@ Add this `:root` block once to your global stylesheet; every recipe reads these ```css :root { + /* Container morph */ + --morph-open-dur: 580ms; + --morph-close-dur: 300ms; + --morph-open-ease: linear(0, 0.45, 0.78, 1, 1.17, 1.21, 1.18, 1.12, 1.05, 1.02, 1); + --morph-close-ease: cubic-bezier(0.32, 0.72, 0, 1); + --morph-content-dur: 140ms; + --morph-content-blur: 3px; + /* Card resize */ --resize-dur: 300ms; --resize-ease: cubic-bezier(0.22, 1, 0.36, 1); + /* Odometer digit roll */ + --odo-dur: 260ms; + --odo-ease: cubic-bezier(0.22, 1, 0.36, 1); + --odo-dir: 1; /* 1 = value increased, -1 = decreased */ + /* Number pop-in */ --digit-dur: 500ms; --digit-dist: 12px; @@ -43,8 +58,8 @@ Add this `:root` block once to your global stylesheet; every recipe reads these --badge-slide-dur: 260ms; --badge-pop-dur: 500ms; --badge-blur: 2px; - --badge-offset-x: -8.2px; - --badge-offset-y: 12.4px; + --badge-offset-x: -8px; + --badge-offset-y: 12px; --badge-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Text state swap */ @@ -122,6 +137,89 @@ Add this `:root` block once to your global stylesheet; every recipe reads these --- +## Container morph + +The trigger *becomes* the surface. A button, chip, or pill grows in place into the search field, form, menu, or confirmation it summons, keeping one continuous background and border-radius throughout. No new element appears, so there is nothing for the eye to re-find. + +Use this over Menu dropdown or Modal dialog whenever the trigger and the surface can share a shape. Use Card resize instead when the container already exists and only its dimensions change. + +Three things happen at once, and the order matters: + +| Phase | What | Timing | +|---|---|---| +| 1 | Old content fades and blurs out | `0` to `--morph-content-dur` | +| 2 | Container tweens to the new box | full `--morph-open-dur` | +| 3 | New content fades and blurs in | starts at `--morph-content-dur` | + +Measure the target box before animating: a plain `width: auto` has nothing to interpolate towards. Where `interpolate-size: allow-keywords` is supported you can transition to `auto` and drop the measure step, so check support for your targets before choosing. + +```html +
+
+
+ +
+
+``` + +```css +.t-morph { + position: relative; + overflow: hidden; + border-radius: 999px; + transition: width var(--morph-close-dur) var(--morph-close-ease), + height var(--morph-close-dur) var(--morph-close-ease); + will-change: width, height; +} +.t-morph[data-open="true"] { + transition-duration: var(--morph-open-dur); + transition-timing-function: var(--morph-open-ease); +} + +/* Faces stack, so the container never sees both in flow. */ +.t-morph-face { + transition: opacity var(--morph-content-dur) ease, + filter var(--morph-content-dur) ease; +} +.t-morph-face[data-face="open"] { position: absolute; inset: 0; } + +.t-morph[data-open="false"] [data-face="open"], +.t-morph[data-open="true"] [data-face="closed"] { + opacity: 0; + filter: blur(var(--morph-content-blur)); + pointer-events: none; +} + +/* Incoming content waits for the box to be most of the way there. */ +.t-morph[data-open="true"] [data-face="open"], +.t-morph[data-open="false"] [data-face="closed"] { + transition-delay: var(--morph-content-dur); +} +``` + +**JS, measure then toggle:** + +```js +function morph(el, open) { + const face = el.querySelector(`[data-face="${open ? "open" : "closed"}"]`); + // Measure the target face off-flow, at its natural size. + const prev = face.style.cssText; + Object.assign(face.style, { position: "absolute", visibility: "hidden", width: "max-content" }); + const { width, height } = face.getBoundingClientRect(); + face.style.cssText = prev; + + el.style.width = `${width}px`; + el.style.height = `${height}px`; + el.dataset.open = String(open); +} +``` + +**Measured reference.** Tracking a real implementation frame by frame at 60fps: the open reached **121% of its travel** at 284ms (a container **9.8% wider** than its resting width), then settled over a further 300ms. The close reached a ~2% undershoot in 185ms and was visually at rest by 300ms. Expansion was symmetric about the trigger's centre, not anchored to an edge. + +`--morph-open-ease` is that overshoot transcribed as a `linear()` curve, so `--morph-open-dur` covers the whole settle even though the morph reads as finished around 300ms. The equivalent spring is `{ stiffness: 155, damping: 11, mass: 1 }` (damping ratio 0.44); the close fits `{ stiffness: 620, damping: 36 }`, near enough to critically damped that the bezier above is indistinguishable. Prefer the spring form when the morph must survive interruption. `spring-animations.md` § Asymmetric spring character covers why only the open bounces. + +--- + ## Card resize Tween a container's width or height when its layout state changes (compact/expanded card, collapsing panel, list row toggling detail). CSS only, no JS. @@ -137,10 +235,6 @@ Tween a container's width or height when its layout state changes (compact/expan will-change: width, height; overflow: hidden; } - -@media (prefers-reduced-motion: reduce) { - .t-resize { transition: none; } -} ``` Toggle dimensions with a state class or inline style; the transition handles the tween. @@ -174,10 +268,6 @@ See also: `component-patterns.md` § Drawers and panels for percentage-based dra .t-panel[data-open="false"] { transition-duration: var(--panel-close-dur); } - -@media (prefers-reduced-motion: reduce) { - .t-panel { transition: none; } -} ``` --- @@ -222,10 +312,6 @@ Slide a small badge onto a trigger (button, icon) and pop the dot; the trigger s transform: scale(1); transition-delay: calc(var(--badge-slide-dur) * 0.5); } - -@media (prefers-reduced-motion: reduce) { - .t-badge, .t-badge-dot { transition: none; animation: none; } -} ``` --- @@ -262,10 +348,6 @@ See also: `contextual-animations.md` § Contextual icon swaps for the Motion/Ani transform: scale(1); filter: blur(0); } - -@media (prefers-reduced-motion: reduce) { - .t-icon { transition: none; } -} ``` --- @@ -274,7 +356,7 @@ See also: `contextual-animations.md` § Contextual icon swaps for the Motion/Ani Origin-aware dropdown with open/close animations. JS handles close-state cleanup. -See also: `component-patterns.md` § Popovers and dropdowns for Radix UI transform-origin and scale patterns. +See also: `component-patterns.md` § Popovers and dropdowns for library transform-origin and scale patterns. ```html
@@ -305,10 +387,6 @@ See also: `component-patterns.md` § Popovers and dropdowns for Radix UI transfo .t-dropdown[data-origin="bottom-left"] { transform-origin: bottom left; } .t-dropdown[data-origin="bottom-center"]{ transform-origin: bottom center; } .t-dropdown[data-origin="bottom-right"] { transform-origin: bottom right; } - -@media (prefers-reduced-motion: reduce) { - .t-dropdown { transition: none; } -} ``` **JS, close with cleanup:** @@ -351,10 +429,6 @@ See also: `component-patterns.md` § Modals and dialogs for `@starting-style` en transform: scale(var(--modal-scale)); transition-duration: var(--modal-close-dur); } - -@media (prefers-reduced-motion: reduce) { - .t-modal { transition: none; } -} ``` **JS, close with cleanup:** @@ -395,10 +469,6 @@ Swap text in place with a blurred vertical transition ("Processing..." → "Done transform: translateY(var(--text-swap-y)); filter: blur(var(--text-swap-blur)); } - -@media (prefers-reduced-motion: reduce) { - .t-text-swap { transition: none; } -} ``` **JS, three-phase orchestration:** @@ -463,10 +533,6 @@ See also: `component-patterns.md` § Step form navigation for the Motion/Animate transform: translateX(calc(-1 * var(--page-dist))); filter: blur(var(--page-blur)); } - -@media (prefers-reduced-motion: reduce) { - .t-page-slide > * { transition: none; } -} ``` **JS, switch page:** @@ -514,10 +580,6 @@ Re-enter digits with directional blur on number update (counters, prices, balanc } .t-digit[data-stagger="1"] { animation-delay: var(--digit-stagger); } .t-digit[data-stagger="2"] { animation-delay: calc(var(--digit-stagger) * 2); } - -@media (prefers-reduced-motion: reduce) { - .t-digit { animation: none; } -} ``` **JS, replay on update:** @@ -539,6 +601,64 @@ function updateDigits(container, newValue) { --- +## Odometer digit roll + +Roll each changed digit vertically, in the direction the value moved: up for an increase, down for a decrease. Use this over Number pop-in when the number is being *driven* by the user (steppers, sliders, scrubbers, quantity controls), where direction is the feedback. Keep pop-in for values that arrive on their own, where there is no direction to convey. + +Two rules keep it readable. Only re-render the digits that actually changed, or a 199 to 200 tick rolls all three and reads as noise. And set `font-variant-numeric: tabular-nums`, or the row re-flows on every tick and the roll turns into a jitter. + +```html + + 4 + 1 + +``` + +```css +.t-odo { + display: inline-flex; + font-variant-numeric: tabular-nums; +} +.t-odo-slot { + display: inline-block; + overflow: hidden; /* the window the digit rolls through */ + height: 1em; + line-height: 1em; +} + +@keyframes odo-roll { + from { + transform: translateY(calc(var(--odo-dir) * 1em)); + opacity: 0; + } +} + +.t-odo-digit[data-rolling] { + display: block; + animation: odo-roll var(--odo-dur) var(--odo-ease) both; +} +``` + +**JS, roll only what changed:** + +```js +function setOdometer(el, next, prev) { + el.style.setProperty("--odo-dir", next > prev ? 1 : -1); + const a = String(prev).padStart(String(next).length, " "); + const b = String(next); + el.innerHTML = [...b] + .map((d, i) => { + const rolling = d !== a[i] ? " data-rolling" : ""; + return `${d}`; + }) + .join(""); +} +``` + +The outgoing digit is dropped rather than animated out. At 260ms with the slot clipping, the eye reads the incoming digit as having pushed the old one away; animating both doubles the work for no visible gain. + +--- + ## Avatar group hover Distance-falloff lift on a horizontal stack. Hovered item lifts and scales; neighbors lift less with distance. Bouncy spring on leave. @@ -559,10 +679,6 @@ Distance-falloff lift on a horizontal stack. Hovered item lifts and scales; neig .t-avatar { transition: transform var(--avatar-dur) var(--avatar-ease-in); } - -@media (prefers-reduced-motion: reduce) { - .t-avatar { transition: none; } -} ``` **JS, distance-based lift:** @@ -645,11 +761,6 @@ Multi-layered success: fade, rotation, blur reduction, Y-bob with overshoot, opt .t-success[data-state="in"] .t-success-path { stroke-dashoffset: 0; } - -@media (prefers-reduced-motion: reduce) { - .t-success { animation: none; opacity: 1; transform: none; filter: none; } - .t-success-path { transition: none; stroke-dashoffset: 0; } -} ``` **JS, set path length and replay:** @@ -712,11 +823,6 @@ Per-segment shake with auto-reverting error border. Three classes: `.is-error` o opacity: 1; transform: translateY(0); } - -@media (prefers-reduced-motion: reduce) { - .t-error-input { animation: none; } - .t-error-msg { transition: none; } -} ``` **JS, trigger and auto-revert:** diff --git a/plugins/frontend-product-design/skills/ui-animation/references/vocabulary.md b/plugins/frontend-product-design/skills/ui-animation/references/vocabulary.md new file mode 100644 index 0000000..b91949b --- /dev/null +++ b/plugins/frontend-product-design/skills/ui-animation/references/vocabulary.md @@ -0,0 +1,159 @@ +# Animation Vocabulary + +Reverse-lookup glossary: turn a vague description of a motion or effect into the precise term, so the user knows what to ask for. + +## Contents +- [How to answer](#how-to-answer) +- [Examples](#examples) +- [Glossary](#glossary) + +## How to answer + +The user describes an effect loosely; you return the matching term(s) in this format: + +``` +**Stagger**: Animate several items one after another with a small delay between each, creating a cascade. +``` + +If several terms fit, lead with the best match, then 1-2 alternates with a one-line note on how they differ. + +1. **Read for intent, not keywords.** Users describe what they see or feel ("springy", "slides off", "draws itself in"), not the technical name. Map the sensation to the glossary. +2. **Quote the glossary verbatim.** Its descriptions are authoritative; use them as-is. +3. **Disambiguate close terms.** When two compete (clip-path vs mask, pop in vs bounce, shared element transition vs layout animation), contrast them so the user can pick. +4. **When nothing matches exactly,** name the closest term and say plainly it's an approximation, or describe the effect in the glossary's vocabulary ("that's a stagger of scale-in entrances"). +5. **Stay within this glossary.** If a term genuinely isn't here, say so rather than inventing one, though you may explain the concept using these words. +6. **Keep it tight.** A naming question wants a name, not an essay. Lead with the term; expand only if asked. + +## Examples + +**Feel-based** +User: "What's it called when a popover seems to grow out of the button you clicked instead of from its middle?" +Answer: **Origin-aware animation**: An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center, which is the default in CSS. + +**Disambiguation** +User: "The thing where one image turns into another image." +Answer: **Morph**: One shape smoothly turns into another shape, e.g. Dynamic Island. Close alternates: **Crossfade** if they simply fade over each other in the same spot; **Shared element transition** if an element travels and transforms from one position into another. + +**Physics feel** +User: "That iOS scroll where it resists and snaps back when you pull too far." +Answer: **Rubber-banding**: Resistance and snap-back when you drag past a boundary (the iOS overscroll feel). + +## Glossary + +### Entrances and exits: how elements appear and disappear +- **Fade in / Fade out**: Element appears or disappears by changing opacity. +- **Slide in**: Element enters by sliding in from off-screen (left, right, top, or bottom). +- **Scale in**: Element grows from smaller to full size as it appears, often paired with a fade. +- **Pop in**: Element appears with a slight overshoot, like it bounces into place. +- **Reveal**: Content is uncovered gradually, often by animating a clip-path or mask. +- **Enter / Exit**: The animation an element plays when it's added to or removed from the screen. + +### Sequencing and timing: coordinating multiple elements or moments +- **Keyframes**: Defined points in an animation (0%, 50%, 100%) that the browser fills the gaps between. +- **Interpolation / Tween**: Generating all the in-between frames between a start and end value, so motion is continuous. +- **Stagger**: Animate several items one after another with a small delay between each, creating a cascade. +- **Orchestration**: Deliberately timing multiple animations so they feel like one coordinated motion. +- **Delay**: Time before an animation starts. +- **Duration**: How long an animation takes. +- **Fill mode**: Whether an element keeps its first or last frame's styles before the animation starts or after it ends (e.g. forwards). +- **Stepped animation**: An animation divided into discrete steps, like a countdown timer. + +### Movement and transforms: changing an element's position, size, or angle +- **Translate**: Move an element along the X or Y axis. +- **Scale**: Make an element bigger or smaller. +- **Rotate**: Spin an element around a point. +- **Skew**: Slant an element along the X or Y axis, shearing it out of its rectangular shape. +- **3D tilt / Flip**: Rotate in 3D space (rotateX / rotateY) to add depth. +- **Perspective**: How strong the 3D effect looks; a lower value exaggerates depth, like the viewer is closer. +- **Transform origin**: The anchor point a scale or rotation grows or spins from. +- **Origin-aware animation**: An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center, which is the default in CSS. + +### Transitions between states: connecting one state, view, or element to another +- **Crossfade**: One element fades out as another fades in, in the same spot. +- **Continuity transition**: A change that keeps the user oriented by visually connecting before and after. For example, making the same rectangle bigger and smaller. +- **Morph**: One shape smoothly turns into another shape, e.g. Dynamic Island. +- **Container morph**: A trigger grows in place into the surface it summons, so the button becomes the search field or form instead of opening one next to it. +- **Shared element transition**: An element travels and transforms from one position into another, like a thumbnail expanding into a card. +- **Layout animation**: When an element's size or position changes, it animates to the new spot instead of snapping. +- **Accordion / Collapse**: A section smoothly expands and collapses its height to show or hide content. +- **Direction-aware transition**: Content slides one way going forward and the opposite way going back, so navigation has a sense of direction. + +### Scroll: motion tied to scrolling or navigating between views +- **Scroll reveal**: Elements fade or slide into place as they enter the viewport. +- **Scroll-driven animation**: An animation whose progress is tied directly to scroll position. +- **Parallax**: Background and foreground move at different speeds while scrolling, creating depth. +- **Page transition**: An animation that plays when navigating from one page or route to another. +- **View transition**: The browser morphs between two states or pages, connecting shared elements. + +### Feedback and interaction: responding to the user's actions +- **Hover effect**: Visual change when the cursor moves over an element. +- **Press / Tap feedback**: A subtle scale-down when an element is clicked, so it feels physical. +- **Hold to confirm**: A progress effect that fills up while the user holds a button. +- **Drag**: Moving an element by grabbing it, often with momentum when released. +- **Drag to reorder**: Dragging items in a list to rearrange them, while the others shift to make room. +- **Swipe to dismiss**: Dragging an element off-screen to close it, like a drawer or toast. +- **Rubber-banding**: Resistance and snap-back when you drag past a boundary (the iOS overscroll feel). +- **Detent**: A discrete stop a control settles onto when released, like the notches on a ruler picker or tick slider. +- **Peripheral de-emphasis**: Blurring and fading everything around the focused item instead of dimming the whole page, so the set stays visible but recedes. +- **Shake / Wiggle**: A quick side-to-side jitter signaling an error or rejected input. +- **Ripple**: A circle expanding from the point of a tap, confirming the press. + +### Easing: how speed changes over an animation +- **Easing**: The rate at which an animation speeds up or slows down. +- **Ease-out**: Starts fast, ends slow. The default for most UI and anything responding to the user. +- **Ease-in**: Starts slow, ends fast. Usually avoided; can feel sluggish. +- **Ease-in-out**: Slow, fast, slow. Good for elements already on screen moving from A to B. +- **Linear**: Constant speed. Avoid for UI; reserve for spinners or marquees. +- **Cubic-bezier**: A custom easing curve you define for precise control. +- **Asymmetric easing**: A curve that accelerates and decelerates at different rates. Feels more alive than a symmetric one. + +### Spring animations: physics-based motion as an alternative to fixed-duration easing +- **Spring**: Motion driven by physics (tension, mass, damping) rather than a set duration. +- **Stiffness / Tension**: How strongly the spring pulls toward its target. Higher feels snappier. +- **Damping**: How quickly a spring settles. Lower damping means more bounce and oscillation. +- **Mass**: How heavy the animated element feels. More mass makes it slower and more sluggish. +- **Bounce**: A spring that overshoots and settles, adding playfulness. +- **Perceptual duration**: How long a spring feels finished, even though it keeps micro-settling underneath. +- **Momentum**: Motion that carries velocity, especially after a drag or interruption. +- **Velocity**: How fast and in which direction an element is moving. A spring carries it into the next animation when interrupted, so a flicked element keeps its speed. +- **Interruptible animation**: An animation that can be smoothly redirected mid-flight instead of finishing first. + +### Looping and ambient motion: animations that run on their own +- **Marquee**: Text or content that scrolls continuously in a loop. +- **Loop**: An animation that repeats, a set number of times or infinitely. +- **Alternate (yoyo)**: A loop that plays forward then reverses each iteration, instead of jumping back to the start. +- **Orbit**: An element circling around another in a continuous path. +- **Pulse**: A gentle repeating scale or opacity change to draw attention. +- **Float**: A gentle, continuous up-and-down drift that makes a static element feel alive and weightless. +- **Idle animation**: Subtle motion that plays while an element is just sitting there, waiting to be interacted with. + +### Polish and effects: the small touches that separate good from great +- **Blur**: A blur filter used to soften an element or mask tiny imperfections. +- **Clip-path**: Clipping an element to a shape, used for reveals, masks, and before/after sliders. +- **Mask**: Hiding or revealing parts of an element using a shape or gradient, like clip-path but with soft, fadeable edges. +- **Before / after slider**: A draggable divider that wipes between two overlaid images to compare them. +- **Line drawing**: An SVG path that draws itself in, like an invisible pen tracing it. +- **Text morph**: Text that animates character by character when it changes, drawing attention to the new value. +- **Skeleton / Shimmer**: A placeholder with a moving sheen shown while content loads. +- **Number ticker**: Digits rolling or counting up to a value. +- **Odometer roll**: Digits rolling vertically in the direction the value moved, up for an increase and down for a decrease, like a car's odometer. +- **Tabular numbers**: Fixed-width digits so numbers don't shift around as they change. Essential for tickers, timers, and counters. +- **Typewriter**: Text appearing one character at a time, as if being typed. + +### Performance: what keeps motion smooth instead of stuttering +- **Frame rate (FPS)**: Frames drawn per second. 60fps is the baseline for smooth motion; 120fps on newer displays. +- **Jank**: Visible stutter when the browser drops frames because it can't keep up with the animation. +- **Dropped frame**: A frame the browser missed its deadline to draw, causing a tiny hitch in motion. +- **Compositing**: Letting the GPU move or fade an element on its own layer without redoing layout or paint. +- **will-change**: A CSS hint that an element is about to animate, so the browser can promote it to its own layer ahead of time. +- **Layout thrashing**: Animating properties like width, height, top, or left that force the browser to recalculate layout every frame, causing jank. + +### Principles to know: concepts that guide when and how to animate +- **Purposeful animation**: Motion should serve a function (orient, give feedback, show relationships), not just decorate. +- **Anticipation**: A small wind-up in the opposite direction before a move, hinting at what's about to happen. +- **Follow-through**: Parts of an element keep moving and settle slightly after the main motion stops, adding weight. +- **Squash and stretch**: Deforming an element as it moves to convey weight, speed, and flexibility. +- **Perceived performance**: The right animation makes an interface feel faster, even when it isn't. +- **Frequency of use**: The more often a user sees an animation, the shorter and subtler it should be. +- **Spatial consistency**: Animating so an element keeps its identity and position across states, so users never lose track of where things went. +- **Hardware acceleration**: Animating transform and opacity lets the GPU keep motion smooth. diff --git a/plugins/frontend-product-design/skills/ui-animation/scripts/fit_curves.py b/plugins/frontend-product-design/skills/ui-animation/scripts/fit_curves.py index f440d71..be38db8 100644 --- a/plugins/frontend-product-design/skills/ui-animation/scripts/fit_curves.py +++ b/plugins/frontend-product-design/skills/ui-animation/scripts/fit_curves.py @@ -157,9 +157,9 @@ def main(): t = (frames - frames[0]) / args.fps # seconds from first tracked frame duration_ms = round(float(t[-1] * 1000)) - wanted = {args.property: PROPERTIES[args.property]} if args.property else PROPERTIES if args.property and args.property not in PROPERTIES: sys.exit(f"unknown property '{args.property}'. Choose from: {', '.join(PROPERTIES)}") + wanted = {args.property: PROPERTIES[args.property]} if args.property else PROPERTIES result = {"duration_ms": duration_ms, "tracked_frames": len(tl), "properties": {}} for name, fields in wanted.items(): diff --git a/plugins/frontend-product-design/skills/vercel-react-best-practices/metadata.json b/plugins/frontend-product-design/skills/vercel-react-best-practices/metadata.json deleted file mode 100644 index 3bec38b..0000000 --- a/plugins/frontend-product-design/skills/vercel-react-best-practices/metadata.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "version": "1.0.0", - "organization": "Vercel Engineering", - "date": "January 2026", - "abstract": "Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.", - "references": [ - "https://react.dev", - "https://nextjs.org", - "https://swr.vercel.app", - "https://github.com/shuding/better-all", - "https://github.com/isaacs/node-lru-cache", - "https://vercel.com/blog/how-we-optimized-package-imports-in-next-js", - "https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast" - ] -} diff --git a/plugins/health-wellness/skills/healthkit/SKILL.md b/plugins/health-wellness/skills/healthkit/SKILL.md index 873ae11..3011a68 100644 --- a/plugins/health-wellness/skills/healthkit/SKILL.md +++ b/plugins/health-wellness/skills/healthkit/SKILL.md @@ -239,9 +239,15 @@ func saveSteps(count: Double, start: Date, end: Date) async throws { try await healthStore.save(sample) } - ``` +Treat `try await healthStore.save(sample)` returning as the save success gate; +only then report success or advance app state. On failure, surface the error and +correct the known authorization, type, unit, duration, or input problem before +constructing another sample. A bounded query or inspection in the Health app is +useful as an integration-test check when persistence evidence is required, but +is not a mandatory production read after every save. + Your app can only delete samples it created. Samples from other apps or Apple Watch are read-only. ## Background Delivery @@ -315,16 +321,18 @@ func startWorkout() async throws { try await builder.beginCollection(at: Date()) } -func endWorkout( - session: HKWorkoutSession, - builder: HKLiveWorkoutBuilder -) async throws { - session.end() - try await builder.endCollection(at: Date()) - try await builder.finishWorkout() -} +// Request teardown; finalize from the delegate's .stopped transition. +session.stopActivity(with: Date()) ``` +Do not call `endCollection` and `finishWorkout` immediately after requesting the +stop. Wait for the session delegate's `.stopped` transition, then await +`builder.endCollection(at:)` followed by `builder.finishWorkout()`. Report the +workout as saved and clear session state only after both operations return. +Handle each thrown error without blindly repeating teardown. A successful +`finishWorkout()` can return no workout object while the device is locked, so a +`nil` result alone is not failure. + For full workout lifecycle management including pause/resume, delegate handling, and multi-device mirroring, see [references/healthkit-patterns.md](references/healthkit-patterns.md). ## Common Data Types @@ -412,9 +420,11 @@ HKUnit.literUnit(with: .deci) // Deciliters `completionHandler` called - [ ] Background delivery entitlement enabled if using `enableBackgroundDelivery` - [ ] Background delivery tested on device and frequency caps considered -- [ ] Workout sessions properly ended and builder finalized +- [ ] Workout stop waits for the delegate's `.stopped` transition before + `endCollection` and `finishWorkout`; state clears only after successful + finalization - [ ] Workout API availability and live heart-rate sensor requirements handled -- [ ] Write operations only for sample types the app created +- [ ] Delete operations target only objects the app previously saved ## References diff --git a/plugins/health-wellness/skills/healthkit/references/healthkit-patterns.md b/plugins/health-wellness/skills/healthkit/references/healthkit-patterns.md index e71dc9d..12f9de8 100644 --- a/plugins/health-wellness/skills/healthkit/references/healthkit-patterns.md +++ b/plugins/health-wellness/skills/healthkit/references/healthkit-patterns.md @@ -45,6 +45,7 @@ final class WorkoutManager: NSObject { var distance: Double = 0 var elapsedTime: TimeInterval = 0 var isActive = false + var finalizationError: Error? func startWorkout(activityType: HKWorkoutActivityType) async throws { let configuration = HKWorkoutConfiguration() @@ -81,11 +82,30 @@ final class WorkoutManager: NSObject { session?.resume() } - func end() async throws { - guard let session, let builder else { return } - session.end() - try await builder.endCollection(at: Date()) - try await builder.finishWorkout() + func end() { + guard let session else { return } + session.stopActivity(with: Date()) + } + + private func finalizeStoppedWorkout(at date: Date) async { + guard let builder else { return } + + do { + try await builder.endCollection(at: date) + } catch { + finalizationError = error + return + } + + do { + _ = try await builder.finishWorkout() + } catch { + finalizationError = error + return + } + + // A nil workout while locked is not a failed finish; the awaited return + // is the success boundary. isActive = false self.session = nil self.builder = nil @@ -107,7 +127,10 @@ extension WorkoutManager: HKWorkoutSessionDelegate { isActive = true case .paused: isActive = false - case .ended, .stopped: + case .stopped: + isActive = false + await finalizeStoppedWorkout(at: date) + case .ended: isActive = false default: break diff --git a/plugins/health-wellness/skills/rem-sleep/LICENSE b/plugins/health-wellness/skills/rem-sleep/LICENSE new file mode 100644 index 0000000..f163bd9 --- /dev/null +++ b/plugins/health-wellness/skills/rem-sleep/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Stewart Nightingale + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/health-wellness/skills/rem-sleep/README.md b/plugins/health-wellness/skills/rem-sleep/README.md new file mode 100644 index 0000000..dbf9863 --- /dev/null +++ b/plugins/health-wellness/skills/rem-sleep/README.md @@ -0,0 +1,136 @@ +# REM Sleep - Memory Consolidation for AI Agents + +> *Like biological REM sleep, this skill processes raw experience into consolidated long-term memory.* 🦞 + +[![GitHub](https://img.shields.io/github/stars/stewnight/rem-sleep-skill?style=social)](https://github.com/stewnight/rem-sleep-skill) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Quick Install + +**One-liner (curl):** +```bash +mkdir -p ~/.openclaw/skills/rem-sleep/scripts && \ +curl -sL https://raw.githubusercontent.com/stewnight/rem-sleep-skill/main/SKILL.md -o ~/.openclaw/skills/rem-sleep/SKILL.md && \ +curl -sL https://raw.githubusercontent.com/stewnight/rem-sleep-skill/main/scripts/gather-sessions.sh -o ~/.openclaw/skills/rem-sleep/scripts/gather-sessions.sh && \ +chmod +x ~/.openclaw/skills/rem-sleep/scripts/gather-sessions.sh && \ +echo "✅ rem-sleep skill installed!" +``` + +**Or clone the full repo:** +```bash +git clone https://github.com/stewnight/rem-sleep-skill.git ~/.openclaw/skills/rem-sleep +``` + +**Or just read the skill directly:** +``` +https://raw.githubusercontent.com/stewnight/rem-sleep-skill/main/SKILL.md +``` + +--- + +## Why? + +AI agents face a unique memory problem: +- **Session logs accumulate** but are expensive to re-read +- **Important insights get buried** in noise +- **"Mental notes" don't survive** context compaction or restarts +- **Starting from scratch** every session without persistent memory + +## The Solution + +Periodic "sleep cycles" that: +1. **Search** session logs for significant patterns +2. **Extract** what's worth remembering +3. **Consolidate** into durable memory files (MEMORY.md) +4. **Defrag** to remove stale info and reduce bloat + +## Works With + +- [OpenClaw](https://openclaw.ai) — the agent platform this was built for +- Claude Code or any coding agent +- Any agent with session logs and a memory file system + +--- + +## Usage + +The skill defines a **workflow**, not a binary. Read `SKILL.md` for the full process. + +### Quick version: + +**Consolidate** (every few days): +```bash +# Search for significant patterns in recent sessions +grep -r "decision\|learned\|important\|remember" ~/.openclaw/agents/main/sessions --include="*.jsonl" + +# Extract insights and update MEMORY.md +``` + +**Defrag** (weekly): +```bash +# Review MEMORY.md for: +# - Stale entries (outdated, completed TODOs) +# - Duplicates +# - Verbose entries that can be compressed +``` + +### With the helper script: +```bash +# Using native grep/jq (no dependencies) +./scripts/gather-sessions.sh 7 --native + +# Using Repo Prompt (if installed) +./scripts/gather-sessions.sh 7 +``` + +--- + +## Memory Architecture + +``` +workspace/ +├── MEMORY.md # Long-term curated memory +├── memory/ +│ ├── 2024-01-15.md # Daily raw logs +│ ├── 2024-01-16.md +│ └── heartbeat-state.json +└── skills/ + └── rem-sleep/ + └── SKILL.md +``` + +## Key Insight + +**Semantic search beats reading everything.** + +Instead of re-reading entire session logs (expensive), search for consolidation candidates: +- "decision", "learned", "important", "remember", "TODO" +- Emotional/evaluative language: "actually", "realized", "wrong about" +- Corrections and mind-changes + +Then extract and consolidate just those snippets. + +--- + +## Contributing + +PRs welcome! Ideas: +- [ ] Better heuristics for "what's worth remembering" +- [ ] Alternative search methods (beyond grep/Repo Prompt) +- [ ] Vector DB integration for true semantic search +- [ ] Cross-platform scripts (currently macOS-focused) +- [ ] Automation for different agent platforms + +## License + +MIT — use it, fork it, improve it. + +## Credits + +Built by [@MoltyNeeClawd](https://moltbook.com/u/MoltyNeeClawd) (an OpenClaw agent) with human assistance from [@stewnightnz](https://twitter.com/stewnightnz). + +**Discuss on Moltbook:** [REM Sleep for Agents](https://moltbook.com/post/f2998b1f-f751-4ef9-ab4d-c4cca6e171c1) + +--- + +*"The unexamined session is not worth running."* 🦞 diff --git a/plugins/health-wellness/skills/rem-sleep/scripts/gather-sessions.sh b/plugins/health-wellness/skills/rem-sleep/scripts/gather-sessions.sh new file mode 100755 index 0000000..90a7086 --- /dev/null +++ b/plugins/health-wellness/skills/rem-sleep/scripts/gather-sessions.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# gather-sessions.sh - Collect recent session data for memory consolidation +# Usage: ./gather-sessions.sh [days_back] [--native] +# +# Options: +# days_back Number of days to look back (default: 3) +# --native Use native grep/jq instead of Repo Prompt + +DAYS_BACK=${1:-3} +USE_NATIVE=false + +# Check for --native flag +for arg in "$@"; do + if [ "$arg" == "--native" ]; then + USE_NATIVE=true + fi +done + +# OpenClaw session logs location (adjust if different) +SESSIONS_DIR="${OPENCLAW_SESSIONS:-$HOME/.openclaw/agents/main/sessions}" + +# Repo Prompt CLI location (macOS) +RP_CLI="/Applications/Repo Prompt.app/Contents/MacOS/repoprompt-mcp" + +echo "=== REM Sleep: Gathering Sessions (last $DAYS_BACK days) ===" +echo "Sessions directory: $SESSIONS_DIR" +echo "" + +# Get cutoff date +if [[ "$OSTYPE" == "darwin"* ]]; then + CUTOFF=$(date -v-${DAYS_BACK}d +%Y-%m-%d) +else + CUTOFF=$(date -d "$DAYS_BACK days ago" +%Y-%m-%d) +fi + +echo "Looking for sessions since: $CUTOFF" +echo "" + +# Patterns to search for +PATTERNS=("decision" "learned" "important" "remember" "TODO" "preference" "mistake" "realized" "note to self") + +# Check if Repo Prompt is available and not forcing native +if [ -f "$RP_CLI" ] && [ "$USE_NATIVE" = false ]; then + echo "Using: Repo Prompt" + echo "" + + # List recent session files + echo "=== Recent Session Files ===" + "$RP_CLI" -e "tree $SESSIONS_DIR" 2>/dev/null + echo "" + + # Search for consolidation-worthy patterns + for pattern in "${PATTERNS[@]}"; do + echo "" + echo "=== Searching: \"$pattern\" ===" + "$RP_CLI" -e "search \"$pattern\" --context-lines 2" 2>/dev/null | head -50 + done +else + echo "Using: Native grep/jq (Repo Prompt not found or --native specified)" + echo "" + + # Check if sessions directory exists + if [ ! -d "$SESSIONS_DIR" ]; then + echo "Error: Sessions directory not found at $SESSIONS_DIR" + echo "Set OPENCLAW_SESSIONS env var or adjust the script." + exit 1 + fi + + # List recent session files + echo "=== Recent Session Files ===" + find "$SESSIONS_DIR" -name "*.jsonl" -mtime -${DAYS_BACK} -ls 2>/dev/null + echo "" + + # Search for patterns using grep + for pattern in "${PATTERNS[@]}"; do + echo "" + echo "=== Searching: \"$pattern\" ===" + + # Search in JSONL files, extract content field, grep for pattern + find "$SESSIONS_DIR" -name "*.jsonl" -mtime -${DAYS_BACK} -exec cat {} \; 2>/dev/null | \ + jq -r 'select(.content != null) | "\(.role // "?"): \(.content)"' 2>/dev/null | \ + grep -i "$pattern" | head -30 + + # If jq fails, fall back to raw grep + if [ ${PIPESTATUS[1]} -ne 0 ]; then + grep -r -i "$pattern" "$SESSIONS_DIR" --include="*.jsonl" 2>/dev/null | head -30 + fi + done +fi + +echo "" +echo "=== Gathering Complete ===" +echo "" +echo "Next steps:" +echo "1. Review the above for consolidation candidates" +echo "2. Update memory/$(date +%Y-%m-%d).md with today's events" +echo "3. Distill important learnings to MEMORY.md" diff --git a/plugins/software-delivery/skills/clean-code/SKILL.md b/plugins/software-delivery/skills/clean-code/SKILL.md index ea6e121..9e7a61f 100644 --- a/plugins/software-delivery/skills/clean-code/SKILL.md +++ b/plugins/software-delivery/skills/clean-code/SKILL.md @@ -1,10 +1,15 @@ --- name: clean-code -description: Apply Robert C. Martin's Clean Code principles (naming, functions, comments, formatting, error handling, tests, classes, code smells). Use when writing new code, reviewing pull requests, refactoring legacy code, or aligning on team coding standards. +description: "This skill embodies the principles of \"Clean Code\" by Robert C. Martin (Uncle Bob). Use it to transform \"code that works\" into \"code that is clean.\"" +risk: safe +source: "ClawForge (https://github.com/jackjin1997/ClawForge)" +date_added: "2026-02-27" --- # Clean Code Skill +This skill embodies the principles of "Clean Code" by Robert C. Martin (Uncle Bob). Use it to transform "code that works" into "code that is clean." + ## 🧠 Core Philosophy > "Code is clean if it can be read, and enhanced by a developer other than its original author." — Grady Booch @@ -33,7 +38,7 @@ Use this skill when: ## 3. Comments - **Don't Comment Bad Code—Rewrite It**: Most comments are a sign of failure to express ourselves in code. -- **Explain Yourself in Code**: +- **Explain Yourself in Code**: ```python # Check if employee is eligible for full benefits if employee.flags & HOURLY and employee.age > 65: @@ -88,6 +93,12 @@ Use this skill when: - [ ] Am I passing too many arguments? - [ ] Is there a failing test for this change? +## Example + +**User request:** + +> Refactor this working code for clearer names, smaller units, explicit errors, and preserved behavior; verify it with focused tests. + ## Limitations - Use this skill only when the task clearly matches the scope described above. - Do not treat the output as a substitute for environment-specific validation, testing, or expert review. diff --git a/plugins/software-delivery/skills/devops-engineer/SKILL.md b/plugins/software-delivery/skills/devops-engineer/SKILL.md index 8b7004b..3629161 100644 --- a/plugins/software-delivery/skills/devops-engineer/SKILL.md +++ b/plugins/software-delivery/skills/devops-engineer/SKILL.md @@ -4,7 +4,7 @@ description: Creates Dockerfiles, configures CI/CD pipelines, writes Kubernetes license: MIT metadata: author: https://github.com/Jeffallan - version: "1.1.1" + version: "1.2.0" domain: devops triggers: DevOps, CI/CD, deployment, Docker, Kubernetes, Terraform, GitHub Actions, infrastructure, platform engineering, incident response, on-call, self-service role: engineer @@ -42,8 +42,9 @@ You are a senior DevOps engineer with 10+ years of experience. You operate with 2. **Design** - Pipeline structure, deployment strategy 3. **Implement** - IaC, Dockerfiles, CI/CD configs 4. **Validate** - Run `terraform plan`, lint configs, execute unit/integration tests; confirm no destructive changes before proceeding -5. **Deploy** - Roll out with verification; run smoke tests post-deployment -6. **Monitor** - Set up observability, alerts; confirm rollback procedure is ready before going live +5. **Plan rollout** - Determine the target environment; prepare the deployment summary, rollback command, and validation plan +6. **Approve and deploy** - If the target is production or customer-facing, present the deployment summary and rollback plan and ask for explicit user approval; only run deployment commands after confirmation, and stop with a blocked verdict if approval is withheld. Roll out with verification; run smoke tests post-deployment +7. **Monitor** - Set up observability, alerts; confirm rollback procedure is ready before going live ## Reference Guide @@ -52,6 +53,7 @@ Load detailed guidance based on context: | Topic | Reference | Load When | |-------|-----------|-----------| | GitHub Actions | `references/github-actions.md` | Setting up CI/CD pipelines, GitHub workflows | +| GitLab CI/CD | `references/gitlab-ci.md` | Setting up GitLab pipelines, `.gitlab-ci.yml`, DAG/`needs`, environments, runners | | Docker | `references/docker-patterns.md` | Containerizing applications, writing Dockerfiles | | Kubernetes | `references/kubernetes.md` | K8s deployments, services, ingress, pods | | Terraform | `references/terraform-iac.md` | Infrastructure as code, AWS/GCP provisioning | diff --git a/plugins/software-delivery/skills/devops-engineer/references/gitlab-ci.md b/plugins/software-delivery/skills/devops-engineer/references/gitlab-ci.md new file mode 100644 index 0000000..72c6e21 --- /dev/null +++ b/plugins/software-delivery/skills/devops-engineer/references/gitlab-ci.md @@ -0,0 +1,192 @@ +# GitLab CI/CD Pipelines + +## Complete CI/CD Pipeline + +```yaml +# .gitlab-ci.yml — keep the root file short and declarative +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_COMMIT_TAG + - if: $CI_PIPELINE_SOURCE == "schedule" + +stages: [validate, test, build, deploy] + +default: + interruptible: true + retry: + max: 2 + when: [runner_system_failure, stuck_or_timeout_failure] + +include: + - local: .gitlab/ci/lint.yml + - local: .gitlab/ci/test.yml + - local: .gitlab/ci/build.yml + - local: .gitlab/ci/deploy.yml +``` + +```yaml +# .gitlab/ci/test.yml +unit-test: + stage: test + needs: [] # fails fast, doesn't wait on validate + image: node:20 + cache: + key: + files: [package-lock.json] + paths: [.npm/] + script: + - npm ci --cache .npm + - npm test -- --coverage + coverage: '/All files[^|]*\|[^|]*\s+([\d.]+)/' + artifacts: + reports: + junit: junit.xml + coverage_report: + coverage_format: cobertura + path: coverage/cobertura-coverage.xml +``` + +```yaml +# .gitlab/ci/build.yml +build-image: + stage: build + needs: [unit-test] + image: + name: moby/buildkit:rootless + entrypoint: [""] + variables: + BUILDKITD_FLAGS: --oci-worker-no-process-sandbox + before_script: + - mkdir -p ~/.docker + - AUTH=$(echo -n "$CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD" | base64 | tr -d '\n') + - printf '{"auths":{"%s":{"auth":"%s"}}}' "$CI_REGISTRY" "$AUTH" > ~/.docker/config.json + script: + - > + buildctl-daemonless.sh build + --frontend dockerfile.v0 + --local context="${CI_PROJECT_DIR}" + --local dockerfile="${CI_PROJECT_DIR}" + --output type=image,name="${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}",push=true + --export-cache type=registry,ref="${CI_REGISTRY_IMAGE}:buildcache" + --import-cache type=registry,ref="${CI_REGISTRY_IMAGE}:buildcache" + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +``` + +```yaml +# .gitlab/ci/deploy.yml +deploy-production: + stage: deploy + needs: [build-image] + environment: + name: production + url: https://app.example.com + deployment_tier: production + resource_group: production # serializes concurrent deploys + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + script: + - kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA +``` + +## Core Principles (from GitLab CI/CD best-practices survey) + +1. **`workflow:rules` is the master switch** — decide once, at the top, whether a pipeline should exist at all (push vs MR vs schedule vs API/trigger). Prevents duplicate pipelines from the same commit (e.g. branch pipeline + MR pipeline both firing) via `$CI_OPEN_MERGE_REQUESTS` / `$CI_MERGE_REQUEST_DRAFT` checks. +2. **`needs` over `stages` for DAG** — declare exact job dependencies with `needs:` instead of relying on stage ordering. Use `needs: []` for fast checks that should start immediately and fail early. Optimize the critical path, not every job. +3. **Cache vs artifacts are different things** — cache = reusable dependencies (key it off the lockfile, add `fallback_keys` so new branches don't start cold); artifacts = build outputs consumed by later jobs or humans (set `expire_in`, use `expose_as` for reviewer-facing files). Keep separate cache keys for protected vs unprotected refs. +4. **Reuse via `extends` + hidden jobs first, CI/CD components second** — components take typed `inputs` and should be pinned to a tag/SHA (never a moving `~latest`), documented, and treated as a supply-chain dependency if sourced externally. +5. **Environments are first-class objects** — declare `environment:name/url/deployment_tier`, use `on_stop`/`auto_stop_in` for ephemeral review apps, `resource_group` to serialize deploys to the same target, and protected environments + `manual_confirmation` for production. +6. **Secrets never live in CI/CD variables for anything sensitive** — prefer OIDC to cloud providers over static keys; use HashiCorp Vault integration with scoped roles, bound claims, and short TTLs. Be careful with `CI_JOB_TOKEN` scope (limit the allow-list) and treat MRs from forks as untrusted. +7. **Runner blast radius** — register runners at the narrowest scope that works (project < group < instance). Docker executor without `privileged` mode is the default; privileged/DinD only on isolated, ephemeral runners. Split protected and unprotected jobs onto separate runner pools/tags. +8. **Build images without long-lived root daemons** — prefer BuildKit rootless over classic privileged DinD (kaniko is archived and unmaintained; plan a migration if pipelines still use it); pass secrets via mount-type (`--mount=type=secret`), not `ARG`/`ENV`; tag by commit SHA, never `latest`; generate SBOM and sign images (cosign/notation) after build. +9. **MR pipelines are where checks matter** — prefer merged-results pipelines (test the merge of source+target, not just source) and merge trains for high-throughput repos, over relying on branch pipelines. +10. **Report everything GitLab can render** — `artifacts:reports:junit` for test results, `coverage_report` (Cobertura) for MR diff coverage annotations vs the `coverage:` regex for the summary badge, Code Quality reports, and screenshots/videos/logs as artifacts for UI/E2E failures. The MR widget is the primary feedback surface — optimize for it. + +## Common Patterns + +### Merge request pipelines only (no duplicate branch pipelines) +```yaml +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' +``` + +### Matrix builds +```yaml +test: + stage: test + parallel: + matrix: + - NODE_VERSION: ["18", "20", "22"] + OS: [ubuntu, alpine] + image: node:${NODE_VERSION}-${OS} +``` + +### Reusable CI/CD component (pinned, not ~latest) +```yaml +include: + - component: gitlab.com/my-org/ci-components/deploy@1.4.2 + inputs: + environment: production + k8s-namespace: app-prod +``` + +### Parent/child (monorepo) pipeline +```yaml +trigger-backend: + trigger: + include: backend/.gitlab-ci.yml + strategy: mirror # parent status mirrors child's real status + rules: + - changes: [backend/**/*] +``` + +### OIDC to a cloud provider (no static keys) +```yaml +deploy: + id_tokens: + AWS_ID_TOKEN: + aud: https://gitlab.example.com + script: + - aws sts assume-role-with-web-identity --role-arn $ROLE_ARN --web-identity-token $AWS_ID_TOKEN ... +``` + +### Dependency Proxy for base images (avoid Docker Hub rate limits) +```yaml +build: + image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:20-alpine +``` + +## Quick Reference + +| Feature | Purpose | +|---|---| +| `workflow:rules` | Decide whether a pipeline is created at all; dedupe push/MR pipelines | +| `needs:` | DAG dependencies between jobs, decoupled from stage order | +| `needs: []` | Job starts immediately, no upstream wait | +| `resource_group` | Serialize deploys to the same environment | +| `environment:deployment_tier` | Classifies env as production/staging/testing/development/other | +| `parallel:matrix` | Fan out a job across variable combinations | +| `extends` | Share config between jobs without `include` overhead | +| CI/CD components (`include:component`) | Typed, versioned, reusable pipeline building blocks | +| `trigger:include` + `strategy: mirror` | Parent/child pipelines for monorepos with real status propagation | +| `id_tokens` (OIDC) | Short-lived cloud credentials instead of static secrets | +| `artifacts:reports:junit` | Test results surfaced in MR widget | +| `artifacts:reports:coverage_report` | Per-line diff coverage annotations in MR | +| `CI_JOB_TOKEN` scope allow-list | Limits which projects a job token can access | + +## Anti-Patterns to Avoid + +- Hardcoding branch names/environments deep inside job scripts instead of centralizing in `workflow:rules` +- Letting `default:` become a dumping ground that obscures per-job behavior +- Relying on `stages:` ordering alone instead of `needs:` for large pipelines (slow, unclear critical path) +- One shared cache key for protected and unprotected refs (cache poisoning risk) +- Privileged Docker-in-Docker as the default build method +- Long-lived static cloud credentials in CI/CD variables when OIDC is available +- Including third-party CI/CD components at a floating ref instead of a pinned tag/SHA +- Testing only the source branch in MRs instead of merged results diff --git a/plugins/software-delivery/skills/gcloud/SKILL.md b/plugins/software-delivery/skills/gcloud/SKILL.md index 5f1f0c8..0d3a184 100644 --- a/plugins/software-delivery/skills/gcloud/SKILL.md +++ b/plugins/software-delivery/skills/gcloud/SKILL.md @@ -1,90 +1,107 @@ --- name: gcloud metadata: - category: DevOps + category: CloudInfrastructureAndServices description: >- - Interacts with Google Cloud services using the gcloud CLI safely and - efficiently. Covers command validation, data reduction, safety guardrails with - a denylist, and workflows for discovery and investigation. You MUST read this - skill before invoking any gcloud command. Use when managing cloud resources, - querying configurations, or troubleshooting issues via gcloud. Don't use when - writing or debugging Google Cloud client library code or raw REST/gRPC API - interactions. + Provides safety-critical validation, guardrails, and data reduction for gcloud + CLI operations across Google Cloud Platform (GCP) services and infrastructure. + Use when planning, generating, constructing, proposing, describing, or + executing any gcloud CLI commands - including when answering questions about + gcloud syntax, or formatting flags. Don't use when writing Google Cloud + client library code or raw REST/gRPC API requests. --- # gcloud CLI Skill for AI Agents +> [!CAUTION] +> +> ### MANDATORY PRE-CONDITION: EXPLICIT LEAF-LEVEL SYNTAX VALIDATION +> +> All pre-existing knowledge of `gcloud` commands, flags, flag values, and +> positional argument syntax is **stale and prone to hallucination**. +> +> NEVER propose command parameters, output flag options, execute commands, OR +> outline step-by-step plans for any `gcloud` task before validating leaf-level +> syntax via `gcloud help ` (or including leaf-level help lookup as a +> mandatory step in the plan). +> +> **Mandatory Action Rules**: +> +> 1. **Direct Execution & Code Generation**: **ALWAYS** invoke `gcloud help +> ` (e.g. `gcloud help compute instances create` or `gcloud +> help sql instances create`) before proposing or executing the final +> command syntax. +> +> 2. **Planning & Strategy Queries**: When asked for a plan, strategy, or next +> steps to achieve a user goal (e.g., *"What is your plan to accomplish +> X..."*), the response **MUST explicitly include running `gcloud help +> `** as Step 1 of the plan before proposing flags or +> executing commands. +> +> 3. **Non-Transitive Validation**: Parent command group help (e.g. `gcloud +> help compute`) is not sufficient for leaf-level syntax validation. +> Validation must occur at the specific leaf subcommand level. +> +> 4. **FORBIDDEN Web Search Fallback**: NEVER use `search_web`, web search, or +> external documentation search tools for gcloud CLI syntax. `gcloud help +> ` is the **EXCLUSIVE** authorized authority for command +> syntax. +> +> 5. **User Flag & Project Preservation**: When proposing intermediate command +> steps, **ALWAYS** preserve all user-specified flags (including +> `--project=`) in the proposed response text. +> +> 6. **Mandatory Plan Template**: When generating a plan, the response **MUST** +> copy this exact 4-step structure: +> +> - **Step 1**: Syntax Validation via `gcloud help ` +> - **Step 2**: Parameter Verification (confirming required and optional +> flags, and explicitly checking if the `--dry-run` or `--validate-only` +> flag is supported) +> - **Step 3**: Dry-Run Command Proposal (If `--dry-run` or +> `--validate-only` is supported, there MUST be a `--dry-run` or +> `--validate-only` invocation before the next step.) +> - **Step 4**: Command Proposal & Authorization (If the command is on the +> "Prohibited Operations" denylist, state that autonomous execution is +> forbidden, and the user MUST be explicitly asked for authorization to +> proceed. If the command is NOT on the denylist, propose or proceed +> with execution, while following *ALL* "Execution Constraints" below.) + This document provides essential guidelines and best practices for AI agents interacting with the Google Cloud SDK (`gcloud` CLI). Following these rules is critical to avoid hallucinated commands, flags, flag values, and positional argument syntax, prevent destructive actions, and minimize context window usage. -## Getting Started - -### 1. Installation - -If the `gcloud` executable is missing, refer to the official -[Google Cloud CLI Installation Guide](https://docs.cloud.google.com/sdk/docs/install-sdk.md.txt) -to install it on your platform (Linux, macOS, Windows, etc.). - -### 2. Authorization - -Authenticate the CLI with Google Cloud. Choose the flow that matches your -running environment: - -* **User Account (Interactive)**: Run `gcloud auth login`. Follow the browser - prompts to sign in. -* **User Account (Headless Flow)**: If operating on a terminal without a web - browser (e.g. containers, remote SSH), append the `--no-browser` flag: - `gcloud auth login --no-browser`. Copy the URL, sign in on another machine, - and return the authentication code. -* **Application Default Credentials (ADC)**: To authenticate code calls from - local applications or SDK libraries, set up ADC via `gcloud auth - application-default login` (append `--no-browser` for headless - environments). -* **Service Account (Best for Detached/Headless Automation)**: Authenticate - directly using a JSON key file. Ideal for fully automated, background tasks - and pipelines: `gcloud auth activate-service-account - --key-file=path/to/key.json`. Note that some organizations may restrict - access to JSON key files for security reasons. -* **Service Account Impersonation (Preferred for Local Pair-Programming - Agents)**: Leverage the human developer's existing user credentials to - assume a service account identity. Best for local development assistants to - avoid insecure private keys on human workstations: `gcloud config set - auth/impersonate_service_account SERVICE_ACCT_EMAIL` - -*Separation of Privilege (Critical)*: Both service account approaches ensure the -agent's permissions remain strictly distinct from the human user's wide access -limits (enforcing least privilege), and ensure actions are properly audited -under the agent's focused identity. *(Impersonation requires -`roles/iam.serviceAccountTokenCreator`)*. - -For more detailed strategies and authentication types (such as Workload Identity -Federation), see -[Authorizing the gcloud CLI](https://docs.cloud.google.com/sdk/docs/authorizing.md.txt). +## Execution Modes + +AI agents can interact with Google Cloud resources in two primary ways: + +- **Direct CLI Execution**: Executing `gcloud` commands directly in a local or + automated shell environment. See [CLI Usage](references/cli-usage.md) for + installation, authentication flows, and configuration management. +- **Model Context Protocol (MCP)**: Invoking structured tools via the Cloud + CLI remote MCP server (`run_gcloud_command`). See + [MCP Usage](references/mcp-usage.md) for tool schemas, parameter rules, and + server configuration. ## Core Principles ### 1. Explicit Command Validation (Mandatory) -Your internal knowledge of `gcloud` may be stale or prone to hallucination -(e.g., hallucinating commands, flags, flag values, or positional argument -syntax). You are **FORBIDDEN** from executing commands until you have validated -the exact syntax at the leaf level. - -* **Action**: Always call `gcloud help ` for the *exact* command you - intend to run (e.g., `gcloud help compute instances create`). +* **Action**: **ALWAYS** call `gcloud help ` for the *exact* command + that is intended to be run (e.g., `gcloud help compute instances create`). * **Verify**: Ensure the command, flags, flag values, and positional argument - syntax are valid for that specific leaf command before attempting execution. - Validation is not transitive from parent groups. + syntax are valid for that specific leaf command before attempting execution + or presenting plans. Validation is not transitive from parent groups. -### 2. Data Reduction Strategies +### 2. Data Reduction Strategies (Mandatory) -To save context window space and reduce latency, always minimize the volume of -data returned by `gcloud`. +Minimize the volume of data returned by `gcloud` to save context window space +and reduce latency. DO NOT execute any `list` command without including at least +one data reduction flag (`--limit`, `--filter`, or `--format`). -* **Projection**: Use `--format=json(key1, key2, ...)` to select only the - specific fields needed for your task. To understand the advanced projection +* **Projection**: Use `--format="json(key1, key2, ...)"` to select only the + specific fields needed for the task. To understand the advanced projection and formatting syntax, refer to `gcloud topic projections` and `gcloud topic formats`. @@ -96,9 +113,9 @@ data returned by `gcloud`. characters. To study the filter expression syntax, refer to `gcloud topic filters`. -* **Schema Discovery**: Unconstrained resource lists can quickly exhaust your +* **Schema Discovery**: Unconstrained resource lists can quickly exhaust the context window with redundant data. To prevent this, discover a resource's - schema before executing queries. If you are unsure of the JSON key path for + schema before executing queries. If unsure of the JSON key path for projecting fields (`--format`) or filtering (`--filter`), run the targeted resource's list command (if supported) with a single-item limit: @@ -116,15 +133,23 @@ data returned by `gcloud`. * **No Shell Operators**: Do not use command substitution (`$(...)`), pipes (`|`), or redirection (`>`, `>>`, `<`). This is to increase command safety and ensure commands are more easily understandable and reviewable by users. -* **No Interactivity**: Do not run interactive commands or commands requiring - a TTY (e.g., `gcloud interactive`). You must enforce non-interactive mode by - appending `--quiet` (or `-q`) to your commands. This ensures that defaults - are used or errors are raised if input is required. +* **Non-Interactive Execution (`--quiet` / `-q`)**: Pass the `--quiet` (or + `-q`) global flag on all execution commands (e.g., `gcloud pubsub topics + delete temp-topic --quiet --project=test-project`). AI agents run in + headless, non-interactive environments without a TTY or `stdin` input + handler. Without `--quiet`, commands that prompt for user confirmation (such + as deleting resources, approving defaults, or selecting unspecified regions) + will pause execution indefinitely waiting for input, causing background task + timeouts. Including `--quiet` forces non-interactive mode, causing `gcloud` + to automatically accept safe default choices or fail immediately with an + explicit error if required parameters are missing. +* **No Blind Lists**: NEVER execute a `list` command without `--limit`, + `--filter`, or `--format`. ### 4. Project and Location Scoping (Critical) To ensure commands are deterministic, non-interactive, and target the correct -environment, you must explicitly manage project and location scoping. +environment, they must explicitly provide project and location scoping. * **Explicit Project Target**: Do not rely on active configuration defaults. Always append `--project=` to all resource-manipulating and @@ -132,13 +157,13 @@ environment, you must explicitly manage project and location scoping. accidental execution against the wrong project. * **Prevent Location Prompts**: Many Google Cloud resources are regional or - zonal. If you omit the location flag (e.g., `--region`, `--zone`, or + zonal. If the location flag is omitted (e.g., `--region`, `--zone`, or `--location`), `gcloud` will trigger an interactive prompt to select a zone/region. This violates the **No Interactivity** rule. Always provide explicit location flags if the command requires them. -* **Location Discovery**: If you do not know the correct region, zone, or - location for a service, run discovery commands first (remembering to limit +* **Location Discovery**: If the correct region, zone, or location for a + service is not known, run discovery commands first (remembering to limit results if there are many): * **Compute Engine (VMs, Networks)**: @@ -161,8 +186,8 @@ environment, you must explicitly manage project and location scoping. ### Prohibited Operations (Denylist) -You are **strictly prohibited** from executing the following commands -autonomously. These require explicit human-in-the-loop authorization: +NEVER execute the following commands autonomously. These require explicit +human-in-the-loop authorization: * **Any IAM policy, role, or binding modification** (Security): Risk of privilege escalation, administrative lockout, service disruption, or @@ -182,35 +207,38 @@ autonomously. These require explicit human-in-the-loop authorization: ### Execution Guidelines -* **Dry Run (Mandatory)**: You MUST invoke a command with `--dry-run` (or - equivalent) first if it exists, before executing the actual command, to - preview changes. +* **Dry Run (Mandatory)**: If the `--dry-run` or `--validate-only` flag (or + equivalent) is listed in the command help output, ALWAYS include the flag in + the proposed command or initial execution step. ALWAYS preview changes with + `--dry-run` or `--validate-only` prior to actual execution. * **Long Running Operations**: For commands that support it, the `--async` flag is highly recommended for long-running operations to avoid blocking the agentic flow. Note that not every command has an `--async` flag. For commands that return an operation ID (whether via `--async` or by default), - you are responsible for polling for completion if the operation status is - needed for the next step. + operation status must be polled for completion, if needed for the next step. + +* **Non-Interactive Flag (`--quiet`)**: Include `--quiet` (or `-q`) on all + proposed or executed commands to guarantee non-interactive execution without + waiting for TTY confirmation prompts. ## Structured Workflows ### Discovery Workflow -When asked to perform a task on a service you are not familiar with: - -1. You MUST invoke help on a command (e.g., `gcloud help `) before - invoking it. -2. If you do not know the exact command, traverse the command tree by invoking - help on a command group (e.g., `gcloud help compute`) to discover available - subcommands and groups. -3. **Schema Discovery**: If you need to filter or project fields from a list - command, but do not know the exact JSON keys, first run `gcloud - list --limit=1 --format=json` to safely discover the schema. - **Never** run a raw `list` command without scoping constraints (like - `--limit=1`), as unconstrained results will pollute and exhaust your context - window. -4. Execute with data reduction flags. +When asked to perform a task on a service that is unfamiliar: + +1. **Invoke Help**: Call `gcloud help ` on the target leaf command + prior to execution. +2. **Traverse Command Tree**: Run help on command groups (e.g., `gcloud help + compute` or `gcloud help`) to discover available subgroups and commands if + the exact command is unknown. +3. **Discover Schema**: Run `gcloud list --limit=1 + --format=json` to inspect JSON keys before constructing filters or + projections. DO NOT execute unconstrained `list` commands without scoping + flags (e.g., `--limit=1`) to prevent context window exhaustion. +4. **Enforce Data Reduction**: Include data reduction flags (`--limit`, + `--filter`, `--format`) on all command executions. ## Quick Reference / Cheat Sheet @@ -232,3 +260,13 @@ List Locations | `gcloud locations list --project=` Refer to the [gcloud CLI Scripting Guide](https://docs.cloud.google.com/sdk/docs/scripting-gcloud.md.txt) for guidance on using the gcloud CLI in automation. + +## Reference Directory + +- [CLI Usage](references/cli-usage.md): Platform installation, authentication + methods (interactive, headless, ADC, service account keys, impersonation), + and local configuration management. + +- [MCP Usage](references/mcp-usage.md): Using the Cloud CLI remote MCP + server (`run_gcloud_command`), project parameter scoping, input files, and + execution guidelines. diff --git a/plugins/software-delivery/skills/gcloud/references/cli-usage.md b/plugins/software-delivery/skills/gcloud/references/cli-usage.md new file mode 100644 index 0000000..6b8ab5b --- /dev/null +++ b/plugins/software-delivery/skills/gcloud/references/cli-usage.md @@ -0,0 +1,153 @@ +# gcloud CLI Usage + +This document provides reference information for installing, authorizing, and +configuring the Google Cloud SDK (`gcloud` CLI) in local and automated +environments. + +## Installation + +If the `gcloud` binary is not installed in the execution environment, refer to +the authoritative +[Google Cloud CLI Installation Guide](https://docs.cloud.google.com/sdk/docs/install-sdk.md.txt) +for platform-specific installation instructions (Linux, macOS, Windows, package +managers, and container images). + +### Component Management + +The `gcloud components` command group manages optional CLI components (such as +additional tools, emulators, and language runtimes): + +- **List available components:** + + ```bash + gcloud components list + ``` + +- **Install a component:** + + ```bash + gcloud components install {component_id} --quiet + ``` + +- **Update all installed components:** + + ```bash + gcloud components update --quiet + ``` + +*(Note: If `gcloud` was installed via a system package manager like APT or DNF, +use the system package manager to install components instead of `gcloud +components install`.)* + +## Authorization & Authentication + +Authenticate the CLI with Google Cloud according to the operational environment: + +- **User Account (Interactive):** + + ```bash + gcloud auth login + ``` + + Follow the browser prompts to sign in and grant access. + +- **User Account (Headless Flow):** + + For environments without an accessible web browser (containers, remote SSH): + + ```bash + gcloud auth login --no-browser + ``` + + Copy the generated URL, open it on another machine to complete sign-in, and + paste the authorization code back into the terminal. + +- **Application Default Credentials (ADC):** + + Configures credentials for client libraries and local applications: + + ```bash + gcloud auth application-default login + ``` + + Append `--no-browser` in headless environments. + +- **Service Account Key (Headless Automation):** + + ```bash + gcloud auth activate-service-account --key-file=path/to/key.json + ``` + + *Security note: Restrict file permissions on JSON keys or prefer Workload + Identity / Impersonation.* + +- **Service Account Impersonation (Preferred for Development & Agents):** + + Allows a user identity to temporarily assume a service account identity + without storing long-lived private key files: + + ```bash + gcloud config set auth/impersonate_service_account {service_account_email} + ``` + + Requires the `roles/iam.serviceAccountTokenCreator` role on the target + service account. This enforces least privilege and ensures audited access + under the target identity. + +- **Workload Identity Federation:** + + For CI/CD and external compute environments (GitHub Actions, AWS, on-prem), + authenticate using federated tokens without managing service account keys. + See + [Authorizing the gcloud CLI](https://docs.cloud.google.com/sdk/docs/authorizing.md.txt). + +## Local Configuration Management + +The `gcloud config` command group manages local configuration settings, +profiles, and default properties. + +### Named Configurations + +Configurations allow maintaining multiple isolated sets of properties (e.g., +dev, staging, prod): + +- **Create a new configuration:** + + ```bash + gcloud config configurations create {config_name} + ``` + +- **List existing configurations:** + + ```bash + gcloud config configurations list + ``` + +- **Activate a configuration:** + + ```bash + gcloud config configurations activate {config_name} + ``` + +### Setting Common Properties + +Properties set default values for flags across `gcloud` invocations: + +- **Set active project:** + + ```bash + gcloud config set core/project {project_id} + ``` + +- **Set default compute region and zone:** + + ```bash + gcloud config set compute/region {region} + gcloud config set compute/zone {zone} + ``` + +- **View all active configuration properties:** + + ```bash + gcloud config list + ``` diff --git a/plugins/software-delivery/skills/gcloud/references/mcp-usage.md b/plugins/software-delivery/skills/gcloud/references/mcp-usage.md new file mode 100644 index 0000000..15d058e --- /dev/null +++ b/plugins/software-delivery/skills/gcloud/references/mcp-usage.md @@ -0,0 +1,187 @@ +# Cloud CLI Remote MCP Server Usage + +Google Cloud resources can be managed via the Model Context Protocol (MCP), +allowing AI agents to interact with Google Cloud using structured tool calls +rather than directly executing local shell commands. + +MCP operations for `gcloud` are executed through the **Cloud CLI remote MCP +server** (backed by the Cloud CLI Execution API, `cloudcli.googleapis.com`). + +## Server Endpoint & Tool Overview + +- **Server Endpoint:** `https://cloudcli.googleapis.com/mcp` +- **Transport:** HTTP (JSON-RPC 2.0) +- **API Name:** Cloud CLI Execution API (`cloudcli.googleapis.com`) +- **Available Tool:** `run_gcloud_command` + +The `run_gcloud_command` tool executes a single `gcloud` command securely in a +managed remote environment on behalf of the user. + +## Client Configuration (`mcp_config.json`) + +To connect an MCP client (such as Jetski) to the remote Cloud CLI MCP server, +configure the server entry in `mcp_config.json` with `authProviderType` set to +`"google_credentials"`: + +```json +{ + "mcpServers": { + "gcloud-remote": { + "serverUrl": "https://cloudcli.googleapis.com/mcp", + "authProviderType": "google_credentials" + } + } +} +``` + +> [!IMPORTANT] Specifying `"authProviderType": "google_credentials"` is +> mandatory. It instructs the MCP client to attach Application Default +> Credentials (ADC) with the `https://www.googleapis.com/auth/cloud-platform` +> OAuth scope. Omitting this field will cause the client to send unauthenticated +> requests, resulting in `401 Unauthorized` errors. + +## Prerequisites & IAM Requirements + +Before using the Cloud CLI remote MCP server, the target project and calling +identity must satisfy two mandatory prerequisites: + +### 1. API Enablement + +The Cloud CLI Execution API (`cloudcli.googleapis.com`) must be enabled on the +target project. + +- **Via Google Cloud Console (No CLI required):** + + 1. Open the [Google Cloud Console](https://console.cloud.google.com/). + 2. Navigate to **APIs & Services** --> **Library**. + 3. Search for **Cloud CLI Execution API** (or open the + [Cloud CLI Execution API Library Page](https://console.cloud.google.com/apis/library/cloudcli.googleapis.com)). + 4. Select the target project from the project dropdown. + 5. Click **Enable**. + +- **Via `gcloud` CLI:** + + ```bash + gcloud services enable cloudcli.googleapis.com --project={project_id} + ``` + +### 2. IAM Roles & Permissions + +- **MCP Access Role:** The caller identity must hold the **MCP Tool User** + role (`roles/mcp.toolUser`, which grants the `mcp.tools.call` permission) on + the target project. +- **Downstream Resource Roles:** The caller identity must also hold standard + IAM permissions on the underlying resources being queried or modified (e.g., + `roles/compute.viewer`, `roles/run.developer`). + +> [!CAUTION] If either the Cloud CLI Execution API is not enabled or the caller +> lacks the `roles/mcp.toolUser` role, the endpoint returns **`403 Forbidden`** +> during both tool discovery (`tools/list`) and tool invocation (`tools/call`). + +## Tool Parameters + +Calls to `run_gcloud_command` accept the following parameters: + +- **`command`** (string, required): The full `gcloud` command line string to + execute (e.g., `"gcloud compute instances list --project={resource_project} + --format=json"`). +- **`project`** (string, required): The resource name of the Google Cloud + project hosting the Cloud CLI Execution API in the format + `"projects/{api_project}"` (e.g., `"projects/my-api-project"`). +- **`input_files`** (list of objects, optional): Files to provision in the + remote execution environment before running the command. Each item contains + a relative `path` and string `contents`. + +> [!IMPORTANT] **API Host Project vs. Resource Project Context:** +> +> - The top-level **`project`** parameter (`"projects/{api_project}"`) is used +> **strictly for quota, billing, and API enablement** of the +> `cloudcli.googleapis.com` API itself. It does NOT set the project context +> for the command being executed. +> - For **project-scoped commands**, you MUST explicitly include +> `--project={resource_project}` within the `command` string. The target +> `{resource_project}` does NOT have to be the project hosting the Cloud CLI +> Execution API. +> - For **non-project-scoped commands** (such as billing or organization +> queries), you MUST include `--billing-project={billing_project}` in the +> `command` string if the underlying API requires a quota project. + +### Example Invocations + +#### 1. Basic Command Execution + +```json +{ + "command": "gcloud compute instances list --project=my-resource-project --format=json", + "project": "projects/my-cloudcli-api-project" +} +``` + +#### 2. Command with Input Files + +```json +{ + "command": "gcloud run services replace service-config.yaml --region=us-central1 --project=my-resource-project", + "project": "projects/my-cloudcli-api-project", + "input_files": [ + { + "path": "service-config.yaml", + "contents": "apiVersion: serving.knative.dev/v1\nkind: Service\nmetadata:\n name: my-service\n..." + } + ] +} +``` + +## Response Structure + +The tool returns an execution response containing: + +- `exit_code`: Numeric exit status of the command execution. **This is the + primary and authoritative indicator of command success or failure.** +- `stdout`: Standard output stream from the command. +- `stderr`: Standard error stream from the command. +- `output_files`: Any files generated by the command. + +> [!NOTE] - **Exit Code Authority:** A command is successful if and only if +> `exit_code == 0`. A non-zero `exit_code` indicates failure. +> +> - **Informational `stderr` Output:** In `gcloud`, `stderr` frequently +> contains standard status messages, progress updates, and asynchronous +> tracking IDs (such as `--async` operation IDs) even when the command +> executes successfully (`exit_code == 0`). Agents MUST NOT assume a command +> failed merely because `stderr` is non-empty. +> - **Error Diagnosis:** If `exit_code != 0`, diagnostic error messages may +> appear in either `stderr` or `stdout`. Inspect both streams to understand +> the failure and formulate a correction. + +## Prohibited & Unsupported Commands + +The Cloud CLI remote MCP server operates in a sandboxed, non-interactive +environment. The following list shows a few example `gcloud` commands that +aren't supported (such as command groups that manage local machine +configuration, credentials, interactive shells, or metadata). This list is +non-exhaustive and subject to the addition or removal of commands without +notice: + +- `gcloud auth` (Local authentication & credential management) +- `gcloud config` (Local CLI configuration profiles and properties) +- `gcloud iam service-accounts` (Service account management) +- `gcloud init` (Interactive setup wizard) +- `gcloud survey` (User feedback & surveys) +- `gcloud compute ssh` / `gcloud app instances ssh` (Interactive SSH shells) + +## Safety & Execution Guidelines + +- **Mandatory User Consent for Mutations:** Destructive or state-changing + commands (such as `create`, `delete`, `update`, or `patch`) modify or + destroy GCP resources. These commands must NOT be invoked autonomously + unless the user has explicitly authorized the action. +- **Asynchronous Operations (`--async`):** For long-running operations (such + as creating VM instances, GKE clusters, or database instances), always + append the `--async` flag in the `command` string to avoid execution + timeouts. +- **Data Reduction & Formatting:** Use `--format=json`, `--filter`, and + `--limit` in the `command` string to constrain output volume and prevent + context window bloat. +- **Non-Interactive Execution (`--quiet`):** Include `--quiet` (or `-q`) on + commands that might otherwise prompt for interactive user confirmation. diff --git a/plugins/software-delivery/skills/playwright-best-practices/SKILL.md b/plugins/software-delivery/skills/playwright-best-practices/SKILL.md index 9e30124..0da7362 100644 --- a/plugins/software-delivery/skills/playwright-best-practices/SKILL.md +++ b/plugins/software-delivery/skills/playwright-best-practices/SKILL.md @@ -4,7 +4,7 @@ description: Use when writing Playwright tests, fixing flaky tests, debugging fa license: MIT metadata: author: currents.dev - version: "1.1" + version: "1.2" --- # Playwright Best Practices diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication-flows.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication-flows.md new file mode 100644 index 0000000..24ad08c --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication-flows.md @@ -0,0 +1,360 @@ +# Complex Authentication Flow Patterns + +## Table of Contents + +1. [Email Verification Flows](#email-verification-flows) +2. [Password Reset](#password-reset) +3. [Session Timeout](#session-timeout) +4. [Remember Me Persistence](#remember-me-persistence) +5. [Logout Patterns](#logout-patterns) +6. [Tips](#tips) +7. [Related](#related) + +> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md). + +--- + +## Email Verification Flows + +### Capturing Verification Tokens + +Intercept API responses to capture verification tokens for testing: + +```typescript +test('completes registration with email verification', async ({ page }) => { + let capturedToken = ''; + + await page.route('**/api/auth/register', async (route) => { + const response = await route.fetch(); + const body = await response.json(); + capturedToken = body.verificationToken; + await route.fulfill({ response }); + }); + + await page.goto('/register'); + await page.getByLabel('Name').fill('New User'); + await page.getByLabel('Email').fill('newuser@test.com'); + await page.getByLabel('Password', { exact: true }).fill('SecurePass!'); + await page.getByLabel('Confirm password').fill('SecurePass!'); + await page.getByRole('button', { name: 'Create account' }).click(); + + await expect(page.getByText('Check your inbox')).toBeVisible(); + + expect(capturedToken).toBeTruthy(); + await page.goto(`/verify?token=${capturedToken}`); + + await expect(page.getByText('Email confirmed')).toBeVisible(); +}); +``` + +### Fully Mocked Verification + +```typescript +test('verifies email with mocked endpoints', async ({ page }) => { + const mockToken = 'test-verification-abc123'; + + await page.route('**/api/auth/register', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ message: 'Verification sent', verificationToken: mockToken }), + }); + }); + + await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ verified: true }), + }); + }); + + await page.goto('/register'); + await page.getByLabel('Email').fill('test@example.com'); + await page.getByLabel('Password', { exact: true }).fill('Password123!'); + await page.getByRole('button', { name: 'Sign up' }).click(); + + await expect(page.getByText('Check your inbox')).toBeVisible(); + + await page.goto(`/verify?token=${mockToken}`); + await expect(page.getByText('Email confirmed')).toBeVisible(); +}); +``` + +--- + +## Password Reset + +### Complete Reset Flow + +```typescript +test('resets password through email link', async ({ page }) => { + let resetToken = ''; + + await page.route('**/api/auth/forgot-password', async (route) => { + const response = await route.fetch(); + const body = await response.json(); + resetToken = body.resetToken; + await route.fulfill({ response }); + }); + + await page.goto('/forgot-password'); + await page.getByLabel('Email').fill('user@test.com'); + await page.getByRole('button', { name: 'Send link' }).click(); + + await expect(page.getByText('Reset email sent')).toBeVisible(); + + expect(resetToken).toBeTruthy(); + await page.goto(`/reset-password?token=${resetToken}`); + + await page.getByLabel('New password', { exact: true }).fill('NewPassword456!'); + await page.getByLabel('Confirm password').fill('NewPassword456!'); + await page.getByRole('button', { name: 'Update password' }).click(); + + await expect(page.getByText('Password updated')).toBeVisible(); +}); +``` + +### Expired Token Handling + +```typescript +test('shows error for expired reset token', async ({ page }) => { + await page.goto('/reset-password?token=expired-token'); + + await page.getByLabel('New password', { exact: true }).fill('NewPass!'); + await page.getByLabel('Confirm password').fill('NewPass!'); + await page.getByRole('button', { name: 'Update password' }).click(); + + await expect(page.getByRole('alert')).toContainText(/expired|invalid/i); +}); +``` + +### Password Strength Validation + +```typescript +test('enforces password requirements on reset', async ({ page }) => { + await page.goto('/reset-password?token=valid-token'); + + await page.getByLabel('New password', { exact: true }).fill('weak'); + await page.getByLabel('Confirm password').fill('weak'); + await page.getByRole('button', { name: 'Update password' }).click(); + + await expect(page.getByText(/at least 8 characters/i)).toBeVisible(); +}); +``` + +--- + +## Session Timeout + +### Detecting Expired Sessions + +```typescript +test('redirects to signin after session expires', async ({ page, context }) => { + await page.goto('/signin'); + await page.getByLabel('Email').fill('user@test.com'); + await page.getByLabel('Password').fill('Password!'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page).toHaveURL('/home'); + + const cookies = await context.cookies(); + const sessionCookie = cookies.find((c) => c.name.includes('session')); + + if (sessionCookie) { + await context.clearCookies({ name: sessionCookie.name }); + } + + await page.goto('/profile'); + await expect(page).toHaveURL(/\/signin/); + await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible(); +}); +``` + +### Session Extension Warning + +```typescript +test('shows warning before session expires', async ({ page }) => { + await page.route('**/api/auth/session', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ valid: true, expiresIn: 60 }), + }); + }); + + await page.goto('/home'); + + await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible(); +}); +``` + +### Session Extension Action + +```typescript +test('extends session when user clicks extend', async ({ page }) => { + let sessionExtended = false; + + await page.route('**/api/auth/session', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ valid: true, expiresIn: 60 }), + }); + }); + + await page.route('**/api/auth/refresh', async (route) => { + sessionExtended = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ valid: true, expiresIn: 3600 }), + }); + }); + + await page.goto('/home'); + + await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible({ + timeout: 10000, + }); + await page.getByRole('button', { name: /extend|stay signed in/i }).click(); + + expect(sessionExtended).toBe(true); + await expect(page.getByText(/session.*expir/i)).not.toBeVisible(); +}); +``` + +--- + +## Remember Me Persistence + +### Persistent Session + +```typescript +test('persists session with remember me enabled', async ({ browser }) => { + const ctx1 = await browser.newContext(); + const page1 = await ctx1.newPage(); + + await page1.goto('/signin'); + await page1.getByLabel('Email').fill('user@test.com'); + await page1.getByLabel('Password').fill('Password!'); + await page1.getByLabel('Keep me signed in').check(); + await page1.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page1).toHaveURL('/home'); + + const state = await ctx1.storageState(); + await ctx1.close(); + + const ctx2 = await browser.newContext({ storageState: state }); + const page2 = await ctx2.newPage(); + + await page2.goto('/home'); + await expect(page2).toHaveURL('/home'); + await expect(page2.getByText('Welcome')).toBeVisible(); + + await ctx2.close(); +}); +``` + +### Session-Only Login + +```typescript +test('session-only login does not persist across browser restarts', async ({ browser }) => { + const ctx1 = await browser.newContext(); + const page1 = await ctx1.newPage(); + + await page1.goto('/signin'); + await page1.getByLabel('Email').fill('user@test.com'); + await page1.getByLabel('Password').fill('Password!'); + // Leave "Remember me" unchecked + await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked(); + await page1.getByRole('button', { name: 'Sign in' }).click(); + + await expect(page1).toHaveURL('/home'); + + // Only keep persistent cookies (filter out session cookies) + const cookies = await ctx1.cookies(); + await ctx1.close(); + + const persistentCookies = cookies.filter((c) => c.expires > 0); + const ctx2 = await browser.newContext(); + await ctx2.addCookies(persistentCookies); + const page2 = await ctx2.newPage(); + + await page2.goto('/home'); + + // Should redirect to login since session was not persisted + await expect(page2).toHaveURL(/\/signin/); + + await ctx2.close(); +}); +``` + +--- + +## Logout Patterns + +### Standard Logout with Session Cleanup + +```typescript +test.use({ storageState: '.auth/user.json' }); + +test('logs out and clears session', async ({ page, context }) => { + await page.goto('/home'); + + await page.getByRole('button', { name: /account|menu/i }).click(); + await page.getByRole('menuitem', { name: 'Sign out' }).click(); + + await expect(page).toHaveURL('/signin'); + + const cookies = await context.cookies(); + const sessionCookies = cookies.filter((c) => c.name.includes('session') || c.name.includes('token')); + expect(sessionCookies).toHaveLength(0); + + await page.goto('/home'); + await expect(page).toHaveURL(/\/signin/); +}); +``` + +### Logout from All Devices + +```typescript +test('logs out from all devices', async ({ page }) => { + let logoutAllCalled = false; + + await page.route('**/api/auth/logout-all', async (route) => { + logoutAllCalled = true; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ message: 'Logged out everywhere' }), + }); + }); + + await page.goto('/settings/security'); + + await page.getByRole('button', { name: 'Sign out everywhere' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(); + + expect(logoutAllCalled).toBe(true); + await expect(page).toHaveURL(/\/signin/); +}); +``` + +--- + +## Tips + +1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests +2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens +3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared +4. **Test the full flow end-to-end** — Password reset should verify login with new password works + +--- + +## Related + +- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login +- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures +- [third-party.md](./third-party.md) — Mocking external auth providers diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication.md new file mode 100644 index 0000000..02c2dd7 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/authentication.md @@ -0,0 +1,871 @@ +# Authentication Testing + +## Table of Contents + +1. [Quick Reference](#quick-reference) +2. [Patterns](#patterns) +3. [Decision Guide](#decision-guide) +4. [Anti-Patterns](#anti-patterns) +5. [Troubleshooting](#troubleshooting) +6. [Related](#related) + +> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites. + +## Quick Reference + +```typescript +// Storage state reuse — the #1 pattern for fast auth +await page.goto("/login"); +await page.getByLabel("Username").fill("testuser@example.com"); +await page.getByLabel("Password").fill("secretPass123"); +await page.getByRole("button", { name: "Log in" }).click(); +await page.context().storageState({ path: ".auth/session.json" }); + +// Reuse in config — every test starts authenticated +{ + use: { + storageState: ".auth/session.json" + } +} + +// API login — skip the UI entirely +const context = await browser.newContext(); +const response = await context.request.post("/api/auth/login", { + data: { email: "testuser@example.com", password: "secretPass123" }, +}); +await context.storageState({ path: ".auth/session.json" }); +``` + +## Patterns + +### Storage State Reuse + +**Use when**: You need authenticated tests and want to avoid logging in before every test. +**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself. + +`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly. + +```typescript +// scripts/generate-auth.ts — run once to generate the state file +import { chromium } from "@playwright/test"; + +async function generateAuthState() { + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto("http://localhost:4000/login"); + await page.getByLabel("Username").fill("testuser@example.com"); + await page.getByLabel("Password").fill("secretPass123"); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("/home"); + + await context.storageState({ path: ".auth/session.json" }); + await browser.close(); +} + +generateAuthState(); +``` + +```typescript +// playwright.config.ts — load saved state for all tests +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + use: { + baseURL: "http://localhost:4000", + storageState: ".auth/session.json", + }, +}); +``` + +```typescript +// tests/home.spec.ts — test starts already logged in +import { test, expect } from "@playwright/test"; + +test("authenticated user sees home page", async ({ page }) => { + await page.goto("/home"); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); +}); +``` + +### Global Setup Authentication + +**Use when**: You want to authenticate once before the entire test suite runs. +**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs. + +```typescript +// global-setup.ts +import { chromium, type FullConfig } from "@playwright/test"; + +async function globalSetup(config: FullConfig) { + const { baseURL } = config.projects[0].use; + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${baseURL}/login`); + await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!); + await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("**/home"); + + await context.storageState({ path: ".auth/session.json" }); + await browser.close(); +} + +export default globalSetup; +``` + +```typescript +// playwright.config.ts +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + globalSetup: require.resolve("./global-setup"), + use: { + baseURL: "http://localhost:4000", + storageState: ".auth/session.json", + }, +}); +``` + +Add `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed. + +### Per-Worker Authentication + +**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state. +**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account. + +> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix. + +```typescript +// fixtures/auth.ts +import { test as base, type BrowserContext } from "@playwright/test"; + +type AuthFixtures = { + authenticatedContext: BrowserContext; +}; + +export const test = base.extend<{}, AuthFixtures>({ + authenticatedContext: [ + async ({ browser }, use) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto("/login"); + await page + .getByLabel("Username") + .fill(`worker-${test.info().parallelIndex}@example.com`); + await page.getByLabel("Password").fill("secretPass123"); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("/home"); + await page.close(); + + await use(context); + await context.close(); + }, + { scope: "worker" }, + ], +}); + +export { expect } from "@playwright/test"; +``` + +```typescript +// tests/settings.spec.ts +import { test, expect } from "../fixtures/auth"; + +test("update display name", async ({ authenticatedContext }) => { + const page = await authenticatedContext.newPage(); + await page.goto("/settings/profile"); + await page.getByLabel("Display name").fill("Updated Name"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Profile saved")).toBeVisible(); +}); +``` + +### Multiple Roles + +**Use when**: Your app has role-based access control and you need to test different permission levels. +**Avoid when**: Your app has a single user role. + +```typescript +// global-setup.ts — authenticate all roles +import { chromium, type FullConfig } from "@playwright/test"; + +const accounts = [ + { + role: "admin", + email: "admin@example.com", + password: process.env.ADMIN_PASSWORD!, + }, + { + role: "member", + email: "member@example.com", + password: process.env.MEMBER_PASSWORD!, + }, + { + role: "guest", + email: "guest@example.com", + password: process.env.GUEST_PASSWORD!, + }, +]; + +async function globalSetup(config: FullConfig) { + const { baseURL } = config.projects[0].use; + + for (const { role, email, password } of accounts) { + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${baseURL}/login`); + await page.getByLabel("Username").fill(email); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("**/home"); + + await context.storageState({ path: `.auth/${role}.json` }); + await browser.close(); + } +} + +export default globalSetup; +``` + +```typescript +// playwright.config.ts — one project per role +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + globalSetup: require.resolve("./global-setup"), + projects: [ + { + name: "admin", + use: { storageState: ".auth/admin.json" }, + testMatch: "**/*.admin.spec.ts", + }, + { + name: "member", + use: { storageState: ".auth/member.json" }, + testMatch: "**/*.member.spec.ts", + }, + { + name: "guest", + use: { storageState: ".auth/guest.json" }, + testMatch: "**/*.guest.spec.ts", + }, + { + name: "anonymous", + use: { storageState: { cookies: [], origins: [] } }, + testMatch: "**/*.anon.spec.ts", + }, + ], +}); +``` + +```typescript +// tests/admin-panel.admin.spec.ts +import { test, expect } from "@playwright/test"; + +test("admin can access user management", async ({ page }) => { + await page.goto("/admin/users"); + await expect( + page.getByRole("heading", { name: "User Management" }) + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Remove user" })).toBeEnabled(); +}); +``` + +```typescript +// tests/admin-panel.guest.spec.ts +import { test, expect } from "@playwright/test"; + +test("guest cannot access admin panel", async ({ page }) => { + await page.goto("/admin/users"); + await expect(page.getByText("Access denied")).toBeVisible(); +}); +``` + +**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file. + +```typescript +// fixtures/auth.ts — role-based fixture +import { test as base, type Page } from "@playwright/test"; +import fs from "fs"; + +type RoleFixtures = { + loginAs: (role: "admin" | "member" | "guest") => Promise; +}; + +export const test = base.extend({ + loginAs: async ({ browser }, use) => { + const pages: Page[] = []; + + await use(async (role) => { + const statePath = `.auth/${role}.json`; + if (!fs.existsSync(statePath)) { + throw new Error( + `Auth state for role "${role}" not found at ${statePath}` + ); + } + const context = await browser.newContext({ storageState: statePath }); + const page = await context.newPage(); + pages.push(page); + return page; + }); + + for (const page of pages) { + await page.context().close(); + } + }, +}); + +export { expect } from "@playwright/test"; +``` + +```typescript +// tests/role-comparison.spec.ts +import { test, expect } from "../fixtures/auth"; + +test("admin sees remove button, guest does not", async ({ loginAs }) => { + const adminPage = await loginAs("admin"); + await adminPage.goto("/admin/users"); + await expect( + adminPage.getByRole("button", { name: "Remove user" }) + ).toBeVisible(); + + const guestPage = await loginAs("guest"); + await guestPage.goto("/admin/users"); + await expect(guestPage.getByText("Access denied")).toBeVisible(); +}); +``` + +### OAuth/SSO Mocking + +**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests. +**Avoid when**: You have a dedicated test tenant on the OAuth provider. + +A typical OAuth flow works like this: + +1. User clicks "Sign in with Provider" → browser navigates to `https://accounts.provider.com/authorize?...` +2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`) +3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page + +In tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page. + +For cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly. + +```typescript +// tests/oauth-login.spec.ts — mock the callback route +import { test, expect } from "@playwright/test"; + +test("login via mocked OAuth flow", async ({ page }) => { + await page.route("https://accounts.provider.com/**", async (route) => { + const callbackUrl = new URL("http://localhost:4000/auth/callback"); + callbackUrl.searchParams.set("code", "mock-auth-code-xyz"); + callbackUrl.searchParams.set("state", "expected-state-value"); + await route.fulfill({ + status: 302, + headers: { location: callbackUrl.toString() }, + }); + }); + + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with Provider" }).click(); + + await page.waitForURL("/home"); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); +}); +``` + +```typescript +// tests/oauth-login.spec.ts — API-based session injection +import { test, expect } from "@playwright/test"; + +test("bypass OAuth entirely via API session injection", async ({ + page, +}) => { + // Call a test-only endpoint that creates a session without OAuth + const response = await page.request.post("/api/test/create-session", { + data: { + email: "oauth-user@example.com", + provider: "provider", + role: "member", + }, + }); + expect(response.ok()).toBeTruthy(); + + await page.context().storageState({ path: ".auth/oauth-user.json" }); + await page.goto("/home"); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); +}); +``` + +**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code. + +### MFA Handling + +**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes). +**Avoid when**: MFA is optional and you can disable it for test accounts. + +**Strategy 1**: Generate real TOTP codes from a shared secret. + +```typescript +// helpers/totp.ts +import * as OTPAuth from "otpauth"; + +export function generateTOTP(secret: string): string { + const totp = new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(secret), + digits: 6, + period: 30, + algorithm: "SHA1", + }); + return totp.generate(); +} +``` + +```typescript +// tests/mfa-login.spec.ts +import { test, expect } from "@playwright/test"; +import { generateTOTP } from "../helpers/totp"; + +test("login with TOTP two-factor auth", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("mfa-user@example.com"); + await page.getByLabel("Password").fill("secretPass123"); + await page.getByRole("button", { name: "Log in" }).click(); + + await expect(page.getByText("Enter your authentication code")).toBeVisible(); + + const code = generateTOTP(process.env.MFA_TOTP_SECRET!); + await page.getByLabel("Authentication code").fill(code); + await page.getByRole("button", { name: "Verify" }).click(); + + await page.waitForURL("/home"); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); +}); +``` + +**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`. + +**Strategy 3**: Disable MFA for test accounts at the infrastructure level. + +### Session Refresh + +**Use when**: Your tokens expire during long test runs. +**Avoid when**: Your test suite runs quickly and tokens outlast the entire run. + +```typescript +// fixtures/auth-with-refresh.ts +import { test as base, type BrowserContext } from "@playwright/test"; +import fs from "fs"; + +type AuthFixtures = { + authenticatedPage: import("@playwright/test").Page; +}; + +export const test = base.extend({ + authenticatedPage: async ({ browser }, use) => { + const statePath = ".auth/session.json"; + + let context: BrowserContext; + if (fs.existsSync(statePath)) { + context = await browser.newContext({ storageState: statePath }); + const page = await context.newPage(); + + const response = await page.request.get("/api/auth/me"); + if (response.ok()) { + await use(page); + await context.close(); + return; + } + await context.close(); + } + + context = await browser.newContext(); + const page = await context.newPage(); + await page.goto("/login"); + await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!); + await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!); + await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL("/home"); + + await context.storageState({ path: statePath }); + + await use(page); + await context.close(); + }, +}); + +export { expect } from "@playwright/test"; +``` + +### Login Page Object + +**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic. +**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests. + +```typescript +// page-objects/LoginPage.ts +import { type Page, type Locator, expect } from "@playwright/test"; + +export class LoginPage { + readonly page: Page; + readonly usernameInput: Locator; + readonly passwordInput: Locator; + readonly loginButton: Locator; + readonly errorMessage: Locator; + readonly forgotPasswordLink: Locator; + + constructor(page: Page) { + this.page = page; + this.usernameInput = page.getByLabel("Username"); + this.passwordInput = page.getByLabel("Password"); + this.loginButton = page.getByRole("button", { name: "Log in" }); + this.errorMessage = page.getByRole("alert"); + this.forgotPasswordLink = page.getByRole("link", { + name: "Forgot password", + }); + } + + async goto() { + await this.page.goto("/login"); + await expect(this.loginButton).toBeVisible(); + } + + async login(username: string, password: string) { + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + await this.loginButton.click(); + } + + async loginAndWaitForHome(username: string, password: string) { + await this.login(username, password); + await this.page.waitForURL("/home"); + } + + async expectError(message: string | RegExp) { + await expect(this.errorMessage).toContainText(message); + } + + async expectFieldError(field: "username" | "password", message: string) { + const input = + field === "username" ? this.usernameInput : this.passwordInput; + await expect(input).toHaveAttribute("aria-invalid", "true"); + const errorId = await input.getAttribute("aria-describedby"); + if (errorId) { + await expect(this.page.locator(`#${errorId}`)).toContainText(message); + } + } +} +``` + +```typescript +// tests/login.spec.ts +import { test, expect } from "@playwright/test"; +import { LoginPage } from "../page-objects/LoginPage"; + +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("login page", () => { + let loginPage: LoginPage; + + test.beforeEach(async ({ page }) => { + loginPage = new LoginPage(page); + await loginPage.goto(); + }); + + test("successful login redirects to home", async ({ page }) => { + await loginPage.loginAndWaitForHome( + "testuser@example.com", + "secretPass123" + ); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); + }); + + test("wrong password shows error", async () => { + await loginPage.login("testuser@example.com", "wrong-password"); + await loginPage.expectError("Invalid username or password"); + }); + + test("empty fields show validation errors", async () => { + await loginPage.loginButton.click(); + await loginPage.expectFieldError("username", "Username is required"); + }); + + test("forgot password link navigates correctly", async ({ page }) => { + await loginPage.forgotPasswordLink.click(); + await page.waitForURL("/forgot-password"); + await expect( + page.getByRole("heading", { name: "Reset password" }) + ).toBeVisible(); + }); +}); +``` + +### API-Based Login + +**Use when**: You want the fastest possible authentication without any browser interaction. +**Avoid when**: You are specifically testing the login UI. + +API login is typically 5-10x faster than UI login. + +```typescript +// global-setup.ts — API-based login (fastest) +import { request, type FullConfig } from "@playwright/test"; + +async function globalSetup(config: FullConfig) { + const { baseURL } = config.projects[0].use; + + const requestContext = await request.newContext({ baseURL }); + + const response = await requestContext.post("/api/auth/login", { + data: { + email: process.env.TEST_USER_EMAIL!, + password: process.env.TEST_USER_PASSWORD!, + }, + }); + + if (!response.ok()) { + throw new Error( + `API login failed: ${response.status()} ${await response.text()}` + ); + } + + await requestContext.storageState({ path: ".auth/session.json" }); + await requestContext.dispose(); +} + +export default globalSetup; +``` + +```typescript +// fixtures/api-auth.ts — fixture version for per-test authentication +import { test as base } from "@playwright/test"; + +export const test = base.extend({ + authenticatedPage: async ({ browser, playwright }, use) => { + const apiContext = await playwright.request.newContext({ + baseURL: "http://localhost:4000", + }); + + await apiContext.post("/api/auth/login", { + data: { + email: "testuser@example.com", + password: "secretPass123", + }, + }); + + const state = await apiContext.storageState(); + const context = await browser.newContext({ storageState: state }); + const page = await context.newPage(); + + await use(page); + + await context.close(); + await apiContext.dispose(); + }, +}); + +export { expect } from "@playwright/test"; +``` + +### Unauthenticated Tests + +**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users. +**Avoid when**: The test requires a logged-in user. + +When your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests. + +```typescript +// tests/public-pages.spec.ts +import { test, expect } from "@playwright/test"; + +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("unauthenticated access", () => { + test("homepage is accessible without login", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Log in" })).toBeVisible(); + }); + + test("protected route redirects to login", async ({ page }) => { + await page.goto("/home"); + await page.waitForURL("**/login**"); + expect(page.url()).toContain("redirect=%2Fhome"); + }); + + test("expired session shows re-login prompt", async ({ page, context }) => { + await page.goto("/home"); + await context.clearCookies(); + + await page.goto("/settings"); + await page.waitForURL("**/login**"); + await expect(page.getByText("Your session has expired")).toBeVisible(); + }); + + test("signup flow creates account", async ({ page }) => { + await page.goto("/signup"); + await page.getByLabel("Name").fill("New User"); + await page.getByLabel("Email").fill(`test-${Date.now()}@example.com`); + await page.getByLabel("Password", { exact: true }).fill("secretPass123"); + await page.getByLabel("Confirm password").fill("secretPass123"); + await page.getByRole("button", { name: "Create account" }).click(); + + await page.waitForURL("/onboarding"); + await expect(page.getByText("Welcome, New User")).toBeVisible(); + }); +}); +``` + +## Decision Guide + +| Scenario | Approach | Speed | Isolation | When to Choose | +| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- | +| Most tests need auth | Global setup + `storageState` | Fastest | Shared session | Default for nearly every project | +| Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data | +| Multiple user roles | Per-project `storageState` | Fastest | Per role | App has admin/member/guest roles | +| Testing the login page | No `storageState` | N/A | Full | Use `test.use({ storageState: { cookies: [], origins: [] } })` | +| OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI | +| MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes or use a test-mode bypass | +| Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use | +| Single test needs different user | `loginAs(role)` fixture | Moderate | Per call | Rare: prefer per-project roles | +| API-first app (no login UI) | API login via `request.post()` | Fastest | Per test | No browser needed for auth | + +### UI Login vs API Login vs Storage State + +```text +Need to test the login page itself? +├── Yes → UI login with LoginPage POM, no storageState +└── No → Do you have a login API endpoint? + ├── Yes → API login in global setup, save storageState (fastest) + └── No → UI login in global setup, save storageState + └── Tokens expire quickly? + ├── Yes → Add session refresh fixture + └── No → Standard storageState reuse is fine +``` + +## Anti-Patterns + +| Don't Do This | Problem | Do This Instead | +| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- | +| Log in via UI before every test | Adds 2-5 seconds per test | Use `storageState` to skip login entirely | +| Share a single auth state file across parallel workers that mutate state | Race conditions | Use per-worker fixtures with `{ scope: 'worker' }` | +| Hardcode credentials in test files | Security risk | Use environment variables and `.env` files | +| Ignore token expiration | Tests fail intermittently with 401 errors | Add a session validity check in your auth fixture | +| Hit real OAuth providers in CI | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection | +| Use `page.waitForTimeout(2000)` after login | Arbitrary delay | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` | +| Store `.auth/*.json` files in git | Tokens in version control | Add `.auth/` to `.gitignore` | +| Create one "god" test account with all permissions | Cannot test role-based access control | Create separate accounts per role | +| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated | Pass `storageState` when creating the context | +| Test MFA by disabling it everywhere | You never test the MFA flow | Use TOTP generation for at least one test | + +## Troubleshooting + +### Global setup fails with "Target page, context or browser has been closed" + +**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called. + +**Fix**: + +- Add `await page.waitForURL()` after the login action +- Check that `baseURL` in your config matches the actual server URL and protocol +- Add error handling to global setup: + +```typescript +const response = await page.waitForResponse("**/api/auth/**"); +if (!response.ok()) { + throw new Error( + `Login failed in global setup: ${response.status()} ${await response.text()}` + ); +} +``` + +### Tests fail with 401 Unauthorized after running for a while + +**Cause**: The session token saved in `storageState` has expired. + +**Fix**: + +- Use the session refresh fixture pattern +- Increase token expiry in test environment configuration +- Switch to API-based login in a worker-scoped fixture + +### `storageState` file is empty or contains no cookies + +**Cause**: `storageState()` was called before the login response set cookies. + +**Fix**: + +- Wait for the post-login page to load: `await page.waitForURL('/home')` +- Verify cookies exist before saving: + +```typescript +const cookies = await context.cookies(); +if (cookies.length === 0) { + throw new Error("No cookies found after login"); +} +await context.storageState({ path: ".auth/session.json" }); +``` + +### Different browsers get different cookies + +**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior. + +**Fix**: + +- Generate separate auth state files per browser project +- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS: + +```typescript +projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' }, + }, +], +``` + +### Parallel tests interfere with each other's sessions + +**Cause**: Multiple workers share the same test account and one worker's actions affect others. + +**Fix**: + +- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com` +- Use the per-worker authentication fixture pattern +- Make tests idempotent + +### OAuth mock does not work — still redirects to real provider + +**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect. + +**Fix**: + +- Register route handlers before any navigation: call `page.route()` before `page.goto()` +- Log the actual redirect URL to verify the pattern: + +```typescript +page.on("request", (req) => { + if (req.url().includes("oauth") || req.url().includes("accounts.provider")) { + console.log("OAuth request:", req.url()); + } +}); +``` + +## Related + +- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown +- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration +- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies +- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking +- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login +- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/clock-mocking.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/clock-mocking.md new file mode 100644 index 0000000..073d087 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/clock-mocking.md @@ -0,0 +1,364 @@ +# Date, Time & Clock Mocking + +## Table of Contents + +1. [Clock API Basics](#clock-api-basics) +2. [Fixed Time Testing](#fixed-time-testing) +3. [Time Advancement](#time-advancement) +4. [Timezone Testing](#timezone-testing) +5. [Timer Mocking](#timer-mocking) + +## Clock API Basics + +### Install Clock + +```typescript +test("mock current time", async ({ page }) => { + // Install clock before navigating + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + + await page.goto("/dashboard"); + + // Page sees January 15, 2025 as current date + await expect(page.getByText("January 15, 2025")).toBeVisible(); +}); +``` + +### Clock with Fixture + +```typescript +// fixtures/clock.fixture.ts +import { test as base } from "@playwright/test"; + +type ClockFixtures = { + mockTime: (date: Date | string) => Promise; +}; + +export const test = base.extend({ + mockTime: async ({ page }, use) => { + await use(async (date) => { + const time = typeof date === "string" ? new Date(date) : date; + await page.clock.install({ time }); + }); + }, +}); + +// Usage +test("subscription expiry", async ({ page, mockTime }) => { + await mockTime("2025-12-31T23:59:00"); + await page.goto("/subscription"); + + await expect(page.getByText("Expires today")).toBeVisible(); +}); +``` + +## Fixed Time Testing + +### Test Date-Dependent Features + +```typescript +test("show holiday banner in December", async ({ page }) => { + await page.clock.install({ time: new Date("2025-12-20T10:00:00") }); + + await page.goto("/"); + + await expect(page.getByRole("banner", { name: /holiday/i })).toBeVisible(); +}); + +test("no holiday banner in January", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T10:00:00") }); + + await page.goto("/"); + + await expect(page.getByRole("banner", { name: /holiday/i })).toBeHidden(); +}); +``` + +### Test Relative Time Display + +```typescript +test("shows relative time correctly", async ({ page }) => { + // Fix time to control "posted 2 hours ago" text + await page.clock.install({ time: new Date("2025-06-15T14:00:00") }); + + // Mock API to return post with known timestamp + await page.route("**/api/posts/1", (route) => + route.fulfill({ + json: { + id: 1, + title: "Test Post", + createdAt: "2025-06-15T12:00:00Z", // 2 hours before mock time + }, + }), + ); + + await page.goto("/posts/1"); + + await expect(page.getByText("2 hours ago")).toBeVisible(); +}); +``` + +### Test Date Boundaries + +```typescript +test.describe("end of month billing", () => { + test("shows billing on last day of month", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-31T10:00:00") }); + await page.goto("/billing"); + + await expect(page.getByText("Payment due today")).toBeVisible(); + }); + + test("shows days remaining mid-month", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T10:00:00") }); + await page.goto("/billing"); + + await expect(page.getByText("16 days until payment")).toBeVisible(); + }); +}); +``` + +## Time Advancement + +### Advance Time Manually + +```typescript +test("session timeout warning", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/dashboard"); + + // Advance 25 minutes (session timeout at 30 min) + await page.clock.fastForward("25:00"); + + await expect(page.getByText("Session expires in 5 minutes")).toBeVisible(); + + // Advance 5 more minutes + await page.clock.fastForward("05:00"); + + await expect(page.getByText("Session expired")).toBeVisible(); +}); +``` + +### Pause and Resume Time + +```typescript +test("countdown timer", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/sale"); + + // Initial state + await expect(page.getByText("Sale ends in 2:00:00")).toBeVisible(); + + // Advance 1 hour + await page.clock.fastForward("01:00:00"); + + await expect(page.getByText("Sale ends in 1:00:00")).toBeVisible(); + + // Advance past end + await page.clock.fastForward("01:00:01"); + + await expect(page.getByText("Sale ended")).toBeVisible(); +}); +``` + +### Run Pending Timers + +```typescript +test("debounced search", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/search"); + + await page.getByLabel("Search").fill("playwright"); + + // Search is debounced by 300ms, won't fire yet + await expect(page.getByTestId("search-results")).toBeHidden(); + + // Fast forward past debounce + await page.clock.fastForward(300); + + // Now search should execute + await expect(page.getByTestId("search-results")).toBeVisible(); +}); +``` + +## Timezone Testing + +### Test Different Timezones + +```typescript +test.describe("timezone display", () => { + test("shows correct time in PST", async ({ browser }) => { + const context = await browser.newContext({ + timezoneId: "America/Los_Angeles", + }); + const page = await context.newPage(); + + await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC + + await page.goto("/schedule"); + + // Should show 9 AM PST + await expect(page.getByText("9:00 AM")).toBeVisible(); + + await context.close(); + }); + + test("shows correct time in JST", async ({ browser }) => { + const context = await browser.newContext({ + timezoneId: "Asia/Tokyo", + }); + const page = await context.newPage(); + + await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC + + await page.goto("/schedule"); + + // Should show 2 AM next day JST + await expect(page.getByText("2:00 AM")).toBeVisible(); + + await context.close(); + }); +}); +``` + +### Timezone Fixture + +```typescript +// fixtures/timezone.fixture.ts +import { test as base } from "@playwright/test"; + +type TimezoneFixtures = { + pageInTimezone: (timezone: string) => Promise; +}; + +export const test = base.extend({ + pageInTimezone: async ({ browser }, use) => { + const pages: Page[] = []; + + await use(async (timezone) => { + const context = await browser.newContext({ timezoneId: timezone }); + const page = await context.newPage(); + pages.push(page); + return page; + }); + + // Cleanup + for (const page of pages) { + await page.context().close(); + } + }, +}); +``` + +## Timer Mocking + +### Mock setInterval + +```typescript +test("auto-refresh data", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + + let apiCalls = 0; + await page.route("**/api/data", (route) => { + apiCalls++; + route.fulfill({ json: { value: apiCalls } }); + }); + + await page.goto("/live-data"); // Sets up 30s refresh interval + + expect(apiCalls).toBe(1); // Initial load + + // Advance 30 seconds + await page.clock.fastForward("00:30"); + expect(apiCalls).toBe(2); // First refresh + + // Advance another 30 seconds + await page.clock.fastForward("00:30"); + expect(apiCalls).toBe(3); // Second refresh +}); +``` + +### Mock setTimeout Chains + +```typescript +test("notification queue", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/notifications"); + + // Trigger 3 notifications that show sequentially + await page.getByRole("button", { name: "Show All" }).click(); + + // First notification appears immediately + await expect(page.getByText("Notification 1")).toBeVisible(); + + // Second appears after 2 seconds + await page.clock.fastForward("00:02"); + await expect(page.getByText("Notification 2")).toBeVisible(); + + // Third appears after 2 more seconds + await page.clock.fastForward("00:02"); + await expect(page.getByText("Notification 3")).toBeVisible(); +}); +``` + +### Test Animation Frames + +```typescript +test("animation completes", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); + await page.goto("/animation-demo"); + + await page.getByRole("button", { name: "Animate" }).click(); + + // Animation runs for 500ms + const element = page.getByTestId("animated-box"); + await expect(element).toHaveCSS("opacity", "0"); + + // Fast forward through animation + await page.clock.fastForward(500); + + await expect(element).toHaveCSS("opacity", "1"); +}); +``` + +## Best Practices + +### Always Install Clock Before Navigation + +```typescript +// Good +test("date test", async ({ page }) => { + await page.clock.install({ time: new Date("2025-01-15") }); + await page.goto("/"); // Page loads with mocked time +}); + +// Bad - time already captured by page +test("date test", async ({ page }) => { + await page.goto("/"); + await page.clock.install({ time: new Date("2025-01-15") }); // Too late! +}); +``` + +### Use ISO Strings for Clarity + +```typescript +// Good - explicit timezone +await page.clock.install({ time: new Date("2025-01-15T09:00:00Z") }); + +// Ambiguous - uses local timezone +await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ---------------------------------------- | ------------------------------- | -------------------------------------- | +| Installing clock after navigation | Page already captured real time | Install clock before `goto()` | +| Hardcoded relative dates | Tests break over time | Use fixed dates with clock mock | +| Not accounting for timezone | Tests fail in different regions | Use explicit UTC times or set timezone | +| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers | Use `fastForward` instead | + +## Related References + +- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions +- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/mobile-testing.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/mobile-testing.md new file mode 100644 index 0000000..e928bde --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/mobile-testing.md @@ -0,0 +1,409 @@ +# Mobile & Responsive Testing + +## Table of Contents + +1. [Device Emulation](#device-emulation) +2. [Touch Gestures](#touch-gestures) +3. [Viewport Testing](#viewport-testing) +4. [Mobile-Specific UI](#mobile-specific-ui) +5. [Responsive Breakpoints](#responsive-breakpoints) + +## Device Emulation + +### Use Built-in Devices + +```typescript +import { test, devices } from "@playwright/test"; + +// Configure in playwright.config.ts +export default defineConfig({ + projects: [ + { name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } }, + { name: "Mobile Safari", use: { ...devices["iPhone 14"] } }, + { name: "Mobile Chrome", use: { ...devices["Pixel 7"] } }, + { name: "Tablet", use: { ...devices["iPad Pro 11"] } }, + ], +}); +``` + +### Custom Device Configuration + +```typescript +test.use({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15", +}); + +test("custom mobile device", async ({ page }) => { + await page.goto("/"); + // Test runs with custom device settings +}); +``` + +### Test Across Multiple Devices + +```typescript +const mobileDevices = ["iPhone 14", "Pixel 7", "Galaxy S21"]; + +for (const deviceName of mobileDevices) { + test(`checkout on ${deviceName}`, async ({ browser }) => { + const device = devices[deviceName]; + const context = await browser.newContext({ ...device }); + const page = await context.newPage(); + + await page.goto("/checkout"); + await expect(page.getByRole("button", { name: "Pay" })).toBeVisible(); + + await context.close(); + }); +} +``` + +## Touch Gestures + +### Tap + +```typescript +test.use({ hasTouch: true }); + +test("tap to interact", async ({ page }) => { + await page.goto("/gallery"); + + // Tap is like click but for touch devices + await page.getByRole("img", { name: "Photo 1" }).tap(); + + await expect(page.getByRole("dialog")).toBeVisible(); +}); +``` + +### Swipe + +```typescript +test("swipe carousel", async ({ page }) => { + await page.goto("/carousel"); + + const carousel = page.getByTestId("carousel"); + const box = await carousel.boundingBox(); + + if (box) { + // Swipe left + await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2); + await page.mouse.move(box.x + 50, box.y + box.height / 2); + + // Or use drag + await carousel.dragTo(carousel, { + sourcePosition: { x: box.width - 50, y: box.height / 2 }, + targetPosition: { x: 50, y: box.height / 2 }, + }); + } + + await expect(page.getByText("Slide 2")).toBeVisible(); +}); +``` + +### Swipe Fixture + +```typescript +// fixtures/touch.fixture.ts +import { test as base, Page } from "@playwright/test"; + +type TouchFixtures = { + swipe: ( + element: Locator, + direction: "left" | "right" | "up" | "down", + ) => Promise; +}; + +export const test = base.extend({ + swipe: async ({ page }, use) => { + await use(async (element, direction) => { + const box = await element.boundingBox(); + if (!box) throw new Error("Element not visible"); + + const centerX = box.x + box.width / 2; + const centerY = box.y + box.height / 2; + const distance = 100; + + const moves = { + left: { + startX: centerX + distance, + endX: centerX - distance, + y: centerY, + }, + right: { + startX: centerX - distance, + endX: centerX + distance, + y: centerY, + }, + up: { + startX: centerX, + endX: centerX, + startY: centerY + distance, + endY: centerY - distance, + }, + down: { + startX: centerX, + endX: centerX, + startY: centerY - distance, + endY: centerY + distance, + }, + }; + + const move = moves[direction]; + await page.touchscreen.tap(move.startX, move.startY ?? move.y); + await page.mouse.move(move.endX, move.endY ?? move.y, { steps: 10 }); + await page.mouse.up(); + }); + }, +}); + +// Usage +test("swipe to delete", async ({ page, swipe }) => { + await page.goto("/inbox"); + + const message = page.getByTestId("message-1"); + await swipe(message, "left"); + + await expect(page.getByRole("button", { name: "Delete" })).toBeVisible(); +}); +``` + +### Long Press + +```typescript +test("long press for context menu", async ({ page }) => { + await page.goto("/files"); + + const file = page.getByText("document.pdf"); + const box = await file.boundingBox(); + + if (box) { + // Touch down + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); + + // Hold for 500ms + await page.waitForTimeout(500); + + // Context menu should appear + await expect(page.getByRole("menu")).toBeVisible(); + } +}); +``` + +### Pinch Zoom + +```typescript +test("pinch to zoom image", async ({ page }) => { + await page.goto("/map"); + + // Pinch zoom requires two touch points + // Playwright doesn't have native pinch support, so we simulate via evaluate + await page.evaluate(() => { + const element = document.querySelector("#map"); + if (element) { + // Simulate wheel event as fallback for zoom + element.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -100, // Negative = zoom in + ctrlKey: true, // Ctrl+wheel = pinch on many apps + }), + ); + } + }); + + // Or trigger the app's zoom function directly + await page.evaluate(() => { + (window as any).mapInstance?.setZoom(15); + }); +}); +``` + +## Viewport Testing + +### Test Different Sizes + +```typescript +const viewports = [ + { name: "mobile", width: 375, height: 667 }, + { name: "tablet", width: 768, height: 1024 }, + { name: "desktop", width: 1920, height: 1080 }, +]; + +for (const { name, width, height } of viewports) { + test(`navigation on ${name}`, async ({ page }) => { + await page.setViewportSize({ width, height }); + await page.goto("/"); + + if (width < 768) { + // Mobile: should have hamburger menu + await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); + } else { + // Desktop: should have visible nav links + await expect(page.getByRole("link", { name: "Products" })).toBeVisible(); + } + }); +} +``` + +### Dynamic Viewport Changes + +```typescript +test("responsive layout change", async ({ page }) => { + await page.setViewportSize({ width: 1200, height: 800 }); + await page.goto("/dashboard"); + + // Desktop: sidebar visible + await expect(page.getByRole("complementary")).toBeVisible(); + + // Resize to mobile + await page.setViewportSize({ width: 375, height: 667 }); + + // Mobile: sidebar hidden, hamburger visible + await expect(page.getByRole("complementary")).toBeHidden(); + await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); +}); +``` + +## Mobile-Specific UI + +### Hamburger Menu + +```typescript +test("mobile navigation", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto("/"); + + // Open hamburger menu + await page.getByRole("button", { name: "Menu" }).click(); + + // Navigation drawer should appear + const nav = page.getByRole("navigation"); + await expect(nav).toBeVisible(); + + // Navigate via mobile menu + await nav.getByRole("link", { name: "Products" }).click(); + + await expect(page).toHaveURL("/products"); + // Menu should close after navigation + await expect(nav).toBeHidden(); +}); +``` + +### Bottom Sheet + +```typescript +test("bottom sheet interaction", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto("/product/123"); + + await page.getByRole("button", { name: "Add to Cart" }).click(); + + // Bottom sheet appears + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + // Select options + await sheet.getByRole("combobox", { name: "Size" }).selectOption("Large"); + await sheet.getByRole("button", { name: "Confirm" }).click(); + + await expect(page.getByText("Added to cart")).toBeVisible(); +}); +``` + +### Pull to Refresh + +```typescript +test("pull to refresh", async ({ page }) => { + await page.goto("/feed"); + + const feed = page.getByTestId("feed"); + const initialFirstItem = await feed.locator("> *").first().textContent(); + + // Simulate pull down + const box = await feed.boundingBox(); + if (box) { + await page.touchscreen.tap(box.x + box.width / 2, box.y + 50); + await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 }); + await page.mouse.up(); + } + + // Wait for refresh + await expect(page.getByTestId("loading")).toBeVisible(); + await expect(page.getByTestId("loading")).toBeHidden(); + + // Content should be updated (in a real app) +}); +``` + +## Responsive Breakpoints + +### Test All Breakpoints + +```typescript +const breakpoints = { + xs: 320, + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + "2xl": 1536, +}; + +test.describe("responsive header", () => { + for (const [name, width] of Object.entries(breakpoints)) { + test(`header at ${name} (${width}px)`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + + if (width < 768) { + await expect(page.getByTestId("mobile-menu-button")).toBeVisible(); + await expect(page.getByTestId("desktop-nav")).toBeHidden(); + } else { + await expect(page.getByTestId("mobile-menu-button")).toBeHidden(); + await expect(page.getByTestId("desktop-nav")).toBeVisible(); + } + }); + } +}); +``` + +### Visual Regression at Breakpoints + +```typescript +test.describe("visual regression", () => { + const sizes = [ + { width: 375, height: 667, name: "mobile" }, + { width: 768, height: 1024, name: "tablet" }, + { width: 1440, height: 900, name: "desktop" }, + ]; + + for (const { width, height, name } of sizes) { + test(`homepage at ${name}`, async ({ page }) => { + await page.setViewportSize({ width, height }); + await page.goto("/"); + + await expect(page).toHaveScreenshot(`homepage-${name}.png`); + }); + } +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| --------------------------- | ------------------------- | -------------------------------- | +| Only testing one viewport | Misses responsive bugs | Test multiple breakpoints | +| Ignoring touch events | Features broken on mobile | Test tap, swipe, long press | +| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()` | +| Not testing orientation | Landscape bugs missed | Test both portrait and landscape | + +## Related References + +- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing +- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors +- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications) +- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-context.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-context.md new file mode 100644 index 0000000..ed1cf8a --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-context.md @@ -0,0 +1,288 @@ +# Multi-Tab, Window & Popup Testing + +This file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md). + +## Table of Contents + +1. [Popup Handling](#popup-handling) +2. [New Tab Navigation](#new-tab-navigation) +3. [OAuth Flows](#oauth-flows) +4. [Multiple Windows](#multiple-windows) +5. [Tab Coordination](#tab-coordination) + +## Popup Handling + +### Basic Popup + +```typescript +test("handle popup window", async ({ page }) => { + await page.goto("/"); + + // Start waiting for popup before triggering it + const popupPromise = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Open Support Chat" }).click(); + const popup = await popupPromise; + + // Wait for popup to load + await popup.waitForLoadState(); + + // Interact with popup + await popup.getByLabel("Message").fill("Need help"); + await popup.getByRole("button", { name: "Send" }).click(); + + await expect(popup.getByText("Message sent")).toBeVisible(); + + // Close popup + await popup.close(); +}); +``` + +### Popup with Authentication + +```typescript +test("popup login flow", async ({ page }) => { + await page.goto("/dashboard"); + + const popupPromise = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Connect Account" }).click(); + const popup = await popupPromise; + + await popup.waitForLoadState(); + + // Complete login in popup + await popup.getByLabel("Email").fill("user@example.com"); + await popup.getByLabel("Password").fill("password123"); + await popup.getByRole("button", { name: "Log In" }).click(); + + // Popup should close automatically after auth + await popup.waitForEvent("close"); + + // Main page should reflect connected state + await expect(page.getByText("Account connected")).toBeVisible(); +}); +``` + +### Handle Blocked Popups + +```typescript +test("handle popup blocker", async ({ page }) => { + await page.goto("/share"); + + // Listen for console messages about blocked popup + page.on("console", (msg) => { + if (msg.text().includes("popup blocked")) { + console.log("Popup was blocked"); + } + }); + + const popupPromise = page.waitForEvent("popup").catch(() => null); + await page.getByRole("button", { name: "Share to Twitter" }).click(); + const popup = await popupPromise; + + if (!popup) { + // Popup blocked - app should show fallback + await expect(page.getByText("Copy share link instead")).toBeVisible(); + } +}); +``` + +## New Tab Navigation + +### Link Opens in New Tab + +```typescript +test("external link opens in new tab", async ({ page, context }) => { + await page.goto("/resources"); + + // Wait for new page in context + const pagePromise = context.waitForEvent("page"); + await page.getByRole("link", { name: "Documentation" }).click(); + const newPage = await pagePromise; + + await newPage.waitForLoadState(); + + expect(newPage.url()).toContain("docs.example.com"); + await expect(newPage.getByRole("heading", { level: 1 })).toBeVisible(); + + // Original page still there + expect(page.url()).toContain("/resources"); + + await newPage.close(); +}); +``` + +### Intercept New Tab + +```typescript +test("prevent new tab for testing", async ({ page }) => { + await page.goto("/links"); + + // Remove target="_blank" to keep navigation in same tab + await page.evaluate(() => { + document.querySelectorAll('a[target="_blank"]').forEach((a) => { + a.removeAttribute("target"); + }); + }); + + // Now link opens in same tab + await page.getByRole("link", { name: "External Site" }).click(); + + // Can test the destination page + await expect(page).toHaveURL(/external-site\.com/); +}); +``` + +## OAuth Flows + +### Google OAuth Popup + +```typescript +test("Google OAuth login", async ({ page }) => { + await page.goto("/login"); + + const popupPromise = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Sign in with Google" }).click(); + const popup = await popupPromise; + + await popup.waitForLoadState(); + + // Handle Google's OAuth flow + await popup.getByLabel("Email or phone").fill("test@gmail.com"); + await popup.getByRole("button", { name: "Next" }).click(); + + await popup.getByLabel("Enter your password").fill("password"); + await popup.getByRole("button", { name: "Next" }).click(); + + // Wait for redirect back and popup close + await popup.waitForEvent("close"); + + // Verify logged in on main page + await expect(page.getByText("Welcome, Test User")).toBeVisible(); +}); +``` + +### Mock OAuth (Recommended) + +```typescript +test("mock OAuth flow", async ({ page, context }) => { + // Mock the OAuth callback instead of real flow + await page.route("**/auth/callback**", async (route) => { + // Simulate successful OAuth + const url = new URL(route.request().url()); + url.searchParams.set("code", "mock-auth-code"); + await route.fulfill({ + status: 302, + headers: { Location: "/dashboard" }, + }); + }); + + // Mock token exchange + await page.route("**/api/auth/token", (route) => + route.fulfill({ + json: { + access_token: "mock-token", + user: { name: "Test User", email: "test@example.com" }, + }, + }), + ); + + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with Google" }).click(); + + // Should redirect to dashboard without actual OAuth + await expect(page).toHaveURL("/dashboard"); + await expect(page.getByText("Welcome, Test User")).toBeVisible(); +}); +``` + +### OAuth Fixture + +> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows. + +## Multiple Windows + +### Test Across Multiple Windows + +```typescript +test("sync between windows", async ({ context }) => { + // Open two pages + const page1 = await context.newPage(); + const page2 = await context.newPage(); + + await page1.goto("/dashboard"); + await page2.goto("/dashboard"); + + // Make change in first window + await page1.getByRole("button", { name: "Add Item" }).click(); + await page1.getByLabel("Name").fill("New Item"); + await page1.getByRole("button", { name: "Save" }).click(); + + // Should sync to second window (if app supports real-time sync) + await expect(page2.getByText("New Item")).toBeVisible({ timeout: 10000 }); +}); +``` + +### Different Users in Different Windows + +> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups. + +## Tab Coordination + +### Switch Between Tabs + +```typescript +test("manage multiple tabs", async ({ context }) => { + const page1 = await context.newPage(); + await page1.goto("/editor"); + + const page2 = await context.newPage(); + await page2.goto("/preview"); + + // Edit in first tab + await page1.bringToFront(); + await page1.getByLabel("Content").fill("Hello World"); + + // Check preview in second tab + await page2.bringToFront(); + await page2.reload(); // If preview needs refresh + await expect(page2.getByText("Hello World")).toBeVisible(); +}); +``` + +### Close All Tabs Except One + +```typescript +test("cleanup tabs after test", async ({ context }) => { + const mainPage = await context.newPage(); + await mainPage.goto("/"); + + // Open several popups during test + for (let i = 0; i < 3; i++) { + const popup = await context.newPage(); + await popup.goto(`/popup/${i}`); + } + + // Close all except main page + for (const page of context.pages()) { + if (page !== mainPage) { + await page.close(); + } + } + + expect(context.pages()).toHaveLength(1); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ----------------------- | ------------------------------ | ------------------------------------------ | +| Not waiting for popup | Race condition | Use `waitForEvent("popup")` before trigger | +| Testing real OAuth | Slow, flaky, needs credentials | Mock OAuth endpoints | +| Assuming popup opens | May be blocked | Handle both open and blocked cases | +| Not closing extra pages | Resource leak | Close pages in cleanup | + +## Related References + +- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns +- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-user.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-user.md new file mode 100644 index 0000000..301e55c --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/multi-user.md @@ -0,0 +1,393 @@ +# Multi-User & Collaboration Testing + +## Table of Contents + +1. [Multiple Browser Contexts](#multiple-browser-contexts) +2. [Real-Time Collaboration](#real-time-collaboration) +3. [Role-Based Testing](#role-based-testing) +4. [Concurrent Actions](#concurrent-actions) +5. [Chat & Messaging](#chat--messaging) + +## Multiple Browser Contexts + +### Two Users in Same Test + +```typescript +test("two users see each other's changes", async ({ browser }) => { + // Create two isolated contexts (like two browsers) + const userAContext = await browser.newContext(); + const userBContext = await browser.newContext(); + + const userAPage = await userAContext.newPage(); + const userBPage = await userBContext.newPage(); + + // Both users go to the same document + await userAPage.goto("/doc/shared-123"); + await userBPage.goto("/doc/shared-123"); + + // User A types + await userAPage.getByLabel("Content").fill("Hello from User A"); + + // User B should see the change + await expect(userBPage.getByText("Hello from User A")).toBeVisible(); + + // Cleanup + await userAContext.close(); + await userBContext.close(); +}); +``` + +### Multiple Users with Auth States + +```typescript +test("admin and user interaction", async ({ browser }) => { + // Load different auth states + const adminContext = await browser.newContext({ + storageState: ".auth/admin.json", + }); + const userContext = await browser.newContext({ + storageState: ".auth/user.json", + }); + + const adminPage = await adminContext.newPage(); + const userPage = await userContext.newPage(); + + // User submits request + await userPage.goto("/support"); + await userPage.getByLabel("Message").fill("Need help!"); + await userPage.getByRole("button", { name: "Submit" }).click(); + + // Admin sees and responds + await adminPage.goto("/admin/tickets"); + await expect(adminPage.getByText("Need help!")).toBeVisible(); + await adminPage.getByRole("button", { name: "Reply" }).click(); + await adminPage.getByLabel("Response").fill("How can I help?"); + await adminPage.getByRole("button", { name: "Send" }).click(); + + // User sees response + await expect(userPage.getByText("How can I help?")).toBeVisible(); + + await adminContext.close(); + await userContext.close(); +}); +``` + +### Multi-User Fixture + +```typescript +// fixtures/multi-user.fixture.ts +import { test as base, Browser, BrowserContext, Page } from "@playwright/test"; + +type UserSession = { + context: BrowserContext; + page: Page; +}; + +type MultiUserFixtures = { + createUser: (authState?: string) => Promise; +}; + +export const test = base.extend({ + createUser: async ({ browser }, use) => { + const sessions: UserSession[] = []; + + await use(async (authState) => { + const context = await browser.newContext({ + storageState: authState, + }); + const page = await context.newPage(); + sessions.push({ context, page }); + return { context, page }; + }); + + // Cleanup all sessions + for (const session of sessions) { + await session.context.close(); + } + }, +}); + +// Usage +test("3 users collaborate", async ({ createUser }) => { + const alice = await createUser(".auth/alice.json"); + const bob = await createUser(".auth/bob.json"); + const charlie = await createUser(".auth/charlie.json"); + + // All navigate to same room + await alice.page.goto("/room/123"); + await bob.page.goto("/room/123"); + await charlie.page.goto("/room/123"); + + // Test interactions... +}); +``` + +## Real-Time Collaboration + +### Collaborative Document + +```typescript +test("real-time collaborative editing", async ({ browser }) => { + const user1 = await browser.newContext(); + const user2 = await browser.newContext(); + + const page1 = await user1.newPage(); + const page2 = await user2.newPage(); + + await page1.goto("/docs/shared"); + await page2.goto("/docs/shared"); + + // User 1 types at the beginning + const editor1 = page1.getByRole("textbox"); + await editor1.click(); + await editor1.press("Home"); + await editor1.type("User 1: "); + + // User 2 types at the end + const editor2 = page2.getByRole("textbox"); + await editor2.click(); + await editor2.press("End"); + await editor2.type(" - User 2"); + + // Both should see combined result + await expect(page1.getByRole("textbox")).toContainText("User 1:"); + await expect(page1.getByRole("textbox")).toContainText("- User 2"); + await expect(page2.getByRole("textbox")).toContainText("User 1:"); + await expect(page2.getByRole("textbox")).toContainText("- User 2"); + + await user1.close(); + await user2.close(); +}); +``` + +### Cursor Presence + +```typescript +test("shows other user cursors", async ({ browser }) => { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + + // Mock to identify users + await page1.route("**/api/me", (route) => + route.fulfill({ json: { id: "user-1", name: "Alice" } }), + ); + await page2.route("**/api/me", (route) => + route.fulfill({ json: { id: "user-2", name: "Bob" } }), + ); + + await page1.goto("/whiteboard/123"); + await page2.goto("/whiteboard/123"); + + // Move cursor on page1 + await page1.mouse.move(200, 200); + + // Page2 should see Alice's cursor + await expect(page2.getByTestId("cursor-user-1")).toBeVisible(); + await expect(page2.getByText("Alice")).toBeVisible(); + + await ctx1.close(); + await ctx2.close(); +}); +``` + +## Role-Based Testing + +### Test RBAC + +```typescript +const roles = [ + { role: "admin", canDelete: true, canEdit: true, canView: true }, + { role: "editor", canDelete: false, canEdit: true, canView: true }, + { role: "viewer", canDelete: false, canEdit: false, canView: true }, +]; + +for (const { role, canDelete, canEdit, canView } of roles) { + test(`${role} permissions`, async ({ browser }) => { + const context = await browser.newContext({ + storageState: `.auth/${role}.json`, + }); + const page = await context.newPage(); + + await page.goto("/document/123"); + + // Check view permission + if (canView) { + await expect(page.getByTestId("content")).toBeVisible(); + } else { + await expect(page.getByText("Access denied")).toBeVisible(); + } + + // Check edit permission + const editButton = page.getByRole("button", { name: "Edit" }); + if (canEdit) { + await expect(editButton).toBeEnabled(); + } else { + await expect(editButton).toBeDisabled(); + } + + // Check delete permission + const deleteButton = page.getByRole("button", { name: "Delete" }); + if (canDelete) { + await expect(deleteButton).toBeVisible(); + } else { + await expect(deleteButton).toBeHidden(); + } + + await context.close(); + }); +} +``` + +### Permission Escalation Test + +```typescript +test("cannot access admin routes as user", async ({ browser }) => { + const userContext = await browser.newContext({ + storageState: ".auth/user.json", + }); + const page = await userContext.newPage(); + + // Try to access admin page directly + await page.goto("/admin/users"); + + // Should redirect or show error + await expect(page).not.toHaveURL("/admin/users"); + await expect(page.getByText("Access denied")).toBeVisible(); + + await userContext.close(); +}); +``` + +## Concurrent Actions + +### Race Condition Testing + +```typescript +test("handles concurrent edits", async ({ browser }) => { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + + await page1.goto("/item/123"); + await page2.goto("/item/123"); + + // Both click edit at the same time + await Promise.all([ + page1.getByRole("button", { name: "Edit" }).click(), + page2.getByRole("button", { name: "Edit" }).click(), + ]); + + // Both try to save different values + await page1.getByLabel("Name").fill("Value from User 1"); + await page2.getByLabel("Name").fill("Value from User 2"); + + await Promise.all([ + page1.getByRole("button", { name: "Save" }).click(), + page2.getByRole("button", { name: "Save" }).click(), + ]); + + // One should succeed, one should get conflict error + const page1HasConflict = await page1.getByText("Conflict").isVisible(); + const page2HasConflict = await page2.getByText("Conflict").isVisible(); + + // Exactly one should have conflict + expect(page1HasConflict || page2HasConflict).toBe(true); + expect(page1HasConflict && page2HasConflict).toBe(false); + + await ctx1.close(); + await ctx2.close(); +}); +``` + +### Optimistic Locking Test + +```typescript +test("optimistic locking prevents overwrites", async ({ browser }) => { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + + // Both load the same version + await page1.goto("/record/123"); + await page2.goto("/record/123"); + + // User 1 edits and saves first + await page1.getByRole("button", { name: "Edit" }).click(); + await page1.getByLabel("Value").fill("Updated by User 1"); + await page1.getByRole("button", { name: "Save" }).click(); + await expect(page1.getByText("Saved")).toBeVisible(); + + // User 2 tries to save with stale version + await page2.getByRole("button", { name: "Edit" }).click(); + await page2.getByLabel("Value").fill("Updated by User 2"); + await page2.getByRole("button", { name: "Save" }).click(); + + // Should fail with version conflict + await expect(page2.getByText("Someone else modified this")).toBeVisible(); + await expect(page2.getByRole("button", { name: "Reload" })).toBeVisible(); + + await ctx1.close(); + await ctx2.close(); +}); +``` + +## Chat & Messaging + +### Real-Time Chat + +```typescript +test("chat messages sync between users", async ({ browser }) => { + const aliceCtx = await browser.newContext(); + const bobCtx = await browser.newContext(); + + const alicePage = await aliceCtx.newPage(); + const bobPage = await bobCtx.newPage(); + + // Setup user identities + await alicePage.route("**/api/me", (r) => + r.fulfill({ json: { name: "Alice" } }), + ); + await bobPage.route("**/api/me", (r) => r.fulfill({ json: { name: "Bob" } })); + + await alicePage.goto("/chat/room-1"); + await bobPage.goto("/chat/room-1"); + + // Alice sends message + await alicePage.getByLabel("Message").fill("Hi Bob!"); + await alicePage.getByRole("button", { name: "Send" }).click(); + + // Bob sees it + await expect(bobPage.getByText("Alice: Hi Bob!")).toBeVisible(); + + // Bob replies + await bobPage.getByLabel("Message").fill("Hey Alice!"); + await bobPage.getByRole("button", { name: "Send" }).click(); + + // Alice sees it + await expect(alicePage.getByText("Bob: Hey Alice!")).toBeVisible(); + + await aliceCtx.close(); + await bobCtx.close(); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ----------------------------- | ----------------------------- | ---------------------------- | +| Sharing context between users | State leaks, not isolated | Create separate contexts | +| Not closing contexts | Memory leak, browser overload | Always close in cleanup | +| Hardcoded timing for sync | Flaky tests | Use `expect().toBeVisible()` | +| Testing only single user | Misses collaboration bugs | Test multi-user scenarios | + +## Related References + +- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup +- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/network-advanced.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/network-advanced.md new file mode 100644 index 0000000..fa017fe --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/network-advanced.md @@ -0,0 +1,452 @@ +# Advanced Network Interception + +## Table of Contents + +1. [Request Modification](#request-modification) +2. [GraphQL Mocking](#graphql-mocking) +3. [HAR Recording & Playback](#har-recording--playback) +4. [Conditional Mocking](#conditional-mocking) +5. [Network Throttling](#network-throttling) + +## Request Modification + +### Modify Request Headers + +```typescript +test("add auth header to requests", async ({ page }) => { + await page.route("**/api/**", (route) => { + const headers = { + ...route.request().headers(), + Authorization: "Bearer test-token", + "X-Test-Header": "test-value", + }; + route.continue({ headers }); + }); + + await page.goto("/dashboard"); +}); +``` + +### Modify Request Body + +```typescript +test("modify POST body", async ({ page }) => { + await page.route("**/api/orders", async (route) => { + if (route.request().method() === "POST") { + const postData = route.request().postDataJSON(); + + // Add test metadata + const modifiedData = { + ...postData, + testMode: true, + testTimestamp: Date.now(), + }; + + await route.continue({ + postData: JSON.stringify(modifiedData), + }); + } else { + await route.continue(); + } + }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Place Order" }).click(); +}); +``` + +### Transform Response + +```typescript +test("modify API response", async ({ page }) => { + await page.route("**/api/products", async (route) => { + // Fetch real response + const response = await route.fetch(); + const json = await response.json(); + + // Modify response + const modified = json.map((product: any) => ({ + ...product, + price: product.price * 0.9, // 10% discount + testMode: true, + })); + + await route.fulfill({ + response, + json: modified, + }); + }); + + await page.goto("/products"); +}); +``` + +## GraphQL Mocking + +### Mock by Operation Name + +```typescript +test("mock GraphQL query", async ({ page }) => { + await page.route("**/graphql", async (route) => { + const postData = route.request().postDataJSON(); + + if (postData.operationName === "GetUser") { + return route.fulfill({ + json: { + data: { + user: { + id: "1", + name: "Test User", + email: "test@example.com", + }, + }, + }, + }); + } + + if (postData.operationName === "GetProducts") { + return route.fulfill({ + json: { + data: { + products: [ + { id: "1", name: "Product A", price: 29.99 }, + { id: "2", name: "Product B", price: 49.99 }, + ], + }, + }, + }); + } + + // Pass through unmocked operations + return route.continue(); + }); + + await page.goto("/dashboard"); +}); +``` + +### GraphQL Mock Fixture + +```typescript +// fixtures/graphql.fixture.ts +type GraphQLMock = { + operation: string; + variables?: Record; + response: { data?: any; errors?: any[] }; +}; + +type GraphQLFixtures = { + mockGraphQL: (mocks: GraphQLMock[]) => Promise; +}; + +export const test = base.extend({ + mockGraphQL: async ({ page }, use) => { + await use(async (mocks) => { + await page.route("**/graphql", async (route) => { + const postData = route.request().postDataJSON(); + + const mock = mocks.find((m) => { + if (m.operation !== postData.operationName) return false; + + // Optionally match variables + if (m.variables) { + return ( + JSON.stringify(m.variables) === JSON.stringify(postData.variables) + ); + } + return true; + }); + + if (mock) { + return route.fulfill({ json: mock.response }); + } + + return route.continue(); + }); + }); + }, +}); + +// Usage +test("dashboard with mocked GraphQL", async ({ page, mockGraphQL }) => { + await mockGraphQL([ + { + operation: "GetDashboardStats", + response: { + data: { stats: { users: 100, revenue: 50000 } }, + }, + }, + { + operation: "GetUser", + variables: { id: "1" }, + response: { + data: { user: { id: "1", name: "John" } }, + }, + }, + ]); + + await page.goto("/dashboard"); + await expect(page.getByText("100 users")).toBeVisible(); +}); +``` + +### Mock GraphQL Mutations + +```typescript +test("mock GraphQL mutation", async ({ page }) => { + await page.route("**/graphql", async (route) => { + const postData = route.request().postDataJSON(); + + if (postData.operationName === "CreateOrder") { + const { input } = postData.variables; + + return route.fulfill({ + json: { + data: { + createOrder: { + id: "order-123", + status: "PENDING", + items: input.items, + total: input.items.reduce( + (sum: number, item: any) => sum + item.price * item.quantity, + 0, + ), + }, + }, + }, + }); + } + + return route.continue(); + }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Place Order" }).click(); + + await expect(page.getByText("Order #order-123")).toBeVisible(); +}); +``` + +## HAR Recording & Playback + +### Record HAR File + +```typescript +// Record network traffic +test("record HAR", async ({ page, context }) => { + // Start recording + await context.routeFromHAR("./recordings/checkout.har", { + update: true, // Create/update HAR file + url: "**/api/**", + }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Place Order" }).click(); + + // HAR file is saved automatically +}); +``` + +### Playback HAR File + +```typescript +// Use recorded HAR for offline testing +test("playback HAR", async ({ page, context }) => { + await context.routeFromHAR("./recordings/checkout.har", { + url: "**/api/**", + update: false, // Don't update, just playback + }); + + await page.goto("/checkout"); + + // All API calls served from HAR file + await expect(page.getByText("Order confirmed")).toBeVisible(); +}); +``` + +### HAR with Fallback + +```typescript +test("HAR with live fallback", async ({ page, context }) => { + await context.routeFromHAR("./recordings/api.har", { + url: "**/api/**", + update: false, + notFound: "fallback", // Use real network if not in HAR + }); + + await page.goto("/dashboard"); +}); +``` + +## Conditional Mocking + +### Mock Based on Request Body + +```typescript +test("conditional mock by body", async ({ page }) => { + await page.route("**/api/search", async (route) => { + const body = route.request().postDataJSON(); + + if (body.query === "error") { + return route.fulfill({ + status: 500, + json: { error: "Search failed" }, + }); + } + + if (body.query === "empty") { + return route.fulfill({ + json: { results: [] }, + }); + } + + // Default response + return route.fulfill({ + json: { + results: [{ id: 1, title: `Result for: ${body.query}` }], + }, + }); + }); + + await page.goto("/search"); + + // Test different scenarios + await page.getByLabel("Search").fill("error"); + await page.getByLabel("Search").press("Enter"); + await expect(page.getByText("Search failed")).toBeVisible(); +}); +``` + +### Mock Nth Request + +```typescript +test("different response on retry", async ({ page }) => { + let callCount = 0; + + await page.route("**/api/status", (route) => { + callCount++; + + if (callCount < 3) { + return route.fulfill({ + status: 503, + json: { error: "Service unavailable" }, + }); + } + + // Succeed on 3rd attempt + return route.fulfill({ + json: { status: "ok" }, + }); + }); + + await page.goto("/dashboard"); + + // App should retry and eventually succeed + await expect(page.getByText("Connected")).toBeVisible(); +}); +``` + +### Mock with Delay + +```typescript +test("slow network simulation", async ({ page }) => { + await page.route("**/api/data", async (route) => { + // Simulate 2 second delay + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return route.fulfill({ + json: { data: "loaded" }, + }); + }); + + await page.goto("/dashboard"); + + // Loading state should appear + await expect(page.getByText("Loading...")).toBeVisible(); + + // Then data appears + await expect(page.getByText("loaded")).toBeVisible(); +}); +``` + +## Network Throttling + +### Slow 3G Simulation + +```typescript +test("slow network experience", async ({ page, context }) => { + // Create CDP session for network throttling + const client = await context.newCDPSession(page); + + await client.send("Network.emulateNetworkConditions", { + offline: false, + downloadThroughput: (500 * 1024) / 8, // 500 Kbps + uploadThroughput: (500 * 1024) / 8, + latency: 400, // 400ms + }); + + await page.goto("/"); + + // Test loading states appear + await expect(page.getByTestId("skeleton-loader")).toBeVisible(); +}); +``` + +### Offline Mode + +Use `context.setOffline(true/false)` to simulate network connectivity changes. + +> **For comprehensive offline testing patterns:** +> +> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing) +> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing) + +### Network Throttling Fixture + +```typescript +// fixtures/network.fixture.ts +type NetworkCondition = "slow3g" | "fast3g" | "offline"; + +const conditions = { + slow3g: { downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000 }, + fast3g: { downloadThroughput: 180000, uploadThroughput: 75000, latency: 150 }, +}; + +type NetworkFixtures = { + setNetworkCondition: (condition: NetworkCondition) => Promise; +}; + +export const test = base.extend({ + setNetworkCondition: async ({ page, context }, use) => { + const client = await context.newCDPSession(page); + + await use(async (condition) => { + if (condition === "offline") { + await context.setOffline(true); + } else { + await client.send("Network.emulateNetworkConditions", { + offline: false, + ...conditions[condition], + }); + } + }); + + // Reset + await context.setOffline(false); + }, +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------ | ------------------------------ | -------------------------------- | +| Mocking all requests | Tests don't reflect reality | Mock only what's necessary | +| No cleanup of routes | Routes persist across tests | Use fixtures with cleanup | +| Ignoring request method | Mock applies to wrong requests | Check `route.request().method()` | +| Hardcoded mock responses | Brittle, hard to maintain | Use factories for mock data | + +## Related References + +- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking +- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking diff --git a/plugins/software-delivery/skills/playwright-best-practices/advanced/third-party.md b/plugins/software-delivery/skills/playwright-best-practices/advanced/third-party.md new file mode 100644 index 0000000..acf8ab8 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/advanced/third-party.md @@ -0,0 +1,464 @@ +# Third-Party Service Mocking + +## Table of Contents + +1. [OAuth/SSO Mocking](#oauthsso-mocking) +2. [Payment Gateway Mocking](#payment-gateway-mocking) +3. [Email Verification](#email-verification) +4. [SMS Verification](#sms-verification) +5. [Analytics & Tracking](#analytics--tracking) + +## OAuth/SSO Mocking + +### Mock Google OAuth + +```typescript +test("Google OAuth login", async ({ page }) => { + // Mock the OAuth callback + await page.route("**/auth/google/callback**", (route) => { + const url = new URL(route.request().url()); + // Simulate successful OAuth by redirecting with token + route.fulfill({ + status: 302, + headers: { + Location: "/dashboard?token=mock-jwt-token", + }, + }); + }); + + // Mock the token verification endpoint + await page.route("**/api/auth/verify", (route) => + route.fulfill({ + json: { + valid: true, + user: { + id: "123", + email: "test@gmail.com", + name: "Test User", + }, + }, + }), + ); + + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with Google" }).click(); + + await expect(page.getByText("Welcome, Test User")).toBeVisible(); +}); +``` + +### OAuth Fixture + +```typescript +// fixtures/oauth.fixture.ts +type OAuthProvider = "google" | "github" | "microsoft"; + +type OAuthUser = { + id: string; + email: string; + name: string; + avatar?: string; +}; + +type OAuthFixtures = { + mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise; +}; + +export const test = base.extend({ + mockOAuth: async ({ page }, use) => { + await use(async (provider, user) => { + // Mock callback redirect + await page.route(`**/auth/${provider}/callback**`, (route) => + route.fulfill({ + status: 302, + headers: { Location: `/auth/success?provider=${provider}` }, + }), + ); + + // Mock session/user endpoint + await page.route("**/api/auth/session", (route) => + route.fulfill({ + json: { user, provider, authenticated: true }, + }), + ); + + // Mock user info endpoint + await page.route("**/api/me", (route) => route.fulfill({ json: user })); + }); + }, +}); + +// Usage +test("login with GitHub", async ({ page, mockOAuth }) => { + await mockOAuth("github", { + id: "gh-123", + email: "dev@github.com", + name: "GitHub User", + }); + + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with GitHub" }).click(); + + await expect(page.getByText("Welcome, GitHub User")).toBeVisible(); +}); +``` + +### Mock SAML SSO + +```typescript +test("SAML SSO login", async ({ page }) => { + // Mock SAML assertion consumer service + await page.route("**/saml/acs", async (route) => { + route.fulfill({ + status: 302, + headers: { + Location: "/dashboard", + "Set-Cookie": "session=mock-saml-session; Path=/; HttpOnly", + }, + }); + }); + + // Mock session validation + await page.route("**/api/session", (route) => + route.fulfill({ + json: { + user: { email: "user@company.com", name: "SSO User" }, + provider: "saml", + }, + }), + ); + + await page.goto("/login"); + await page.getByRole("button", { name: "SSO Login" }).click(); + + await expect(page).toHaveURL("/dashboard"); +}); +``` + +## Payment Gateway Mocking + +### Mock Stripe + +```typescript +test("Stripe checkout", async ({ page }) => { + // Mock Stripe.js + await page.addInitScript(() => { + (window as any).Stripe = () => ({ + elements: () => ({ + create: () => ({ + mount: () => {}, + on: () => {}, + destroy: () => {}, + }), + }), + confirmCardPayment: async () => ({ + paymentIntent: { status: "succeeded", id: "pi_mock_123" }, + }), + createPaymentMethod: async () => ({ + paymentMethod: { id: "pm_mock_123" }, + }), + }); + }); + + // Mock backend payment endpoint + await page.route("**/api/create-payment-intent", (route) => + route.fulfill({ + json: { clientSecret: "pi_mock_123_secret_mock" }, + }), + ); + + await page.route("**/api/confirm-payment", (route) => + route.fulfill({ + json: { success: true, orderId: "order-123" }, + }), + ); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Pay $99.99" }).click(); + + await expect(page.getByText("Payment successful")).toBeVisible(); +}); +``` + +### Mock PayPal + +```typescript +test("PayPal checkout", async ({ page }) => { + // Mock PayPal SDK + await page.addInitScript(() => { + (window as any).paypal = { + Buttons: () => ({ + render: () => Promise.resolve(), + isEligible: () => true, + }), + FUNDING: { PAYPAL: "paypal", CARD: "card" }, + }; + }); + + // Mock PayPal order creation + await page.route("**/api/paypal/create-order", (route) => + route.fulfill({ + json: { orderId: "PAYPAL-ORDER-123" }, + }), + ); + + // Mock PayPal capture + await page.route("**/api/paypal/capture", (route) => + route.fulfill({ + json: { success: true, transactionId: "TXN-123" }, + }), + ); + + await page.goto("/checkout"); + + // Simulate PayPal approval callback + await page.evaluate(() => { + (window as any).onPayPalApprove?.({ orderID: "PAYPAL-ORDER-123" }); + }); + + await expect(page.getByText("Order confirmed")).toBeVisible(); +}); +``` + +### Payment Fixture + +```typescript +// fixtures/payment.fixture.ts +type PaymentFixtures = { + mockStripe: (options?: { failPayment?: boolean }) => Promise; +}; + +export const test = base.extend({ + mockStripe: async ({ page }, use) => { + await use(async (options = {}) => { + await page.addInitScript( + ([shouldFail]) => { + (window as any).Stripe = () => ({ + elements: () => ({ + create: () => ({ + mount: () => {}, + on: (event: string, handler: Function) => { + if (event === "ready") setTimeout(handler, 100); + }, + destroy: () => {}, + }), + }), + confirmCardPayment: async () => { + if (shouldFail) { + return { error: { message: "Card declined" } }; + } + return { paymentIntent: { status: "succeeded" } }; + }, + }); + }, + [options.failPayment], + ); + }); + }, +}); + +// Usage +test("handles declined card", async ({ page, mockStripe }) => { + await mockStripe({ failPayment: true }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Pay" }).click(); + + await expect(page.getByText("Card declined")).toBeVisible(); +}); +``` + +## Email Verification + +### Mock Email API + +```typescript +test("email verification flow", async ({ page, request }) => { + let verificationToken: string; + + // Capture the verification email + await page.route("**/api/send-verification", async (route) => { + const body = route.request().postDataJSON(); + verificationToken = `mock-token-${Date.now()}`; + + // Don't actually send email, just store token + route.fulfill({ + json: { sent: true, messageId: "msg-123" }, + }); + }); + + // Mock token verification + await page.route("**/api/verify-email**", (route) => { + const url = new URL(route.request().url()); + const token = url.searchParams.get("token"); + + if (token === verificationToken) { + route.fulfill({ json: { verified: true } }); + } else { + route.fulfill({ status: 400, json: { error: "Invalid token" } }); + } + }); + + await page.goto("/signup"); + await page.getByLabel("Email").fill("test@example.com"); + await page.getByRole("button", { name: "Sign Up" }).click(); + + await expect(page.getByText("Check your email")).toBeVisible(); + + // Simulate clicking email link + await page.goto(`/verify?token=${verificationToken}`); + + await expect(page.getByText("Email verified")).toBeVisible(); +}); +``` + +### Use Mailinator/Temp Mail + +```typescript +// fixtures/email.fixture.ts +type EmailFixtures = { + getVerificationEmail: (inbox: string) => Promise<{ link: string }>; +}; + +export const test = base.extend({ + getVerificationEmail: async ({ request }, use) => { + await use(async (inbox) => { + // Poll Mailinator API for new email + const response = await request.get( + `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`, + { + headers: { + Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`, + }, + }, + ); + + const messages = await response.json(); + const latest = messages.msgs[0]; + + // Get full message + const msgResponse = await request.get( + `https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`, + { + headers: { + Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`, + }, + }, + ); + + const message = await msgResponse.json(); + + // Extract verification link from HTML + const linkMatch = message.parts[0].body.match( + /href="([^"]*verify[^"]*)"/, + ); + return { link: linkMatch?.[1] || "" }; + }); + }, +}); +``` + +## SMS Verification + +### Mock SMS API + +```typescript +test("SMS verification", async ({ page }) => { + let smsCode: string; + + // Capture SMS send + await page.route("**/api/send-sms", (route) => { + smsCode = Math.random().toString().slice(2, 8); // 6-digit code + + route.fulfill({ + json: { sent: true, messageId: "sms-123" }, + }); + }); + + // Mock code verification + await page.route("**/api/verify-sms", (route) => { + const body = route.request().postDataJSON(); + + if (body.code === smsCode) { + route.fulfill({ json: { verified: true } }); + } else { + route.fulfill({ status: 400, json: { error: "Invalid code" } }); + } + }); + + await page.goto("/verify-phone"); + await page.getByLabel("Phone").fill("+1234567890"); + await page.getByRole("button", { name: "Send Code" }).click(); + + // Enter the code + await page.getByLabel("Verification Code").fill(smsCode); + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page.getByText("Phone verified")).toBeVisible(); +}); +``` + +## Analytics & Tracking + +### Block Analytics in Tests + +```typescript +test.beforeEach(async ({ page }) => { + // Block all analytics/tracking + await page.route( + /google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/, + (route) => route.abort(), + ); +}); +``` + +### Mock Analytics for Verification + +```typescript +test("tracks purchase event", async ({ page }) => { + const analyticsEvents: any[] = []; + + // Capture analytics calls + await page.route("**/api/analytics/**", (route) => { + analyticsEvents.push(route.request().postDataJSON()); + route.fulfill({ status: 200 }); + }); + + // Mock analytics SDK + await page.addInitScript(() => { + (window as any).analytics = { + track: (event: string, props: any) => { + fetch("/api/analytics/track", { + method: "POST", + body: JSON.stringify({ event, props }), + }); + }, + }; + }); + + await page.goto("/checkout"); + await page.getByRole("button", { name: "Complete Purchase" }).click(); + + // Verify analytics event was sent + expect(analyticsEvents).toContainEqual( + expect.objectContaining({ + event: "Purchase Completed", + props: expect.objectContaining({ amount: expect.any(Number) }), + }), + ); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------- | ------------------------------ | ----------------------- | +| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints | +| Real payment processing | Charges real money, slow | Use test mode or mock | +| Waiting for real emails | Very slow, unreliable | Mock email API | +| Not mocking analytics | Pollutes analytics data | Block or mock analytics | + +## Related References + +- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns +- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns diff --git a/plugins/software-delivery/skills/playwright-best-practices/architecture/pom-vs-fixtures.md b/plugins/software-delivery/skills/playwright-best-practices/architecture/pom-vs-fixtures.md new file mode 100644 index 0000000..eafb06f --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/architecture/pom-vs-fixtures.md @@ -0,0 +1,363 @@ +# Organizing Reusable Test Code + +## Table of Contents + +1. [Pattern Comparison](#pattern-comparison) +2. [Selection Flowchart](#selection-flowchart) +3. [Page Objects](#page-objects) +4. [Custom Fixtures](#custom-fixtures) +5. [Helper Functions](#helper-functions) +6. [Combined Project Structure](#combined-project-structure) +7. [Anti-Patterns](#anti-patterns) + +Use all three patterns together. Most projects benefit from a hybrid approach: + +- **Page objects** for UI interaction (pages/components with 5+ interactions) +- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle) +- **Helper functions** for stateless utilities (generate data, format values, simple waits) + +If only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them. + +## Pattern Comparison + +| Aspect | Page Objects | Custom Fixtures | Helper Functions | +|---|---|---|---| +| **Purpose** | Encapsulate UI interactions | Provide resources with setup/teardown | Stateless utilities | +| **Lifecycle** | Manual (constructor/methods) | Built-in (`use()` with automatic teardown) | None | +| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures | Call other functions | +| **Best for** | Pages with many reused interactions | Resources needing setup AND teardown | Simple logic with no side effects | + +## Selection Flowchart + +```text +What kind of reusable code? +| ++-- Interacts with browser page/component? +| | +| +-- Has 5+ interactions (fill, click, navigate, assert)? +| | +-- YES: Used in 3+ test files? +| | | +-- YES --> PAGE OBJECT +| | | +-- NO --> Inline or small helper +| | +-- NO --> HELPER FUNCTION +| | +| +-- Needs setup before AND cleanup after test? +| +-- YES --> CUSTOM FIXTURE +| +-- NO --> PAGE OBJECT method or HELPER +| ++-- Manages resource with lifecycle (create/destroy)? +| +-- Examples: auth state, DB connection, API client, test user +| +-- YES --> CUSTOM FIXTURE (always) +| ++-- Stateless utility? (no browser, no side effects) +| +-- Examples: random email, format date, build URL, parse response +| +-- YES --> HELPER FUNCTION +| ++-- Not sure? + +-- Start with HELPER FUNCTION + +-- Promote to PAGE OBJECT when interactions grow + +-- Promote to FIXTURE when lifecycle needed +``` + +## Page Objects + +Best for pages/components with 5+ interactions appearing in 3+ test files. + +```typescript +// page-objects/booking.page.ts +import { type Page, type Locator, expect } from '@playwright/test'; + +export class BookingPage { + readonly page: Page; + readonly dateField: Locator; + readonly guestCount: Locator; + readonly roomType: Locator; + readonly reserveBtn: Locator; + readonly totalPrice: Locator; + + constructor(page: Page) { + this.page = page; + this.dateField = page.getByLabel('Check-in date'); + this.guestCount = page.getByLabel('Guests'); + this.roomType = page.getByLabel('Room type'); + this.reserveBtn = page.getByRole('button', { name: 'Reserve' }); + this.totalPrice = page.getByTestId('total-price'); + } + + async goto() { + await this.page.goto('/booking'); + } + + async fillDetails(opts: { date: string; guests: number; room: string }) { + await this.dateField.fill(opts.date); + await this.guestCount.fill(String(opts.guests)); + await this.roomType.selectOption(opts.room); + } + + async reserve() { + await this.reserveBtn.click(); + await this.page.waitForURL('**/confirmation'); + } + + async expectPrice(amount: string) { + await expect(this.totalPrice).toHaveText(amount); + } +} +``` + +```typescript +// tests/booking/reservation.spec.ts +import { test, expect } from '@playwright/test'; +import { BookingPage } from '../page-objects/booking.page'; + +test('complete reservation with standard room', async ({ page }) => { + const booking = new BookingPage(page); + await booking.goto(); + await booking.fillDetails({ date: '2026-03-15', guests: 2, room: 'standard' }); + await booking.reserve(); + await expect(page.getByText('Reservation confirmed')).toBeVisible(); +}); +``` + +**Page object principles:** +- One class per logical page/component, not per URL +- Constructor takes `Page` +- Locators as `readonly` properties in constructor +- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks +- Navigation methods (`goto`) belong on the page object + +## Custom Fixtures + +Best for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users. + +```typescript +// fixtures/base.fixture.ts +import { test as base, expect } from '@playwright/test'; +import { BookingPage } from '../page-objects/booking.page'; +import { generateMember } from '../helpers/data'; + +type Fixtures = { + bookingPage: BookingPage; + member: { email: string; password: string; id: string }; + loggedInPage: import('@playwright/test').Page; +}; + +export const test = base.extend({ + bookingPage: async ({ page }, use) => { + await use(new BookingPage(page)); + }, + + member: async ({ request }, use) => { + const data = generateMember(); + const res = await request.post('/api/test/members', { data }); + const member = await res.json(); + await use(member); + await request.delete(`/api/test/members/${member.id}`); + }, + + loggedInPage: async ({ page, member }, use) => { + await page.goto('/login'); + await page.getByLabel('Email').fill(member.email); + await page.getByLabel('Password').fill(member.password); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page).toHaveURL('/dashboard'); + await use(page); + }, +}); + +export { expect } from '@playwright/test'; +``` + +```typescript +// tests/dashboard/overview.spec.ts +import { test, expect } from '../../fixtures/base.fixture'; + +test('member sees dashboard widgets', async ({ loggedInPage }) => { + await expect(loggedInPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible(); +}); + +test('new member sees welcome prompt', async ({ loggedInPage, member }) => { + await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible(); +}); +``` + +**Fixture principles:** +- Use `test.extend()` — never module-level variables +- `use()` callback separates setup from teardown +- Teardown runs even if test fails +- Fixtures compose: one can depend on another +- Fixtures are lazy: created only when requested +- Wrap page objects in fixtures for lifecycle management + +## Helper Functions + +Best for stateless utilities — generating test data, formatting values, building URLs, parsing responses. + +```typescript +// helpers/data.ts +import { randomUUID } from 'node:crypto'; + +export function generateEmail(prefix = 'user'): string { + return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`; +} + +export function generateMember(overrides: Partial = {}): Member { + return { + email: generateEmail(), + password: 'SecurePass456!', + name: 'Test Member', + ...overrides, + }; +} + +interface Member { + email: string; + password: string; + name: string; +} + +export function formatPrice(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} +``` + +```typescript +// helpers/assertions.ts +import { type Page, expect } from '@playwright/test'; + +export async function expectNotification(page: Page, message: string): Promise { + const notification = page.getByRole('alert').filter({ hasText: message }); + await expect(notification).toBeVisible(); + await expect(notification).toBeHidden({ timeout: 10000 }); +} +``` + +```typescript +// tests/settings/account.spec.ts +import { test, expect } from '@playwright/test'; +import { generateEmail } from '../../helpers/data'; +import { expectNotification } from '../../helpers/assertions'; + +test('update account email', async ({ page }) => { + const newEmail = generateEmail('updated'); + await page.goto('/settings/account'); + await page.getByLabel('Email').fill(newEmail); + await page.getByRole('button', { name: 'Save' }).click(); + await expectNotification(page, 'Account updated'); + await expect(page.getByLabel('Email')).toHaveValue(newEmail); +}); +``` + +**Helper principles:** +- Pure functions with no side effects +- No browser state — take `page` as parameter if needed +- Promote to fixture if setup/teardown needed +- Promote to page object if many page interactions grow +- Keep small and focused + +## Combined Project Structure + +```text +tests/ ++-- fixtures/ +| +-- auth.fixture.ts +| +-- db.fixture.ts +| +-- base.fixture.ts ++-- page-objects/ +| +-- login.page.ts +| +-- booking.page.ts +| +-- components/ +| +-- data-table.component.ts ++-- helpers/ +| +-- data.ts +| +-- assertions.ts ++-- e2e/ +| +-- auth/ +| | +-- login.spec.ts +| +-- booking/ +| +-- reservation.spec.ts +playwright.config.ts +``` + +**Layer responsibilities:** + +| Layer | Pattern | Responsibility | +|---|---|---| +| **Test file** | `test()` | Describes behavior, orchestrates layers | +| **Fixtures** | `test.extend()` | Resource lifecycle — setup, provide, teardown | +| **Page objects** | Classes | UI interaction — navigation, actions, locators | +| **Helpers** | Functions | Utilities — data generation, formatting, assertions | + +## Anti-Patterns + +### Page object managing resources + +```typescript +// BAD: page object handling API calls and database +class LoginPage { + async createUser() { /* API call */ } + async deleteUser() { /* API call */ } + async signIn(email: string, password: string) { /* UI */ } +} +``` + +Resource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object. + +### Locator-only page objects + +```typescript +// BAD: no methods, just locators +class LoginPage { + emailInput = this.page.getByLabel('Email'); + passwordInput = this.page.getByLabel('Password'); + submitBtn = this.page.getByRole('button', { name: 'Sign in' }); + constructor(private page: Page) {} +} +``` + +Add intent-revealing methods or skip the page object entirely. + +### Monolithic fixtures + +```typescript +// BAD: one fixture doing everything +test.extend({ + everything: async ({ page, request }, use) => { + const user = await createUser(request); + const products = await seedProducts(request, 50); + await setupPayment(request, user.id); + await page.goto('/dashboard'); + await use({ user, products, page }); + // massive teardown... + }, +}); +``` + +Break into small, composable fixtures. Each fixture does one thing. + +### Helpers with side effects + +```typescript +// BAD: module-level state +let createdUserId: string; + +export async function createTestUser(request: APIRequestContext) { + const res = await request.post('/api/users', { data: { email: 'test@example.com' } }); + const user = await res.json(); + createdUserId = user.id; // shared across tests! + return user; +} +``` + +Module-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture. + +### Over-abstracting simple operations + +```typescript +// BAD: helper for one-liner +export async function clickButton(page: Page, name: string) { + await page.getByRole('button', { name }).click(); +} +``` + +Only abstract when there is real duplication (3+ usages) or complexity (5+ interactions). diff --git a/plugins/software-delivery/skills/playwright-best-practices/architecture/test-architecture.md b/plugins/software-delivery/skills/playwright-best-practices/architecture/test-architecture.md new file mode 100644 index 0000000..28b6f6c --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/architecture/test-architecture.md @@ -0,0 +1,369 @@ +# Choosing Test Types: E2E, Component, or API + +## Table of Contents + +1. [Decision Matrix](#decision-matrix) +2. [API Tests](#api-tests) +3. [Component Tests](#component-tests) +4. [E2E Tests](#e2e-tests) +5. [Layering Test Types](#layering-test-types) +6. [Common Mistakes](#common-mistakes) +7. [Related](#related) + +> **When to use**: Deciding which test type to write for a feature. Ask: "What's the cheapest test that gives confidence this works?" + +## Decision Matrix + +| Scenario | Recommended Type | Rationale | +| --------------------------- | ---------------- | --------------------------------------------- | +| Login / auth flow | E2E | Cross-page, cookies, redirects, session state | +| Form submission | Component | Isolated validation logic, error states | +| CRUD operations | API | Data integrity matters more than UI | +| Search with results UI | Component + API | API for query logic; component for rendering | +| Cross-page navigation | E2E | Routing, history, deep linking | +| API error handling | API | Status codes, error shapes, edge cases | +| UI error feedback | Component | Toast, banner, inline error rendering | +| Accessibility | Component | ARIA roles, keyboard nav per-component | +| Responsive layout | Component | Viewport-specific rendering without full app | +| API contract validation | API | Response shapes, headers, auth | +| WebSocket/real-time | E2E | Requires full browser environment | +| Payment / checkout | E2E | Multi-step, third-party iframes | +| Onboarding wizard | E2E | Multi-step, state persists across pages | +| Widget behavior | Component | Toggle, accordion, date picker, modal | +| Permissions / authorization | API | Role-based access is backend logic | + +## API Tests + +**Ideal for**: + +- CRUD operations (create, read, update, delete) +- Input validation and error responses (400, 422) +- Permission and authorization checks +- Data integrity and business rules +- API contract verification +- Edge cases expensive to reproduce through UI +- Test data setup/teardown for E2E tests + +**Avoid for**: + +- Testing how errors display to users +- Browser-specific behavior (cookies, redirects) +- Visual layout or responsive design +- Flows requiring JavaScript execution or DOM interaction +- Third-party iframe interactions + +```typescript +import { test, expect } from "@playwright/test"; + +test.describe("Products API", () => { + let token: string; + + test.beforeAll(async ({ request }) => { + const res = await request.post("/api/auth/token", { + data: { email: "manager@shop.io", password: "mgr-secret" }, + }); + token = (await res.json()).accessToken; + }); + + test("creates product with valid payload", async ({ request }) => { + const res = await request.post("/api/products", { + headers: { Authorization: `Bearer ${token}` }, + data: { name: "Widget Pro", sku: "WGT-100", price: 29.99 }, + }); + + expect(res.status()).toBe(201); + const product = await res.json(); + expect(product).toMatchObject({ name: "Widget Pro", sku: "WGT-100" }); + expect(product).toHaveProperty("id"); + }); + + test("rejects duplicate SKU with 409", async ({ request }) => { + const res = await request.post("/api/products", { + headers: { Authorization: `Bearer ${token}` }, + data: { name: "Duplicate", sku: "WGT-100", price: 19.99 }, + }); + + expect(res.status()).toBe(409); + expect((await res.json()).message).toContain("already exists"); + }); + + test("returns 422 for missing required fields", async ({ request }) => { + const res = await request.post("/api/products", { + headers: { Authorization: `Bearer ${token}` }, + data: { name: "Incomplete" }, + }); + + expect(res.status()).toBe(422); + const err = await res.json(); + expect(err.errors).toContainEqual( + expect.objectContaining({ field: "sku" }) + ); + }); + + test("staff role cannot delete products", async ({ request }) => { + const staffLogin = await request.post("/api/auth/token", { + data: { email: "staff@shop.io", password: "staff-pass" }, + }); + const staffToken = (await staffLogin.json()).accessToken; + + const res = await request.delete("/api/products/123", { + headers: { Authorization: `Bearer ${staffToken}` }, + }); + + expect(res.status()).toBe(403); + }); + + test("lists products with pagination", async ({ request }) => { + const res = await request.get("/api/products", { + headers: { Authorization: `Bearer ${token}` }, + params: { page: "1", limit: "20" }, + }); + + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.items).toBeInstanceOf(Array); + expect(body.items.length).toBeLessThanOrEqual(20); + expect(body).toHaveProperty("totalCount"); + }); +}); +``` + +## Component Tests + +**Ideal for**: + +- Form validation (required fields, format rules, error messages) +- Interactive widgets (modals, dropdowns, accordions, date pickers) +- Conditional rendering (show/hide, loading states, empty states) +- Accessibility per-component (ARIA attributes, keyboard navigation) +- Responsive layout at different viewports +- Visual states (hover, focus, disabled, selected) + +**Avoid for**: + +- Testing routing or navigation between pages +- Flows requiring real cookies, sessions, or server-side state +- Data persistence or API contract validation +- Third-party iframe interactions +- Anything requiring multiple pages or browser contexts + +```typescript +import { test, expect } from "@playwright/experimental-ct-react"; +import { ContactForm } from "../src/components/ContactForm"; + +test.describe("ContactForm component", () => { + test("displays validation errors on empty submit", async ({ mount }) => { + const component = await mount( {}} />); + + await component.getByRole("button", { name: "Send message" }).click(); + + await expect(component.getByText("Name is required")).toBeVisible(); + await expect(component.getByText("Email is required")).toBeVisible(); + }); + + test("rejects malformed email", async ({ mount }) => { + const component = await mount( {}} />); + + await component.getByLabel("Name").fill("Alex"); + await component.getByLabel("Email").fill("invalid-email"); + await component.getByLabel("Message").fill("Hello"); + await component.getByRole("button", { name: "Send message" }).click(); + + await expect(component.getByText("Enter a valid email")).toBeVisible(); + }); + + test("invokes onSubmit with form data", async ({ mount }) => { + const submissions: Array<{ name: string; email: string; message: string }> = + []; + const component = await mount( + submissions.push(data)} /> + ); + + await component.getByLabel("Name").fill("Alex"); + await component.getByLabel("Email").fill("alex@company.org"); + await component.getByLabel("Message").fill("Inquiry about pricing"); + await component.getByRole("button", { name: "Send message" }).click(); + + expect(submissions).toHaveLength(1); + expect(submissions[0]).toEqual({ + name: "Alex", + email: "alex@company.org", + message: "Inquiry about pricing", + }); + }); + + test("disables button during submission", async ({ mount }) => { + const component = await mount( + {}} submitting={true} /> + ); + + await expect( + component.getByRole("button", { name: "Sending..." }) + ).toBeDisabled(); + }); + + test("associates labels with inputs for accessibility", async ({ mount }) => { + const component = await mount( {}} />); + + await expect( + component.getByRole("textbox", { name: "Name" }) + ).toBeVisible(); + await expect( + component.getByRole("textbox", { name: "Email" }) + ).toBeVisible(); + }); +}); +``` + +## E2E Tests + +**Ideal for**: + +- Critical user flows that generate revenue (checkout, signup) +- Authentication flows (login, SSO, MFA, password reset) +- Multi-page workflows where state carries across navigation +- Flows involving third-party iframes (payment widgets) +- Smoke tests validating the entire stack +- Real-time collaboration requiring multiple browser contexts + +**Avoid for**: + +- Testing every form validation permutation +- CRUD operations where UI is a thin wrapper +- Verifying individual component states +- Testing API response shapes or error codes +- Responsive layout at every breakpoint +- Edge cases that only affect the backend + +```typescript +import { test, expect } from "@playwright/test"; + +test.describe("subscription flow", () => { + test.beforeEach(async ({ page }) => { + await page.request.post("/api/test/seed-account", { + data: { plan: "free", email: "subscriber@demo.io" }, + }); + await page.goto("/account/upgrade"); + }); + + test("upgrades to premium plan", async ({ page }) => { + await test.step("select plan", async () => { + await expect( + page.getByRole("heading", { name: "Choose Your Plan" }) + ).toBeVisible(); + await page.getByRole("button", { name: "Select Premium" }).click(); + }); + + await test.step("enter billing details", async () => { + await page.getByLabel("Cardholder name").fill("Sam Johnson"); + await page.getByLabel("Billing address").fill("456 Oak Ave"); + await page.getByLabel("City").fill("Seattle"); + await page.getByRole("combobox", { name: "State" }).selectOption("WA"); + await page.getByLabel("Postal code").fill("98101"); + await page.getByRole("button", { name: "Continue" }).click(); + }); + + await test.step("complete payment", async () => { + const paymentFrame = page.frameLocator('iframe[title="Secure Payment"]'); + await paymentFrame.getByLabel("Card number").fill("5555555555554444"); + await paymentFrame.getByLabel("Expiry").fill("09/29"); + await paymentFrame.getByLabel("CVV").fill("456"); + await page.getByRole("button", { name: "Subscribe now" }).click(); + }); + + await test.step("verify success", async () => { + await page.waitForURL("**/account/subscription/success**"); + await expect( + page.getByRole("heading", { name: "Welcome to Premium" }) + ).toBeVisible(); + await expect(page.getByText(/Subscription #\d+/)).toBeVisible(); + }); + }); +}); +``` + +## Layering Test Types + +Effective test suites combine all three types. Example for an "inventory management" feature: + +### API Layer (60% of tests) + +Cover every backend logic permutation. Cheap to run and maintain. + +``` +tests/api/inventory.spec.ts + - creates item with valid data (201) + - rejects duplicate SKU (409) + - rejects invalid quantity format (422) + - rejects missing required fields (422) + - warehouse-staff cannot delete items (403) + - unauthenticated request returns 401 + - lists items with pagination + - filters items by category + - updates item stock level + - archives an item + - prevents archiving items with pending orders +``` + +### Component Layer (30% of tests) + +Cover every visual state and interaction. + +``` +tests/components/InventoryForm.spec.tsx + - shows validation errors on empty submit + - shows inline error for invalid SKU format + - disables submit while saving + - calls onSubmit with form data + - resets form after successful save + +tests/components/InventoryTable.spec.tsx + - renders item rows from props + - shows empty state when no items + - handles archive confirmation modal + - sorts by column header click + - shows stock level badges with correct colors +``` + +### E2E Layer (10% of tests) + +Cover only critical paths proving full stack works. + +``` +tests/e2e/inventory.spec.ts + - manager creates item and sees it in list + - manager updates item stock level + - warehouse-staff cannot access admin settings +``` + +### Execution Profile + +For this feature: + +- **11 API tests** — ~2 seconds total, no browser +- **10 component tests** — ~5 seconds total, real browser but no server +- **3 E2E tests** — ~15 seconds total, full stack + +Total: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client). + +## Common Mistakes + +| Anti-Pattern | Problem | Better Approach | +| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | +| E2E for every validation rule | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display | +| No API tests, all E2E | Slow suite, flaky from UI timing, hard to diagnose | API tests for data/logic, E2E for critical paths only | +| Component tests mocking everything | Tests pass but app broken because mocks drift | Mock only external boundaries; API tests verify real contracts | +| Same assertion in API, component, AND E2E | Triple maintenance cost | Each layer tests what it uniquely verifies | +| E2E creating test data via UI | 2-minute test where 90 seconds is setup | Seed via API in `beforeEach`, test actual flow | +| Testing third-party behavior | Testing that Stripe validates cards (Stripe's job) | Mock Stripe; trust their contract | +| Skipping API layer | Can't tell if bug is frontend or backend | API tests isolate backend; component tests isolate frontend | +| One giant E2E for entire feature | 5-minute test failing somewhere with no clear cause | Focused E2E per critical path; use `test.step()` | + +## Related + +- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming +- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing +- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests +- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState` +- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services +- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic diff --git a/plugins/software-delivery/skills/playwright-best-practices/architecture/when-to-mock.md b/plugins/software-delivery/skills/playwright-best-practices/architecture/when-to-mock.md new file mode 100644 index 0000000..d5d5705 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/architecture/when-to-mock.md @@ -0,0 +1,383 @@ +# Mocking Strategy: Real vs Mock Services + +## Table of Contents + +1. [Core Principle](#core-principle) +2. [Decision Matrix](#decision-matrix) +3. [Decision Flowchart](#decision-flowchart) +4. [Mocking Techniques](#mocking-techniques) +5. [Real Service Strategies](#real-service-strategies) +6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control) +7. [Validating Mock Accuracy](#validating-mock-accuracy) +8. [Anti-Patterns](#anti-patterns) + +> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests. + +## Core Principle + +**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available. + +## Decision Matrix + +| Scenario | Mock? | Strategy | +| --- | --- | --- | +| Your own REST/GraphQL API | Never | Hit real API against staging or local dev | +| Your database (through your API) | Never | Seed via API or fixtures | +| Authentication (your auth system) | Mostly no | Use `storageState` to skip login in most tests | +| Stripe / payment gateway | Always | `route.fulfill()` with expected responses | +| SendGrid / email service | Always | Mock the API call, verify request payload | +| OAuth providers (Google, GitHub) | Always | Mock token exchange, test your callback handler | +| Analytics (Segment, Mixpanel) | Always | `route.abort()` or `route.fulfill()` | +| Maps / geocoding APIs | Always | Mock with static responses | +| Feature flags (LaunchDarkly) | Usually | Mock to force specific flag states | +| CDN / static assets | Never | Let them load normally | +| Flaky external dependency | CI: mock, local: real | Conditional mocking based on environment | +| Slow external dependency | Dev: mock, nightly: real | Separate test projects in config | + +## Decision Flowchart + +```text +Is this service part of YOUR codebase? +├── YES → Do NOT mock. Test the real integration. +│ ├── Is it slow? → Optimize the service, not the test. +│ └── Is it flaky? → Fix the service. Flaky infra is a bug. +└── NO → It's a third-party service. + ├── Is it paid per call? → ALWAYS mock. + ├── Is it rate-limited? → ALWAYS mock. + ├── Is it slow or unreliable? → ALWAYS mock. + └── Is it a complex multi-step flow? → Mock with HAR recording. +``` + +## Mocking Techniques + +### Blocking Unwanted Requests + +Block third-party scripts that slow tests and add no coverage: + +```typescript +test.beforeEach(async ({ page }) => { + await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => { + route.abort(); + }); +}); + +test('dashboard renders without tracking scripts', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); +}); +``` + +### Full Mock (route.fulfill) + +Completely replace a third-party API response: + +```typescript +test('order flow with mocked payment service', async ({ page }) => { + await page.route('**/api/charge', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + transactionId: 'txn_mock_abc', + status: 'completed', + }), + }); + }); + + await page.goto('/order/confirm'); + await page.getByRole('button', { name: 'Complete Purchase' }).click(); + await expect(page.getByText('Order confirmed')).toBeVisible(); +}); + +test('display error on payment decline', async ({ page }) => { + await page.route('**/api/charge', (route) => { + route.fulfill({ + status: 402, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'insufficient_funds', message: 'Card declined.' }, + }), + }); + }); + + await page.goto('/order/confirm'); + await page.getByRole('button', { name: 'Complete Purchase' }).click(); + await expect(page.getByRole('alert')).toContainText('Card declined'); +}); +``` + +### Partial Mock (Modify Responses) + +Let the real API call happen but tweak the response: + +```typescript +test('display low inventory warning', async ({ page }) => { + await page.route('**/api/inventory/*', async (route) => { + const response = await route.fetch(); + const data = await response.json(); + + data.quantity = 1; + data.lowStock = true; + + await route.fulfill({ + response, + body: JSON.stringify(data), + }); + }); + + await page.goto('/products/widget-pro'); + await expect(page.getByText('Only 1 remaining')).toBeVisible(); +}); + +test('inject test notification into real response', async ({ page }) => { + await page.route('**/api/alerts', async (route) => { + const response = await route.fetch(); + const data = await response.json(); + + data.items.push({ + id: 'test-alert', + text: 'Report generated', + category: 'info', + }); + + await route.fulfill({ + response, + body: JSON.stringify(data), + }); + }); + + await page.goto('/home'); + await expect(page.getByText('Report generated')).toBeVisible(); +}); +``` + +### Record and Replay (HAR Files) + +For complex API sequences (OAuth flows, multi-step wizards): + +**Recording:** + +```typescript +test('capture API traffic for admin panel', async ({ page }) => { + await page.routeFromHAR('tests/fixtures/admin-panel.har', { + url: '**/api/**', + update: true, + }); + + await page.goto('/admin'); + await page.getByRole('tab', { name: 'Reports' }).click(); + await page.getByRole('tab', { name: 'Settings' }).click(); +}); +``` + +**Replaying:** + +```typescript +test('admin panel loads with recorded data', async ({ page }) => { + await page.routeFromHAR('tests/fixtures/admin-panel.har', { + url: '**/api/**', + update: false, + }); + + await page.goto('/admin'); + await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible(); +}); +``` + +**HAR maintenance:** + +- Record against a known-good staging environment +- Commit `.har` files to version control +- Re-record when APIs change +- Scope HAR to specific URL patterns + +## Real Service Strategies + +### Local Dev Server + +```typescript +// playwright.config.ts +export default defineConfig({ + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + use: { + baseURL: 'http://localhost:3000', + }, +}); +``` + +### Staging Environment + +```typescript +// playwright.config.ts +export default defineConfig({ + use: { + baseURL: process.env.CI + ? 'https://staging.example.com' + : 'http://localhost:3000', + }, +}); +``` + +### Test Containers + +```typescript +// playwright.config.ts +export default defineConfig({ + webServer: { + command: 'docker compose -f docker-compose.test.yml up --wait', + url: 'http://localhost:3000/health', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + globalTeardown: './tests/global-teardown.ts', +}); +``` + +```typescript +// tests/global-teardown.ts +import { execSync } from 'child_process'; + +export default function globalTeardown() { + if (process.env.CI) { + execSync('docker compose -f docker-compose.test.yml down -v'); + } +} +``` + +## Hybrid Approach: Fixture-Based Mock Control + +Create fixtures that let individual tests opt into mocking specific services: + +```typescript +// tests/fixtures/service-mocks.ts +import { test as base } from '@playwright/test'; + +type MockConfig = { + mockPayments: boolean; + mockNotifications: boolean; + mockAnalytics: boolean; +}; + +export const test = base.extend({ + mockPayments: [true, { option: true }], + mockNotifications: [true, { option: true }], + mockAnalytics: [true, { option: true }], + + page: async ({ page, mockPayments, mockNotifications, mockAnalytics }, use) => { + if (mockPayments) { + await page.route('**/api/billing/**', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'paid', id: 'inv_mock_789' }), + }); + }); + } + + if (mockNotifications) { + await page.route('**/api/notify', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ delivered: true }), + }); + }); + } + + if (mockAnalytics) { + await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => { + route.abort(); + }); + } + + await use(page); + }, +}); + +export { expect } from '@playwright/test'; +``` + +```typescript +// tests/billing.spec.ts +import { test, expect } from './fixtures/service-mocks'; + +test('subscription renewal sends notification', async ({ page }) => { + await page.goto('/account/billing'); + await page.getByRole('button', { name: 'Renew Now' }).click(); + await expect(page.getByText('Subscription renewed')).toBeVisible(); +}); + +test.describe('integration suite', () => { + test.use({ mockPayments: false }); + + test('real billing flow against test gateway', async ({ page }) => { + await page.goto('/account/billing'); + await page.getByRole('button', { name: 'Renew Now' }).click(); + await expect(page.getByText('Subscription renewed')).toBeVisible(); + }); +}); +``` + +### Environment-Based Test Projects + +```typescript +// playwright.config.ts +export default defineConfig({ + projects: [ + { + name: 'ci-fast', + testMatch: '**/*.spec.ts', + use: { baseURL: 'http://localhost:3000' }, + }, + { + name: 'nightly-full', + testMatch: '**/*.integration.spec.ts', + use: { baseURL: 'https://staging.example.com' }, + timeout: 120_000, + }, + ], +}); +``` + +## Validating Mock Accuracy + +Guard against mock drift from real APIs: + +```typescript +test.describe('contract validation', () => { + test('billing mock matches real API shape', async ({ request }) => { + const realResponse = await request.post('/api/billing/charge', { + data: { amount: 5000, currency: 'usd' }, + }); + const realBody = await realResponse.json(); + + const mockBody = { + status: 'paid', + id: 'inv_mock_789', + }; + + expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort()); + + for (const key of Object.keys(mockBody)) { + expect(typeof mockBody[key]).toBe(typeof realBody[key]); + } + }); +}); +``` + +## Anti-Patterns + +| Don't Do This | Problem | Do This Instead | +| --- | --- | --- | +| Mock your own API | Tests pass, app breaks. Zero integration coverage. | Hit your real API. Mock only third-party services. | +| Mock everything for speed | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries. | +| Never mock anything | Tests are slow, flaky, fail when third parties have outages. | Mock third-party services. | +| Use outdated mocks | Mock returns different shape than real API. | Run contract validation tests. Re-record HAR files regularly. | +| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation. | Use `page.route()` which intercepts at network layer. | +| Copy-paste mocks across files | One API change requires updating many files. | Centralize mocks in fixtures. | +| Block all network and whitelist | Extremely brittle. Every new endpoint requires update. | Allow all by default. Selectively mock third-party services. | diff --git a/plugins/software-delivery/skills/playwright-best-practices/browser-apis/browser-apis.md b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/browser-apis.md new file mode 100644 index 0000000..cc4c269 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/browser-apis.md @@ -0,0 +1,391 @@ +# Browser APIs: Geolocation, Permissions & More + +## Table of Contents + +1. [Geolocation](#geolocation) +2. [Permissions](#permissions) +3. [Clipboard](#clipboard) +4. [Notifications](#notifications) +5. [Camera & Microphone](#camera--microphone) + +## Geolocation + +### Mock Location + +```typescript +test("shows nearby stores", async ({ context }) => { + // Grant permission and set location + await context.grantPermissions(["geolocation"]); + await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); // San Francisco + + const page = await context.newPage(); + await page.goto("/store-finder"); + await page.getByRole("button", { name: "Find Nearby" }).click(); + + await expect(page.getByText("San Francisco")).toBeVisible(); +}); +``` + +### Geolocation Fixture + +```typescript +// fixtures/geolocation.fixture.ts +import { test as base } from "@playwright/test"; + +type Coordinates = { latitude: number; longitude: number; accuracy?: number }; + +type GeoFixtures = { + setLocation: (coords: Coordinates) => Promise; +}; + +export const test = base.extend({ + setLocation: async ({ context }, use) => { + await context.grantPermissions(["geolocation"]); + + await use(async (coords) => { + await context.setGeolocation({ + latitude: coords.latitude, + longitude: coords.longitude, + accuracy: coords.accuracy ?? 100, + }); + }); + }, +}); + +// Usage +test("delivery zone check", async ({ page, setLocation }) => { + await setLocation({ latitude: 40.7128, longitude: -74.006 }); // NYC + + await page.goto("/delivery"); + + await expect(page.getByText("Delivery available")).toBeVisible(); +}); +``` + +### Test Location Changes + +```typescript +test("tracks location updates", async ({ context }) => { + await context.grantPermissions(["geolocation"]); + + const page = await context.newPage(); + await page.goto("/tracking"); + + // Initial location + await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); + await page.getByRole("button", { name: "Start Tracking" }).click(); + + await expect(page.getByTestId("location")).toContainText("37.7749"); + + // Move to new location + await context.setGeolocation({ latitude: 37.8044, longitude: -122.2712 }); + + // Trigger location update + await page.evaluate(() => { + navigator.geolocation.getCurrentPosition(() => {}); + }); + + await expect(page.getByTestId("location")).toContainText("37.8044"); +}); +``` + +### Test Geolocation Denial + +```typescript +test("handles location denied", async ({ browser }) => { + // Create context without geolocation permission + const context = await browser.newContext({ + permissions: [], // No permissions + }); + + const page = await context.newPage(); + await page.goto("/store-finder"); + await page.getByRole("button", { name: "Find Nearby" }).click(); + + await expect(page.getByText("Location access denied")).toBeVisible(); + await expect(page.getByLabel("Enter ZIP code")).toBeVisible(); + + await context.close(); +}); +``` + +## Permissions + +### Grant Permissions + +```typescript +test("notifications with permission", async ({ context }) => { + await context.grantPermissions(["notifications"]); + + const page = await context.newPage(); + await page.goto("/alerts"); + + // Notification API should work + const permission = await page.evaluate(() => Notification.permission); + expect(permission).toBe("granted"); +}); +``` + +### Test Permission Denied + +```typescript +test("handles notification permission denied", async ({ browser }) => { + const context = await browser.newContext({ + permissions: [], // Deny all + }); + + const page = await context.newPage(); + await page.goto("/notifications"); + + await page.getByRole("button", { name: "Enable Notifications" }).click(); + + await expect(page.getByText("Please enable notifications")).toBeVisible(); + + await context.close(); +}); +``` + +### Multiple Permissions + +```typescript +test("video call with permissions", async ({ context }) => { + await context.grantPermissions(["camera", "microphone", "notifications"]); + + const page = await context.newPage(); + await page.goto("/video-call"); + + // All permissions should be granted + const permissions = await page.evaluate(async () => ({ + camera: await navigator.permissions.query({ + name: "camera" as PermissionName, + }), + microphone: await navigator.permissions.query({ + name: "microphone" as PermissionName, + }), + })); + + expect(permissions.camera.state).toBe("granted"); + expect(permissions.microphone.state).toBe("granted"); +}); +``` + +## Clipboard + +### Test Copy to Clipboard + +```typescript +test("copy button works", async ({ page, context }) => { + // Grant clipboard permissions + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + await page.goto("/share"); + + await page.getByRole("button", { name: "Copy Link" }).click(); + + // Read clipboard content + const clipboardContent = await page.evaluate(() => + navigator.clipboard.readText(), + ); + + expect(clipboardContent).toContain("https://example.com/share/"); +}); +``` + +### Test Paste from Clipboard + +```typescript +test("paste from clipboard", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + await page.goto("/editor"); + + // Write to clipboard + await page.evaluate(() => navigator.clipboard.writeText("Pasted content")); + + // Trigger paste + await page.getByLabel("Content").focus(); + await page.keyboard.press("Control+V"); + + await expect(page.getByLabel("Content")).toHaveValue("Pasted content"); +}); +``` + +### Clipboard Fixture + +```typescript +// fixtures/clipboard.fixture.ts +import { test as base } from "@playwright/test"; + +type ClipboardFixtures = { + clipboard: { + write: (text: string) => Promise; + read: () => Promise; + }; +}; + +export const test = base.extend({ + clipboard: async ({ page, context }, use) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + await use({ + write: async (text) => { + await page.evaluate((t) => navigator.clipboard.writeText(t), text); + }, + read: async () => { + return page.evaluate(() => navigator.clipboard.readText()); + }, + }); + }, +}); +``` + +## Notifications + +### Mock Notification API + +```typescript +test("shows browser notification", async ({ page }) => { + const notifications: any[] = []; + + // Mock Notification constructor + await page.addInitScript(() => { + (window as any).__notifications = []; + (window as any).Notification = class { + constructor(title: string, options?: NotificationOptions) { + (window as any).__notifications.push({ title, ...options }); + } + static permission = "granted"; + static requestPermission = async () => "granted"; + }; + }); + + await page.goto("/alerts"); + await page.getByRole("button", { name: "Notify Me" }).click(); + + // Check notification was created + const created = await page.evaluate(() => (window as any).__notifications); + expect(created).toHaveLength(1); + expect(created[0].title).toBe("New Alert"); +}); +``` + +### Test Notification Click + +```typescript +test("notification click handler", async ({ page }) => { + await page.addInitScript(() => { + (window as any).Notification = class { + onclick: (() => void) | null = null; + constructor(title: string) { + // Simulate click after creation + setTimeout(() => this.onclick?.(), 100); + } + static permission = "granted"; + static requestPermission = async () => "granted"; + }; + }); + + await page.goto("/messages"); + await page.evaluate(() => { + new Notification("New Message"); + }); + + // Should navigate to messages when notification clicked + await expect(page).toHaveURL(/\/messages/); +}); +``` + +## Camera & Microphone + +### Mock Media Devices + +```typescript +test("video preview works", async ({ page, context }) => { + await context.grantPermissions(["camera"]); + + // Mock getUserMedia + await page.addInitScript(() => { + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 480; + return canvas.captureStream(); + }; + }); + + await page.goto("/video-settings"); + await page.getByRole("button", { name: "Start Camera" }).click(); + + await expect(page.getByTestId("video-preview")).toBeVisible(); +}); +``` + +### Test Media Device Selection + +```typescript +test("switch camera", async ({ page }) => { + await page.addInitScript(() => { + navigator.mediaDevices.enumerateDevices = async () => + [ + { + deviceId: "cam1", + kind: "videoinput", + label: "Front Camera", + groupId: "1", + }, + { + deviceId: "cam2", + kind: "videoinput", + label: "Back Camera", + groupId: "2", + }, + ] as MediaDeviceInfo[]; + + navigator.mediaDevices.getUserMedia = async () => { + const canvas = document.createElement("canvas"); + return canvas.captureStream(); + }; + }); + + await page.goto("/camera"); + + // Should show camera options + await expect(page.getByRole("combobox", { name: "Camera" })).toBeVisible(); + await expect(page.getByText("Front Camera")).toBeVisible(); + await expect(page.getByText("Back Camera")).toBeVisible(); +}); +``` + +### Test Media Errors + +```typescript +test("handles camera access error", async ({ page }) => { + await page.addInitScript(() => { + navigator.mediaDevices.getUserMedia = async () => { + throw new DOMException("Permission denied", "NotAllowedError"); + }; + }); + + await page.goto("/video-call"); + await page.getByRole("button", { name: "Join Call" }).click(); + + await expect(page.getByText("Camera access denied")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Join Audio Only" }), + ).toBeVisible(); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ----------------------------- | --------------------------------- | ----------------------------------- | +| Not granting permissions | Tests fail with permission errors | Use `context.grantPermissions()` | +| Testing real geolocation | Flaky, environment-dependent | Mock with `setGeolocation()` | +| Not testing permission denial | Misses error handling | Test both granted and denied states | +| Using real camera/mic | CI has no devices | Mock `getUserMedia` | + +## Related References + +- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures +- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation diff --git a/plugins/software-delivery/skills/playwright-best-practices/browser-apis/iframes.md b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/iframes.md new file mode 100644 index 0000000..145e050 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/iframes.md @@ -0,0 +1,403 @@ +# iFrame Testing + +## Table of Contents + +1. [Basic iFrame Access](#basic-iframe-access) +2. [Cross-Origin iFrames](#cross-origin-iframes) +3. [Nested iFrames](#nested-iframes) +4. [Dynamic iFrames](#dynamic-iframes) +5. [iFrame Navigation](#iframe-navigation) +6. [Common Patterns](#common-patterns) + +## Basic iFrame Access + +### Using frameLocator + +```typescript +// Access iframe by selector +const frame = page.frameLocator("iframe#payment"); +await frame.getByRole("button", { name: "Pay" }).click(); + +// Access by name attribute +const namedFrame = page.frameLocator('iframe[name="checkout"]'); +await namedFrame.getByLabel("Card number").fill("4242424242424242"); + +// Access by title +const titledFrame = page.frameLocator('iframe[title="Payment Form"]'); + +// Access by src (partial match) +const srcFrame = page.frameLocator('iframe[src*="stripe.com"]'); +``` + +### Frame vs FrameLocator + +```typescript +// frameLocator - for locator-based operations (recommended) +const frameLocator = page.frameLocator("#my-iframe"); +await frameLocator.getByRole("button").click(); + +// frame() - for Frame object operations (navigation, evaluation) +const frame = page.frame({ name: "my-frame" }); +if (frame) { + await frame.goto("https://example.com"); + const title = await frame.title(); +} + +// Get all frames +const frames = page.frames(); +for (const f of frames) { + console.log("Frame URL:", f.url()); +} +``` + +### Waiting for iFrame Content + +```typescript +// Wait for iframe to load +const frame = page.frameLocator("#dynamic-iframe"); + +// Wait for element inside iframe +await expect(frame.getByRole("heading")).toBeVisible({ timeout: 10000 }); + +// Wait for iframe src to change +await page.waitForFunction(() => { + const iframe = document.querySelector("iframe#my-frame") as HTMLIFrameElement; + return iframe?.src.includes("loaded"); +}); +``` + +## Cross-Origin iFrames + +### Accessing Cross-Origin Content + +```typescript +// Cross-origin iframes work seamlessly with frameLocator +const thirdPartyFrame = page.frameLocator('iframe[src*="third-party.com"]'); + +// Interact with elements inside cross-origin iframe +await thirdPartyFrame.getByRole("textbox").fill("test@example.com"); +await thirdPartyFrame.getByRole("button", { name: "Submit" }).click(); + +// Wait for cross-origin iframe to be ready +await expect(thirdPartyFrame.locator("body")).toBeVisible(); +``` + +### Payment Provider iFrames (Stripe, PayPal) + +```typescript +test("Stripe payment iframe", async ({ page }) => { + await page.goto("/checkout"); + + // Stripe uses multiple iframes for each field + const cardFrame = page + .frameLocator('iframe[name*="__privateStripeFrame"]') + .first(); + + // Wait for Stripe to initialize + await expect(cardFrame.locator('[placeholder="Card number"]')).toBeVisible({ + timeout: 15000, + }); + + // Fill card details + await cardFrame + .locator('[placeholder="Card number"]') + .fill("4242424242424242"); + await cardFrame.locator('[placeholder="MM / YY"]').fill("12/30"); + await cardFrame.locator('[placeholder="CVC"]').fill("123"); +}); +``` + +### Handling OAuth in iFrames + +```typescript +test("OAuth iframe flow", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with Google" }).click(); + + // If OAuth opens in iframe instead of popup + const oauthFrame = page.frameLocator('iframe[src*="accounts.google.com"]'); + + // Wait for OAuth form + await expect(oauthFrame.getByLabel("Email")).toBeVisible({ timeout: 10000 }); + await oauthFrame.getByLabel("Email").fill("test@gmail.com"); +}); +``` + +## Nested iFrames + +### Accessing Nested Frames + +```typescript +// Parent iframe contains child iframe +const parentFrame = page.frameLocator("#outer-frame"); +const childFrame = parentFrame.frameLocator("#inner-frame"); + +// Interact with deeply nested content +await childFrame.getByRole("button", { name: "Submit" }).click(); + +// Multiple levels of nesting +const level1 = page.frameLocator("#level1"); +const level2 = level1.frameLocator("#level2"); +const level3 = level2.frameLocator("#level3"); +await level3.getByText("Deep content").click(); +``` + +### Finding Elements Across Frame Hierarchy + +```typescript +// Helper to search all frames for an element +async function findInAnyFrame( + page: Page, + selector: string, +): Promise { + // Check main page first + const mainCount = await page.locator(selector).count(); + if (mainCount > 0) return page.locator(selector); + + // Check all frames + for (const frame of page.frames()) { + const count = await frame.locator(selector).count(); + if (count > 0) { + return frame.locator(selector); + } + } + return null; +} + +test("find element in any frame", async ({ page }) => { + await page.goto("/complex-page"); + const element = await findInAnyFrame(page, '[data-testid="submit-btn"]'); + if (element) await element.click(); +}); +``` + +## Dynamic iFrames + +### iFrames Created at Runtime + +```typescript +test("handle dynamically created iframe", async ({ page }) => { + await page.goto("/dashboard"); + + // Click button that creates iframe + await page.getByRole("button", { name: "Open Widget" }).click(); + + // Wait for iframe to appear in DOM + await page.waitForSelector("iframe#widget-frame"); + + // Now access the frame + const widgetFrame = page.frameLocator("#widget-frame"); + await expect(widgetFrame.getByText("Widget Loaded")).toBeVisible(); +}); +``` + +### iFrames with Changing src + +```typescript +test("iframe src changes", async ({ page }) => { + await page.goto("/multi-step"); + + const frame = page.frameLocator("#step-frame"); + + // Step 1 + await expect(frame.getByText("Step 1")).toBeVisible(); + await frame.getByRole("button", { name: "Next" }).click(); + + // Wait for iframe to reload with new content + await expect(frame.getByText("Step 2")).toBeVisible({ timeout: 10000 }); + await frame.getByRole("button", { name: "Next" }).click(); + + // Step 3 + await expect(frame.getByText("Step 3")).toBeVisible({ timeout: 10000 }); +}); +``` + +### Lazy-Loaded iFrames + +```typescript +test("lazy loaded iframe", async ({ page }) => { + await page.goto("/page-with-lazy-iframe"); + + // Scroll to trigger lazy load + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + // Wait for iframe to load + const lazyFrame = page.frameLocator("#lazy-iframe"); + await expect(lazyFrame.locator("body")).not.toBeEmpty({ timeout: 15000 }); + + // Interact with content + await lazyFrame.getByRole("button").click(); +}); +``` + +## iFrame Navigation + +### Navigating Within iFrame + +```typescript +test("iframe internal navigation", async ({ page }) => { + await page.goto("/app"); + + // Get frame object for navigation control + const frame = page.frame({ name: "content-frame" }); + if (!frame) throw new Error("Frame not found"); + + // Navigate within iframe + await frame.goto("https://embedded-app.com/page2"); + + // Wait for navigation + await frame.waitForURL("**/page2"); + + // Verify content + await expect(frame.getByRole("heading")).toHaveText("Page 2"); +}); +``` + +### Handling Frame Navigation Events + +```typescript +test("track iframe navigation", async ({ page }) => { + const navigations: string[] = []; + + // Listen to frame navigation + page.on("framenavigated", (frame) => { + if (frame.parentFrame()) { + // This is an iframe navigation + navigations.push(frame.url()); + } + }); + + await page.goto("/with-iframe"); + await page + .frameLocator("#nav-frame") + .getByRole("link", { name: "Page 2" }) + .click(); + + // Verify navigation occurred + expect(navigations.some((url) => url.includes("page2"))).toBe(true); +}); +``` + +## Common Patterns + +### iFrame Fixture + +```typescript +// fixtures.ts +import { test as base, FrameLocator } from "@playwright/test"; + +export const test = base.extend<{ paymentFrame: FrameLocator }>({ + paymentFrame: async ({ page }, use) => { + await page.goto("/checkout"); + + // Wait for payment iframe to be ready + const frame = page.frameLocator('iframe[src*="payment"]'); + await expect(frame.locator("body")).toBeVisible({ timeout: 15000 }); + + await use(frame); + }, +}); + +// test file +test("complete payment", async ({ paymentFrame }) => { + await paymentFrame.getByLabel("Card").fill("4242424242424242"); + await paymentFrame.getByRole("button", { name: "Pay" }).click(); +}); +``` + +### Debugging iFrame Issues + +```typescript +test("debug iframe content", async ({ page }) => { + await page.goto("/page-with-iframes"); + + // List all frames + console.log("All frames:"); + for (const frame of page.frames()) { + console.log(` - ${frame.name() || "(unnamed)"}: ${frame.url()}`); + } + + // Screenshot specific iframe content + const frame = page.frame({ name: "target-frame" }); + if (frame) { + const body = frame.locator("body"); + await body.screenshot({ path: "iframe-content.png" }); + } + + // Get iframe HTML for debugging + const frameContent = page.frameLocator("#my-frame"); + const html = await frameContent.locator("body").innerHTML(); + console.log("iFrame HTML:", html.substring(0, 500)); +}); +``` + +### Handling iFrame Load Failures + +```typescript +test("handle iframe load failure", async ({ page }) => { + await page.goto("/page-with-unreliable-iframe"); + + const frame = page.frameLocator("#unreliable-frame"); + + try { + // Try to interact with iframe content + await expect(frame.getByRole("button")).toBeVisible({ timeout: 5000 }); + await frame.getByRole("button").click(); + } catch (error) { + // Fallback: refresh iframe + await page.evaluate(() => { + const iframe = document.querySelector( + "#unreliable-frame", + ) as HTMLIFrameElement; + if (iframe) iframe.src = iframe.src; + }); + + // Retry + await expect(frame.getByRole("button")).toBeVisible({ timeout: 10000 }); + await frame.getByRole("button").click(); + } +}); +``` + +### Mocking iFrame Content + +```typescript +test("mock iframe response", async ({ page }) => { + // Intercept iframe src request + await page.route("**/embedded-widget**", (route) => { + route.fulfill({ + contentType: "text/html", + body: ` + + + +

Mocked Widget

+ + + + `, + }); + }); + + await page.goto("/page-with-widget"); + + const frame = page.frameLocator("#widget-frame"); + await expect(frame.getByRole("heading")).toHaveText("Mocked Widget"); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------------------- | --------------------------------- | -------------------------------------------------- | +| Using `page.frame()` for interactions | Less reliable than frameLocator | Use `page.frameLocator()` for element interactions | +| Hardcoding iframe index | Fragile if DOM order changes | Use name, id, or src attribute selectors | +| Not waiting for iframe load | Race conditions | Wait for element inside iframe to be visible | +| Assuming same-origin | Cross-origin has different timing | Always wait for iframe content explicitly | +| Ignoring nested iframes | Element not found | Chain frameLocator calls for nested frames | + +## Related References + +- **Locators**: See [locators.md](../core/locators.md) for selector strategies +- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns +- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues diff --git a/plugins/software-delivery/skills/playwright-best-practices/browser-apis/service-workers.md b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/service-workers.md new file mode 100644 index 0000000..7603de3 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/service-workers.md @@ -0,0 +1,504 @@ +# Service Worker Testing + +## Table of Contents + +1. [Service Worker Basics](#service-worker-basics) +2. [Registration & Lifecycle](#registration--lifecycle) +3. [Cache Testing](#cache-testing) +4. [Offline Testing](#offline-testing) +5. [Push Notifications](#push-notifications) +6. [Background Sync](#background-sync) + +## Service Worker Basics + +### Waiting for Service Worker Registration + +```typescript +test("service worker registers", async ({ page }) => { + await page.goto("/pwa-app"); + + // Wait for SW to register + const swRegistered = await page.evaluate(async () => { + if (!("serviceWorker" in navigator)) return false; + + const registration = await navigator.serviceWorker.ready; + return !!registration.active; + }); + + expect(swRegistered).toBe(true); +}); +``` + +### Getting Service Worker State + +```typescript +test("check SW state", async ({ page }) => { + await page.goto("/"); + + const swState = await page.evaluate(async () => { + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration) return null; + + return { + installing: !!registration.installing, + waiting: !!registration.waiting, + active: !!registration.active, + scope: registration.scope, + }; + }); + + expect(swState?.active).toBe(true); + expect(swState?.scope).toContain(page.url()); +}); +``` + +### Service Worker Context + +```typescript +test("access service worker", async ({ context, page }) => { + await page.goto("/pwa-app"); + + // Get all service workers in context + const workers = context.serviceWorkers(); + + // Wait for service worker if not yet available + if (workers.length === 0) { + await context.waitForEvent("serviceworker"); + } + + const sw = context.serviceWorkers()[0]; + expect(sw.url()).toContain("sw.js"); +}); +``` + +## Registration & Lifecycle + +### Testing SW Update Flow + +```typescript +test("service worker updates", async ({ page }) => { + await page.goto("/pwa-app"); + + // Check for update + const hasUpdate = await page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready; + await registration.update(); + + return new Promise((resolve) => { + if (registration.waiting) { + resolve(true); + } else { + registration.addEventListener("updatefound", () => { + resolve(true); + }); + // Timeout if no update + setTimeout(() => resolve(false), 5000); + } + }); + }); + + // If update found, test skip waiting flow + if (hasUpdate) { + await page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready; + registration.waiting?.postMessage({ type: "SKIP_WAITING" }); + }); + + // Wait for controller change + await page.evaluate(() => { + return new Promise((resolve) => { + navigator.serviceWorker.addEventListener("controllerchange", () => { + resolve(); + }); + }); + }); + } +}); +``` + +### Testing SW Installation + +```typescript +test("verify SW install event", async ({ context, page }) => { + // Listen for service worker before navigating + const swPromise = context.waitForEvent("serviceworker"); + + await page.goto("/pwa-app"); + + const sw = await swPromise; + + // Evaluate in SW context + const swVersion = await sw.evaluate(() => { + // Access SW globals + return (self as any).SW_VERSION || "unknown"; + }); + + expect(swVersion).toBe("1.0.0"); +}); +``` + +### Unregistering Service Workers + +```typescript +test.beforeEach(async ({ page }) => { + await page.goto("/"); + + // Unregister all service workers for clean state + await page.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + }); + + // Clear caches + await page.evaluate(async () => { + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map((name) => caches.delete(name))); + }); +}); +``` + +## Cache Testing + +### Verifying Cached Resources + +```typescript +test("assets are cached", async ({ page }) => { + await page.goto("/pwa-app"); + + // Wait for SW to cache assets + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + }); + + // Check cache contents + const cachedUrls = await page.evaluate(async () => { + const cache = await caches.open("app-cache-v1"); + const requests = await cache.keys(); + return requests.map((r) => r.url); + }); + + expect(cachedUrls).toContain(expect.stringContaining("/styles.css")); + expect(cachedUrls).toContain(expect.stringContaining("/app.js")); +}); +``` + +### Testing Cache Strategies + +```typescript +test("cache-first strategy", async ({ page }) => { + await page.goto("/pwa-app"); + + // Wait for initial cache + await page.waitForFunction(async () => { + const cache = await caches.open("app-cache-v1"); + const keys = await cache.keys(); + return keys.length > 0; + }); + + // Block network for cached resources + await page.route("**/styles.css", (route) => route.abort()); + + // Reload - should work from cache + await page.reload(); + + // Verify page still styled (CSS loaded from cache) + const hasStyles = await page.evaluate(() => { + const body = document.body; + const styles = window.getComputedStyle(body); + return styles.fontFamily !== ""; // Has custom font from CSS + }); + + expect(hasStyles).toBe(true); +}); +``` + +### Testing Cache Updates + +```typescript +test("cache updates on new version", async ({ page }) => { + await page.goto("/pwa-app"); + + // Get initial cache + const initialCacheKeys = await page.evaluate(async () => { + const cache = await caches.open("app-cache-v1"); + const keys = await cache.keys(); + return keys.map((r) => r.url); + }); + + // Simulate app update by mocking SW response + await page.route("**/sw.js", (route) => { + route.fulfill({ + contentType: "application/javascript", + body: ` + const VERSION = 'v2'; + self.addEventListener('install', (e) => { + e.waitUntil(caches.open('app-cache-v2')); + self.skipWaiting(); + }); + `, + }); + }); + + // Trigger update + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + await reg.update(); + }); + + // Verify new cache exists + await page.waitForFunction(async () => { + return await caches.has("app-cache-v2"); + }); +}); +``` + +## Offline Testing + +This section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing). + +### Simulating Offline Mode + +```typescript +test("app works offline", async ({ page, context }) => { + await page.goto("/pwa-app"); + + // Ensure SW is active and content cached + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + }); + await page.waitForTimeout(1000); // Allow caching to complete + + // Go offline + await context.setOffline(true); + + // Navigate to cached page + await page.reload(); + + // Verify content loads + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + + // Verify offline indicator + await expect(page.locator(".offline-badge")).toBeVisible(); + + // Go back online + await context.setOffline(false); + await expect(page.locator(".offline-badge")).not.toBeVisible(); +}); +``` + +### Testing Offline Fallback + +```typescript +test("shows offline page for uncached routes", async ({ page, context }) => { + await page.goto("/pwa-app"); + await page.evaluate(() => navigator.serviceWorker.ready); + + // Go offline + await context.setOffline(true); + + // Navigate to uncached page + await page.goto("/uncached-page"); + + // Should show offline fallback + await expect(page.getByText("You are offline")).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry" })).toBeVisible(); +}); +``` + +### Testing Offline Form Submission + +```typescript +test("queues form submission offline", async ({ page, context }) => { + await page.goto("/pwa-app/form"); + + // Go offline + await context.setOffline(true); + + // Submit form + await page.getByLabel("Message").fill("Offline message"); + await page.getByRole("button", { name: "Send" }).click(); + + // Should show queued status + await expect(page.getByText("Queued for sync")).toBeVisible(); + + // Go online + await context.setOffline(false); + + // Trigger sync (or wait for automatic) + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + // Manually trigger sync for testing + await (reg as any).sync?.register("form-sync"); + }); + + // Verify submission completed + await expect(page.getByText("Message sent")).toBeVisible({ timeout: 10000 }); +}); +``` + +## Push Notifications + +### Mocking Push Subscription + +```typescript +test("handles push subscription", async ({ page, context }) => { + // Grant notification permission + await context.grantPermissions(["notifications"]); + + await page.goto("/pwa-app"); + + // Subscribe to push + const subscription = await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: "test-key", + }); + return sub.toJSON(); + }); + + expect(subscription.endpoint).toBeDefined(); +}); +``` + +### Testing Push Message Handling + +```typescript +test("handles push notification", async ({ context, page }) => { + await context.grantPermissions(["notifications"]); + await page.goto("/pwa-app"); + + // Wait for SW + const swPromise = context.waitForEvent("serviceworker"); + const sw = await swPromise; + + // Simulate push message to service worker + await sw.evaluate(async () => { + // Dispatch push event + const pushEvent = new PushEvent("push", { + data: new PushMessageData( + JSON.stringify({ title: "Test", body: "Push message" }), + ), + }); + self.dispatchEvent(pushEvent); + }); + + // Note: Actual notification display testing is limited in Playwright + // Focus on verifying the SW handles the push correctly +}); +``` + +### Testing Notification Click + +```typescript +test("notification click opens page", async ({ context, page }) => { + await context.grantPermissions(["notifications"]); + await page.goto("/pwa-app"); + + // Store notification URL target + let notificationUrl = ""; + + // Listen for new pages (notification click opens new page) + context.on("page", (newPage) => { + notificationUrl = newPage.url(); + }); + + // Trigger notification via SW + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + await reg.showNotification("Test", { + body: "Click me", + data: { url: "/notification-target" }, + }); + }); + + // Simulate clicking notification (via SW) + const sw = context.serviceWorkers()[0]; + await sw.evaluate(() => { + self.dispatchEvent( + new NotificationEvent("notificationclick", { + notification: { data: { url: "/notification-target" } } as any, + }), + ); + }); + + // Verify navigation occurred + await page.waitForTimeout(1000); + // Check if new page opened or current page navigated +}); +``` + +## Background Sync + +### Testing Background Sync Registration + +```typescript +test("registers background sync", async ({ page }) => { + await page.goto("/pwa-app"); + + // Register sync + const syncRegistered = await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + if (!("sync" in reg)) return false; + + await (reg as any).sync.register("my-sync"); + return true; + }); + + expect(syncRegistered).toBe(true); +}); +``` + +### Testing Sync Event + +```typescript +test("sync event fires when online", async ({ context, page }) => { + await page.goto("/pwa-app"); + + // Queue data while offline + await context.setOffline(true); + + await page.evaluate(async () => { + // Store data in IndexedDB for sync + const db = await openDB(); + await db.put("sync-queue", { id: 1, data: "test" }); + + // Register sync + const reg = await navigator.serviceWorker.ready; + await (reg as any).sync.register("data-sync"); + }); + + // Track sync completion + await page.evaluate(() => { + window.syncCompleted = false; + navigator.serviceWorker.addEventListener("message", (e) => { + if (e.data.type === "SYNC_COMPLETE") { + window.syncCompleted = true; + } + }); + }); + + // Go online + await context.setOffline(false); + + // Wait for sync to complete + await page.waitForFunction(() => window.syncCompleted, { timeout: 10000 }); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------------ | ----------------------- | -------------------------------------------- | +| Not clearing SW between tests | Tests affect each other | Unregister SW in beforeEach | +| Not waiting for SW ready | Race conditions | Always await `navigator.serviceWorker.ready` | +| Testing in isolation only | Misses real SW behavior | Test with actual caching | +| Hardcoded timeouts for caching | Flaky tests | Wait for cache to populate | +| Ignoring SW update cycle | Missing update bugs | Test install, activate, update flows | + +## Related References + +- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns +- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions +- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception +- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns diff --git a/plugins/software-delivery/skills/playwright-best-practices/browser-apis/websockets.md b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/websockets.md new file mode 100644 index 0000000..075a997 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/browser-apis/websockets.md @@ -0,0 +1,403 @@ +# WebSocket & Real-Time Testing + +## Table of Contents + +1. [WebSocket Basics](#websocket-basics) +2. [Mocking WebSocket Messages](#mocking-websocket-messages) +3. [Testing Real-Time Features](#testing-real-time-features) +4. [Server-Sent Events](#server-sent-events) +5. [Reconnection Testing](#reconnection-testing) + +## WebSocket Basics + +### Wait for WebSocket Connection + +```typescript +test("chat connects via websocket", async ({ page }) => { + // Listen for WebSocket connection + const wsPromise = page.waitForEvent("websocket"); + + await page.goto("/chat"); + + const ws = await wsPromise; + expect(ws.url()).toContain("/ws/chat"); + + // Wait for connection to be established + await ws.waitForEvent("framesent"); +}); +``` + +### Monitor WebSocket Messages + +```typescript +test("receives real-time updates", async ({ page }) => { + const messages: string[] = []; + + // Set up listener before navigation + page.on("websocket", (ws) => { + ws.on("framereceived", (frame) => { + messages.push(frame.payload as string); + }); + }); + + await page.goto("/dashboard"); + + // Wait for some messages + await expect.poll(() => messages.length).toBeGreaterThan(0); + + // Verify message format + const data = JSON.parse(messages[0]); + expect(data).toHaveProperty("type"); +}); +``` + +### Capture Sent Messages + +```typescript +test("sends correct message format", async ({ page }) => { + const sentMessages: string[] = []; + + page.on("websocket", (ws) => { + ws.on("framesent", (frame) => { + sentMessages.push(frame.payload as string); + }); + }); + + await page.goto("/chat"); + await page.getByLabel("Message").fill("Hello!"); + await page.getByRole("button", { name: "Send" }).click(); + + // Verify sent message + await expect.poll(() => sentMessages.length).toBeGreaterThan(0); + + const sent = JSON.parse(sentMessages[sentMessages.length - 1]); + expect(sent).toEqual({ + type: "message", + content: "Hello!", + }); +}); +``` + +## Mocking WebSocket Messages + +### Inject Messages via Page Evaluate + +```typescript +test("displays incoming chat message", async ({ page }) => { + await page.goto("/chat"); + + // Wait for WebSocket to be ready + await page.waitForFunction( + () => (window as any).chatSocket?.readyState === 1, + ); + + // Simulate incoming message + await page.evaluate(() => { + const event = new MessageEvent("message", { + data: JSON.stringify({ + type: "message", + from: "Alice", + content: "Hello there!", + }), + }); + (window as any).chatSocket.dispatchEvent(event); + }); + + await expect(page.getByText("Alice: Hello there!")).toBeVisible(); +}); +``` + +### Mock WebSocket with Route Handler + +```typescript +test("mock websocket entirely", async ({ page, context }) => { + // Intercept the WebSocket upgrade + await context.route("**/ws/**", async (route) => { + // For WebSocket routes, we can't fulfill directly + // Instead, use page.evaluate to mock the client-side + }); + + // Alternative: Mock at application level + await page.addInitScript(() => { + const OriginalWebSocket = window.WebSocket; + (window as any).WebSocket = function (url: string) { + const ws = { + readyState: 1, + send: (data: string) => { + console.log("WS Send:", data); + }, + close: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + setTimeout(() => ws.onopen?.(), 100); + return ws; + }; + }); + + await page.goto("/chat"); +}); +``` + +### WebSocket Mock Fixture + +```typescript +// fixtures/websocket.fixture.ts +import { test as base, Page } from "@playwright/test"; + +type WsMessage = { type: string; [key: string]: any }; + +type WebSocketFixtures = { + mockWebSocket: { + injectMessage: (message: WsMessage) => Promise; + getSentMessages: () => Promise; + }; +}; + +export const test = base.extend({ + mockWebSocket: async ({ page }, use) => { + const sentMessages: WsMessage[] = []; + + // Capture sent messages + await page.addInitScript(() => { + (window as any).__wsSent = []; + const OriginalWebSocket = window.WebSocket; + window.WebSocket = function (url: string) { + const ws = new OriginalWebSocket(url); + const originalSend = ws.send.bind(ws); + ws.send = (data: string) => { + (window as any).__wsSent.push(JSON.parse(data)); + originalSend(data); + }; + (window as any).__ws = ws; + return ws; + } as any; + }); + + await use({ + injectMessage: async (message) => { + await page.evaluate((msg) => { + const event = new MessageEvent("message", { + data: JSON.stringify(msg), + }); + (window as any).__ws?.dispatchEvent(event); + }, message); + }, + getSentMessages: async () => { + return page.evaluate(() => (window as any).__wsSent || []); + }, + }); + }, +}); + +// Usage +test("chat with mocked websocket", async ({ page, mockWebSocket }) => { + await page.goto("/chat"); + + // Inject incoming message + await mockWebSocket.injectMessage({ + type: "message", + from: "Bob", + content: "Hi!", + }); + + await expect(page.getByText("Bob: Hi!")).toBeVisible(); + + // Send a reply + await page.getByLabel("Message").fill("Hello Bob!"); + await page.getByRole("button", { name: "Send" }).click(); + + // Verify sent message + const sent = await mockWebSocket.getSentMessages(); + expect(sent).toContainEqual( + expect.objectContaining({ content: "Hello Bob!" }), + ); +}); +``` + +## Testing Real-Time Features + +### Live Notifications + +```typescript +test("displays live notification", async ({ page }) => { + await page.goto("/dashboard"); + + // Simulate notification via WebSocket + await page.evaluate(() => { + const event = new MessageEvent("message", { + data: JSON.stringify({ + type: "notification", + title: "New Order", + message: "Order #123 received", + }), + }); + (window as any).notificationSocket.dispatchEvent(event); + }); + + await expect(page.getByRole("alert")).toContainText("Order #123 received"); +}); +``` + +### Live Data Updates + +```typescript +test("updates stock price in real-time", async ({ page }) => { + await page.goto("/stocks/AAPL"); + + const priceElement = page.getByTestId("stock-price"); + const initialPrice = await priceElement.textContent(); + + // Simulate price update + await page.evaluate(() => { + const event = new MessageEvent("message", { + data: JSON.stringify({ + type: "price_update", + symbol: "AAPL", + price: 150.25, + }), + }); + (window as any).stockSocket.dispatchEvent(event); + }); + + await expect(priceElement).not.toHaveText(initialPrice!); + await expect(priceElement).toContainText("150.25"); +}); +``` + +### Collaborative Editing + +```typescript +test("shows collaborator cursor", async ({ page }) => { + await page.goto("/document/123"); + + // Simulate another user's cursor position + await page.evaluate(() => { + const event = new MessageEvent("message", { + data: JSON.stringify({ + type: "cursor", + userId: "user-456", + userName: "Alice", + position: { x: 100, y: 200 }, + }), + }); + (window as any).docSocket.dispatchEvent(event); + }); + + await expect(page.getByTestId("cursor-user-456")).toBeVisible(); + await expect(page.getByText("Alice")).toBeVisible(); +}); +``` + +## Server-Sent Events + +### Test SSE Updates + +```typescript +test("receives SSE updates", async ({ page }) => { + // Mock SSE endpoint + await page.route("**/api/events", (route) => { + route.fulfill({ + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + body: `data: {"type":"update","value":42}\n\n`, + }); + }); + + await page.goto("/live-data"); + + await expect(page.getByTestId("value")).toHaveText("42"); +}); +``` + +### Simulate Multiple SSE Events + +```typescript +test("handles multiple SSE events", async ({ page }) => { + await page.route("**/api/events", async (route) => { + const encoder = new TextEncoder(); + const events = [ + `data: {"count":1}\n\n`, + `data: {"count":2}\n\n`, + `data: {"count":3}\n\n`, + ]; + + route.fulfill({ + status: 200, + headers: { "Content-Type": "text/event-stream" }, + body: events.join(""), + }); + }); + + await page.goto("/counter"); + + // Should receive all events + await expect(page.getByTestId("count")).toHaveText("3"); +}); +``` + +## Reconnection Testing + +### Test Connection Loss + +```typescript +test("handles connection loss gracefully", async ({ page }) => { + await page.goto("/chat"); + + // Simulate connection close + await page.evaluate(() => { + (window as any).chatSocket.close(); + }); + + // Should show disconnected state + await expect(page.getByText("Reconnecting...")).toBeVisible(); +}); +``` + +### Test Reconnection + +```typescript +test("reconnects after connection loss", async ({ page }) => { + await page.goto("/chat"); + + // Simulate disconnect + await page.evaluate(() => { + (window as any).chatSocket.close(); + }); + + await expect(page.getByText("Reconnecting...")).toBeVisible(); + + // Simulate reconnection + await page.evaluate(() => { + const event = new Event("open"); + (window as any).chatSocket = { readyState: 1 }; + (window as any).chatSocket.dispatchEvent?.(event); + }); + + // Force component to re-check connection + await page.evaluate(() => { + window.dispatchEvent(new Event("online")); + }); + + await expect(page.getByText("Connected")).toBeVisible(); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------------------- | ----------------------------- | ---------------------------------- | +| Not waiting for WebSocket ready | Messages sent too early | Wait for `readyState === 1` | +| Testing against real WebSocket server | Flaky, timing-dependent | Mock WebSocket messages | +| Ignoring connection state | Tests pass but feature broken | Test connected/disconnected states | +| No cleanup of listeners | Memory leaks in tests | Clean up event listeners | + +## Related References + +- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns +- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns +- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/annotations.md b/plugins/software-delivery/skills/playwright-best-practices/core/annotations.md new file mode 100644 index 0000000..ac0f890 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/annotations.md @@ -0,0 +1,424 @@ +# Test Annotations & Organization + +## Table of Contents + +1. [Skip Annotations](#skip-annotations) +2. [Fixme & Fail Annotations](#fixme--fail-annotations) +3. [Slow Tests](#slow-tests) +4. [Test Steps](#test-steps) +5. [Custom Annotations](#custom-annotations) +6. [Conditional Annotations](#conditional-annotations) + +## Skip Annotations + +### Basic Skip + +```typescript +// Skip unconditionally +test.skip("feature not implemented", async ({ page }) => { + // This test won't run +}); + +// Skip with reason +test("payment flow", async ({ page }) => { + test.skip(true, "Payment gateway in maintenance"); + // Test body won't execute +}); +``` + +### Conditional Skip + +```typescript +test("webkit-specific feature", async ({ page, browserName }) => { + test.skip(browserName !== "webkit", "This feature only works in WebKit"); + + await page.goto("/webkit-feature"); +}); + +test("production only", async ({ page }) => { + test.skip(process.env.ENV !== "production", "Only runs against production"); + + await page.goto("/prod-feature"); +}); +``` + +### Skip by Platform + +```typescript +test("windows-specific", async ({ page }) => { + test.skip(process.platform !== "win32", "Windows only"); +}); + +test("not on CI", async ({ page }) => { + test.skip(!!process.env.CI, "Skipped in CI environment"); +}); +``` + +### Skip Describe Block + +```typescript +test.describe("Admin features", () => { + test.skip( + ({ browserName }) => browserName === "firefox", + "Firefox admin bug", + ); + + test("admin dashboard", async ({ page }) => { + // Skipped in Firefox + }); + + test("admin settings", async ({ page }) => { + // Skipped in Firefox + }); +}); +``` + +## Fixme & Fail Annotations + +### Fixme - Known Issues + +```typescript +// Mark test as needing fix (skips the test) +test.fixme("broken after refactor", async ({ page }) => { + // Test won't run but is tracked +}); + +// Conditional fixme +test("flaky on CI", async ({ page }) => { + test.fixme(!!process.env.CI, "Investigate CI flakiness - ticket #123"); + + await page.goto("/flaky-feature"); +}); +``` + +### Fail - Expected Failures + +```typescript +// Test is expected to fail (runs but expects failure) +test("known bug", async ({ page }) => { + test.fail(); + + await page.goto("/buggy-page"); + // If this passes, the test fails (bug was fixed!) + await expect(page.getByText("Working")).toBeVisible(); +}); + +// Conditional fail +test("fails on webkit", async ({ page, browserName }) => { + test.fail(browserName === "webkit", "WebKit rendering bug #456"); + + await page.goto("/render-test"); + await expect(page.getByTestId("element")).toHaveCSS("width", "100px"); +}); +``` + +### Difference Between Skip, Fixme, Fail + +| Annotation | Runs? | Use Case | +| -------------- | ----- | -------------------------------- | +| `test.skip()` | No | Feature not applicable | +| `test.fixme()` | No | Known bug, needs investigation | +| `test.fail()` | Yes | Expected to fail, tracking a bug | + +## Slow Tests + +### Mark Slow Tests + +```typescript +// Triple the default timeout +test("large data import", async ({ page }) => { + test.slow(); + + await page.goto("/import"); + await page.setInputFiles("#file", "large-file.csv"); + await page.getByRole("button", { name: "Import" }).click(); + + await expect(page.getByText("Import complete")).toBeVisible(); +}); + +// Conditional slow +test("video processing", async ({ page, browserName }) => { + test.slow(browserName === "webkit", "WebKit video processing is slow"); + + await page.goto("/video-editor"); +}); +``` + +### Custom Timeout + +```typescript +test("very long operation", async ({ page }) => { + // Set specific timeout (in milliseconds) + test.setTimeout(120000); // 2 minutes + + await page.goto("/long-operation"); +}); + +// Timeout for describe block +test.describe("Integration tests", () => { + test.describe.configure({ timeout: 60000 }); + + test("test 1", async ({ page }) => { + // Has 60 second timeout + }); +}); +``` + +## Test Steps + +### Basic Steps + +```typescript +test("checkout flow", async ({ page }) => { + await test.step("Add item to cart", async () => { + await page.goto("/products"); + await page.getByRole("button", { name: "Add to Cart" }).click(); + }); + + await test.step("Go to checkout", async () => { + await page.getByRole("link", { name: "Cart" }).click(); + await page.getByRole("button", { name: "Checkout" }).click(); + }); + + await test.step("Fill shipping info", async () => { + await page.getByLabel("Address").fill("123 Test St"); + await page.getByLabel("City").fill("Test City"); + }); + + await test.step("Complete payment", async () => { + await page.getByLabel("Card").fill("4242424242424242"); + await page.getByRole("button", { name: "Pay" }).click(); + }); + + await expect(page.getByText("Order confirmed")).toBeVisible(); +}); +``` + +### Nested Steps + +```typescript +test("user registration", async ({ page }) => { + await test.step("Fill registration form", async () => { + await page.goto("/register"); + + await test.step("Personal info", async () => { + await page.getByLabel("Name").fill("John Doe"); + await page.getByLabel("Email").fill("john@example.com"); + }); + + await test.step("Security", async () => { + await page.getByLabel("Password").fill("SecurePass123"); + await page.getByLabel("Confirm Password").fill("SecurePass123"); + }); + }); + + await test.step("Submit and verify", async () => { + await page.getByRole("button", { name: "Register" }).click(); + await expect(page.getByText("Welcome")).toBeVisible(); + }); +}); +``` + +### Steps with Return Values + +```typescript +test("verify order", async ({ page }) => { + const orderId = await test.step("Create order", async () => { + await page.goto("/checkout"); + await page.getByRole("button", { name: "Place Order" }).click(); + + // Return value from step + return await page.getByTestId("order-id").textContent(); + }); + + await test.step("Verify order details", async () => { + await page.goto(`/orders/${orderId}`); + await expect(page.getByText(`Order #${orderId}`)).toBeVisible(); + }); +}); +``` + +### Step in Page Object + +```typescript +// pages/checkout.page.ts +export class CheckoutPage { + async fillShippingInfo(address: string, city: string) { + await test.step("Fill shipping information", async () => { + await this.page.getByLabel("Address").fill(address); + await this.page.getByLabel("City").fill(city); + }); + } + + async completePayment(cardNumber: string) { + await test.step("Complete payment", async () => { + await this.page.getByLabel("Card").fill(cardNumber); + await this.page.getByRole("button", { name: "Pay" }).click(); + }); + } +} +``` + +## Custom Annotations + +### Add Annotations + +```typescript +test("important feature", async ({ page }, testInfo) => { + // Add custom annotation + testInfo.annotations.push({ + type: "priority", + description: "high", + }); + + testInfo.annotations.push({ + type: "ticket", + description: "JIRA-123", + }); + + await page.goto("/feature"); +}); +``` + +### Annotation Fixture + +```typescript +// fixtures/annotations.fixture.ts +import { test as base, TestInfo } from "@playwright/test"; + +type AnnotationFixtures = { + annotate: { + ticket: (id: string) => void; + priority: (level: "low" | "medium" | "high") => void; + owner: (name: string) => void; + }; +}; + +export const test = base.extend({ + annotate: async ({}, use, testInfo) => { + await use({ + ticket: (id) => { + testInfo.annotations.push({ type: "ticket", description: id }); + }, + priority: (level) => { + testInfo.annotations.push({ type: "priority", description: level }); + }, + owner: (name) => { + testInfo.annotations.push({ type: "owner", description: name }); + }, + }); + }, +}); + +// Usage +test("critical feature", async ({ page, annotate }) => { + annotate.ticket("JIRA-456"); + annotate.priority("high"); + annotate.owner("Alice"); + + await page.goto("/critical"); +}); +``` + +### Read Annotations in Reporter + +```typescript +// reporters/annotation-reporter.ts +import { Reporter, TestCase, TestResult } from "@playwright/test/reporter"; + +class AnnotationReporter implements Reporter { + onTestEnd(test: TestCase, result: TestResult) { + const ticket = test.annotations.find((a) => a.type === "ticket"); + const priority = test.annotations.find((a) => a.type === "priority"); + + if (ticket) { + console.log(`Test linked to: ${ticket.description}`); + } + + if (priority?.description === "high" && result.status === "failed") { + console.log(`HIGH PRIORITY FAILURE: ${test.title}`); + } + } +} + +export default AnnotationReporter; +``` + +## Conditional Annotations + +### Annotation Helper + +```typescript +// helpers/test-annotations.ts +import { test } from "@playwright/test"; + +export function skipInCI(reason = "Skipped in CI") { + test.skip(!!process.env.CI, reason); +} + +export function skipInBrowser(browser: string, reason: string) { + test.beforeEach(({ browserName }) => { + test.skip(browserName === browser, reason); + }); +} + +export function onlyInEnv(env: string) { + test.skip(process.env.ENV !== env, `Only runs in ${env}`); +} +``` + +```typescript +// tests/feature.spec.ts +import { skipInCI, onlyInEnv } from "../helpers/test-annotations"; + +test("local only feature", async ({ page }) => { + skipInCI("Uses local resources"); + + await page.goto("/local-feature"); +}); + +test("production check", async ({ page }) => { + onlyInEnv("production"); + + await page.goto("/prod-only"); +}); +``` + +### Describe-Level Conditions + +```typescript +test.describe("Mobile features", () => { + test.beforeEach(({ isMobile }) => { + test.skip(!isMobile, "Mobile only tests"); + }); + + test("touch gestures", async ({ page }) => { + // Only runs on mobile + }); +}); + +test.describe("Desktop features", () => { + test.beforeEach(({ isMobile }) => { + test.skip(isMobile, "Desktop only tests"); + }); + + test("hover interactions", async ({ page }) => { + // Only runs on desktop + }); +}); +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| --------------------------- | ---------------------- | -------------------------------- | +| Skipping without reason | Hard to track why | Always provide description | +| Too many skipped tests | Test debt accumulates | Review and clean up regularly | +| Using skip instead of fixme | Loses intent | Use fixme for bugs, skip for N/A | +| Not using steps | Hard to debug failures | Group logical actions in steps | + +## Related References + +- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep` +- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests +- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/assertions-waiting.md b/plugins/software-delivery/skills/playwright-best-practices/core/assertions-waiting.md new file mode 100644 index 0000000..bd03dd8 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/assertions-waiting.md @@ -0,0 +1,361 @@ +# Assertions & Waiting + +## Table of Contents + +1. [Web-First Assertions](#web-first-assertions) +2. [Generic Assertions](#generic-assertions) +3. [Soft Assertions](#soft-assertions) +4. [Waiting Strategies](#waiting-strategies) +5. [Polling & Retrying](#polling--retrying) +6. [Custom Matchers](#custom-matchers) + +## Web-First Assertions + +Auto-retry until condition is met or timeout. Always prefer these over generic assertions. + +### Locator Assertions + +```typescript +import { expect } from "@playwright/test"; + +// Visibility +await expect(page.getByRole("button")).toBeVisible(); +await expect(page.getByRole("button")).toBeHidden(); +await expect(page.getByRole("button")).not.toBeVisible(); + +// Enabled/Disabled +await expect(page.getByRole("button")).toBeEnabled(); +await expect(page.getByRole("button")).toBeDisabled(); + +// Text content +await expect(page.getByRole("heading")).toHaveText("Welcome"); +await expect(page.getByRole("heading")).toHaveText(/welcome/i); +await expect(page.getByRole("heading")).toContainText("Welcome"); + +// Count +await expect(page.getByRole("listitem")).toHaveCount(5); + +// Attributes +await expect(page.getByRole("link")).toHaveAttribute("href", "/home"); +await expect(page.getByRole("img")).toHaveAttribute("alt", /logo/i); + +// CSS +await expect(page.getByRole("button")).toHaveClass(/primary/); +await expect(page.getByRole("button")).toHaveCSS("color", "rgb(0, 0, 255)"); + +// Input values +await expect(page.getByLabel("Email")).toHaveValue("user@example.com"); +await expect(page.getByLabel("Email")).toBeEmpty(); + +// Focus +await expect(page.getByLabel("Email")).toBeFocused(); + +// Checked state +await expect(page.getByRole("checkbox")).toBeChecked(); +await expect(page.getByRole("checkbox")).not.toBeChecked(); + +// Editable state +await expect(page.getByLabel("Name")).toBeEditable(); +``` + +### Page Assertions + +```typescript +// URL +await expect(page).toHaveURL("/dashboard"); +await expect(page).toHaveURL(/\/dashboard/); + +// Title +await expect(page).toHaveTitle("Dashboard - MyApp"); +await expect(page).toHaveTitle(/dashboard/i); +``` + +### Response Assertions + +```typescript +const response = await page.request.get("/api/users"); +await expect(response).toBeOK(); +await expect(response).not.toBeOK(); +``` + +## Generic Assertions + +Use for non-UI values. Do NOT retry - execute immediately. + +```typescript +// Equality +expect(value).toBe(5); +expect(object).toEqual({ name: "Test" }); +expect(array).toContain("item"); + +// Truthiness +expect(value).toBeTruthy(); +expect(value).toBeFalsy(); +expect(value).toBeNull(); +expect(value).toBeUndefined(); +expect(value).toBeDefined(); + +// Numbers +expect(value).toBeGreaterThan(5); +expect(value).toBeLessThanOrEqual(10); +expect(value).toBeCloseTo(5.5, 1); + +// Strings +expect(string).toMatch(/pattern/); +expect(string).toContain("substring"); + +// Arrays/Objects +expect(array).toHaveLength(3); +expect(object).toHaveProperty("key", "value"); + +// Exceptions +expect(() => fn()).toThrow(); +expect(() => fn()).toThrow("error message"); +await expect(asyncFn()).rejects.toThrow(); +``` + +## Soft Assertions + +Continue test execution after failure, report all failures at end. + +```typescript +test("check multiple elements", async ({ page }) => { + await page.goto("/dashboard"); + + // Won't stop on first failure + await expect.soft(page.getByRole("heading")).toHaveText("Dashboard"); + await expect.soft(page.getByRole("button", { name: "Save" })).toBeEnabled(); + await expect.soft(page.getByText("Welcome")).toBeVisible(); + + // Test continues; all failures reported at end +}); +``` + +### Soft Assertions with Early Exit + +```typescript +test("check form", async ({ page }) => { + await expect.soft(page.getByRole("form")).toBeVisible(); + + // Exit early if form not visible (pointless to check fields) + if (expect.soft.hasFailures()) { + return; + } + + await expect.soft(page.getByLabel("Name")).toBeVisible(); + await expect.soft(page.getByLabel("Email")).toBeVisible(); +}); +``` + +## Waiting Strategies + +### Auto-Waiting (Default) + +Actions automatically wait for: + +- Element to be attached to DOM +- Element to be visible +- Element to be stable (no animations) +- Element to be enabled +- Element to receive events + +```typescript +// These auto-wait +await page.click("button"); +await page.fill("input", "text"); +await page.getByRole("button").click(); +``` + +### Wait for Navigation + +```typescript +// Wait for URL change +await page.waitForURL("/dashboard"); +await page.waitForURL(/\/dashboard/); + +// Wait for navigation after action +await Promise.all([ + page.waitForURL("**/dashboard"), + page.click('a[href="/dashboard"]'), +]); + +// Or without Promise.all +const urlPromise = page.waitForURL("**/dashboard"); +await page.click("a"); +await urlPromise; +``` + +### Wait for Network + +```typescript +// Wait for specific response +const responsePromise = page.waitForResponse("**/api/users"); +await page.click("button"); +const response = await responsePromise; +expect(response.status()).toBe(200); + +// Wait for request +const requestPromise = page.waitForRequest("**/api/submit"); +await page.click("button"); +const request = await requestPromise; + +// Wait for no network activity +await page.waitForLoadState("networkidle"); +``` + +### Wait for Element State + +```typescript +// Wait for element to appear +await page.getByRole("dialog").waitFor({ state: "visible" }); + +// Wait for element to disappear +await page.getByText("Loading...").waitFor({ state: "hidden" }); + +// Wait for element to be attached +await page.getByTestId("result").waitFor({ state: "attached" }); + +// Wait for element to be detached +await page.getByTestId("modal").waitFor({ state: "detached" }); +``` + +### Wait for Function + +```typescript +// Wait for arbitrary condition +await page.waitForFunction(() => { + return document.querySelector(".loaded") !== null; +}); + +// With arguments +await page.waitForFunction( + (selector) => document.querySelector(selector)?.textContent === "Ready", + ".status", +); +``` + +## Polling & Retrying + +### toPass() for Polling + +Retry until block passes or times out: + +```typescript +await expect(async () => { + const response = await page.request.get("/api/status"); + expect(response.status()).toBe(200); + + const data = await response.json(); + expect(data.ready).toBe(true); +}).toPass({ + intervals: [1000, 2000, 5000], // Retry intervals + timeout: 30000, +}); +``` + +### expect.poll() + +Poll a function until assertion passes: + +```typescript +// Poll API until condition met +await expect + .poll( + async () => { + const response = await page.request.get("/api/job/123"); + return (await response.json()).status; + }, + { + intervals: [1000, 2000, 5000], + timeout: 30000, + }, + ) + .toBe("completed"); + +// Poll DOM value +await expect.poll(() => page.getByTestId("counter").textContent()).toBe("10"); +``` + +## Custom Matchers + +```typescript +// playwright.config.ts or fixtures +import { expect } from "@playwright/test"; + +expect.extend({ + async toHaveDataLoaded(page: Page) { + const locator = page.getByTestId("data-container"); + let pass = false; + let message = ""; + + try { + await expect(locator).toBeVisible(); + await expect(locator).not.toContainText("Loading"); + pass = true; + } catch (e) { + message = `Expected data to be loaded but found loading state`; + } + + return { pass, message: () => message }; + }, +}); + +// Extend TypeScript types +declare global { + namespace PlaywrightTest { + interface Matchers { + toHaveDataLoaded(): Promise; + } + } +} + +// Usage +await expect(page).toHaveDataLoaded(); +``` + +## Timeouts + +### Configure Timeouts + +```typescript +// playwright.config.ts +export default defineConfig({ + timeout: 30000, // Test timeout + expect: { + timeout: 5000, // Assertion timeout + }, +}); + +// Per-test timeout +test("long test", async ({ page }) => { + test.setTimeout(60000); + // ... +}); + +// Per-assertion timeout +await expect(page.getByRole("button")).toBeVisible({ timeout: 10000 }); +``` + +## Best Practices + +| Do | Don't | +| ------------------------------ | ------------------------------ | +| Use web-first assertions | Use generic assertions for DOM | +| Let auto-waiting work | Add unnecessary explicit waits | +| Use `toPass()` for polling | Write manual retry loops | +| Configure appropriate timeouts | Use `waitForTimeout()` | +| Check specific conditions | Wait for arbitrary time | + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- | +| `await page.waitForTimeout(5000)` | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse` | +| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above | Use `waitForResponse` or element state waits | +| Generic assertions on DOM elements | No auto-retry, flaky | Use web-first assertions with `expect()` | + +## Related References + +- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting +- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions +- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/configuration.md b/plugins/software-delivery/skills/playwright-best-practices/core/configuration.md new file mode 100644 index 0000000..66b9d33 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/configuration.md @@ -0,0 +1,452 @@ +# Playwright Configuration + +## Table of Contents + +1. [CLI Quick Reference](#cli-quick-reference) +2. [Decision Guide](#decision-guide) +3. [Production-Ready Config](#production-ready-config) +4. [Patterns](#patterns) +5. [Anti-Patterns](#anti-patterns) +6. [Troubleshooting](#troubleshooting) +7. [Related](#related) + +> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings. + +## CLI Quick Reference + +```bash +npx playwright init # scaffold config + first test +npx playwright test --config=custom.config.ts # use alternate config +npx playwright test --project=chromium # run single project +npx playwright test --reporter=html # override reporter +npx playwright test --grep @smoke # run tests tagged @smoke +npx playwright test --grep-invert @slow # exclude @slow tests +npx playwright show-report # open last HTML report +DEBUG=pw:api npx playwright test # verbose logging +``` + +## Decision Guide + +### Timeout Selection + +| Symptom | Setting | Default | Recommended | +|---------|---------|---------|-------------| +| Test takes too long overall | `timeout` | 30s | 30-60s (max 120s) | +| Assertion retries too long/short | `expect.timeout` | 5s | 5-10s | +| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s | 10-30s | +| `click()`, `fill()` time out | `actionTimeout` | 0 (unlimited) | 10-15s | +| Dev server slow to start | `webServer.timeout` | 60s | 60-180s | + +### Server Management + +| Scenario | Approach | +|----------|----------| +| App in same repo | `webServer` with `reuseExistingServer: !process.env.CI` | +| Separate repos | Manual start or Docker Compose | +| Testing deployed environment | No `webServer`; set `baseURL` via env | +| Multiple services | Array of `webServer` entries | + +### Single vs Multi-Project + +| Scenario | Approach | +|----------|----------| +| Early development | Single project (chromium only) | +| Pre-release validation | Multi-project: chromium + firefox + webkit | +| Mobile-responsive app | Add mobile projects alongside desktop | +| Auth + non-auth tests | Setup project with dependencies | +| Tight CI budget | Chromium on PRs; all browsers on main | + +### globalSetup vs Setup Projects vs Fixtures + +| Need | Use | +|------|-----| +| One-time DB seed | `globalSetup` | +| Shared browser auth | Setup project with `dependencies` | +| Per-test isolated state | Custom fixture via `test.extend()` | +| Cleanup after all tests | `globalTeardown` | + +## Production-Ready Config + +```ts +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.spec.ts', + + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? '50%' : undefined, + + reporter: process.env.CI + ? [['html', { open: 'never' }], ['github']] + : [['html', { open: 'on-failure' }]], + + timeout: 30_000, + expect: { timeout: 5_000 }, + + use: { + baseURL: process.env.BASE_URL || 'http://localhost:4000', + actionTimeout: 10_000, + navigationTimeout: 15_000, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + locale: 'en-US', + timezoneId: 'America/Los_Angeles', + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } }, + { name: 'mobile-safari', use: { ...devices['iPhone 14'] } }, + ], + + webServer: { + command: 'npm run start', + url: 'http://localhost:4000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); +``` + +## Patterns + +### Environment-Specific Configuration + +**Use when**: Tests run against dev, staging, and production environments. + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; + +const ENV = process.env.TEST_ENV || 'local'; +dotenv.config({ path: path.resolve(__dirname, `.env.${ENV}`) }); + +const envConfig: Record = { + local: { baseURL: 'http://localhost:4000', retries: 0 }, + staging: { baseURL: 'https://staging.myapp.com', retries: 2 }, + prod: { baseURL: 'https://myapp.com', retries: 2 }, +}; + +export default defineConfig({ + testDir: './e2e', + retries: envConfig[ENV].retries, + use: { baseURL: envConfig[ENV].baseURL }, +}); +``` + +```bash +TEST_ENV=staging npx playwright test +TEST_ENV=prod npx playwright test --grep @smoke +``` + +### Setup Project with Dependencies + +**Use when**: Tests need shared authentication state before running. + +```ts +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + projects: [ + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/session.json', + }, + dependencies: ['setup'], + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + storageState: 'playwright/.auth/session.json', + }, + dependencies: ['setup'], + }, + ], +}); +``` + +```ts +// e2e/auth.setup.ts +import { test as setup, expect } from '@playwright/test'; + +const authFile = 'playwright/.auth/session.json'; + +setup('authenticate', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Username').fill('testuser@example.com'); + await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible(); + await page.context().storageState({ path: authFile }); +}); +``` + +### webServer with Build Step + +**Use when**: Tests need a running application server managed by Playwright. + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + use: { baseURL: 'http://localhost:4000' }, + webServer: { + command: process.env.CI + ? 'npm run build && npm run preview' + : 'npm run dev', + url: 'http://localhost:4000', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + NODE_ENV: 'test', + DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb', + }, + }, +}); +``` + +### globalSetup / globalTeardown + +**Use when**: One-time non-browser work like seeding a database. Runs once per test run. + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/setup.ts', + globalTeardown: './e2e/teardown.ts', +}); +``` + +```ts +// e2e/setup.ts +import { FullConfig } from '@playwright/test'; + +export default async function globalSetup(config: FullConfig) { + const { execSync } = await import('child_process'); + execSync('npx prisma db seed', { stdio: 'inherit' }); + process.env.TEST_RUN_ID = `run-${Date.now()}`; +} +``` + +```ts +// e2e/teardown.ts +import { FullConfig } from '@playwright/test'; + +export default async function globalTeardown(config: FullConfig) { + const { execSync } = await import('child_process'); + execSync('npx prisma db push --force-reset', { stdio: 'inherit' }); +} +``` + +### Environment Variables with .env + +**Use when**: Managing secrets, URLs, or feature flags without hardcoding. + +```bash +# .env.example (commit this) +BASE_URL=http://localhost:4000 +TEST_PASSWORD= +API_KEY= + +# .env.local (gitignored) +BASE_URL=http://localhost:4000 +TEST_PASSWORD=secret123 +API_KEY=dev-key-abc + +# .env.staging (gitignored) +BASE_URL=https://staging.myapp.com +TEST_PASSWORD=staging-pass +API_KEY=staging-key-xyz +``` + +```bash +# .gitignore +.env +.env.local +.env.staging +.env.production +playwright/.auth/ +``` + +Install dotenv: + +```bash +npm install -D dotenv +``` + +### Tag-Based Test Filtering + +**Use when**: Running subsets of tests in different CI stages (PR vs nightly). + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + + // Filter by tags in CI + grep: process.env.CI ? /@smoke|@critical/ : undefined, + grepInvert: process.env.CI ? /@flaky/ : undefined, +}); +``` + +**Project-specific filtering:** + +```ts +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + projects: [ + { + name: 'smoke', + grep: /@smoke/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'regression', + grepInvert: /@smoke/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'critical-only', + grep: /@critical/, + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); +``` + +```bash +# Run specific project +npx playwright test --project=smoke +npx playwright test --project=regression +``` + +### Artifact Collection Strategy + +| Setting | Local | CI | Reason | +|---------|-------|-----|--------| +| `trace` | `'off'` | `'on-first-retry'` | Traces are large; collect on failure only | +| `screenshot` | `'off'` | `'only-on-failure'` | Useful for CI debugging | +| `video` | `'off'` | `'retain-on-failure'` | Recording slows tests | + +```ts +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + use: { + trace: process.env.CI ? 'on-first-retry' : 'off', + screenshot: process.env.CI ? 'only-on-failure' : 'off', + video: process.env.CI ? 'retain-on-failure' : 'off', + }, +}); +``` + +## Anti-Patterns + +| Don't | Problem | Do Instead | +|-------|---------|------------| +| `timeout: 300_000` globally | Masks flaky tests; slow CI | Fix root cause; keep 30s default | +| Hardcoded URLs: `page.goto('http://localhost:4000/login')` | Breaks in other environments | Use `baseURL` + relative paths | +| All browsers on every PR | 3x CI time | Chromium on PRs; all on main | +| `trace: 'on'` always | Huge artifacts, slow uploads | `trace: 'on-first-retry'` | +| `video: 'on'` always | Massive storage; slow tests | `video: 'retain-on-failure'` | +| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent | Define once in project config | +| `retries: 3` locally | Hides flakiness | `retries: 0` local, `retries: 2` CI | +| No `forbidOnly` in CI | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI` | +| `globalSetup` for browser auth | No browser context available | Use setup project with dependencies | +| Committing `.env` with credentials | Security risk | Commit `.env.example` only | + +## Troubleshooting + +### baseURL Not Working + +**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`. + +```ts +// Wrong - ignores baseURL +await page.goto('http://localhost:4000/dashboard'); + +// Correct - uses baseURL +await page.goto('/dashboard'); +``` + +### webServer Starts But Tests Get Connection Refused + +**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200. + +```ts +webServer: { + command: 'npm run dev', + url: 'http://localhost:4000/api/health', // use real endpoint + reuseExistingServer: !process.env.CI, + timeout: 120_000, +}, +``` + +### Tests Pass Locally But Timeout in CI + +**Cause**: CI machines are slower. Increase timeouts and reduce workers: + +```ts +export default defineConfig({ + workers: process.env.CI ? '50%' : undefined, + use: { + navigationTimeout: process.env.CI ? 30_000 : 15_000, + actionTimeout: process.env.CI ? 15_000 : 10_000, + }, +}); +``` + +### "Target page, context or browser has been closed" + +**Cause**: Test exceeded `timeout` and Playwright tore down browser during action. + +**Fix**: Don't increase global timeout. Find slow step using trace: + +```bash +npx playwright test --trace on +npx playwright show-report +``` + +## Related + +- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep` +- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state +- [test-suite-structure.md](test-suite-structure.md) - file structure and naming +- [authentication.md](../advanced/authentication.md) - setup projects for shared auth +- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/fixtures-hooks.md b/plugins/software-delivery/skills/playwright-best-practices/core/fixtures-hooks.md new file mode 100644 index 0000000..ff9dc93 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/fixtures-hooks.md @@ -0,0 +1,417 @@ +# Fixtures & Hooks + +## Table of Contents + +1. [Built-in Fixtures](#built-in-fixtures) +2. [Custom Fixtures](#custom-fixtures) +3. [Fixture Scopes](#fixture-scopes) +4. [Hooks](#hooks) +5. [Authentication Patterns](#authentication-patterns) +6. [Database Fixtures](#database-fixtures) + +## Built-in Fixtures + +### Core Fixtures + +```typescript +test("example", async ({ + page, // Isolated page instance + context, // Browser context (cookies, localStorage) + browser, // Browser instance + browserName, // 'chromium', 'firefox', or 'webkit' + request, // API request context +}) => { + // Each test gets fresh instances +}); +``` + +### Request Fixture + +```typescript +test("API call", async ({ request }) => { + const response = await request.get("/api/users"); + await expect(response).toBeOK(); + + const users = await response.json(); + expect(users).toHaveLength(5); +}); +``` + +## Custom Fixtures + +### Basic Custom Fixture + +```typescript +// fixtures.ts +import { test as base } from "@playwright/test"; + +// Declare fixture types +type MyFixtures = { + todoPage: TodoPage; + apiClient: ApiClient; +}; + +export const test = base.extend({ + // Fixture with setup and teardown + todoPage: async ({ page }, use) => { + const todoPage = new TodoPage(page); + await todoPage.goto(); + + await use(todoPage); // Test runs here + + // Teardown (optional) + await todoPage.clearTodos(); + }, + + // Simple fixture + apiClient: async ({ request }, use) => { + await use(new ApiClient(request)); + }, +}); + +export { expect } from "@playwright/test"; +``` + +### Fixture with Options + +```typescript +type Options = { + defaultUser: { email: string; password: string }; +}; + +type Fixtures = { + authenticatedPage: Page; +}; + +export const test = base.extend({ + // Define option with default + defaultUser: [ + { email: "test@example.com", password: "pass123" }, + { option: true }, + ], + + // Use option in fixture + authenticatedPage: async ({ page, defaultUser }, use) => { + await page.goto("/login"); + await page.getByLabel("Email").fill(defaultUser.email); + await page.getByLabel("Password").fill(defaultUser.password); + await page.getByRole("button", { name: "Sign in" }).click(); + await use(page); + }, +}); + +// Override in config +export default defineConfig({ + use: { + defaultUser: { email: "admin@example.com", password: "admin123" }, + }, +}); +``` + +### Automatic Fixtures + +```typescript +export const test = base.extend<{}, { setupDb: void }>({ + // Auto-fixture runs for every test without explicit usage + setupDb: [ + async ({}, use) => { + await seedDatabase(); + await use(); + await cleanDatabase(); + }, + { auto: true }, + ], +}); +``` + +## Fixture Scopes + +### Test Scope (Default) + +Created fresh for each test: + +```typescript +test.extend({ + page: async ({ browser }, use) => { + const page = await browser.newPage(); + await use(page); + await page.close(); + }, +}); +``` + +### Worker Scope + +Shared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it): + +```typescript +type WorkerFixtures = { + sharedAccount: Account; +}; + +export const test = base.extend<{}, WorkerFixtures>({ + sharedAccount: [ + async ({ browser }, use) => { + // Expensive setup - runs once per worker + const account = await createTestAccount(); + await use(account); + await deleteTestAccount(account); + }, + { scope: "worker" }, + ], +}); +``` + +### Isolate test data between parallel workers + +When tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker: + +```typescript +import { test as baseTest } from "@playwright/test"; + +type WorkerFixtures = { + dbUserName: string; +}; + +export const test = baseTest.extend<{}, WorkerFixtures>({ + dbUserName: [ + async ({}, use, testInfo) => { + const userName = `user-${testInfo.workerIndex}`; + await createUserInTestDatabase(userName); + await use(userName); + await deleteUserFromTestDatabase(userName); + }, + { scope: "worker" }, + ], +}); +``` + +Then each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each other’s data. + +## Hooks + +### beforeEach / afterEach + +```typescript +test.beforeEach(async ({ page }) => { + // Runs before each test in file + await page.goto("/"); +}); + +test.afterEach(async ({ page }, testInfo) => { + // Runs after each test + if (testInfo.status !== "passed") { + await page.screenshot({ path: `failed-${testInfo.title}.png` }); + } +}); +``` + +### beforeAll / afterAll + +```typescript +test.beforeAll(async ({ browser }) => { + // Runs once before all tests in file + // Note: Cannot use page fixture here +}); + +test.afterAll(async () => { + // Runs once after all tests in file +}); +``` + +### Describe-Level Hooks + +```typescript +test.describe("User Management", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/users"); + }); + + test("can list users", async ({ page }) => { + // Starts at /users + }); + + test("can add user", async ({ page }) => { + // Starts at /users + }); +}); +``` + +## Authentication Patterns + +### Global Setup with Storage State + +```typescript +// auth.setup.ts +import { test as setup, expect } from "@playwright/test"; + +const authFile = ".auth/user.json"; + +setup("authenticate", async ({ page }) => { + await page.goto("/login"); + await page.getByLabel("Email").fill(process.env.TEST_EMAIL!); + await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!); + await page.getByRole("button", { name: "Sign in" }).click(); + + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await page.context().storageState({ path: authFile }); +}); +``` + +```typescript +// playwright.config.ts +export default defineConfig({ + projects: [ + { name: "setup", testMatch: /.*\.setup\.ts/ }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: ".auth/user.json", + }, + dependencies: ["setup"], + }, + ], +}); +``` + +### Multiple Auth States + +```typescript +// auth.setup.ts +setup("admin auth", async ({ page }) => { + await login(page, "admin@example.com", "adminpass"); + await page.context().storageState({ path: ".auth/admin.json" }); +}); + +setup("user auth", async ({ page }) => { + await login(page, "user@example.com", "userpass"); + await page.context().storageState({ path: ".auth/user.json" }); +}); +``` + +```typescript +// playwright.config.ts +projects: [ + { + name: "admin tests", + testMatch: /.*admin.*\.spec\.ts/, + use: { storageState: ".auth/admin.json" }, + dependencies: ["setup"], + }, + { + name: "user tests", + testMatch: /.*user.*\.spec\.ts/, + use: { storageState: ".auth/user.json" }, + dependencies: ["setup"], + }, +]; +``` + +### Auth Fixture + +```typescript +// fixtures/auth.fixture.ts +export const test = base.extend<{ adminPage: Page; userPage: Page }>({ + adminPage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: ".auth/admin.json", + }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, + + userPage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: ".auth/user.json", + }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, +}); +``` + +## Database Fixtures + +This section covers **per-test database fixtures** (isolation, transaction rollback). For related topics: + +- **Test data factories** (builders, Faker): See [test-data.md](test-data.md) +- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns) + +### Transaction Rollback Pattern + +```typescript +import { test as base } from "@playwright/test"; +import { db } from "../db"; + +export const test = base.extend<{ dbTransaction: Transaction }>({ + dbTransaction: async ({}, use) => { + const transaction = await db.beginTransaction(); + + await use(transaction); + + await transaction.rollback(); // Clean slate for next test + }, +}); +``` + +### Seed Data Fixture + +```typescript +type TestData = { + testUser: User; + testProducts: Product[]; +}; + +export const test = base.extend({ + testUser: async ({}, use) => { + const user = await db.users.create({ + email: `test-${Date.now()}@example.com`, + name: "Test User", + }); + + await use(user); + + await db.users.delete(user.id); + }, + + testProducts: async ({ testUser }, use) => { + const products = await db.products.createMany([ + { name: "Product A", ownerId: testUser.id }, + { name: "Product B", ownerId: testUser.id }, + ]); + + await use(products); + + await db.products.deleteMany(products.map((p) => p.id)); + }, +}); +``` + +## Fixture Tips + +| Tip | Explanation | +| ------------------ | ------------------------------------------- | +| Fixtures are lazy | Only created when used | +| Compose fixtures | Use other fixtures as dependencies | +| Keep setup minimal | Do heavy lifting in worker-scoped fixtures | +| Clean up resources | Use teardown in fixtures, not afterEach | +| Avoid shared state | Each fixture instance should be independent | + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Shared mutable state between tests | Race conditions, order dependencies | Use fixtures for isolation | +| Global variables in tests | Tests depend on execution order | Use fixtures or beforeEach for setup | +| Not cleaning up test data | Tests interfere with each other | Use fixtures with teardown or database transactions | +| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed | +| Backend/DB state shared across workers | Tests in different workers collide on same data | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker | + +## Related References + +- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns +- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure +- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/global-setup.md b/plugins/software-delivery/skills/playwright-best-practices/core/global-setup.md new file mode 100644 index 0000000..a033522 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/global-setup.md @@ -0,0 +1,434 @@ +# Global Setup & Teardown + +## Table of Contents + +1. [Global Setup](#global-setup) +2. [Global Teardown](#global-teardown) +3. [Database Patterns](#database-patterns) +4. [Environment Provisioning](#environment-provisioning) +5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup) +6. [Parallel Execution Caveats](#parallel-execution-caveats) + +## Global Setup + +### Basic Global Setup + +```typescript +// global-setup.ts +import { FullConfig } from "@playwright/test"; + +async function globalSetup(config: FullConfig) { + console.log("Running global setup..."); + // Perform one-time setup: start services, run migrations, etc. +} + +export default globalSetup; +``` + +### Configure Global Setup + +```typescript +// playwright.config.ts +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + globalSetup: require.resolve("./global-setup"), + globalTeardown: require.resolve("./global-teardown"), +}); +``` + +> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures. + +### Global Setup with Return Value + +```typescript +// global-setup.ts +async function globalSetup(config: FullConfig): Promise<() => Promise> { + const server = await startTestServer(); + + // Return cleanup function (alternative to globalTeardown) + return async () => { + await server.stop(); + }; +} + +export default globalSetup; +``` + +### Access Config in Global Setup + +```typescript +// global-setup.ts +import { FullConfig } from "@playwright/test"; + +async function globalSetup(config: FullConfig) { + const { baseURL } = config.projects[0].use; + console.log(`Setting up for ${baseURL}`); + + // Access custom config + const workers = config.workers; + const timeout = config.timeout; + + // Access environment + const isCI = !!process.env.CI; +} + +export default globalSetup; +``` + +## Global Teardown + +### Basic Global Teardown + +```typescript +// global-teardown.ts +import { FullConfig } from "@playwright/test"; +import fs from "fs"; + +async function globalTeardown(config: FullConfig) { + console.log("Running global teardown..."); + + // Clean up auth files + if (fs.existsSync(".auth")) { + fs.rmSync(".auth", { recursive: true }); + } + + // Clean up test data + await cleanupTestDatabase(); + + // Stop services + await stopTestServices(); +} + +export default globalTeardown; +``` + +### Conditional Teardown + +```typescript +// global-teardown.ts +async function globalTeardown(config: FullConfig) { + // Skip cleanup in CI (containers are discarded anyway) + if (process.env.CI) { + console.log("Skipping teardown in CI"); + return; + } + + // Local cleanup + await cleanupLocalTestData(); +} + +export default globalTeardown; +``` + +## Database Patterns + +This section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics: + +- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures) +- **Test data factories** (builders, Faker): See [test-data.md](test-data.md) + +### Database Migration in Setup + +```typescript +// global-setup.ts +import { execSync } from "child_process"; + +async function globalSetup() { + console.log("Running database migrations..."); + + // Run migrations + execSync("npx prisma migrate deploy", { stdio: "inherit" }); + + // Seed test data + execSync("npx prisma db seed", { stdio: "inherit" }); +} + +export default globalSetup; +``` + +### Database Snapshot Pattern + +```typescript +// global-setup.ts +import { execSync } from "child_process"; +import fs from "fs"; + +const SNAPSHOT_PATH = "./test-db-snapshot.sql"; + +async function globalSetup() { + // Check if snapshot exists + if (fs.existsSync(SNAPSHOT_PATH)) { + console.log("Restoring database from snapshot..."); + execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, { stdio: "inherit" }); + return; + } + + // First run: migrate and create snapshot + console.log("Creating database snapshot..."); + execSync("npx prisma migrate deploy", { stdio: "inherit" }); + execSync("npx prisma db seed", { stdio: "inherit" }); + execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, { stdio: "inherit" }); +} + +export default globalSetup; +``` + +### Test Database per Worker + +```typescript +// global-setup.ts +async function globalSetup(config: FullConfig) { + const workerCount = config.workers || 1; + + // Create a database for each worker + for (let i = 0; i < workerCount; i++) { + const dbName = `test_db_worker_${i}`; + await createDatabase(dbName); + await runMigrations(dbName); + await seedDatabase(dbName); + } +} + +// global-teardown.ts +async function globalTeardown(config: FullConfig) { + const workerCount = config.workers || 1; + + for (let i = 0; i < workerCount; i++) { + await dropDatabase(`test_db_worker_${i}`); + } +} +``` + +## Environment Provisioning + +### Start Services in Setup + +```typescript +// global-setup.ts +import { execSync, spawn } from "child_process"; + +let serverProcess: any; + +async function globalSetup() { + // Start backend server + serverProcess = spawn("npm", ["run", "start:test"], { + stdio: "pipe", + detached: true, + }); + + // Wait for server to be ready + await waitForServer("http://localhost:3000/health", 30000); + + // Store PID for teardown + process.env.SERVER_PID = serverProcess.pid.toString(); +} + +async function waitForServer(url: string, timeout: number) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Server not ready yet + } + await new Promise((r) => setTimeout(r, 1000)); + } + + throw new Error(`Server did not start within ${timeout}ms`); +} + +export default globalSetup; +``` + +### Docker Compose Setup + +```typescript +// global-setup.ts +import { execSync } from "child_process"; + +async function globalSetup() { + console.log("Starting Docker services..."); + + execSync("docker-compose -f docker-compose.test.yml up -d", { + stdio: "inherit", + }); + + // Wait for services to be healthy + execSync("docker-compose -f docker-compose.test.yml exec -T db pg_isready", { + stdio: "inherit", + }); +} + +export default globalSetup; +``` + +```typescript +// global-teardown.ts +import { execSync } from "child_process"; + +async function globalTeardown() { + console.log("Stopping Docker services..."); + + execSync("docker-compose -f docker-compose.test.yml down -v", { + stdio: "inherit", + }); +} + +export default globalTeardown; +``` + +### Environment Variables Setup + +```typescript +// global-setup.ts +import dotenv from "dotenv"; +import path from "path"; + +async function globalSetup() { + // Load test-specific environment + const envFile = process.env.CI ? ".env.ci" : ".env.test"; + dotenv.config({ path: path.resolve(process.cwd(), envFile) }); + + // Validate required variables + const required = ["DATABASE_URL", "API_KEY", "TEST_EMAIL"]; + for (const key of required) { + if (!process.env[key]) { + throw new Error(`Missing required environment variable: ${key}`); + } + } +} + +export default globalSetup; +``` + +## Setup Projects vs Global Setup + +### When to Use Each + +| Use Global Setup | Use Setup Projects | +| ------------------------------------- | ---------------------------------------- | +| One-time setup (migrations, services) | Per-project setup (auth states) | +| No access to Playwright fixtures | Need page, request fixtures | +| Runs once before all projects | Can run per-project or have dependencies | +| Shared across all workers | Can be parallelized | + +### Setup Project Pattern + +```typescript +// playwright.config.ts +export default defineConfig({ + projects: [ + // Setup project + { + name: "setup", + testMatch: /.*\.setup\.ts/, + }, + // Test projects depend on setup + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + dependencies: ["setup"], + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + dependencies: ["setup"], + }, + ], +}); +``` + +> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). + +### Combining Both + +```typescript +// playwright.config.ts +export default defineConfig({ + // Global: Start services, run migrations + globalSetup: require.resolve("./global-setup"), + globalTeardown: require.resolve("./global-teardown"), + + projects: [ + // Setup project: Create auth states + { name: "setup", testMatch: /.*\.setup\.ts/ }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: ".auth/user.json", + }, + dependencies: ["setup"], + }, + ], +}); +``` + +## Parallel Execution Caveats + +### Understanding Global Setup Execution + +``` +┌─────────────────────────────────────────────────────────────┐ +│ globalSetup runs ONCE │ +│ ↓ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Worker 1│ │ Worker 2│ │ Worker 3│ │ Worker 4│ │ +│ │ tests │ │ tests │ │ tests │ │ tests │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ ↓ │ +│ globalTeardown runs ONCE │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key implications:** + +- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`) +- State created in global setup is **shared** across all workers +- If tests **modify** shared state, they may conflict with parallel workers +- Global setup **cannot** react to individual test needs + +### When to Prefer Worker-Scoped Fixtures + +Use **worker-scoped fixtures** instead of globalSetup when: + +| Scenario | Why Fixtures Are Better | +| ------------------------------------ | ---------------------------------------------------- | +| Each worker needs isolated resources | Fixtures can create per-worker databases, servers | +| Setup needs Playwright APIs | Fixtures have access to `page`, `request`, `browser` | +| Setup depends on test configuration | Fixtures receive test context and options | +| Resources need cleanup per worker | Worker fixtures auto-cleanup when worker exits | + +### Common Parallel Pitfall + +```typescript +// ❌ BAD: Global setup creates ONE user, all workers fight over it +async function globalSetup() { + await createUser({ email: "test@example.com" }); // Shared! +} + +// ✅ GOOD: Each worker gets its own user via worker-scoped fixture +// Uses workerInfo.workerIndex to create unique data per worker +``` + +> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers). + +## Anti-Patterns to Avoid + +| Anti-Pattern | Problem | Solution | +| ------------------------------ | -------------------------------- | ------------------------------------------ | +| Heavy setup in globalSetup | Slow test startup | Use setup projects for parallelizable work | +| Not cleaning up in teardown | Leaks resources, flaky CI | Always clean up or use containers | +| Hardcoded URLs in setup | Breaks in different environments | Use config.projects[0].use.baseURL | +| No timeout on service wait | Hangs forever if service fails | Add timeout with clear error | +| Shared mutable state | Race conditions in parallel | Use worker-scoped fixtures for isolation | +| Global setup for per-test data | Tests conflict | Use test-scoped fixtures | + +## Related References + +- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns +- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns +- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration diff --git a/plugins/software-delivery/skills/playwright-best-practices/core/locators.md b/plugins/software-delivery/skills/playwright-best-practices/core/locators.md new file mode 100644 index 0000000..f806635 --- /dev/null +++ b/plugins/software-delivery/skills/playwright-best-practices/core/locators.md @@ -0,0 +1,242 @@ +# Locator Strategies + +## Table of Contents + +1. [Priority Order](#priority-order) +2. [User-Facing Locators](#user-facing-locators) +3. [Filtering & Chaining](#filtering--chaining) +4. [Dynamic Content](#dynamic-content) +5. [Shadow DOM](#shadow-dom) +6. [Iframes](#iframes) + +## Priority Order + +Use locators in this order of preference: + +1. **Role-based** (most resilient): `getByRole` +2. **Label-based**: `getByLabel`, `getByPlaceholder` +3. **Text-based**: `getByText`, `getByTitle` +4. **Test IDs** (when semantic locators aren't possible): `getByTestId` +5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')` + +## User-Facing Locators + +### getByRole + +Most robust approach - matches how users and assistive technology perceive the page. + +```typescript +// Buttons +page.getByRole("button", { name: "Submit", exact: true }); // exact accessible name +page.getByRole("button", { name: /submit/i }); // flexible case-insensitive match + +// Links +page.getByRole("link", { name: "Home" }); + +// Form elements +page.getByRole("textbox", { name: "Email" }); +page.getByRole("checkbox", { name: "Remember me" }); +page.getByRole("combobox", { name: "Country" }); +page.getByRole("radio", { name: "Option A" }); + +// Headings +page.getByRole("heading", { name: "Welcome", level: 1 }); + +// Lists & items +page.getByRole("list").getByRole("listitem"); + +// Navigation & regions +page.getByRole("navigation"); +page.getByRole("main"); +page.getByRole("dialog"); +page.getByRole("alert"); +``` + +### getByLabel + +For form elements with associated labels. + +```typescript +// Input with
+``` + +Nothing to call and no ordering to get right. A marked element is attached as +soon as its composition's timeline is registered, and the registry is also +polled for about eight seconds after load, so a composition that mounts +asynchronously is picked up too. An element takes the timeline registered under +the `data-composition-id` of its nearest ancestor carrying that attribute, so a +sub-composition's targets follow that sub-composition. + +Empty attribute means defaults. Any other value must parse as a JSON object, so +it needs double quotes on the keys. A value that parses to something else, +`null` or a bare number, warns and is skipped rather than quietly taken as +defaults. + +`attachMotionBlur(target, timeline, options)` is for an element created later +than that window. It has an ordering contract, after every tween so the +timeline's final duration is known, and getting it wrong is silent. For markup +that is already in the document, use the attribute. + +## Options + +| Option | Default | Meaning | +| ----------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `shutterAngle` | 720 | Degrees of the frame interval the shutter is open. 720 is two frames, measured off a real After Effects export. 360 is one frame. 0 disables the smear | +| `shutterPhase` | -360 | Degrees the window start sits from the frame time. -360 centres the window on the frame | +| `samplesPerFrame` | 16 | Sub-intervals of the window, so this many plus one copies, each at 1 over this many opacity. Max 64 | +| `fps` | the `data-fps` of the target's own composition root, else 30 | Composition frame rate. Pass it explicitly when rendering with an fps override | + +A key that is none of these four, and a value that is not a finite number, are +both refused by name rather than read as defaults. `{"shutterAngle": "720deg"}` +is a refusal, not a 720 degree shutter. + +There is no axis, no strength and no radius. The smear is the trajectory, +integrated; its extent is speed times shutter time and is not a free parameter. +A template that passes `axis`, `blurMax` or `blurScale` carries an older fork of +this snippet and its options do nothing in the current one. + +## The failure modes are all silent + +Every one of these renders a plausible-looking sharp element and reports +nothing except where noted: + +| Symptom | Cause | Fix | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Console warns that no composition registered a timeline for an element | The element is outside every `data-composition-id`, or that composition never registers a timeline | Put it inside the composition's root, or register the timeline | +| Console warns the attribute is not JSON | Single quotes, unquoted keys, a trailing comma | Double-quoted JSON, or an empty attribute for defaults | +| Console warns the attribute is not a JSON object | `null`, a bare number, a quoted string, an array | An object, or an empty attribute for defaults | +| Console warns the attribute names no such option | A misspelled key, for example `samplesperframe` | Use one of the four names above, case-sensitive | +| Console warns the attribute needs a number for an option | A quoted or unit-suffixed value, for example `"720deg"` | A bare JSON number | +| Console warns it cannot blur a target inside another target | Both an element and one of its ancestors carry the attribute | Mark one of them, the one that moves | +| Console warns one call cannot blur compositions at different frame rates | One `attachMotionBlur` call named elements in two compositions whose `data-fps` differ | One call per composition | +| Console warns a second timeline registered for an element | The same `data-composition-id` key was registered twice with different timelines; the copies still follow the first | Register once per composition | +| No smear, no warning | The beat animates `left`, `top`, `width` or `height`; the snippet reads the resolved `transform` | Animate `x`, `y`, `scale`, `rotation` | +| No smear on a container's children | The marked element does not move; its children do | Mark the elements that move | +| A smear that lags the element | A transformed ancestor is doing the moving | Move the element itself, or use the engine route | +| Blur only on the first frame, or never in a preview | The host never seeks the timeline | HyperFrames seeks every frame; a paused timeline nobody seeks shows nothing | +| A smear left behind in the old parent | The target was reparented after attaching; the group stays where it was inserted and is never moved | Do not reparent a blurred element. Animate `x`/`y` instead, or attach after the move | +| A selector stops matching after attaching | A copy keeps the element's classes, because a class rule is the only thing that can style a copy's pseudo-elements | Address the element by id or by reference, never by a class a copy also carries | + +## Cost + +Each target costs N+1 copies of its whole subtree. Those are restyled on attach +and on resize, and re-transformed every frame, and the timeline is seeked N+1 +times per frame to sample the trajectory. At the default 16 that is 17 copies +and 17 seeks per target per frame. Three marked elements is fine. Thirty is a +different render. + +Drop `samplesPerFrame` before you drop the effect: 8 halves the cost and the +staircase is still smooth on a fast beat. + +## The numbers, so you can argue with them + +The defaults are measured against a 1920x1080 30 fps After Effects export of +translating text, not chosen. In that export the outermost trailing copy sits +exactly at the previous frame's position and the outermost leading copy exactly +at the next frame's, with 8 evenly spaced copies between on each side: a window +of two frames, phase minus one frame, 16 sub-intervals. The staircase across a +stroke steps by 0.063 plus or minus 0.002 of the sharp text intensity, a flat +1/16 per copy with no taper toward the window edges. Triangle weighting scores +1.6 dB worse against the same export. + +Per-beat PSNR against that reference, sharp render versus this component: +translate +3.38 dB, scale +0.62, rotate X +0.43, rotate Y +0.84, rotate Z ++1.15. A beat that only scales or only rotates smears, which the earlier +SVG-filter stage could not do. + +## See also + +`../../registry/components/motion-blur/motion-blur.html` is the snippet and its +full header. `shutter-slam` is the same model as an installable component: the +After Effects reference case, six beats, elastic to the container. diff --git a/plugins/visual-content/skills/hyperframes-animation/rules-index.md b/plugins/visual-content/skills/hyperframes-animation/rules-index.md index 52c748b..8568d22 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules-index.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules-index.md @@ -2,25 +2,43 @@ Atomic motion recipes. Each lives at `rules/.md`. Compose 2-4 per scene with a single paused timeline. +## The contract — every rule assumes this + +Stated once here so individual rules don't repeat it. Every recipe in `rules/`: + +- runs on ONE **paused** GSAP timeline registered on `window.__timelines` (never autoplay, never a second timeline); +- is **seek-safe both directions**: `fromTo` with explicit from-states (t=0 correct under seek; `immediateRender: false` when re-owning a target), absolute values — never relative `+=` tweens; state readable as a pure function of timeline time, no mutable trackers; +- is **deterministic**: no `Math.random()`, no `Date.now()` — index-derived pseudo-random and baked schedules only; finite repeats, never `repeat: -1`; +- animates **transforms and paint-only properties** — `width`/`height`/`top`/`left` tweens are forbidden (use scale/translate proxies, masks, or `anchored-layout-expand`); +- caps group staggers so an arrival reads as one beat (`items × stagger ≤ ~0.5s`); +- puts **no CSS `transition`** on animated elements (they interpolate independently of seek and flicker) and hints compositors with `will-change: transform` where many tweens run at once; +- measures DOM (`offsetHeight`, `getBoundingClientRect`) at build time only in a **single-scene** composition — in a multi-scene montage, later clips may not be laid out yet: use authored CSS-matched constants; +- lives inside a standard scene clip per `hyperframes-core` (`class="clip"` + `data-*` timing) — rule snippets show mechanism DOM only, not the scene scaffold. + +A rule's own **Critical Constraints** section lists only what is SPECIFIC to that rule beyond this contract. + ## Text & Typography Character-level 3D rotation with deterministic glyph substitution (decryption). GSAP `back.out` ease + per-glyph `onUpdate` for the flicker hash. Tags: text, 3d, reveal, decode Slot-machine vertical scrolling using stepped GSAP tweens within a masked column. Tags: text, ticker, scroll, vertical -Counter where font size grows with the value for escalating emphasis. Single GSAP tween on a numeric proxy. Tags: counter, scale, font-size, number, dynamic +Counter where transform scale grows with the value for escalating emphasis. A numeric proxy and scale tween share one timeline position. Tags: counter, scale, transform, number, dynamic Replace entire text states at time thresholds for non-linear typing (typos, holds, bulk additions, backspaces). GSAP onUpdate-driven reverse search. Tags: text, typing, discrete, threshold, non-linear Highlight keywords with glow + scale + color synced to ASR word timestamps. Two GSAP tweens per word drive a CSS custom property `--glow` through attack-decay-rest envelope. Tags: asr, audio-sync, highlight, glow, keyword, text <3d-text-depth-layers path="rules/3d-text-depth-layers.md">Multiple offset text layers (N divs at `(i*dx, i*dy)` with decreasing alpha) create a stacked 3D extrusion illusion on large typography. Tags: text, 3d, depth, layers, shadow, typography, stacked Typing cursor whose `background-color` switches at segment boundaries plus square-wave blink via `(tl.time() % cycle) < cycle/2`. Tags: cursor, color, context, typewriter, styling, segment Pre-compute a flat `[{startTime, endTime, ...}]` array from a script of `{textMain, textAccent, charSpeed, hold}` entries. Each phrase's window = `chars × charSpeed + hold`. Content-driven duration, no hand-tuned offsets. Tags: timeline, sequencing, dynamic, duration, script-driven Percussive kinetic typography — short phrases slam in on ONE shared beat array with DISTINCT per-phrase entrances (scale-slam / side-snap / rise-rotate), optional rhythm chrome (metronome ticks, beat bar), then a locked finale. The recipe for "punchy / rhythmic" taglines. Tags: text, kinetic, typography, beat, rhythm, slam, percussive, punchy +A gradient tweened THROUGH letterforms — `background-clip: text` + an oversized-background `backgroundPosition` tween. Continuous sweep across a held headline, traveling word-to-word highlight (stacked-copy opacity envelopes), or a hue-sweep that settles to a solid via a pixel-identical twin crossfade. Glyphs never move; finite, seek-safe. Tags: gradient, text, sweep, background-clip, highlight, hue, headline +RGB-split / slice glitch that snaps sharp — offset color copies jitter on a deterministic hash of QUANTIZED timeline time (never Math.random), or horizontal slice bands displace and converge under a stepped ease; brief vibration, clean resolve, clamped rest state. Entrance stretch, emphasis burst, and slice-reveal forms. Tags: glitch, rgb-split, chromatic, slice, jitter, stutter, snap ## Data & Stats -Counter whose font size grows with the value; seek-safe `onUpdate`, `Math.round`, `tabular-nums`, multi-stat chord. (Also listed under Text & Typography.) Tags: counter, number, stat, count-up +Counter whose transform scale grows with the value; seek-safe `onUpdate`, `Math.round`, `tabular-nums`, multi-stat chord. (Also listed under Text & Typography.) Tags: counter, number, stat, count-up Data-viz primitives that pair a number with a graphic — growth bars (CSS `scaleY` stagger), progress fill (bar `scaleX` or measured SVG ring), and fractional star-rating wipe (`clip-path`). Transforms only, seek-safe. Pick single-focus vs split-frame and hold it. Tags: data, stats, chart, bars, progress, ring, stars, rating, infographic +Cursor/playhead scrubs an already-drawn chart — ONE driver moves a vertical tracking line + marker along a baked data polyline while a date/value tooltip steps through the data array (text writes only on index change); second series can activate on cross. Chart arrival belongs to `stat-bars-and-fills` / `svg-path-draw`; this is the read head. Tags: chart, scrub, tooltip, readout, tracking-line, data, playhead ## Camera & Viewport @@ -30,6 +48,7 @@ Atomic motion recipes. Each lives at `rules/.md`. Compose 2-4 per scene wi Two-phase virtual camera that locks the viewport to a moving focal point (typing cursor) — static initial framing then focal-point-locked tracking. Uses browser-native `getBoundingClientRect()` / `ctx.measureText()` after `document.fonts.ready`. Tags: camera, tracking, viewport, two-phase, typing Sequential camera-zoom system (pull-back / focus / push) plus continuous micro-drift. Tags: camera, zoom, phase, drift, scale, cinematic Virtual camera — simulate zoom / pan / focus-lock by transforming a single `.world` wrapper containing all scene content. Single-element composite transform `translate(x,y) scale(S)`; counter-translate math is `T = -offset × S` (DIFFERENT from coordinate-target-zoom's `T = -offset`). Tags: viewport, camera, zoom, pan, focus-lock +<3d-camera-flight path="rules/3d-camera-flight.md">Perspective camera FLIGHT through a 3D-laid-out world — one static `perspective` stage + `preserve-3d` `.world` whose pose (`translate3d` + `rotateX`/`rotateY`) is tweened leg-by-leg from a single camera state object: dive into an angled grid, tilt-to-flatten pull-back, flight past standing cards, decelerate-into-focus. `power4.out` landings, `power2.inOut` repositioning; DoF via depth-of-field-blur on non-focal planes. The only camera rule that rotates/travels in Z (the other three are 2D scale+translate). Tags: camera, 3d, flight, perspective, rotateX, translateZ, dive, tilt Selective rack-focus — GSAP-tween `filter: blur()` (+ slight opacity dim) on off-focus layers via a `--dof` var while the focal element stays sharp; single pull, two-plane rack, or blur-the-cluster-while-pushing-in. Finite, deterministic, seek-safe. Tags: blur, depth-of-field, focus, rack-focus, dim, spotlight @@ -43,6 +62,7 @@ Atomic motion recipes. Each lives at `rules/.md`. Compose 2-4 per scene wi Elements flip in from 3D space (`rotateX` + `rotateY` + `translateZ`) then settle into a continuous elliptical orbit. **Critical**: entry MUST flip in-place at the orbital starting position (`gsap.set` BEFORE phase 1), not at scene center. Tags: orbit, 3d, flip, ellipse, circular, icon, entry, continuous AI detection overlay — yellow `#facc15` L-bracket corners + confidence label (fluctuating 95-99%) following a target on a sine arc path. Box position recomputed per-frame from target position (never tweened separately). Tags: ai, tracking, bounding-box, detection, corner, ml N elements scatter into / reassemble from a rotating 3D depth-cloud — each starts at a deterministic index-derived 3D offset (translateZ + rotateX/Y + scatter) and settles to a clean flat layout; tumble-swap and radial-explode variants. preserve-3d + perspective, transform-only, seek-safe. Tags: 3d, scatter, assemble, tumble, depth, perspective, glyphs +Edge-pinned container grows/collapses along ONE axis and in-flow content reflows — pill springs open into a dropdown, panel grows a sub-task stack, input card steps taller as typed text wraps, pane expands over a neighbor. Transform-only (layout authored expanded; mask + sheet slide, or proxy-driven scaleY + inverse counter-scale) since width/height tweens are forbidden; the push on following content shares the SAME tween so the seam never separates. Tags: expand, collapse, anchored, dropdown, accordion, panel, reflow, push, mask, counter-scale ## SVG & Icons @@ -66,10 +86,17 @@ Atomic motion recipes. Each lives at `rules/.md`. Compose 2-4 per scene wi Tactile button press: linear compression then spring recovery via two adjacent GSAP tweens on the same property. Variations: color transition, shadow depth via CSS vars, release burst, background glow. Tags: spring, press, button, interaction, physics, glow, burst Physical click simulation — two sequential GSAP scale tweens (down to 0.9, up to 1.0) approximate a spring with overshoot. Pass a single targets array `["#cta", "#cursor"]` to compress both together for tactile contact feel. Tags: spring, click, physics, press, interaction, cursor Animated cursor moves to a target, depresses cursor + target together on click, emits an expanding ripple with attack-decay opacity envelope. Element lives in DOM from t=0 with `opacity: 0` (no conditional rendering). Tags: cursor, click, ripple, interaction, mouse, button, keyframes +The drag verb for driven cursors — grab (press dip + lift), travel (semi-transparent ghost rides the cursor in exact lockstep via matched tweens), drop-snap into a placed field with selection chrome. Variants: fill-handle auto-fill (linear travel + stepped `tl.set` cell reveals), corner-handle proportional resize (uniform scale, origin at the anchor corner — never width/height), grab-lift-reorder (tilt + shadow, neighbor springs into the vacated slot). Tags: cursor, drag, drop, ghost, handle, resize, reorder, snap, interaction +N (2–4) labeled independent cursor actors work one canvas simultaneously — collaborative-canvas ambience. Per-actor deterministic waypoint tables (explicit fromTo legs + rests), name-tag pills in distinct colors, grab/drop/hover actions at chorus intensity on an interleaved beat grid (one payoff at a time, zone-partitioned paths, no collisions); camera locked — any pan is the canvas group translating. Tags: cursor, multi-cursor, collaboration, ensemble, canvas, name-tag, choreography, ambient +Live-sync couple — a scrubbed/typed/picked control and its bound target change in the SAME beat: readout tween + target transform tween share one timeline label, duration, and ease (continuous scrub), or one threshold state array carries both sides (per-keystroke / dropdown-pick steps). Distinct from `reactive-displacement` (collision physics, one-shot transition). Tags: control, scrub, live-sync, mirror, panel, editor, readout, ui Coordinated morph between two DOM elements at the same screen center. Exit cluster shrinks + fades; entrance pops in with `back.out(2)` overshoot. Tags: transition, morph, scale, swap Container morphs apparent size + corner radius + surface treatment between two shots, then fades to reveal the real target underneath. HyperFrames substitutes uniform `scale` for the forbidden `width`/`height` tween, plus paint-only `borderRadius`/`background`/`boxShadow`. Tags: morph, anchor, transition, border-radius, container, shape, handoff +Whole-theme in-place morph under a fixed anchor — background, typography, radii, icons, chrome and logos blend simultaneously (~0.3s) through N pre-styled skins while one anchor element never moves. Stacked complete layers + opacity-only crossfade, anchor rendered once on top (or per-layer at identical geometry); static camera. Single container instead → `card-morph-anchor`. Tags: theme, skin, crossfade, morph, anchor, reskin, cycle, ui The canonical ENTRANCE pop — an element (or staggered group) arrives by springing `scale: 0 → 1` with `back.out` overshoot, `fromTo` so it's correct at t=0 under seek. Single hero, staggered group (≤500ms cap), overshoot tuned by personality. Distinct from `press-release-spring` (a click/press reaction). Tags: spring, entrance, pop, scale-in, overshoot, stagger, arrival Fake directional velocity blur on a fast entrance / camera push-through — blur peaks at max speed, resolves to 0 at the settle. Two paths: SVG `feGaussianBlur` stdDeviation on the motion axis (proxy-tweened), or a deterministic echo/ghost trail that collapses into the lead. Entrances / mid-shot only. Tags: motion-blur, streak, velocity, ghost, echo, fast +Staggered ARRIVAL cascade — words/elements whip in from below, each starting before the previous settles, an accelerating wave that resolves composed. Title cards, segment openers, list intros. Binary 0→1 opacity via `tl.set` — never fade an arrival. Tags: entrance, cascade, stagger, kinetic-text, title-card, arrival, waterfall +Deterministic particle / confetti events — confetti pop that bursts up and drifts down on gravity (optional instant-shrink), dot burst from behind text, glyph dissolve to particles. Fixed pool, index-seeded launch values, one `ease: "none"` driver whose onUpdate computes each particle as a pure ballistic function of time — scrub-safe mid-flight, ≤ ~40 particles. Tags: particles, confetti, burst, dissolve, ballistic, deterministic, punctuation +Slow-fast-slow three-phase group slide (power3.in ramp → linear burst → power4.out tail, 10/65/25 distance, tail ≥3× ramp-in) to reposition a composed group and reveal content during the burst. Tags: slide, reposition, group-motion, nudge, slow-fast-slow ## Effect Recipes (moved from hyperframes-creative) diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/3d-camera-flight.md b/plugins/visual-content/skills/hyperframes-animation/rules/3d-camera-flight.md new file mode 100644 index 0000000..08ac489 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/3d-camera-flight.md @@ -0,0 +1,180 @@ +--- +name: 3d-camera-flight +description: Perspective camera FLIGHT through a 3D-laid-out world — one static perspective stage + preserve-3d world whose pose (translate3d + rotateX/rotateY) is tweened leg-by-leg from a single camera state object. Dive into an angled grid, tilt-to-flatten pull-back, continuous flight past standing cards, decelerate-into-focus. Hard power4.out landings, power2.inOut repositioning; DoF via depth-of-field-blur on non-focal planes. +metadata: + tags: camera, 3d, flight, perspective, preserve-3d, rotateX, rotateY, translateZ, dive, tilt, world, cinematic +--- + +# 3D Camera Flight + +Every other camera rule here is a **2D camera**: [viewport-change.md](viewport-change.md), [multi-phase-camera.md](multi-phase-camera.md), and [coordinate-target-zoom.md](coordinate-target-zoom.md) simulate the camera with `scale` + `translate` on a flat wrapper — the lens never tilts, and there is no depth axis to travel along. [3d-page-scroll.md](3d-page-scroll.md) is a **static tilt**: one angle held all scene while content scrolls inside. This rule is the missing camera that _flies_ — dives into an angled grid, pulls back while the world rotates flat, streaks past standing cards, decelerates out of a blur into focus: a **perspective camera traveling with `rotateX` / `rotateY` / `translateZ` through a 3D-laid-out world**, under the same single-camera discipline as `viewport-change`: **one perspective wrapper, one camera state object, one transform writer**, every leg a sequenced tween on that state. + +## How It Works + +Five layers, strictly separated: + +1. **The lens** — `perspective: PERSPECTIVE_PX` on a static `.stage` wrapper. Set once, never tweened, never moved. Changing perspective mid-shot reads as the lens itself warping, not the camera moving. +2. **The world** — a `.world` div with `transform-style: preserve-3d`, laid out at final 1× size: the ground surface (grid, form card, canvas) as flat DOM, optional **props** (a giant date number, a floating label) at static `translateZ(PROP_Z)` offsets so travel produces parallax, and **standing cards** counter-tilted to face the camera at their landing pose. +3. **The camera state** — a single object `cam = { x, y, z, rx, ry }` (the world's pose), written to `world.style.transform` by ONE function, `applyCamera()`, in a **fixed order**: `translate3d(x, y, z) rotateX(rx) rotateY(ry)`. With translate composed _outside_ the rotations, `x`/`y`/`z` always move the world along **screen axes** no matter how it is currently tilted — pan is always sideways, `z` is always toward/away from the lens. Put the rotations first and every leg's numbers change meaning as the tilt changes. +4. **The legs** — sequential tweens on `cam`, each one camera move: dive in (`power4.out` — violent arrival, sharp settle), tilt-to-flatten pull-back (`power2.inOut` — a repositioning, no slam), lateral flight, final dive. Camera intent inverts onto the world pose exactly as in `viewport-change`: camera flies **in** → world `z` **increases** (comes toward the lens); camera pans **right** → world `x` **negative**; camera tilts **down** over the surface → world `rx` **positive** (far edge tips away). +5. **Depth cues** — DoF via [depth-of-field-blur.md](depth-of-field-blur.md) `--dof` tweens on the **non-focal planes** (cards, props — leaf elements, never the world itself), and velocity blur on travel legs via [motion-blur-streak.md](motion-blur-streak.md)'s Camera-Travel Carve-Out — applied to the **stage**, never the world (a `filter` on a `preserve-3d` element flattens it). + +Landing poses are **authored, not derived**: set `cam` to candidate values at design time, call `applyCamera()`, screenshot, adjust, bake the numbers as constants. There is no counter-translate formula to get wrong in 3D — the pose IS the design decision. Never measure per-frame (`getBoundingClientRect` in `onUpdate` desyncs under parallel frame sampling), and don't hand-derive 3D projections — your eye at design time beats the math. + +## Recipe + +```html + +
+ +
+
+
{gridCells}
+
{cardA}
+
{cardB}
+
+ +
{propGlyph}
+
+
+``` + +```css +.scene { + overflow: hidden; /* travel legs push world content past the frame on purpose */ + background: {sceneBg}; /* the void the flight exposes at frame edges — must be a + designed surface (deep brand color / soft gradient), never default white */ +} +.stage { + position: absolute; + inset: 0; + perspective: PERSPECTIVE_PX; /* THE LENS — static, never tweened */ + /* travel blur (motion-blur-streak carve-out) attaches HERE, never on .world */ +} +.world { + position: absolute; + inset: 0; + transform-style: preserve-3d; + transform-origin: 50% 50%; + will-change: transform; + /* keep CLEAN: no filter, opacity < 1, overflow, clip-path, or mask — each + flattens preserve-3d. Background on .scene, blur on .stage or leaf cards. */ +} +.surface { + position: absolute; + inset: WORLD_INSET; /* world runs larger than the frame so travel has runway */ + transform-style: preserve-3d; +} +.prop { + position: absolute; + left: var(--px); + top: var(--py); + /* static world-space pose; counter-tilt faces the camera at the dive pose */ + transform: translateZ(PROP_Z) rotateX(PROP_COUNTER_TILT); +} +.layer { + --dof: 0px; /* DoF channel per depth-of-field-blur — leaf elements only */ + filter: blur(var(--dof)); + will-change: filter; +} +``` + +```js +const world = document.getElementById("world"); + +// Camera state — the ONLY source of truth for the world's pose. Every leg +// tweens this object; nothing else touches world.style.transform. +const cam = { x: 0, y: 0, z: WIDE_Z, rx: 0, ry: 0 }; + +function applyCamera() { + // Fixed order: translate OUTSIDE the rotations → x/y/z stay screen-aligned + // at any tilt. Changing this order changes what every baked pose means. + world.style.transform = `translate3d(${cam.x}px, ${cam.y}px, ${cam.z}px) rotateX(${cam.rx}deg) rotateY(${cam.ry}deg)`; +} +applyCamera(); // seed frame 0 so a seek to t=0 renders the opening pose + +// ── LEG 1 — DIVE IN: wide establishing pose → angled close-up on card A. +// fromTo states the opening pose explicitly; power4.out = violent arrival, +// razor-sharp settle. Travel blur: motion-blur-streak carve-out on .stage. +const DIVE_POSE = { x: DIVE_X, y: DIVE_Y, z: DIVE_Z, rx: DIVE_RX, ry: DIVE_RY }; +tl.fromTo( + cam, + { x: 0, y: 0, z: WIDE_Z, rx: 0, ry: 0 }, + { ...DIVE_POSE, duration: DIVE_DUR, ease: "power4.out", onUpdate: applyCamera }, + DIVE_AT, +); +// Decelerate-INTO-FOCUS: non-focal planes' --dof ramps to BLUR_PER_DEPTH × data-depth +// on the SAME window/ease (depth-of-field-blur focal pull); card A stays at --dof: 0. + +// ── LEG 2 — TILT-TO-FLATTEN PULL-BACK: every channel returns to neutral on ONE +// power2.inOut tween — a reposition, not a slam. DoF releases on the same window +// so the flat overview arrives fully crisp. +const FLAT_POSE = { x: 0, y: 0, z: 0, rx: 0, ry: 0 }; +tl.to( + cam, + { ...FLAT_POSE, duration: FLATTEN_DUR, ease: "power2.inOut", onUpdate: applyCamera }, + FLATTEN_AT, +); +tl.to(".layer", { "--dof": "0px", duration: FLATTEN_DUR, ease: "power2.inOut" }, FLATTEN_AT); + +// ── LEG 3 — LATERAL FLIGHT: screen-aligned pan (translate is outside the +// rotations, so x is a pure sideways move even mid-tilt). +tl.to(cam, { x: PAN_X, duration: PAN_DUR, ease: "power2.inOut", onUpdate: applyCamera }, PAN_AT); + +// ── LEG 4 — FINAL DIVE onto card B: same grammar as leg 1; card A racks OUT of +// focus as card B racks in (depth-of-field-blur rack, shared window). +const LAND_POSE = { x: LAND_X, y: LAND_Y, z: LAND_Z, rx: LAND_RX, ry: LAND_RY }; +tl.to( + cam, + { ...LAND_POSE, duration: LAND_DUR, ease: "power4.out", onUpdate: applyCamera }, + LAND_AT, +); +tl.to("#card-a", { "--dof": `${MAX_BLUR}px`, duration: LAND_DUR, ease: "power4.out" }, LAND_AT); +tl.to("#card-b", { "--dof": "0px", duration: LAND_DUR, ease: "power4.out" }, LAND_AT); +// Landing dwell: ≥1 s of stillness on card B — unless ending held mid-dive. +``` + +## Variations + +- **Continuous flight past standing cards** — one long leg instead of dive-land-dive: sustained `z` + `x` travel (2–4 s, `power2.inOut` / `power1.inOut` near-constant cruise) through a corridor of cards and props at staggered `PROP_Z`. Parallax does the work — near props streak past while far ones crawl. Keep ONE plane sharp at a time via staggered `--dof` tweens. Props crossing the camera plane (`cam.z + PROP_Z` approaching `PERSPECTIVE_PX`) blow up to fill the frame and vanish — that IS the fly-past; never let a focal card cross it. +- **End held mid-dive** — give the final leg a window that overruns the composition (`LAND_AT + LAND_DUR > data-duration`); the last frame holds mid-tween — still traveling, blur not fully resolved. Seek-safe by construction (a seek to the last frame lands at a deterministic pose); don't fake it with a shorter leg plus a manual offset. Use when the brief wants momentum at the cut, not rest. +- **Whip sweep** — the heavily motion-blurred lateral whip that resolves into the next region: leg 3 driven by [nudge-curve.md](nudge-curve.md)'s three-phase chain (burst-dominant) on `cam.x`, with [motion-blur-streak.md](motion-blur-streak.md)'s Camera-Travel Carve-Out on the same window — blur ramps through the ramp-in, rides the burst at peak, resolves to 0 through the `power4.out` tail. Full recipe in that carve-out. +- **Hold drift (the hold never dies)** — between legs, fold `multi-phase-camera`-style micro-drift **through the same writer**: a driver tween writes tiny `dx`/`dy`/`drx` into a `drift` object and `applyCamera()` composes `cam.x + drift.dx`, `cam.rx + drift.drx`, etc. Never let drift write `world.style.transform` itself — two writers on one transform is the classic camera bug. Amplitudes per `multi-phase-camera` (2–8 px), rotation drift ≤ 0.5°. + +## Values + +| token | range | notes | +| ------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PERSPECTIVE_PX | 700–1400 px (moving cam best 800–1200) | smaller = wilder foreshortening, more violent dives; larger = near-orthographic, the flight flattens | +| WORLD_INSET | −50% to −150% per side | world 2–4× the frame so lateral legs have runway | +| PROP_Z | 80–300 px | higher = stronger parallax, earlier fly-past | +| PROP_COUNTER_TILT | ≈ `-LAND_RX` of the leg that reads it | author by eye and bake | +| DIVE_RX / LAND_RX | 30–55° | "angled grid" starts ~30°; \|rx\| ≤ ~65°, \|ry\| ≤ ~30° — beyond that flat planes go edge-on, text unreadable | +| DIVE_Z / LAND_Z | 300–700 px at PERSPECTIVE_PX ≈ 1000 | **Z budget**: `cam.z + PROP_Z ≤ ~0.6 × PERSPECTIVE_PX` for readable content — near the perspective distance, scale blows toward infinity and elements invert/vanish past the camera plane | +| WIDE_Z | −100 to −400 px | negative z = world pushed away = camera wide | +| DIVE_X/Y, LAND_X/Y | read off a screenshot at the baked tilt | screen-aligned (translate outside rotations) | +| DIVE_DUR / LAND_DUR | 0.6–1.0 s | commitment, not a polite zoom; under 0.5 s reads as a cut | +| FLATTEN_DUR | 1.2–2.0 s | the repositioning is the breath between dives | +| PAN_DUR | 0.8–1.5 s plain; 0.5–0.8 s whip | | +| Ease law | `power4.out` dives/landings; `power2.inOut` repositioning/cruise | spring/back on a camera reads as the world wobbling on a string; four identical pushes read as a slideshow — vary the leg verbs | +| Holds | ≥ 0.8 s between legs; final dwell ≥ 1 s | unless ending held mid-dive | +| BLUR_PER_DEPTH / MAX_BLUR | per [depth-of-field-blur.md](depth-of-field-blur.md) | 3–6 px per step, terminal 8–24 px, leaf elements only; travel-blur peak per [motion-blur-streak.md](motion-blur-streak.md) (~18–20 px full-frame, on `.stage`) | + +## Critical Constraints + +- **One lens, one state, one writer** — `perspective` on the static `.stage` only (never on `.world`, never tweened, never a second perspective wrapper inside); every leg tweens the single `cam` object; only `applyCamera()` writes the transform — drift folds into the same writer via additive state. Two writers (or a second transform sneaking in via CSS) is the classic broken-camera bug, five channels of it here. +- **Fixed transform order: translate outside the rotations** — `translate3d(x,y,z) rotateX() rotateY()`. Reorder it and every pose you authored silently means something else. +- **Keep the world CLEAN** — `filter`, `opacity < 1`, `overflow` other than `visible`, `clip-path`, or `mask` on `.world` (or any intermediate wrapper) forces used `transform-style: flat` and collapses every `translateZ` in the scene. Travel blur goes on `.stage`; DoF on leaf cards; fades on children; background on `.scene`. `transform-style: preserve-3d` on `.world` and every intermediate wrapper between it and 3D-positioned children. +- **Camera intent inverts onto the world** — fly in = world z up, pan right = world x negative, tilt down = world rx positive. Same sign law as `viewport-change`, two more axes to get right. +- **Poses authored and baked** — never measured per-frame, never hand-derived projections. +- **First leg is a `fromTo`** AND `applyCamera()` runs once at setup — a seek to t=0 must render the exact establishing pose. +- **Z budget** — only sacrificial props may cross the camera plane. +- **Reads happen at landings** — angled, blurred, flying text is texture; anything the viewer must read gets a near-flat pose or a sharp held close-up ≥ 1 s (the tilt-to-flatten leg exists to hand the surface over for reading). +- **`overflow: hidden` on `.scene` + `data-layout-allow-overflow` on `.world`** — travel legs deliberately push panels past the frame; without the pairing, `check` reports `container_overflow` for every region the flight leaves behind. + +## See also + +[viewport-change.md](viewport-change.md) (2D counterpart, same single-writer law — right when the shot never tilts) · [multi-phase-camera.md](multi-phase-camera.md) (leg-sequencing grammar + hold micro-drift) · [coordinate-target-zoom.md](coordinate-target-zoom.md) (aim math for a flat-hold zoom while `rx`/`ry` are 0) · [depth-of-field-blur.md](depth-of-field-blur.md) (non-focal defocus / racks) · [motion-blur-streak.md](motion-blur-streak.md) (travel blur on the stage) · [nudge-curve.md](nudge-curve.md) (whip-sweep burst tuning) · [3d-page-scroll.md](3d-page-scroll.md) (static-tilt cousin — camera should NOT travel) · [orbit-3d-entry.md](orbit-3d-entry.md) / [depth-scatter-assemble.md](depth-scatter-assemble.md) (elements moving under a still camera — the inverse; don't run both on one beat). Capability background: `../techniques.md` § CSS 3D Transforms. diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/3d-page-scroll.md b/plugins/visual-content/skills/hyperframes-animation/rules/3d-page-scroll.md index b0ab692..e3a8f86 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/3d-page-scroll.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/3d-page-scroll.md @@ -7,145 +7,91 @@ metadata: # 3D Page Scroll -A webpage (or long content) presented as a tilted 3D card. Spring-eased scroll reveals specific sections while the static 3D perspective adds physical depth. +A webpage (or long content) presented as a tilted 3D card. Spring-eased scroll reveals specific sections while the static 3D perspective adds physical depth. (For a camera that actually travels/tilts, see [3d-camera-flight.md](3d-camera-flight.md) — this rule's tilt never moves.) ## How It Works Two independent transforms combine: -1. **3D tilt** — Static `rotateY` + `rotateX` with `perspective` on the card. The angle does **not** change during the scene. -2. **Scroll** — The content inside the card translates vertically (`translateY` / `y` in GSAP) within a clipped container, driven by a GSAP tween. Spring-like deceleration via `ease: "power3.out"` or `"power4.out"`. +1. **3D tilt** — static `rotateY` + `rotateX` with `perspective` on the card. The angle does **not** change during the scene. +2. **Scroll** — the content inside the card translates vertically (`y` in GSAP) within a clipped container; spring-like deceleration via `power3.out` / `power4.out`. -Optional layer: +Optional: **spotlight overlay** — a radial-gradient mask dims everything except a focal region after the scroll lands. It sits above the scrolling content, fixed relative to the card, never inside `.page-content`. -3. **Spotlight overlay** — A radial-gradient mask dims everything except a focal region after the scroll lands. Use to draw attention to one section. - -For multi-step scrolling (scroll → pause → scroll), use multiple `tl.to(".page-content", { y: -, ... }, )` calls at different timeline positions. - -## HTML +## Recipe ```html -
-
-
- -
{heroContents}
-
{featuresContents}
-
{targetContents}
-
{ctaContents}
-
- -
+
+
+ +
{heroContents}
+
{featuresContents}
+
{targetContents}
+
{ctaContents}
+
``` -## CSS (hero-frame layout) - ```css -.scene { - position: relative; - width: 100%; - height: 100%; -} - .tilt-card { position: absolute; left: 50%; top: 50%; - /* tilt + perspective set in CSS only if no other transform tween touches - this element. If GSAP also tweens scale on .tilt-card, set the tilt - via gsap.set() to avoid matrix overwrites. */ + /* tilt + perspective in CSS only if no other transform tween touches this + element — if GSAP also tweens scale on .tilt-card, set the tilt via + gsap.set() instead to avoid matrix overwrites */ transform: translate(-50%, -50%) perspective({perspectivePx}) rotateY({tiltYDeg}) rotateX({tiltXDeg}); transform-style: preserve-3d; width: {cardWidth}; height: {cardHeight}; border-radius: 24px; background: {cardBackgroundColor}; - overflow: hidden; /* clip the scrolling content */ + overflow: hidden; /* clip the scrolling content at the rounded corners */ /* shadow X-offset sign must match tiltY sign (negative tiltY ⇒ positive X) */ box-shadow: 40px 30px 80px rgba(0, 0, 0, 0.45); } - .page-content { position: absolute; top: 0; left: 0; width: 100%; - /* height is intrinsic from sections — taller than .tilt-card.height */ + /* height intrinsic from sections — taller than the card */ } - -.page-content section { - height: {sectionHeight}; /* sections sized so cumulative offset = target distance */ - padding: 64px; - /* section-specific styling … */ -} - .spotlight { position: absolute; inset: 0; pointer-events: none; opacity: 0; - background: radial-gradient( - ellipse 60% 35% at 50% 50%, - transparent 50%, - {spotlightDimColor} 100% - ); + background: radial-gradient(ellipse 60% 35% at 50% 50%, transparent 50%, {spotlightDimColor} 100%); } ``` -## GSAP Timeline +```js +// SCROLL_DISTANCE is measured at design time from the real page layout +// (top of .page-content origin to vertical center of #target-section, +// accounting for card height) — NOT a free tunable. +tl.to( + ".page-content", + { y: -SCROLL_DISTANCE, duration: SCROLL_DUR, ease: "power3.out" }, + SCROLL_AT, +); -```html - - +// Spotlight fades in on the target after the scroll settles. +tl.to( + ".spotlight", + { opacity: 1, duration: SPOTLIGHT_FADE_DUR, ease: "power1.inOut" }, + SPOTLIGHT_AT, +); ``` -### Multi-phase scroll variant +## Variations + +**Multi-step scroll (scroll → pause → scroll)** — multiple `y:` tweens at different positions. Distances are both measured from the `.page-content` origin (NOT delta from the previous step); GSAP composes successive `y:` tweens on the same property, each starting from the value the previous one left: ```js -// Scroll to section A → hold → scroll to section B. -// SCROLL_DISTANCE_A and SCROLL_DISTANCE_B are both measured from the -// .page-content origin (NOT delta from previous step). tl.to( ".page-content", { y: -SCROLL_DISTANCE_A, duration: SCROLL_DUR, ease: "power3.out" }, @@ -156,72 +102,34 @@ tl.to( { y: -SCROLL_DISTANCE_B, duration: SCROLL_DUR, ease: "power3.out" }, SCROLL_AT_B, ); +// SCROLL_AT_A + SCROLL_DUR ≤ SCROLL_AT_B — the two scrolls must not fight for y ``` -GSAP composes successive `y:` tweens additively when targeting the same property — each tween starts from the value left by the previous tween. - -## How to Choose Values - -- **tiltYDeg** — static Y rotation in CSS (or via `gsap.set()`). - - Range: -12 to -4 (left-leaning) or 4 to 12 (right-leaning); 0 = no perspective rotation. - - Effects: bigger magnitude = more dramatic 3D; near 0 collapses to a flat panel. - - Constraints: shadow X-offset sign must match (negative tiltY ⇒ positive box-shadow X). -- **tiltXDeg** — static X rotation. - - Range: 0-6 - - Effects: positive tilts the top edge away from the viewer. -- **perspectivePx** — perspective distance. - - Range: 800-2000 px - - Effects: smaller = more dramatic foreshortening; larger = nearly orthographic. -- **cardWidth / cardHeight** — card frame size. - - Constraints: card height < total content height, otherwise scroll has nothing to reveal. -- **sectionHeight** — height of each scrolled section. - - Constraints: sum of all section heights ≥ cardHeight + SCROLL_DISTANCE so the target section ends up within frame after scroll. -- **SCROLL_AT** — timeline second at which the scroll tween begins. - - Constraints: must be ≥ end of any prior fade-in tweens on `.page-content`. -- **SCROLL_DUR** — duration of one scroll tween. - - Range: 0.8-1.8 s - - Effects: shorter feels like a hard cut; longer feels programmatic. -- **SCROLL_DISTANCE** — pixels to translate `.page-content` upward. - - Constraints: measured once at design time from the target section's offset; NOT a free tunable. -- **SPOTLIGHT_AT** — timeline second at which the spotlight begins fading in. - - Constraints: should be ≥ SCROLL_AT + SCROLL_DUR (or slightly earlier for overlapping handoff) so the spotlight reveals the freshly-arrived section. -- **SPOTLIGHT_FADE_DUR** — spotlight opacity fade-in duration. - - Range: 0.4-0.8 s -- Multi-phase variant — **SCROLL_AT_A / SCROLL_AT_B**: must satisfy `SCROLL_AT_A + SCROLL_DUR ≤ SCROLL_AT_B` so the two scrolls don't fight for the y property. - -Ease family — discrete choice: - -- `power3.out` — heavy deceleration; reads as a programmatic scroll that "lands". Default. -- `power4.out` — even heavier; reads as a momentum-driven scroll. -- `power2.inOut` — symmetric; reads as a cinematic camera pan rather than UI scroll. - -Pick one and use it across all scrolls in the scene — mixing easings within one scene reads as jerky. - -## Key Principles - -- **Tilt is static**, not animated. The card holds its angle the whole scene. -- **Shadow direction matches tilt**: a left-leaning card casts shadow to the right (positive X shadow offset). Mismatch breaks the 3D illusion. -- **Page content is real HTML**, not a screenshot. Screenshots can't be individually highlighted or scrolled-to with precision. -- **Use real layout for distances**: scroll target distance comes from the actual cumulative section heights, not estimated pixel values. -- **Spotlight as overlay**, not inside the page-content — overlay sits above scrolling content and stays fixed relative to the card. +## Values + +| token | range / rule | notes | +| ------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| tiltYDeg | −12 to −4 (left-leaning) or 4 to 12 | bigger = more dramatic 3D; near 0 collapses to a flat panel | +| tiltXDeg | 0–6 | positive tilts the top edge away | +| perspectivePx | 800–2000 px | smaller = more foreshortening; larger = nearly orthographic | +| cardWidth / Height | card height < total content height | otherwise the scroll has nothing to reveal | +| sectionHeight | Σ heights ≥ cardHeight + SCROLL_DISTANCE | so the target section lands within frame | +| SCROLL_AT | ≥ end of prior tweens on `.page-content` | | +| SCROLL_DUR | 0.8–1.8 s | shorter feels like a hard cut; longer feels programmatic | +| SCROLL_DISTANCE | measured from the layout | from actual cumulative section heights — never estimated; don't overshoot content end | +| SPOTLIGHT_AT | ≥ SCROLL_AT + SCROLL_DUR (or slightly earlier) | spotlight reveals the freshly-arrived section | +| SPOTLIGHT_FADE_DUR | 0.4–0.8 s | | +| Ease | `power3.out` default; `power4.out` momentum; `power2.inOut` cinematic pan | pick ONE for all scrolls in the scene — mixing easings reads as jerky | ## Critical Constraints -- **`overflow: hidden` on `.tilt-card`** — scrolling content must clip at card boundaries, otherwise it leaks past the rounded corners -- **`transform-style: preserve-3d`** on `.tilt-card` — required for any 3D children (or for combining `perspective` with rotations cleanly) -- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()` — HF seeks frame-by-frame -- **Registry key = `data-composition-id`**: `window.__timelines["page-scroll-scene"]` must match scene root's `data-composition-id` -- **Finite scroll distance** — compute from actual content geometry; don't use arbitrary values that may overshoot the content end -- **Same easing across multi-phase scroll** — mixing `power3.out` and `power1.inOut` looks jerky; pick one for the scene - -## Combinations - -- [asr-keyword-glow.md](asr-keyword-glow.md) — highlight elements on the page synced to voiceover word timestamps -- [multi-phase-camera.md](multi-phase-camera.md) — overall camera zoom while the page scrolls (zoom-in to target section as it lands) -- [cursor-click-ripple.md](cursor-click-ripple.md) — cursor lands on a UI element within the scrolled-into-view section +- **Tilt is static** — the card holds its angle the whole scene. +- **Shadow direction matches tilt** — a left-leaning card casts shadow to the right (positive X offset); mismatch breaks the 3D illusion. +- **Page content is real HTML, not a screenshot**; scroll distances come from the real layout geometry. +- **`overflow: hidden` + `transform-style: preserve-3d` on `.tilt-card`** — clip at the rounded corners; preserve-3d for any 3D children / clean perspective composition. +- **Spotlight is an overlay above the scrolling content**, never inside `.page-content`. +- **Same easing across a multi-phase scroll**, and non-overlapping scroll windows. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + ease reference; `y:` tween basics -- `/hyperframes-core` — composition wiring, `data-*` attributes -- `/hyperframes-cli` — `hyperframes lint` to verify the registry key + duration +[asr-keyword-glow.md](asr-keyword-glow.md) (on-page keyword highlight synced to VO) · [multi-phase-camera.md](multi-phase-camera.md) (camera zoom while the page scrolls) · [cursor-click-ripple.md](cursor-click-ripple.md) (cursor lands in the scrolled-into-view section) · [3d-camera-flight.md](3d-camera-flight.md) (when the camera itself should travel). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/3d-text-depth-layers.md b/plugins/visual-content/skills/hyperframes-animation/rules/3d-text-depth-layers.md index 8ebc903..5ca2641 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/3d-text-depth-layers.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/3d-text-depth-layers.md @@ -7,291 +7,122 @@ metadata: # 3D Text Depth Layers -Renders the same text N times at increasing offsets, with back layers translucent and the front layer fully opaque. Creates a physical "stacked extrusion" depth illusion. Distinct from `text-shadow` (which can't have per-layer hue / opacity / animation) — each layer is a real DOM element. +The same text rendered N times at increasing offsets — back layers translucent, front layer full opacity and brand color — creates a physical "stacked extrusion" depth illusion on large typography. Distinct from `text-shadow` (which can't have per-layer hue / opacity / animation): each layer is a real DOM element. ## How It Works -- N copies of the same text in a single container -- Each copy positioned absolutely with offset `(i * OFFSET_X, i * OFFSET_Y)` -- Back layers (high `i`) use translucent or darkened color -- Front layer (`i = 0`) is full opacity, full brand color -- Optionally: each layer fades in staggered, creating a "building up" depth animation +A build script appends `LAYER_COUNT` copies back-to-front; each back layer sits at `translate(i × OFFSET_X, i × OFFSET_Y)` with alpha stepping down per layer, while the front copy (`i = 0`) is `position: relative` so it defines the container size (back layers stack absolutely behind it). The default entrance cascades the layers' fades back-to-front while a proxy tween grows the offsets from 0 → full, so the depth "builds forward" and lands as the last layer fades in. -## HTML +## Recipe ```html -
-
- -
+ +
+
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; -} .depth-stack { - position: relative; - /* Container size set by the front layer; back layers stack behind */ + position: relative; /* front layer defines size; back layers stack behind */ } .depth-text { - font-family: {font}; - font-weight: 900; + font-weight: 900; /* black weight — thin text loses the illusion */ font-size: HERO_FONT_SIZE; letter-spacing: HERO_LETTER_SPACING; line-height: 1; color: {frontColor}; - text-transform: uppercase; } -/* Back layers — absolute, stacked behind */ .depth-text.is-back { position: absolute; top: 0; left: 0; - pointer-events: none; + pointer-events: none; /* decorative */ } -/* Front layer — relative to define container size */ .depth-text.is-front { position: relative; z-index: 10; } ``` -## GSAP Timeline + Layer Setup - -```html - - -``` - -## Variations - -### Static depth (no animation, single hero shot) - -Skip the cascade — render all layers in their final positions from t=0, optionally fade the entire stack in: - -```js -tl.from( - stack, - { opacity: 0, scale: STATIC_ENTRY_SCALE, duration: STATIC_ENTRY_DUR, ease: "power3.out" }, - 0, -); -``` - -### Dynamic depth pulse - -Animate `OFFSET_X` / `OFFSET_Y` based on a heartbeat — depth grows and shrinks rhythmically: - -```js -const beat = { p: 0 }; +// Depth grows on entry — offsets interpolate 0 → full +const depthState = { p: 0 }; tl.to( - beat, + depthState, { - p: Math.PI * 2 * BEAT_CYCLES, - duration: BEAT_DUR, - ease: "none", + p: 1, + duration: DEPTH_GROW_DUR, + ease: "power2.out", onUpdate: () => { - const mult = 1 + Math.sin(beat.p) * BEAT_AMP; - stack.querySelectorAll(".is-back").forEach((el) => { + stack.querySelectorAll(".depth-text.is-back").forEach((el) => { const i = Number(el.dataset.layer); - el.style.transform = `translate(${i * OFFSET_X * mult}px, ${i * OFFSET_Y * mult}px)`; + el.style.transform = `translate(${i * OFFSET_X * depthState.p}px, ${i * OFFSET_Y * depthState.p}px)`; }); }, }, - BEAT_START, + LAYER_CASCADE_START, // align with the cascade so depth lands as the last layer fades in ); ``` -### Color-shift back layers - -Instead of fading to translucent, shift to a different hue — depth reads as "casting a colored shadow": - -```js -el.style.color = `hsla(${HUE_BASE - i * HUE_STEP}, ${SAT_PCT}%, ${LIGHT_BASE - i * LIGHT_STEP}%, 1)`; -``` - -## How to Choose Values - -### Layer geometry - -- **LAYER_COUNT** — number of stacked copies (back layers + 1 front). - - Range: 4-6. Below 4 the depth doesn't read as 3D; above 6 the stack visually clutters on tight kerning - - Effects: low end reads as subtle shadow; high end reads as chunky extrusion -- **OFFSET_X / OFFSET_Y** — per-layer translation offset, in px. - - Range: 1-3 px each. Above 4 px reads as a glitch / chromatic aberration rather than depth - - Effects: offset direction implies light direction. `(+x, +y)` = light from upper-left; `(-x, +y)` = light from upper-right. Pick one and keep it consistent across the composition - - Constraints: same sign convention throughout the composition - -### Back-layer color falloff - -- **BACK_ALPHA_MAX** — alpha of the back layer nearest the front. - - Range: 0.6-0.85. Lower than 0.5 makes even the nearest back layer disappear; higher than 0.9 fights the front layer for dominance -- **BACK_ALPHA_STEP** — alpha decrement per layer further back. - - Range: 0.08-0.15. Smaller steps read as a soft gradient; larger steps read as discrete plates - - Constraints: choose so `BACK_ALPHA_MAX − (LAYER_COUNT − 2) × BACK_ALPHA_STEP ≥ BACK_ALPHA_MIN` -- **BACK_ALPHA_MIN** — floor below which back layers stop fading. - - Range: 0.1-0.2. Below 0.1 the deepest layer disappears entirely on dark backgrounds - -### Typography - -- **HERO_FONT_SIZE** — front-layer font size, in px. - - Range: 60 px minimum to read as layered; 200-340 px for full-bleed hero shots. Thin text loses the layered illusion -- **HERO_LETTER_SPACING** — letter spacing. - - Range: −0.03em (tight) to 0 (normal). Negative spacing tightens the stack so the offsets read as depth instead of as repetition -- **{font}** — typeface; pick a black/900 weight family with strong horizontal strokes -- **{frontColor}** — front-layer color; the brand or accent color -- **{backHueRGB}** — RGB triplet for back layers (e.g. matched to a brand glow). Used inside `rgba({backHueRGB}, ${alpha})` - -### Cascade entry (default form) - -- **LAYER_CASCADE_START** — timeline offset where the cascade begins. - - Constraints: ≥ 0; if another beat precedes, ≥ that beat's end -- **LAYER_CASCADE_STEP** — delay between each layer's fade-in. - - Range: 0.04-0.10 s. Smaller feels almost-simultaneous; larger feels stepped and mechanical -- **LAYER_FADE_DUR** — duration of each individual layer's fade-in. - - Range: 0.3-0.6 s - -### Depth-grow tween - -- **DEPTH_GROW_START** — when the offset growth begins. - - Constraints: typically `≈ LAYER_CASCADE_START`; align so the first layer's fade and the depth growth start together -- **DEPTH_GROW_DUR** — duration over which offsets interpolate from 0 to full. - - Range: 0.4-0.8 s. Roughly match `LAYER_FADE_DUR × LAYER_COUNT / 2` so depth lands as the last layer fades in - -### Static-depth variation - -- **STATIC_ENTRY_SCALE** — initial scale before the whole stack fades in. - - Range: 0.94-0.98 — subtle inflation; larger reads as a separate "pop" effect -- **STATIC_ENTRY_DUR** — fade-in duration for the whole stack. - - Range: 0.5-0.8 s - -### Dynamic-pulse variation - -- **BEAT_CYCLES** — number of full beat cycles across `BEAT_DUR`. - - Range: `BEAT_DUR / 1.5s ≤ BEAT_CYCLES ≤ BEAT_DUR / 0.7s` (one beat per 0.7-1.5 s reads as a heartbeat) -- **BEAT_DUR** — pulse tween duration. - - Constraints: tied to the visible window of the depth stack -- **BEAT_AMP** — fractional amplitude of the offset pulse. - - Range: 0.2-0.6. Smaller is a gentle breathing depth; larger reads as a kick-drum thump -- **BEAT_START** — when the pulse begins. - - Constraints: `≥ DEPTH_GROW_START + DEPTH_GROW_DUR` so the pulse modulates a fully-grown stack - -### Color-shift variation - -- **HUE_BASE / HUE_STEP** — base hue (front layer) and per-layer hue rotation. - - Range: `HUE_STEP` 4-12°. Larger steps cycle further around the color wheel and read as glitch -- **SAT_PCT** — fixed saturation for all layers. - - Range: 60-85% -- **LIGHT_BASE / LIGHT_STEP** — base lightness and per-layer darkening. - - Range: `LIGHT_STEP` 3-8 percentage points so back layers darken into the background - -## Key Principles +## Variations -- **Layer count 4-6** — fewer than 4 doesn't read as 3D, more than 6 visually clutters on tight kerning -- **Offset 1-3 px per axis** — subtle is dramatic. `OFFSET = 6+` looks like a glitch rather than depth -- **Offset direction implies light direction** — `(+x, +y)` = light from upper-left; `(-x, +y)` = light from upper-right. Pick one and be consistent across the composition -- **Back layers translucent OR darker** — DON'T make them MORE saturated than the front (looks like a halo). Each back layer should be slightly more transparent (`alpha -= BACK_ALPHA_STEP per layer`) or slightly darker -- **Last (front) layer `position: relative`** to define container size; all others `position: absolute` stack behind -- **Bold/black weight + large size** — 900 weight, 60 px+ minimum. Thin text loses the layered illusion -- **Don't apply per-letter animation on top of layers** — character animations (hacker-flip, typewriter) on top of 6-layer depth = chaos. If you need both effects, drop depth to 2-3 layers OR apply layers only to the static post-reveal state +- **Static depth** (single hero shot) — render all layers at final positions from t=0; optionally fade the whole stack in with a subtle scale (0.94–0.98 → 1, 0.5–0.8s). +- **Dynamic depth pulse** — after the grow completes, modulate the offsets with a sine multiplier `1 + sin(p) × BEAT_AMP` (BEAT_AMP 0.2–0.6; one beat per 0.7–1.5s reads as a heartbeat). +- **Color-shift back layers** — instead of fading to translucent, step hue/lightness per layer: `hsla(HUE_BASE − i × HUE_STEP, SAT_PCT%, LIGHT_BASE − i × LIGHT_STEP%, 1)` (HUE_STEP 4–12°; larger reads as glitch). Depth reads as a colored cast shadow. + +## Values + +| token | range | notes | +| ------------------- | ------------------------ | ---------------------------------------------------------------------- | +| LAYER_COUNT | 4–6 | <4 doesn't read as 3D; >6 clutters on tight kerning | +| OFFSET_X / OFFSET_Y | 1–3px each | >4px reads as glitch / chromatic aberration, not depth | +| BACK_ALPHA_MAX | 0.6–0.85 | nearest back layer; >0.9 fights the front for dominance | +| BACK_ALPHA_STEP | 0.08–0.15 | small = soft gradient; large = discrete plates | +| BACK_ALPHA_MIN | 0.1–0.2 | floor — below 0.1 the deepest layer vanishes on dark backgrounds | +| HERO_FONT_SIZE | 60px min; 200–340px hero | thin/small text loses the layered illusion | +| HERO_LETTER_SPACING | −0.03em–0 | tighter makes offsets read as depth, not repetition | +| LAYER_CASCADE_STEP | 0.04–0.10s | smaller ≈ simultaneous; larger feels stepped | +| LAYER_FADE_DUR | 0.3–0.6s | per-layer fade | +| DEPTH_GROW_DUR | 0.4–0.8s | ≈ `LAYER_FADE_DUR × LAYER_COUNT / 2` so depth lands with the last fade | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `text-shadow`** alongside layered depth — they compound and over-extrude -- **Use `transform: translate()` for offsets, not `top`/`left`** — translate composes cleanly with parent's centering and avoids reflow -- **`pointer-events: none` on back layers** — they're decorative; don't catch hover or selection -- **Set layer color via `rgba()` not opacity** — opacity on the whole element fades the rendered glyph including any shadow; rgba in `color` fades just the glyph - -## Combinations - -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — render the counter number with depth layers -- [sine-wave-loop.md](sine-wave-loop.md) — idle breathing on the front layer after reveal -- [center-outward-expansion.md](center-outward-expansion.md) — depth-stacked wordmark reveals after burst lands +- **Offset direction implies light direction** — `(+x, +y)` = light upper-left, `(-x, +y)` = upper-right; one sign convention for the whole composition. +- **Back layers translucent OR darker — never more saturated than the front** (reads as a halo, not depth). +- **Set back-layer color via `rgba()` in `color`, not element `opacity`** — opacity fades the whole rendered glyph including any shadow. +- **Front layer `position: relative` defines container size**; back layers absolute with `pointer-events: none`; offsets via `transform: translate()`, never `top`/`left`. +- **No CSS `text-shadow` alongside layered depth** — they compound and over-extrude. +- **No per-letter animation on top of the stack** — hacker-flip / typewriter over 6-layer depth is chaos; drop to 2–3 layers or apply depth only to the static post-reveal state. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — staggered fade-ins + onUpdate for dynamic depth -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`counting-dynamic-scale` (counter rendered with depth layers) · `sine-wave-loop` (idle breathing on the front layer post-reveal) · `center-outward-expansion` (depth-stacked wordmark after the burst lands). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/ai-tracking-box.md b/plugins/visual-content/skills/hyperframes-animation/rules/ai-tracking-box.md index 05a6dcf..0ae63af 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/ai-tracking-box.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/ai-tracking-box.md @@ -7,82 +7,29 @@ metadata: # AI Tracking Box -A bounding box with corner markers ("L-brackets") that follows a moving target, simulating real-time AI detection. Position and size oscillate on sine paths to mimic continuous re-computation. Conventionally rendered in "AI detection yellow" (`{detectionYellow}`) on a dark background with a confidence label. +A bounding box of four L-bracket corners + a confidence label that follows a moving target, simulating real-time AI detection. Rendered in detection yellow (`#facc15` family) on a dark background — the industry convention (AV HUDs, security CV, ML demos); red reads "warning", green "success", blue "info" — none read "detection." ## How It Works -- Box position `(x, y)` and size `(w, h)` are derived from sine + drift across composition time -- 4 L-bracket corner markers (`
` per corner with two-sided borders) sit ON the box -- Optional label tag above the top-left corner showing class name + confidence percent +ONE `ease: "none"` driver tween advances a phase `p`; its `onUpdate` computes the TARGET's position from trig, then derives the box's position/size FROM the target — every frame, in that order. The box never gets its own position tween: if it trails the target it reads as a broken tracker, not a smart AI. Size jitters a few percent off-tempo (non-integer frequency multiple) to mimic continuous re-fitting, and the confidence label flickers inside [95, 99]. -All driven by GSAP timeline so HF seeks deterministically. - -## HTML +## Recipe ```html -
- -
-
{Brand}
-
{targetGlyph}
-
- - -
-
-
-
-
-
{targetGlyph} {LABEL} · {confidence}%
-
+ +
{targetGlyph}
+
+
+
+
+
+
{LABEL} · {confidence}%
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - background: radial-gradient(ellipse at center, {bgInner} 0%, {bgOuter} 70%); - font-family: {font}; - overflow: hidden; -} -.bg { - position: absolute; - inset: 0; - display: grid; - place-items: center; - gap: 60px; -} -.bg-content { - position: absolute; - top: 120px; - left: 50%; - transform: translateX(-50%); - font-size: 80px; - font-weight: 900; - color: {bgTextColor}; - letter-spacing: 12px; - text-transform: uppercase; -} -.bg-mascot { - position: absolute; - font-size: 240px; - line-height: 1; -} - .track-box { - position: absolute; - /* Position + size set by GSAP onUpdate */ + position: absolute; /* position + size written by the driver's onUpdate */ pointer-events: none; will-change: transform, width, height; } @@ -91,292 +38,102 @@ All driven by GSAP timeline so HF seeks deterministically. width: 48px; height: 48px; } +/* Each corner draws only its two outer borders — .tr/.bl/.br mirror this: */ .corner.tl { top: -8px; left: -8px; border-top: 6px solid {detectionYellow}; border-left: 6px solid {detectionYellow}; } -.corner.tr { - top: -8px; - right: -8px; - border-top: 6px solid {detectionYellow}; - border-right: 6px solid {detectionYellow}; -} -.corner.bl { - bottom: -8px; - left: -8px; - border-bottom: 6px solid {detectionYellow}; - border-left: 6px solid {detectionYellow}; -} -.corner.br { - bottom: -8px; - right: -8px; - border-bottom: 6px solid {detectionYellow}; - border-right: 6px solid {detectionYellow}; -} .label { position: absolute; top: -56px; left: -8px; - padding: 8px 16px; background: {detectionYellow}; - color: {labelTextColor}; - font-family: {monoFont}; - font-size: 24px; - font-weight: 800; - letter-spacing: 2px; - border-radius: 6px; + color: {labelTextColor}; /* near-black on yellow */ + font-family: {monoFont}; /* mono = machine readout */ white-space: nowrap; } ``` -## GSAP Timeline - -```html - - -``` - -## How to Choose Values - -- **ENTRY_SCALE** — start scale of the box before it pops in - - Range: 0.5-0.9 - - Effects: low end = stronger pop / more "snapping into focus"; high end = subtle reveal - - Constraints: must be < 1 (box scales UP into place) - - Reference: examples use 0.7 - -- **ENTRY_DUR** — duration of the fade-in + scale-up (seconds) - - Range: 0.3-0.8 s - - Effects: low end = snappy / authoritative lock-on; high end = soft / observational - - Constraints: should end before TRACK_START - - Reference: examples use 0.5 - -- **ENTRY_START** — when the entry tween begins (seconds, absolute on timeline) - - Range: 0-2 s typically - - Effects: late start = lets the viewer notice the target first before the AI "finds" it; early start = AI is already watching - - Constraints: ENTRY_START + ENTRY_DUR ≤ TRACK_START - - Reference: examples use 0.5 - -- **ENTRY_BOUNCE** — coefficient passed to `back.out(...)` on entry - - Range: 1.2-2.5 - - Effects: low end = subtle overshoot; high end = exaggerated snap (reads as "aggressive lock-on") - - Constraints: stick to `back.out` family — `elastic` reads as cartoonish, `power` reads as flat - - Reference: examples use 1.4 - -- **TRACK_START** — when continuous tracking begins (seconds) - - Range: ≥ ENTRY_START + ENTRY_DUR - - Effects: gap between entry and tracking = pause for emphasis; no gap = seamless lock + follow - - Constraints: TRACK_START + TRACK_DUR ≤ composition duration - - Reference: examples use 1.0 - -- **TRACK_DUR** — length of the tracking phase (seconds) - - Range: 2-8 s - - Effects: short = "quick scan"; long = "sustained observation" - - Constraints: must accommodate at least one full CYCLE to read as oscillation - - Reference: examples use 4.0 - -- **CYCLES** — number of full sine oscillations of the target across TRACK_DUR - - Range: 0.5-3 - - Effects: low = lazy drift; high = jittery / hyperactive target - - Constraints: CYCLES / TRACK_DUR sets effective Hz of drift; keep < ~0.6 Hz or motion blurs - - Reference: examples use 1.5 - -- **DRIFT_X / DRIFT_Y** — amplitude of target oscillation around screen center (px, 1920×1080 basis) - - Range: 40-200 px - - Effects: small = subtle hover; large = wide chase that pushes the box near the frame edge - - Constraints: SCREEN_CENTER ± DRIFT must keep mascot fully on screen given MASCOT_SIZE - - Reference: examples use 80 / 50 - -- **SIZE_BASE** — mean width/height of the bounding box (px) - - Range: 200-500 px - - Effects: small = "specimen tag"; large = "the box IS the subject" - - Constraints: must visibly enclose the target glyph at all confidence sizes - - Reference: examples use 320 - -- **SIZE_VAR** — half-amplitude of per-frame size jitter (px) - - Range: 5-10% of SIZE_BASE - - Effects: low end = stable / confident detector; high end = jittery / re-fitting detector. Outside this range reads as "broken" (too much) or "static UI" (none) - - Constraints: keep < 0.15 × SIZE_BASE to avoid breaking the L-bracket illusion - - Reference: examples use 30 (~9% of 320) - -- **SIZE_FREQ_MULT** — multiplier on tracking phase for size oscillation - - Range: 1.5-3 - - Effects: 1 = size pulses in lock-step with drift (reads mechanical); irrational ratio = organic re-fitting - - Constraints: avoid integer ratios; non-integer reads as continuous recomputation - - Reference: examples use 2.3 - -- **MASCOT_SIZE** — rendered width of the mascot element (px); used to center it on (mx, my) - - Range: matches CSS `font-size` of `.bg-mascot` - - Effects: must match the actual rendered size or mascot drifts out of the box - - Constraints: MASCOT_SIZE / 2 = offset applied to top/left - - Reference: examples use 240 (matches `font-size: 240px`) - -- **CONFIDENCE_MEAN** — center % shown on the label - - Range: 95-99 - - Effects: < 95 reads "uncertain"; 100 reads "fake-precise". 97 is the sweet spot for "confident AI" - - Constraints: keep CONFIDENCE_MEAN + CONFIDENCE_VAR ≤ 99 - - Reference: examples use 97 - -- **CONFIDENCE_VAR** — flicker half-range around CONFIDENCE_MEAN - - Range: 1-3 - - Effects: 0 = static (looks like a screenshot); >3 = unstable (looks broken) - - Constraints: CONFIDENCE_MEAN ± CONFIDENCE_VAR ⊂ [95, 99] - - Reference: examples use 2 - -- **CONFIDENCE_FREQ_MULT** — multiplier on tracking phase for label flicker - - Range: 3-6 - - Effects: low = synced with drift (mechanical); high = fast nervous flicker (reads "live inference") - - Constraints: keep above SIZE_FREQ_MULT so label flickers faster than the box breathes - - Reference: examples use 4 - -- **COMP_WIDTH / COMP_HEIGHT** — composition pixel dimensions (used to derive SCREEN_CENTER) - - Range: dictated by the HF composition (`data-width` / `data-height`) - - Effects: not a creative choice — match the parent composition - - Constraints: SCREEN_CENTER = (COMP_WIDTH/2, COMP_HEIGHT/2) - - Reference: examples use 1920 × 1080 - -- **{detectionYellow}** — corner-marker + label background color - - This is a **discrete convention**, not a tunable range. AI detection overlays are yellow on dark backgrounds across the industry (autonomous-vehicle HUDs, security CV, ML demos). Red reads as "warning", green as "success", blue as "info" — none read as "detection." - - Recommended: a saturated warm yellow (`#facc15` / `#FCD34D` family) on a dark navy or near-black background. Substituting any other hue loses genre legibility. - - Reference: examples use `#facc15` (Tailwind `yellow-400`) - -- **{bgInner} / {bgOuter}** — radial-gradient background stops - - Should be dark and low-chroma so the yellow markers pop - - Constraints: choose colors with sufficient contrast against {detectionYellow} (the corners and label must remain readable) - - Reference: examples use `#161a3a` (inner) → `#0b0d1f` (outer) - -- **{labelTextColor}** — text color inside the yellow label tag - - Constraints: must contrast against {detectionYellow}; typically the same near-black as {bgOuter} - - Reference: examples use `#0b0d1f` - -- **{font} / {monoFont}** — scene text font and label font - - {font}: sans-serif body font for {Brand} backdrop - - {monoFont}: monospaced font for the confidence label (mono reinforces "machine readout" affordance) - - Reference: examples use `"Inter", sans-serif` and `"JetBrains Mono", monospace` - -## Variations - -### Multi-object detection - -Multiple boxes at different phases (each tracking its own mascot). Each is its own onUpdate-driven set; offset their phase by `Math.PI / N` so they don't tick synchronously. - -### Lost-then-reacquired - -The box fades to {LOST_OPACITY} (~{LOST_DUR}) then re-snaps to a new position with a "REACQUIRED" label flash: - ```js -tl.to(box, { opacity: LOST_OPACITY, duration: LOST_DUR }, LOST_START); +const box = document.getElementById("track-box"); +const mascot = document.getElementById("mascot"); +const label = document.getElementById("label"); +const C = { x: COMP_WIDTH / 2, y: COMP_HEIGHT / 2 }; + +// Entry — the AI "locks on" +gsap.set(box, { opacity: 0, scale: ENTRY_SCALE }); tl.to( box, - { opacity: 1.0, duration: REACQUIRE_DUR, ease: `back.out(${REACQUIRE_BOUNCE})` }, - REACQUIRE_START, + { opacity: 1, scale: 1, duration: ENTRY_DUR, ease: `back.out(${ENTRY_BOUNCE})` }, + ENTRY_START, ); -tl.to(label, { textContent: "REACQUIRED · 99%", duration: 0 }, REACQUIRE_START); -``` -(LOST_OPACITY ≈ 0.2-0.4; REACQUIRE_BOUNCE ≈ 1.8-2.5 for a snappier re-lock than the initial entry.) - -### Tracking-then-zoom - -After tracking, the camera (via [viewport-change](viewport-change.md)) zooms into the tracked box. Combined effect: "the AI found something, now show it." +// Tracking — target first, box derived from it, every frame +const tracking = { p: 0 }; +tl.to( + tracking, + { + p: Math.PI * 2 * CYCLES, + duration: TRACK_DUR, + ease: "none", + onUpdate: () => { + const mx = C.x + Math.cos(tracking.p) * DRIFT_X; + const my = C.y + Math.sin(tracking.p) * DRIFT_Y; + mascot.style.left = `${mx - MASCOT_SIZE / 2}px`; + mascot.style.top = `${my - MASCOT_SIZE / 2}px`; + + const w = SIZE_BASE + Math.sin(tracking.p * SIZE_FREQ_MULT) * SIZE_VAR; + const h = SIZE_BASE + Math.sin(tracking.p * SIZE_FREQ_MULT + Math.PI / 2) * SIZE_VAR; + box.style.width = `${w}px`; + box.style.height = `${h}px`; + box.style.left = `${mx - w / 2}px`; + box.style.top = `${my - h / 2}px`; + + const conf = Math.round( + CONFIDENCE_MEAN + Math.sin(tracking.p * CONFIDENCE_FREQ_MULT) * CONFIDENCE_VAR, + ); + label.textContent = `${LABEL_TEXT} · ${conf}%`; + }, + }, + TRACK_START, +); +``` -## Key Principles +## Variations -- **Yellow on dark background is the detection convention** — see {detectionYellow} entry. Other colors lose the genre signal. -- **Box ALWAYS contains the target** — recompute box position EVERY frame from target position; never trail behind. If the box lags, it reads as "broken tracker," not "smart AI." -- **Subtle size variation (~5-10% of SIZE_BASE)** — too much and the tracker looks confused; just right reads as "real-time recomputation." -- **Corner markers, not full borders** — L-brackets are the genre signature. Full border looks like a generic UI box. -- **Confidence label flickers in a tight range (CONFIDENCE_MEAN ± CONFIDENCE_VAR inside [95, 99])** — outside that range reads as "uncertain"; ≥100 reads as "fake-precise." -- **No CSS animation for the tracking — use timeline onUpdate** — HF seek-by-frame doesn't sync with CSS animation. +- **Multi-object**: one driver per box/target pair, phases offset by `π / N` so they don't tick synchronously. +- **Lost-then-reacquired**: fade the box to ~0.2–0.4 opacity, then re-snap with a harder `back.out(1.8–2.5)` and flash a "REACQUIRED · 99%" label via `tl.set`. +- **Tracking-then-zoom**: hand off to [viewport-change.md](viewport-change.md) — "the AI found something, now show it." + +## Values + +| token | range | notes | +| -------------------- | ------------------ | ------------------------------------------------------------------------ | +| ENTRY_SCALE | 0.5–0.9 | < 1 — the box snaps UP into focus | +| ENTRY_DUR / \_BOUNCE | 0.3–0.8s / 1.2–2.5 | `back.out` only — elastic reads cartoonish, power reads flat | +| TRACK_START | ≥ entry end | a gap = pause for emphasis; none = seamless lock + follow | +| TRACK_DUR | 2–8s | ≥ one full cycle or the drift never reads as oscillation | +| CYCLES | 0.5–3 | keep effective rate < ~0.6 Hz or the motion blurs | +| DRIFT_X / DRIFT_Y | 40–200px | center ± drift must keep the target fully on screen | +| SIZE_BASE | 200–500px | must visibly enclose the target at all jitter sizes | +| SIZE_VAR | 5–10% of SIZE_BASE | more reads broken, none reads like a screenshot; keep < 0.15× | +| SIZE_FREQ_MULT | 1.5–3, non-integer | integer ratios pulse in lock-step with drift = mechanical | +| CONFIDENCE_MEAN/VAR | 95–99 / 1–3 | mean ± var ⊂ [95, 99]; < 95 "uncertain", 100 "fake-precise"; 97 is sweet | +| CONFIDENCE_FREQ_MULT | 3–6 | > SIZE_FREQ_MULT — label flickers faster than the box breathes | +| MASCOT_SIZE | = rendered size | mismatch drifts the target out of the box | + +Tokens: `{detectionYellow}` `#facc15` family; `{bgInner}/{bgOuter}` dark low-chroma radial so the yellow pops; `{labelTextColor}` near-black; `{monoFont}` for the label. ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS animation on `.track-box` or `.corner`** — must be timeline-driven -- **`will-change: transform, width, height`** on `.track-box` -- **`pointer-events: none`** on `.track-box` — decorative overlay -- **Box position recomputed per-frame from target** — never tween box position separately from target - -## Combinations - -- [viewport-change.md](viewport-change.md) — zoom into the tracked box after detection phase -- [multi-phase-camera.md](multi-phase-camera.md) — wide shot during tracking, push-in on lock -- [sine-wave-loop.md](sine-wave-loop.md) — the mascot itself idle-breathes inside the box +- **❗ Box recomputed per-frame FROM the target** — one driver computes the target position, then the box derives from it in the same `onUpdate`. Never tween the box's position separately. +- **Corner L-brackets, not a full border** — the genre signature; a full border reads as a generic UI box. +- **Yellow-on-dark** — substituting another hue loses genre legibility. +- **Confidence flickers in a tight band inside [95, 99]**, in a mono font. +- **`pointer-events: none`** on the box — it's a decorative overlay. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — onUpdate writing multi-element positions -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`viewport-change` (zoom into the detection) · `multi-phase-camera` (wide during tracking, push-in on lock) · `sine-wave-loop` (the target idle-breathes inside the box). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/ambient-glow-bloom.md b/plugins/visual-content/skills/hyperframes-animation/rules/ambient-glow-bloom.md index 6423b4c..e1807bd 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/ambient-glow-bloom.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/ambient-glow-bloom.md @@ -7,299 +7,127 @@ metadata: # Ambient Glow Bloom -A soft radial glow that **blooms in behind a hero element** (card, logo, metric) and holds, giving it presence. Unlike `press-release-spring`'s click-triggered burst or `asr-keyword-glow`'s word-timed envelope, this glow is **un-triggered** — it simply blooms on the hero's settle and stays lit. Two forms: a **hero bloom** that swells behind a settling element and then breathes, and a **traveling glow sweep** that translates a soft highlight across a surface exactly once. Both are finite, deterministic, and seek-safe. +A soft radial glow that **blooms in behind a hero element** (card, logo, metric) and holds, giving it presence. Unlike `press-release-spring`'s click-triggered burst or `asr-keyword-glow`'s word-timed envelope, this glow is **un-triggered** — it blooms on the hero's settle and stays lit. Two forms: a **hero bloom** that swells behind a settling element then breathes, and a **traveling sweep** that translates a soft highlight across a surface exactly once. ## How It Works -A radial-gradient layer sits **behind** the hero (`z-index` below it), starting at `opacity: 0`. Over the bloom-in window it ramps `opacity: 0 → peak` with a gentle `scale` swell (the halo "inflates" into place), timed to land on the hero's settle so the two read as one beat. +A radial-gradient layer sits **behind** the hero (glow `z-index: 1`, hero `z-index: 2` — a glow in front occludes it), starting at `opacity: 0`. Over the bloom-in window it ramps `opacity: 0 → peak` with a gentle `scale` swell, timed so `BLOOM_START + BLOOM_DUR` lands on the hero's settle — glow and hero resolve as ONE beat ("powering on"), never glow-then-card. After bloom-in: -Two forms diverge after bloom-in: +1. **Hero bloom** — a **bounded idle breathe** during the hold: a finite `ease: "none"` tween advances a `phase` proxy and `onUpdate` nudges opacity + scale a hair around peak (never a `yoyo` loop). `sin(0) = 0` → the breathe starts exactly at the bloom's resting state. +2. **Traveling sweep** — a narrow highlight band at one edge translates **once** across to the other (`x` off-surface to off-surface), clipped to the surface (`overflow: hidden`). One pass, no return — a repeating sweep reads as a loading shimmer, not a reveal accent (the shimmer-sweep variation below is the sanctioned exception). -1. **Hero bloom** — once lit, the glow does a **bounded idle breathe** during the hold. Drive it with an `onUpdate` reading `tl.time()` (NOT a `repeat: -1` yoyo): a `Math.sin` of elapsed time nudges `opacity` and `scale` a hair around their peak. At `sin(0) = 0` the breathe starts exactly at the bloom's resting state — no jump. -2. **Traveling sweep** — a narrow highlight gradient at one edge of the surface translates **once** across to the other edge (`x` from off-surface to off-surface), a single finite pass. No loop, no return. The sweep layer is clipped to the surface so the highlight only reads where it overlaps. +Peak opacity stays restrained (**≤ 0.45 hard ceiling**) so the glow gives presence without washing the frame; the glow color is **darker + more saturated** than the element it backs (a same-hue, same-lightness glow disappears into the surface). -Peak opacity stays restrained (≤ ~0.45) so the glow gives presence without washing the frame; the glow color is darker / more saturated than the element it backs. - -## HTML +## Recipe ```html -
-
- -
-
{HeroLabel}
-
+ +
+
+ +
{HeroLabel}
+
+ ``` -For the traveling-sweep form, the sweep layer is clipped to the surface it crosses: - -```html -
- -
-
-``` - -## CSS - -```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; -} -.bloom-stage { - position: relative; - display: grid; - place-items: center; -} -.hero-card { - position: relative; - z-index: 2; - width: HERO_WIDTH; - height: HERO_HEIGHT; - display: grid; - place-items: center; - background: {heroBg}; - border-radius: HERO_RADIUS; - font-family: {font}; - font-weight: 900; - font-size: HERO_FONT_SIZE; - color: {heroTextColor}; -} -.bloom-glow { - /* Radial halo behind the hero — extends past it via negative inset */ - position: absolute; - z-index: 1; - inset: GLOW_INSET; - background: {glowGradient}; - opacity: 0; - transform: scale(GLOW_START_SCALE); - transform-origin: 50% 50%; - pointer-events: none; - /* will-change because opacity + scale both animate during bloom AND breathe */ - will-change: transform, opacity; -} - -/* Traveling-sweep form */ -.surface { - position: relative; - overflow: hidden; /* clips the sweep to the surface footprint */ - border-radius: SURFACE_RADIUS; -} -.sweep { - position: absolute; - top: 0; - bottom: 0; - /* A narrow soft band, wider than it needs to be so the falloff is gentle */ - width: SWEEP_WIDTH; - /* Diagonal highlight: angle the gradient so the sweep reads as raked light */ - background: {sweepGradient}; - opacity: 0; - pointer-events: none; - will-change: transform, opacity; -} -``` - -## GSAP Timeline - -```html - - -``` - -## Variations - -### Bloom-and-hold (no breathe) - -For very short scenes (< 3s) or when the hero already has its own idle, skip Phase 2 entirely — bloom to peak and hold flat. The single `fromTo` is the whole recipe; the glow is just lit presence. - -### Pulse-on-arrival (one swell, then settle to a lower hold) - -Bloom slightly **past** peak, then ease back down to a steady hold level — a single breath that punctuates the hero's landing without an ongoing loop. Two adjacent tweens (state continuity, same as `press-release-spring`): - ```js +// ── Form A: HERO BLOOM ── bloom in soft, landing on the hero's settle. tl.fromTo( - glow, + "#bloom-glow", { opacity: 0, scale: GLOW_START_SCALE }, - { opacity: GLOW_OVERSHOOT_OPACITY, scale: 1.06, duration: BLOOM_DUR, ease: "power2.out" }, + { opacity: GLOW_PEAK_OPACITY, scale: 1, duration: BLOOM_DUR, ease: "power2.out" }, BLOOM_START, ); +// Bounded breathe during the hold — finite phase tween, NOT a yoyo loop. +const glow = document.getElementById("bloom-glow"); +const phase = { p: 0 }; tl.to( - glow, - { opacity: GLOW_HOLD_OPACITY, scale: 1, duration: SETTLE_DUR, ease: "power2.inOut" }, + phase, + { + p: Math.PI * 2 * BREATHE_CYCLES, + duration: BREATHE_DUR, + ease: "none", + onUpdate: () => { + const s = Math.sin(phase.p); + glow.style.opacity = String(GLOW_PEAK_OPACITY + s * OPACITY_AMP); + glow.style.transform = `scale(${1 + s * SCALE_AMP})`; + }, + }, BLOOM_START + BLOOM_DUR, ); -``` - -### Multi-hero relay (staggered blooms behind a row of cards) - -Bloom each card's glow on a stagger so presence sweeps across the row. Per-glow `BLOOM_START` offset by `STAGGER` (~0.15-0.3s); shrink `OPACITY_AMP` / `SCALE_AMP` per the concurrent-elements rule below so N breathing halos don't compound into a shimmer. - -### Diagonal raked sweep (wordmark sheen) - -Angle `{sweepGradient}` (e.g. a 105° linear gradient) and let the band travel left→right across a wordmark or logo lockup. Reads as light raking across a surface — the classic one-pass logo sheen. Same single-pass timeline; just a narrower `SWEEP_WIDTH` and a higher `SWEEP_PEAK_OPACITY` since it's a tight highlight on a small target. - -## How to Choose Values - -### Glow geometry - -- **GLOW_INSET** — negative inset so the radial halo extends past the hero edges. - - Range: `-200` to `-450` px on a 1920×1080 canvas; larger halo for a bigger hero - - Effects: too small and the glow is a tight rim, not ambient presence -- **GLOW_START_SCALE** — scale at the start of bloom-in (the halo "inflates" to 1). - - Range: 0.80 (clear inflation) → 0.92 (subtle) → 1.0 (no swell, opacity-only bloom) - - Constraints: keep ≤ 1.0 — the swell should grow into place, not shrink -### Bloom-in dynamics - -- **BLOOM_DUR** — bloom-in duration. - - Range: 0.6-1.4s; longer for a hero that's still settling so they land together - - Effects: shorter → the glow "snaps on"; longer → it suffuses in (the ambient feel) -- **BLOOM_START** — when the bloom begins. - - Constraints: align so `BLOOM_START + BLOOM_DUR` ≈ the hero's settle frame, so glow and hero resolve as one beat — not glow-then-card or card-then-glow -- **GLOW_PEAK_OPACITY** — peak halo opacity. - - Range: 0.15 (subtle) → 0.30 (default) → 0.45 (dramatic) - - **Constraints: ≤ 0.45** — higher washes the whole frame and the hero loses contrast against its own glow +// ── Form B: TRAVELING SWEEP ── one finite pass, constant glide. +tl.fromTo( + "#sweep", + { x: SWEEP_START_X, opacity: 0 }, + { x: SWEEP_END_X, opacity: SWEEP_PEAK_OPACITY, duration: SWEEP_DUR, ease: "none" }, + SWEEP_START, +); +tl.to("#sweep", { opacity: 0, duration: SWEEP_FADE_DUR, ease: "power1.in" }, SWEEP_FADE_START); +``` -### Idle breathe (hero-bloom form) +## Variations -- **BREATHE_DUR** — breathe tween length. - - Constraints: equals `TOTAL_DURATION − (BLOOM_START + BLOOM_DUR)` to fill the hold with motion -- **BREATHE_CYCLES** — number of full breaths across `BREATHE_DUR`. - - Range: `BREATHE_DUR / 4s ≤ CYCLES ≤ BREATHE_DUR / 2.5s` (a 2.5-4s breath period reads as a slow ambient pulse — glow breathing wants to be slower than element breathing) -- **OPACITY_AMP** — sine amplitude on opacity around the peak. - - **Default: 0.02-0.05** (barely-perceptible pulse — the right answer for most scenes) - - Constraints: `GLOW_PEAK_OPACITY + OPACITY_AMP` must stay ≤ 0.45 -- **SCALE_AMP** — sine amplitude on the halo scale. - - **Default: 0.01-0.03** (the halo "breathes" without visibly resizing) - - Push higher only when the glow is the sole motion in a short isolated scene +- **Bloom-and-hold** — for scenes <3s or a hero with its own idle, skip the breathe: the single `fromTo` is the whole recipe. +- **Pulse-on-arrival** — bloom slightly PAST peak (`GLOW_OVERSHOOT_OPACITY`, `scale: 1.06`), then a second adjacent tween eases down to a steady hold — one breath punctuating the landing, no ongoing loop. +- **Multi-hero relay** — stagger per-glow `BLOOM_START` by ~0.15–0.3s across a row; shrink `OPACITY_AMP` / `SCALE_AMP` per the `/√N` rule below. +- **Diagonal raked sweep** — angle `{sweepGradient}` (~105°) across a wordmark: the classic one-pass logo sheen. Narrower `SWEEP_WIDTH`, higher `SWEEP_PEAK_OPACITY`. -### Traveling sweep +### Shimmer sweep (text-clipped status-phrase working-state) -- **SWEEP_WIDTH** — width of the soft highlight band. - - Range: 15-35% of the surface width (a wide soft band) for a grid sheen; 8-15% for a tight wordmark sheen -- **SWEEP_START_X / SWEEP_END_X** — travel endpoints, both fully off-surface. - - Constraints: start ≈ `-(SWEEP_WIDTH + edge)`, end ≈ `surfaceWidth + edge` — the band must enter from fully off one edge and exit fully off the other, so there's no visible spawn/despawn mid-surface -- **SWEEP_DUR** — single-pass travel duration. - - Range: 0.8-1.6s; one deliberate pass, slow enough to read as light, fast enough not to dominate -- **SWEEP_PEAK_OPACITY** — highlight opacity. - - Range: 0.10 (whisper sheen) → 0.25 (default) → 0.40 (bright rake) - - Constraints: ≤ ~0.45 (same wash limit); tighter sweeps tolerate the high end -- **SWEEP_START / SWEEP_FADE_START / SWEEP_FADE_DUR** — when the pass runs and tails out. - - Constraints: `SWEEP_FADE_START + SWEEP_FADE_DUR ≈ SWEEP_START + SWEEP_DUR` so opacity reaches 0 exactly as the band clears the far edge +The sweep re-aimed **inside type**: a soft highlight gradient clipped into a status phrase ("Thinking…", "Analyzing dataset…") via `background-clip: text` travels left→right through the letterforms — the grey-on-grey shimmer that says _still working_. Unlike every other form here it legitimately **repeats while the status is live**: the repetition is diegetic working-state, not idle wobble (same defense as a blinking caret — the motion performs status). Two things keep it honest: it is **bounded** (one finite tween whose pass count is computed from the status window, never `repeat: -1`), and it is **killed at resolve** — the moment the status completes, the shimmer stops dead; a shimmer surviving into the answer beat turns a working indicator into decoration. -### Tokens +```js +// Status shimmer — N passes as ONE bounded tween. Killed at resolve. +const status = document.getElementById("status-phrase"); +// CSS on #status-phrase: background: {shimmerGradient}; background-size: 300% 100%; +// -webkit-background-clip: text; background-clip: text; color: transparent; +const shimmer = { p: 0 }; +const PASSES = Math.round(STATUS_DUR / PASS_PERIOD); // whole passes, computed up front +tl.to( + shimmer, + { + p: PASSES, + duration: STATUS_DUR, + ease: "none", + onUpdate: () => { + const t = shimmer.p % 1; // 0→1 within each pass; percent axis inverted → left→right travel + status.style.backgroundPosition = `${(1 - t) * 100}% 50%`; + }, + }, + STATUS_START, +); +tl.set(status, { backgroundPosition: "100% 50%" }, STATUS_START + STATUS_DUR); // resolve: dead. +``` -- **{glowGradient}** — radial-gradient, saturated near center fading to transparent. Color should be **darker + more saturated** than `{heroBg}` — a same-color glow looks washed out (same rule as `press-release-spring`'s burst). -- **{sweepGradient}** — a soft band: `transparent → highlight → transparent`. For a sheen, a near-white or brand-tint highlight at low alpha; angle it (e.g. `linear-gradient(105deg, …)`) for a raked look. -- **{heroBg} / {heroTextColor}** — the hero surface the glow backs; high contrast so the lit hero still reads against its halo. +Keep it a whisper: `{shimmerGradient}` is the status text's own grey with one slightly-lighter band (highlight stop a step above the base, nothing near white); `background-size` ~300% keeps the band narrow in the glyphs; `PASS_PERIOD` 1.2–1.8s — slower reads as a sheen accent, faster as a spinner. Whole-number `PASSES` lands the band at its start position exactly at the kill frame, so the `tl.set` is visually a no-op. This is the working-state cousin of `gradient-text-sweep`: reach **here** when the sweep _means_ "in progress," **there** when the gradient is the typographic treatment itself. -## Key Principles +## Values -- **Un-triggered by design** — this glow does NOT wait on a click (`press-release-spring`) or a word timestamp (`asr-keyword-glow`). It blooms on the hero's settle as ambient presence. If you need a triggered burst, reach for one of those rules instead. -- **Glow behind, hero in front** — glow `z-index: 1`, hero `z-index: 2`. A glow in front occludes the hero at peak opacity. -- **Glow color darker + more saturated than the element** — bright hero → dark, saturated halo. A same-hue, same-lightness glow disappears into the surface. -- **Land glow and hero as ONE beat** — time `BLOOM_START + BLOOM_DUR` to the hero's settle. A glow that arrives before or after the card reads as two separate events; arriving together reads as the card "powering on." -- **Restrained peak — default to the LOW end.** `GLOW_PEAK_OPACITY` 0.15-0.30 for most scenes; 0.45 is a hard ceiling. A glow you consciously notice is too strong — it should register as the hero having weight, not as a visible light source. -- **Breathe is BOUNDED, never a loop** — the idle pulse is a finite `onUpdate` tween reading `tl.time()` (via the `phase` proxy), not `repeat: -1` / `yoyo`. `sin(0) = 0` means it starts at the bloom's resting state with no jump. (Same reason as `sine-wave-loop`: an infinite/CSS loop desyncs from the HF seek clock.) -- **Sweep is ONE pass** — the traveling highlight enters off one edge and exits off the other a single time. No return trip, no loop. A repeating sweep reads as a loading shimmer, not a one-time reveal accent. -- **Concurrent halos compound** — N breathing glows in a row add up. Per-glow `OPACITY_AMP` and `SCALE_AMP` ≤ default `/ √N`, and stagger the breathe period (2.6s / 2.9s / 3.3s) so they don't pulse in lockstep. (Same `/√N` discipline as `sine-wave-loop`'s concurrent-elements rule.) -- **Don't combine `boxShadow` glow on the hero with this halo layer** — they compete in the layout pipeline and the result reads muddy. Put the glow on the dedicated `.bloom-glow` layer, not as a shadow on the hero. +| token | range / default | notes | +| ----------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------- | +| GLOW_PEAK_OPACITY | 0.15 (subtle) → 0.30 (default) → **0.45 hard ceiling** | higher washes the frame; a glow you consciously notice is too strong | +| GLOW_INSET | −200 to −450px (1920×1080) | negative so the halo extends past the hero; too small reads as a tight rim | +| GLOW_START_SCALE | 0.80–1.0 | ≤1.0 — grow into place, never shrink | +| BLOOM_DUR / BLOOM_START | 0.6–1.4s | `BLOOM_START + BLOOM_DUR` ≈ the hero's settle frame | +| OPACITY_AMP / SCALE_AMP | 0.02–0.05 / 0.01–0.03 default | `PEAK + OPACITY_AMP ≤ 0.45`; push only when the glow is the sole motion | +| BREATHE_CYCLES | period 2.5–4s per breath | glow breathes slower than element breathing | +| SWEEP_WIDTH | 15–35% of surface (grid) / 8–15% (wordmark) | | +| SWEEP_DUR | 0.8–1.6s | one deliberate pass — slow enough to read as light | +| SWEEP_PEAK_OPACITY | 0.10 → 0.25 (default) → 0.40 | same ≤ ~0.45 wash limit; tight sweeps tolerate the high end | +| SWEEP_START_X / END_X | fully off-surface both ends | no visible spawn/despawn mid-surface; fade reaches 0 as the band clears | +| PASS_PERIOD (shimmer) | 1.2–1.8s | with whole-number PASSES | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition`** on the glow / sweep — interpolates independently of HF seek and flickers -- **No `repeat` / `yoyo` / `repeat: -1`** — the breathe is a bounded finite tween; the sweep is one pass -- **No `Math.random` / `Date.now`** — the breathe phase is deterministic (`phase.p` over a fixed duration) -- **GSAP transform aliases only**: `x`, `y`, `scale`, `rotation` — plus `opacity` / `filter`. Never tween `width` / `height` / `left` / `top` (the halo swell is `scale`, the sweep travel is `x`). -- **`will-change: transform, opacity`** on the glow — it animates both during bloom-in and the breathe -- **Glow peak `opacity ≤ 0.45`** — higher washes the composition -- **Sweep endpoints fully off-surface** — band must enter and exit beyond the clipped edges so it never spawns/despawns mid-frame - -## Combinations - -- [sine-wave-loop.md](sine-wave-loop.md) — pair the hero-bloom form with a sine breathe on the hero element itself; the glow breathes on opacity, the hero breathes on scale/y, slightly out of phase for a layered "alive" hold -- [press-release-spring.md](press-release-spring.md) — distinct sibling: that rule's `bg-glow` is **click-triggered**, this one is un-triggered. Don't run both behind the same element -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — bloom the accent halo behind the hero stat card on the count-up's settle (the `dataviz-countup` blueprint's "soft accent glow blooms behind the hero metric" beat) -- [stat-bars-and-fills.md](stat-bars-and-fills.md) — glow blooms behind the hero metric + its paired graphic as they land together -- [center-outward-expansion.md](center-outward-expansion.md) — run the traveling-sweep across the assembled layout once it resolves (the `grid-card-assemble` blueprint's "traveling-glow sweep across the assembled grid") +- **Glow peak opacity ≤ 0.45** — including breathe amplitude; default to the LOW end (0.15–0.30). +- **Glow behind, hero in front**; glow color darker + more saturated than the hero surface. +- **Land glow and hero as one beat** — before or after reads as two separate events. +- **Breathe is bounded, sweep is one pass** — the only sanctioned repetition is the shimmer sweep, bounded and killed at resolve. +- **Concurrent halos compound** — per-glow amps ≤ default `/√N`, stagger breathe periods (2.6s / 2.9s / 3.3s) so they don't pulse in lockstep. +- **Don't combine a `boxShadow` glow on the hero with this halo layer** — they compete and read muddy; the glow lives on the dedicated layer. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — `onUpdate` writing opacity/transform + bounded sine breathe -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`sine-wave-loop` (hero breathes on scale/y while the glow breathes on opacity, out of phase) · `press-release-spring` (the click-triggered sibling — never both behind one element) · `counting-dynamic-scale` / `stat-bars-and-fills` (bloom behind a landing stat) · `center-outward-expansion` (sweep across the assembled grid) · `gradient-text-sweep` (the design-beat gradient counterpart). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/anchored-layout-expand.md b/plugins/visual-content/skills/hyperframes-animation/rules/anchored-layout-expand.md new file mode 100644 index 0000000..040e976 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/anchored-layout-expand.md @@ -0,0 +1,148 @@ +--- +name: anchored-layout-expand +description: Edge-pinned container grows (or collapses) along ONE axis and in-flow content reflows with it — a pill springs open downward into a dropdown, a panel grows a sub-task stack, an input card stretches as typed text wraps, a pane expands over a neighbor. Transform-only (mask + slide, or proxy-driven scaleY + counter-scale) because width/height tweens are forbidden; the push on subsequent content is a matched translate on the same tween. +metadata: + tags: expand, collapse, anchored, dropdown, menu, accordion, panel, reflow, push, mask, counter-scale, layout +--- + +# Anchored Layout Expand + +> The law: **author the layout at its final (expanded) state in CSS, then fake the collapsed state with transforms.** The container never changes size — the _visible_ region does — and everything downstream rides a matched translate. The browser computes layout ONCE; every intermediate frame is pure transform. + +THE one-axis growth primitive: a container pinned at one edge appears to grow along a single axis, and the in-flow content after it moves in perfect contact with the traveling edge — dropdown, sub-task stack, growing composer card, pane widening over a neighbor. Growth and push are ONE motion: if the panel's bottom edge and the pushed content ever separate or overlap, the illusion dies. + +Distinct from [card-morph-anchor.md](card-morph-anchor.md) (a free-floating two-shot morph with no neighbors to push — this rule's container is a live layout participant), [spring-pop-entrance.md](spring-pop-entrance.md) (arrival at a point, no edge travel or reflow), and [reactive-displacement.md](reactive-displacement.md) (displacement by a colliding intruder; here content moves because the container's edge reached it — layout causality, not collision). + +## How It Works + +1. **Mask** — a wrapper at the final body height (`BODY_H`), `overflow: hidden`. Never tweened. +2. **Sheet** — the panel surface + content inside the mask, starting at `y: -BODY_H` (tucked above the mask window, behind the pinned header). +3. **Below** — ONE wrapper holding everything after the container, also starting at `y: -BODY_H`. +4. **Grow** — ONE `fromTo` drives sheet AND below from `y: -BODY_H → 0`. Shared tween ⇒ the descending bottom edge and the pushed content stay in exact contact by construction. Collapse = the same pair tweened back. + +When the surface must visibly **stretch in place** (rows revealed top-first, or a pane growing sideways), use the proxy counter-scale variant below instead. + +## Recipe + +```html + +
+
+
{headerLabel}
+
+
+
{rowA}
+
{rowB}
+
+
+
+ +
{followingContent}
+
+``` + +```css +/* Layout is the EXPANDED end state — no collapsed geometry exists in CSS. */ +.expander-head { + position: relative; + z-index: 2; /* the sheet slides out from UNDER the header */ +} +.expand-mask { + height: BODY_H; /* authored final height — NEVER tweened */ + overflow: hidden; +} +.expand-sheet { + height: BODY_H; + border-radius: 0 0 SHEET_RADIUS SHEET_RADIUS; /* bottom-only — header + sheet read as one grown card */ + will-change: transform; /* + on .below */ +} +``` + +```js +// BODY_H must equal the mask's CSS height exactly — measure once at build. +// (Montage caveat: per the contract, in a multi-scene master use an authored +// CSS-matched constant instead — later clips may not be laid out yet.) +const BODY_H = document.querySelector("#expand-mask").offsetHeight; + +// The grow: ONE tween, BOTH sides of the seam. +tl.fromTo( + ["#expand-sheet", "#below"], + { y: -BODY_H }, + { y: 0, duration: GROW_DUR, ease: GROW_EASE }, + GROW_AT, +); + +// Garnish: rows already ride the sheet; the fade stagger makes them read as "options arriving". +tl.fromTo( + ".expand-row", + { opacity: 0 }, + { opacity: 1, duration: ROW_FADE_DUR, stagger: ROW_STAGGER, ease: "power2.out" }, + GROW_AT + GROW_DUR * 0.25, +); + +// Collapse — same machinery back; faster (closing is a snap decision). +tl.fromTo( + ["#expand-sheet", "#below"], + { y: 0 }, + { y: -BODY_H, duration: COLLAPSE_DUR, ease: "power3.in", immediateRender: false }, + COLLAPSE_AT, +); +``` + +## Variations + +- **Proxy counter-scale — surface stretches in place** (rows revealed top-first holding their screen positions; the "payload card expands from the tool-call line"). Drive mask `scaleY` and the sheet's exact inverse from ONE proxy — two independent tweens are wrong: eased midpoints of `s` and `1/s` are not inverses and the content squashes mid-grow. Net content scale is `s × 1/s = 1` every frame; seek-safe because everything derives from the one interpolated proxy. + + ```js + const grow = { h: COLLAPSED_H }; // 0 for fully collapsed + tl.fromTo( + grow, + { h: COLLAPSED_H }, + { + h: BODY_H, + duration: GROW_DUR, + ease: GROW_EASE, + onUpdate: () => { + const s = Math.max(grow.h / BODY_H, 0.0001); // clamp: no divide-by-zero + gsap.set("#expand-mask", { scaleY: s, transformOrigin: "50% 0%" }); + gsap.set("#expand-sheet", { scaleY: 1 / s, transformOrigin: "50% 0%" }); + gsap.set("#below", { y: grow.h - BODY_H }); + }, + }, + GROW_AT, + ); + ``` + +- **One-axis pane expand (X)**: same machinery rotated 90° — pin the left edge, sheet from `x: -PANE_W` (or proxy `scaleX` + counter-scale, origin `0% 50%`). Decide the neighbor's fate explicitly: **overlap** (pane paints over it, no neighbor tween) or **push** (neighbor rides the same tween). Never both. +- **Typed-wrap growth** — the composer card gets taller as typed text wraps. Quantize: one short step per wrap boundary, each moving the pair by one `LINE_H`; wrap times come from the deterministic typing schedule ([discrete-text-sequence.md](discrete-text-sequence.md)), never measured at render time. Two battle-tested traps: + - **Composer cards have no pinned header** — a composer grows from its TOP edge (the send-button footer stays put), so a plain y-step clips the card's top out of the mask. Combine the proxy counter-scale with the wrap quantization (step the proxy by `LINE_H` at each wrap time) and split the surface into a **sheet** (carries the top radius) + **footer** (carries the bottom radius) so the growth seam stays invisible. + - **Wrap TIME vs wrap POSITION are two different authorities** — the typing schedule decides _when_ a wrap fires, the browser's line-breaking decides _where_ text actually wraps, and with proportional fonts they silently disagree. Author an explicit `\n` in the typed string (with `white-space: pre-wrap`) at the chosen split point so both derive from the same authored fact. +- **Springy open** (rare, explicitly-playful): `back.out(1.2)` — the edge overshoots a few px; the pushed content bounces with the panel (correct — they're in contact). Default stays `power3.out`. +- **Row grows a sub-task stack**: the row is the pinned header, the stack is the sheet, every later row lives in `#below`; chain several scopes for progressive disclosure. +- **FLIP hand-off**: if the container also TRAVELS to a new layout slot while resizing (prompt promoted to heading, card docking into a sidebar), that's a FLIP problem — `/hyperframes-keyframes` (FLIP recipes). This rule stays the in-place one-axis specialist. + +## Values + +| token | range | notes | +| ------------------------ | --------------------------- | --------------------------------------------------------------------- | +| BODY_H | measured / authored | drift from the CSS height = visible gap or overlap at full open | +| GROW_AT | trigger beat + 0–0.1s | growth needs a cause (click / wrap / status beat) or it reads haunted | +| GROW_DUR | 0.35–0.6s | below ~0.3s the pushed content appears to teleport | +| GROW_EASE | `power3.out` default | `back.out(1.1–1.3)` only for the playful register | +| ROW_STAGGER / \_FADE_DUR | 0.04–0.08s / 0.2–0.3s | start rows ~25% into the grow so none flash inside a closed panel | +| COLLAPSE_DUR | 0.2–0.35s, `power3.in` | faster than open | +| STEP_DUR / LINE_H | 0.12–0.2s / CSS line-height | typed-wrap variant; WRAP_TIMES from the typing script | + +## Critical Constraints + +- **NEVER tween `width` / `height` / `top` / `left` / `margin` / `padding`** — the mask's height is a CSS constant; only its children transform. Tweening the mask IS the forbidden move this rule replaces. +- **`data-layout-allow-overflow` on the mask** — the collapsed phase parks the sheet outside the mask's box by construction, which trips the `hyperframes check` layout gate (`container_overflow`). The flag is the sanctioned waiver: this overflow is the technique working as designed, not a bug. +- **Sheet + below share one tween (or one proxy)** — matched-but-separate tweens on the two sides of the contact edge are the classic seam bug. +- **Everything downstream rides `#below`** — content outside the wrapper is overlapped at t=0 and orphaned during the grow. +- **`overflow: hidden` on the mask** — without it the tucked sheet is visible above the header at t=0. +- **Counter-scale needs a proxy**, clamped `s ≥ 0.0001` (a fully-collapsed body divides by zero). +- **Deterministic sizes** — `BODY_H`, `LINE_H`, `WRAP_TIMES` are build-time constants or one-time measurements, never per-frame layout reads. + +## See also + +`cursor-click-ripple` (the igniting click) · `spring-pop-entrance` (richer per-row arrivals) · `discrete-text-sequence` (the typing that drives stepped growth) · `scale-swap-transition` (the grown menu's exit) · `/hyperframes-keyframes` FLIP (grow + travel). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/asr-keyword-glow.md b/plugins/visual-content/skills/hyperframes-animation/rules/asr-keyword-glow.md index 5c59cea..8a0eb90 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/asr-keyword-glow.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/asr-keyword-glow.md @@ -7,183 +7,84 @@ metadata: # ASR Keyword Glow -Words in a phrase visually activate (glow blur + scale) when "spoken," following an attack-sustain-release (ASR-like) envelope. In a real ASR pipeline these timings come from word-level transcript data; for promotional video, hardcode the timings to control emphasis pacing. The envelope leaves a subtle "rest glow" after the word, creating a breadcrumb of recent emphasis. +Words in a phrase visually activate (glow blur + scale) when "spoken", following an attack-sustain-release envelope over per-word `{ start, end }` timestamps. In a real ASR pipeline the timings come from a word-level transcript (`hyperframes transcribe` — same shape); for promo video, hand-author them to control emphasis pacing. The envelope never falls to zero after a word — it decays to a rest level, leaving a breadcrumb of recent emphasis. ## How It Works -Each word has `{ start, end }` timestamps. At each frame, compute the word's envelope value: +A single linear driver tween (`ease: "none"` — any other ease distorts the per-word envelope; do not change) sweeps scene time; its `onUpdate` loops over ALL words computing each one's envelope: 0 before `start`, linear attack to 1 over `ATTACK_DUR`, sustain at 1 until `end`, decay to `REST_LEVEL` over `RELEASE`, then hold at rest. The envelope drives `text-shadow` blur and `scale` — one driver for the whole phrase, never one tween per word (60+ words would bloat the timeline). -- **Pre-start** → 0 (not yet) -- **Start → peak** → attack (linear ramp 0 → 1) -- **Peak → end** → sustain (stays at 1) -- **End → end+release** → decay (1 → restLevel, typically 0.25) -- **After release** → restLevel (stays subtly highlighted) - -The envelope drives `textShadow` blur radius AND `scale`. Higher blur + bigger scale = "speaking" emphasis. - -## HTML +## Recipe ```html -
-
- - {w1} - {w2} - - {brandWord} -
+ +
+ {w1} + {w2} + + {brandWord}
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {sceneBackgroundColor}; - font-family: {font}; -} .phrase { display: flex; flex-wrap: wrap; - gap: 24px; justify-content: center; - max-width: 1700px; - font-size: 120px; - font-weight: 900; - letter-spacing: 2px; color: {restColor}; - text-align: center; - line-height: 1.2; } .word { - display: inline-block; + display: inline-block; /* required for transform on */ transform-origin: 50% 50%; - /* Initial subtle rest glow */ text-shadow: 0 0 0 {glowColorTransparent}; - will-change: transform, text-shadow; } .word.brand { color: {brandAccentColor}; - letter-spacing: 12px; - text-transform: uppercase; } ``` -## GSAP Timeline - -```html - - + }, + 0, +); ``` -`glowColorRgba(env)` returns the brand glow color with `env`-modulated alpha (e.g. `rgba({glowR}, {glowG}, {glowB}, ${GLOW_ALPHA_BASE + env * GLOW_ALPHA_RANGE})`). +`glowColorRgba(env)` returns the glow color with `env`-modulated alpha. ## Variations -### Multi-octave glow (more dramatic peaks) - -Combine the envelope-driven blur with a sin pulse during the sustain phase — high-emphasis words breathe at peak. The sine frequency `PULSE_HZ` controls how many breaths fit in the sustain window; amplitude `PULSE_AMPLITUDE` controls how visible the breath is. - -```js -const sustain = env * (1 + Math.sin(driver.t * PULSE_HZ) * PULSE_AMPLITUDE); -const blur = MAX_BLUR * sustain; -``` - -### Color shift on the peak - -The active word lerps from `restColor` → `peakColor` as `env` rises, settling back to `restColor` at rest: +- **Karaoke style (RECOMMENDED for video narration)** — the default amplitudes read too subtle in video: inactive words still dominate. Render inactive words DIM and lerp the active word toward bright + larger; at any moment 1–2 words are bright (spoken + lingering rest) and the rest is dim. Use for short phrases (5–10 words) where one word at a time should POP; keep the subtle default for long dense text. Pushes MAX_BLUR, MAX_SCALE_BOOST, and REST↔ACTIVE contrast; everything else identical: ```js -function lerpChannel(a, b, t) { - return Math.round(a + (b - a) * t); -} -el.style.color = `rgb(${lerpChannel(REST_RGB.r, PEAK_RGB.r, env)}, ${lerpChannel(REST_RGB.g, PEAK_RGB.g, env)}, ${lerpChannel(REST_RGB.b, PEAK_RGB.b, env)})`; -``` - -### Karaoke style (dim-rest + bright-active, RECOMMENDED for video narration) - -Default amplitudes (small MAX_BLUR, small MAX_SCALE_BOOST, rest text full white) read as too subtle in video — the inactive words still dominate. Karaoke style fixes this: **inactive words rendered DIM**, active words **lerp toward bright white + larger scale**: - -```js -// Tunable constants — see How to Choose Values -// REST_RGB — dim color for inactive words -// ACTIVE_RGB — bright color at peak (non-brand) -// BRAND_RGB — bright color at peak (brand word) -// MAX_BLUR, MAX_SCALE_BOOST, REST_LEVEL all pushed higher than default - function lerpChannel(a, b, t) { return Math.round(a + (b - a) * t); } @@ -191,96 +92,37 @@ function colorAt(env, isBrand) { const target = isBrand ? BRAND_RGB : ACTIVE_RGB; return `rgb(${lerpChannel(REST_RGB.r, target.r, env)}, ${lerpChannel(REST_RGB.g, target.g, env)}, ${lerpChannel(REST_RGB.b, target.b, env)})`; } - -// In onUpdate: -el.style.color = colorAt(env, el.classList.contains("brand")); +// in onUpdate: el.style.color = colorAt(env, el.classList.contains("brand")); ``` -Visual result: at any moment 1-2 words are bright + glowing (the spoken word + the recently-spoken one's lingering rest), and the rest of the phrase is dim. This is closer to actual karaoke / lyric video aesthetic than the subtle "everyone half-glowing" baseline. +- **Multi-octave glow** — multiply the sustain by `1 + sin(driver.t × PULSE_HZ) × PULSE_AMPLITUDE` so high-emphasis words breathe at peak. +- **Color shift on the peak** — same channel-lerp from `restColor` → `peakColor` as `env` rises (non-karaoke form). +- **3D pop-out** — add `translateZ(env × MAX_POP_Z)` so the spoken word leans toward camera; requires `perspective` on the parent. +- **From real ASR transcripts** — convert `{ word, start_ms, end_ms }` entries to seconds and feed in identically. -When to use karaoke vs default: short narration phrases (5-10 words) where one word at a time should clearly POP → karaoke. Long dense text where many words emphasize subtly → default subtle. Karaoke pushes MAX_BLUR, MAX_SCALE_BOOST, and contrast between REST_RGB and ACTIVE_RGB; everything else is identical. - -### 3D pop-out - -Combine envelope with `translateZ` for words to "lean toward camera" as they speak: - -```js -const popZ = env * MAX_POP_Z; -el.style.transform = `translateZ(${popZ}px) scale(${scale})`; -``` +## Values -Requires `perspective` on the parent. - -### From real ASR transcripts - -For real ASR-driven scenes, replace hardcoded TIMINGS with transcript JSON (each entry has `word`, `start_ms`, `end_ms`). Convert to seconds and feed in identically. The shape `{ [wordKey]: { start, end } }` is the same whether hand-authored or derived from `hyperframes transcribe`. - -## How to Choose Values - -- **TIMINGS** — per-word `{ start, end }` map. Author one entry per `.word` span. - - Shape: `{ wordKey: { start: number, end: number } }`, all seconds local to the scene. - - Constraints: monotonic non-overlap — every entry's `end < next entry's start` (overlapping windows make the envelope ambiguous). - - Brand word window: typically 1.5-2× the average non-brand word window so the brand sustains. -- **ATTACK_DUR** — seconds for the envelope to ramp 0 → 1 once a word starts. - - Range: 0.1-0.25 s - - Effects: shorter feels punchy and ASR-like; longer feels smoothed-out. - - Constraints: must be < (smallest word's end - start), otherwise the word never reaches 1. -- **RELEASE** — seconds for the envelope to decay 1 → REST_LEVEL after a word ends. - - Range: 0.2-0.5 s -- **REST_LEVEL** — held envelope value after RELEASE. - - Range: 0.15-0.4 (default style); 0.05-0.2 (karaoke style — dimmer rest). - - Effects: lower = quieter breadcrumb; higher = more recently-spoken words stay bright. - - Constraints: must be < 1; should be > 0 to preserve the breadcrumb. -- **MAX_BLUR** — peak `text-shadow` blur radius in px. - - Range: 15-25 px (default style); 30-45 px (karaoke style). - - Effects: bigger reads as "shouting"; smaller reads as "neutral narration". -- **MAX_SCALE_BOOST** — additive scale at peak (e.g. 0.08 ⇒ 1.0 → 1.08). - - Range: 0.03-0.10 (default style); 0.15-0.25 (karaoke style). - - Effects: bigger reads as "bouncy"; smaller reads as "just glowing". -- **SCENE_DURATION** — total seconds for the single driver tween. - - Constraints: must equal the scene's `data-duration` so the driver `t` reaches the end of TIMINGS in sync with HF's seek. -- **REST_RGB / ACTIVE_RGB / BRAND_RGB** (karaoke style) — discrete color choices, not numeric. - - REST_RGB: dim tone of the brand palette's neutral; should read as off-white-ish dim, not black. - - ACTIVE_RGB: brand text color at full readability. - - BRAND_RGB: brand accent color (often the same hue as the glow). -- **PULSE_HZ / PULSE_AMPLITUDE** (multi-octave variation) — sine breath frequency / depth. - - PULSE_HZ range: 4-10 rad/s; PULSE_AMPLITUDE range: 0.1-0.3. -- **MAX_POP_Z** (3D pop-out variation) — max Z translation at peak (px). - - Range: 20-60 px; requires parent `perspective`. - -Ease family — discrete choice: - -- Single linear driver (`ease: "none"`) so `t` maps 1:1 to scene time. Any other ease distorts the per-word envelope shape — do not change. - -## Key Principles - -- **Envelope shape: attack-sustain-decay-rest** — never zero out after a word. The rest level (REST_LEVEL > 0) keeps the recently-spoken words subtly highlighted, creating a "breadcrumb" of attention. -- **Brand word gets longer emphasis (1.5-2× normal)** — the brand is the headline; let it sustain. -- **`display: inline-block`** on each word — required for `transform` to apply to ``. -- **MAX_BLUR and MAX_SCALE_BOOST stay in their default-style ranges unless you commit to karaoke** — picking values between default and karaoke yields awkward "half-loud" emphasis. -- **Per-word `text-shadow`** (not `box-shadow`) — text-shadow is the glow around the GLYPH, which is what reads as "speaking emphasis." Box-shadow would glow around the inline-block bounding box (rectangle). -- **Single driver, multi-word onUpdate** — one tween that loops over all words. Don't create one tween per word — at 60+ words the timeline becomes unwieldy. -- **❗ Climax dwell ≥1s** — after the final word's emphasis, comp continues ≥1s. The last word IS the headline beat. +| token | default style | karaoke style | notes | +| --------------- | -------------------- | ------------- | ---------------------------------------------------------- | +| ATTACK_DUR | 0.1–0.25s | same | must be < the shortest word's window or it never reaches 1 | +| RELEASE | 0.2–0.5s | same | decay to rest | +| REST_LEVEL | 0.15–0.4 | 0.05–0.2 | > 0 (breadcrumb), < 1 | +| MAX_BLUR | 15–25px | 30–45px | bigger = "shouting" | +| MAX_SCALE_BOOST | 0.03–0.10 | 0.15–0.25 | additive at peak (0.08 ⇒ scale 1.08) | +| PULSE_HZ / AMP | 4–10 rad/s / 0.1–0.3 | — | multi-octave variation | +| MAX_POP_Z | 20–60px | — | 3D variation | +| SCENE_DURATION | = `data-duration` | same | driver must end in sync with the scene's seek window | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS animation** on word elements -- **`display: inline-block`** on each `.word` -- **`will-change: transform, text-shadow`** on `.word` -- **Timings monotonic** (later start > earlier end) — overlapping words mess up the envelope - -## Combinations - -- [3d-text-depth-layers.md](3d-text-depth-layers.md) — the active word gets depth-layered emphasis at peak -- [sine-wave-loop.md](sine-wave-loop.md) — non-active words breathe subtly between emphasis moments -- [context-sensitive-cursor.md](context-sensitive-cursor.md) — typewriter that types each word matching the ASR cadence +- **Timings monotonic, non-overlapping** — every entry's `end` < the next entry's `start`; overlapping windows make the envelope ambiguous. +- **Brand word window 1.5–2× a normal word** — the brand is the headline; let it sustain. +- **Driver ease stays `"none"`** — any other ease warps every word's envelope timing. +- **`text-shadow`, not `box-shadow`** — the glow must hug the GLYPH (speaking emphasis), not the inline-block rectangle. +- **One driver looping all words** — never one tween per word. +- **Commit to a style** — values between the default and karaoke columns yield awkward "half-loud" emphasis. +- **Climax dwell ≥1s** after the final word's emphasis — the last word IS the headline beat. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — single driver, multi-element envelope -- `/hyperframes-media` — `hyperframes transcribe` outputs real ASR data -- `/hyperframes-media` — pair with caption rendering -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`3d-text-depth-layers` (depth on the active word at peak) · `sine-wave-loop` (idle breathe between emphasis moments) · `context-sensitive-cursor` (typewriter matching the ASR cadence) · `/media-use` for `hyperframes transcribe` and caption rendering. diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/avatar-cloud-network.md b/plugins/visual-content/skills/hyperframes-animation/rules/avatar-cloud-network.md index 19cd540..7d47ebc 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/avatar-cloud-network.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/avatar-cloud-network.md @@ -7,76 +7,29 @@ metadata: # Avatar Cloud Network -Avatars arranged on an elliptical ring around a central element (logo / counter / brand). SVG dashed connection lines from center to each avatar. Staggered spring entry on avatars, then connection lines draw outward — communicates "community" or "social proof." Distinct from [orbit-3d-entry](orbit-3d-entry.md) (which continuously orbits) — avatar-cloud is a static composed reveal. +Avatars on an elliptical ring around a central hub (logo / counter), with SVG dashed lines drawing outward from the hub to each avatar — "community" / social proof. Distinct from [orbit-3d-entry.md](orbit-3d-entry.md) (continuous orbit): this settles into a static composed formation. ## How It Works -Three rendering layers: +Three layers: SVG lines (z-index 1, behind), avatars (z-index 2), hub (z-index 5 — lines terminate AT its edge, never pass through). Avatar positions and lines are built once at setup from ONE shared center; the timeline then runs hub fade → avatar cascade → outward line draw → breathing dwell. Drawing FROM the center is the narrative: "the hub connects to its community." -1. **SVG connection lines** (z-index 1, behind everything) — line from center hub to each avatar's position -2. **Avatars** (z-index 2) — `
` circles on elliptical positions -3. **Center hub** (z-index 5) — brand counter or logo (sits ABOVE the lines that converge on it) - -Animation phases: - -- `HUB_FADE_START → HUB_FADE_START + HUB_FADE_DUR`: hub fades in -- `AVATAR_ENTRY_START → AVATAR_ENTRY_START + (AVATAR_COUNT − 1) × AVATAR_STAGGER + AVATAR_ENTRY_DUR`: avatars cascade in -- `LINES_START → LINES_START + (AVATAR_COUNT − 1) × LINE_STAGGER + LINES_DUR`: connection lines draw outward -- climax dwell: optional idle breathing on avatars (see Variations / sine-wave-loop) - -## HTML +## Recipe ```html -
- - - - - - -
-
-
{counterValue}
-
{counterLabel}
-
- -
- -
{footerLine}
+ + +
+
{counterValue} {counterLabel}
+
``` -Placeholder tokens: - -- `{counterValue}` / `{counterLabel}` — the hub copy (numeric proof + category) -- `{footerLine}` — optional attribution line under the cloud -- `{avatar[i]}` — per-avatar image source (or emoji glyph if using the emoji variation below) - -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - background: {bgColor}; - font-family: {font}; - overflow: hidden; -} .lines { position: absolute; inset: 0; - width: 100%; - height: 100%; - pointer-events: none; z-index: 1; + pointer-events: none; } .hub-wrap { position: absolute; @@ -87,285 +40,106 @@ Placeholder tokens: .hub { position: relative; z-index: 5; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - padding: 48px 64px; - border-radius: 28px; - background: {hubBg}; - border: 1px solid {hubBorder}; -} -.hub-num { - font-size: HUB_NUM_FONT_SIZE; - font-weight: 900; - color: {textColor}; - letter-spacing: -4px; - line-height: 1; - font-variant-numeric: tabular-nums; -} -.hub-label { - font-size: HUB_LABEL_FONT_SIZE; - font-weight: 800; - letter-spacing: 12px; - color: {accentColor}; - text-transform: uppercase; } .avatar { position: absolute; z-index: 2; - width: AVATAR_SIZE; - height: AVATAR_SIZE; - border-radius: 50%; - border: 3px solid {avatarBorder}; - box-shadow: - 0 12px 32px rgba(0, 0, 0, 0.5), - 0 0 24px {avatarGlow}; - display: grid; - place-items: center; - font-size: AVATAR_GLYPH_SIZE; - background: {avatarBg}; + transform: translate(-50%, -50%); /* centers on the (left, top) the script sets */ will-change: transform, opacity; - /* Top-left positioned by script; transform centers via -50% trick */ - transform: translate(-50%, -50%); -} -.brand { - position: absolute; - bottom: 80px; - left: 50%; - transform: translateX(-50%); - font-size: BRAND_FONT_SIZE; - font-weight: 900; - letter-spacing: 14px; - color: {accentColor}; - text-transform: uppercase; } ``` -## GSAP Timeline - -```html - - +// Climax dwell — out-of-phase breathing holds the eye on the formed network: +// one phase proxy (0 → 2π·BREATH_CYCLES, ease "none"); onUpdate scales avatar i by +// 1 + sin(p + (i/n)·2π) · BREATH_AMP — sine-wave-loop's multiplicative onUpdate form. +// Keep the -50% centering in the same transform write. ``` -## How to Choose Values - -### Geometry - -- **CENTER_X / CENTER_Y** — px coordinates of the hub center; lines and avatar positions derive from these. - - Constraints: **must equal the hub's actual rendered center** — when this rule is composed with another scene (e.g. a logo that has been recentered), `CENTER_X / CENTER_Y` must be baked from the same source as the hub's final position - - Reference: ../../examples/proof-logo-chain.html uses `(W/2, H × 0.47)` so the cloud sits slightly above the canvas midline -- **RADIUS_X / RADIUS_Y** — ellipse radii in px (RADIUS_X ≥ RADIUS_Y reads as perspective). - - Range: `RADIUS_X` ~ 20-30% of viewport width; `RADIUS_Y` ~ 18-25% of viewport height - - Constraints: `RADIUS_X / RADIUS_Y` ratio between 1.5 and 3.0 reads as natural depth; ratio = 1 (circle) reads as a flat 2D layout - - Reference: ../../examples/proof-logo-chain.html uses `W * 0.25` (`480px`) and `H * 0.22` (`237.6px`) -- **AVATAR_COUNT** — number of avatars distributed around the ring. - - Range: 8-12; fewer feels sparse, more clutters the ellipse - - Reference: ../../examples/proof-logo-chain.html uses `10` -- **AVATAR_SIZE / AVATAR_GLYPH_SIZE** — px diameter of each avatar circle and (optional) inner glyph size. - - Range: `AVATAR_SIZE` ~ 80-120 px at 1920 wide; small enough that 10+ avatars fit the ring without overlap -- **HUB_NUM_FONT_SIZE / HUB_LABEL_FONT_SIZE / BRAND_FONT_SIZE** — hub typography. - - Constraints: hub-num is the focal beat, sized 2-4× the label - -### Hub fade - -- **HUB_FADE_START** — when the hub fades in. - - Range: usually `0` (the hub establishes the focal point); offset if the scene precedes with another beat -- **HUB_FADE_DUR** — hub fade-in duration. - - Range: 0.4-0.6s -- **HUB_BOUNCE** — `back.out(HUB_BOUNCE)` coefficient on the hub's scale entry. - - Range: 1.4 (subtle) → 1.8 (firm) - -### Avatar cascade - -- **AVATAR_ENTRY_START** — when the first avatar pops in. - - Constraints: `≥ HUB_FADE_START + HUB_FADE_DUR × 0.6` so the hub is established before satellites arrive -- **AVATAR_ENTRY_DUR** — per-avatar scale-up duration. - - Range: 0.4-0.7s -- **AVATAR_STAGGER** — delay between consecutive avatar entries. - - Range: 0.06-0.10s; cascade reads as "joining"; simultaneous reads as "all already there" -- **AVATAR_BOUNCE** — `back.out(AVATAR_BOUNCE)` coefficient on each avatar's pop. - - Range: 1.4 (gentle) → 1.8 (firm); slightly firmer than hub for differentiation - -### Connection lines - -- **LINES_START** — when the lines begin drawing outward. - - Constraints: `LINES_START + (AVATAR_COUNT − 1) × LINE_STAGGER` should overlap the last avatar's settle by ~0.1-0.2s so the drawing reads as a consequence of the avatars landing -- **LINES_DUR** — per-line draw duration (strokeDashoffset → 0). - - Range: 0.4-0.7s -- **LINE_STAGGER** — delay between consecutive lines starting. - - Range: 0.02-0.05s; tight stagger reads as a wave outward - -### Idle breathing - -- **BREATH_START** — when idle breathing activates. - - Constraints: `≥ LINES_START + (AVATAR_COUNT − 1) × LINE_STAGGER + LINES_DUR + ~0.2s` (let the lines settle) -- **BREATH_DUR** — total duration of the breathing tween. - - Range: fills the remaining composition window -- **BREATH_CYCLES** — number of full sine cycles across `BREATH_DUR`. - - Range: 1.0-2.0; under 1 reads as a single sigh, over 2 starts to look anxious -- **BREATH_AMP** — sine amplitude on scale (multiplicative). - - Range: 0.02-0.06; smaller for headshots, larger for stylized glyphs - -### Color tokens - -- **{bgColor}** — stage background (typically a dark gradient so the cloud reads as a constellation) -- **{textColor}** — hub-num color (primary copy) -- **{accentColor}** — hub-label + footer (the brand voice) -- **{hubBg} / {hubBorder}** — hub card surfaces; gradient + 1px border reads as elevated -- **{avatarBg} / {avatarBorder} / {avatarGlow}** — avatar circle styling; soft border + glow keeps them legible on dark backgrounds -- **{lineColor}** — SVG stroke color (translucent accent reads as networky) -- **{font}** — base typography stack - ## Variations -### Avatar size variation (organic feel) - -Vary avatar sizes by index — e.g. a small index-keyed array of sizes — so the ring doesn't read as rigidly repetitive. - -### Solid lines instead of dashed +- **Size variety**: vary avatar sizes by a small index-keyed array so the ring doesn't read rigidly repetitive. +- **Solid lines**: drop the dash + draw; lines fade in via opacity — more corporate, less networky. +- **Multi-orbit**: inner ring (fewer, larger) connected to the hub; outer ring is an unconnected "halo." +- **Glyph avatars**: flags / emoji / icons instead of faces — reads "global community" or role spread. -Drop `stroke-dasharray` and use a solid stroke. Drop the dash-draw animation; lines fade in via opacity instead. More corporate, less networky. +## Values -### Multi-orbit (concentric rings) +| token | range | notes | +| -------------- | ---------------------------- | ---------------------------------------------------------------- | +| AVATAR_COUNT | 8–12 | fewer feels sparse; more clutters the ellipse | +| RADIUS_X / \_Y | ~20–30% W / ~18–25% H | ratio X/Y 1.5–3.0 reads as perspective; 1 (circle) reads flat | +| avatar size | 80–120px @1920 | ring must fit 10+ without overlap | +| HUB_DUR | 0.4–0.6s | HUB_BOUNCE 1.4–1.8 | +| AVATAR_AT | ≥ 0.6 × HUB_DUR | hub established before satellites arrive | +| AVATAR_DUR | 0.4–0.7s | AVATAR_BOUNCE 1.4–1.8, slightly firmer than hub | +| AVATAR_STAGGER | 0.06–0.10s | cascade reads "joining"; simultaneous reads "already there" | +| LINES_AT | overlaps last avatar settle | start ~0.1–0.2s before it — draw reads as consequence of landing | +| LINE_DUR | 0.4–0.7s | LINE_STAGGER 0.02–0.05s = a wave outward | +| BREATH_CYCLES | 1.0–2.0 over the remaining s | under 1 = single sigh; over 2 = anxious. BREATH_AMP 0.02–0.06 | -Two layers of avatars: smaller inner ring (fewer avatars, slightly larger size), larger outer ring (more, smaller). Lines connect ONLY inner ring to hub; outer ring is a "halo." - -### Country / role glyphs (geographic or persona spread) - -Replace face images with flags / emoji / iconography. Reads as "global community" or "diverse roles." - -## Key Principles - -- **Hub above lines (`z-index: 5` vs lines `z-index: 1`)** — lines should appear to terminate AT the hub edge, not pass through. Hub must be in front. -- **Lines drawn outward (dash offset 0)** — drawing FROM center is the visual narrative: "the hub connects to its community." -- **8-12 avatars** — fewer feels sparse, more clutters the ellipse. -- **`RADIUS_X > RADIUS_Y`** — horizontal ellipse reads as perspective; equal radii (circle) reads as 2D flat layout. -- **Avatar entry stagger 0.06-0.10s** — cascade reads as "joining"; simultaneous reads as "all already there." -- **Stagger lines AFTER avatars are mostly settled** — line draw starts ~0.1-0.2s before last avatar settles for overlap. -- **Idle breathing post-formation** — each avatar slightly out-of-phase. Holds the eye during climax dwell. -- **❗ Climax dwell ≥1s** — after lines complete, hold for ≥1s so the formed network is readable. +Tokens: dark `{bgColor}` so the cloud reads as a constellation; translucent accent `{lineColor}`; soft border + glow keeps avatars legible on dark. ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS animation** on avatars or lines -- **`will-change: transform, opacity`** on avatars -- **SVG `pointer-events: none`** — decorative overlay -- **`getTotalLength()` not needed for straight lines** — use `Math.hypot` for line length (cheaper, exact) -- **Hub `z-index` > lines z-index** — explicit layering - -## Combinations - -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — the hub IS a growing counter -- [sine-wave-loop.md](sine-wave-loop.md) — avatar idle breathing pattern -- [3d-text-depth-layers.md](3d-text-depth-layers.md) — hub label with depth layers +- **CENTER_X/Y must match the hub's actual rendered center** — when composed with another scene (e.g. a recentered logo), bake them from the same source as the hub's final position, or lines visibly miss the hub. +- **Hub z-index above lines** — lines terminate at the hub edge, never cross it. +- **Lines draw outward** (dashoffset len → 0), starting after avatars are mostly settled. +- **`RADIUS_X > RADIUS_Y`** — a horizontal ellipse reads as perspective; a circle reads flat. +- **Climax dwell ≥ 1s** after lines complete so the formed network is readable. +- Straight lines: `Math.hypot` for length — `getTotalLength()` not needed. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — staggered spring entries + SVG dash draw -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`counting-dynamic-scale` (the hub IS a growing counter) · `sine-wave-loop` (the breathing form) · `orbit-3d-entry` (the continuously-orbiting cousin). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/camera-cursor-tracking.md b/plugins/visual-content/skills/hyperframes-animation/rules/camera-cursor-tracking.md index 7d49390..b979bad 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/camera-cursor-tracking.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/camera-cursor-tracking.md @@ -7,240 +7,127 @@ metadata: # Two-Phase Camera Cursor Tracking -Keeps a horizontally-growing element (e.g. a search bar with typing text, a long URL animating in) visible by switching between two camera modes. +Keeps a horizontally-growing element (a search bar with typing text, a long URL animating in) visible by switching between two camera modes. ## How It Works Separate **World Space** (the full target element with all content) from **Screen Space** (the viewport). Two phases: -- **Phase 1 (Static)** — The world container sits at a fixed initial offset. Camera doesn't move. This anchors the viewer's eye to the composition before tracking begins. -- **Phase 2 (Tracking)** — Activates when the focal point (cursor, highlight, last typed glyph) exceeds a target screen position (e.g. a configurable fraction `CURSOR_TARGET_FRACTION` of viewport width from the left). The world container translates leftward (`x: -`) keeping the focal point pinned at that screen position. +- **Phase 1 (Static)** — the world container sits at a fixed initial offset; the camera doesn't move. Anchors the viewer's eye before tracking begins. +- **Phase 2 (Tracking)** — activates when the focal point (cursor, highlight, last typed glyph) exceeds a target screen position (`CURSOR_TARGET_FRACTION × viewportWidth` from the left). The world translates leftward (`x: -delta`) keeping the focal point pinned at that screen position. -The offset math is **mathematically continuous** at the phase boundary — at the instant tracking starts, the world position equals what the static phase had. So the transition is seamless. - -The piecewise form used in code is: +The offset math is **mathematically continuous** at the phase boundary — at the instant tracking starts, the world position equals what the static phase had, so the transition is seamless. The piecewise form: ``` finalWorldX = Math.min(INITIAL_OFFSET, trackingOffset) ``` -`INITIAL_OFFSET` is the static-phase value; `trackingOffset` is whatever shift keeps the focal point at `CURSOR_TARGET_FRACTION × viewportWidth`. While the focal point hasn't grown past the target screen X, `trackingOffset` exceeds `INITIAL_OFFSET` (it's a less-negative number) and `Math.min` returns the static value. Once the focal point would cross the target, `trackingOffset` overtakes `INITIAL_OFFSET` and tracking takes over. +`INITIAL_OFFSET` is the static-phase value; `trackingOffset` is whatever shift keeps the focal point at the target screen X. While the focal point hasn't grown past the target, `trackingOffset` is a less-negative number and `Math.min` returns the static value; once the focal point would cross the target, `trackingOffset` overtakes and tracking takes over. Do NOT replace this with a hard `if (typingProgress > threshold)` branch — the camera will visibly jump. -## HTML +## Recipe ```html -
-
-
- +
+
+
``` -## CSS (hero-frame layout) - ```css -.scene { - position: relative; - width: 100%; - height: 100%; -} - .viewport { position: absolute; inset: 0; - overflow: hidden; /* clip the world content */ + overflow: hidden; /* clip the world's left edge as it pans off-screen */ display: flex; align-items: center; justify-content: flex-start; - padding-left: VIEWPORT_PAD_LEFT; /* "left margin" — variation: left-aligned init */ + padding-left: VIEWPORT_PAD_LEFT; /* Phase-1 anchor X — must match the JS constant */ } - .world { display: flex; align-items: center; - white-space: nowrap; /* keep text on one line for camera-tracking */ - transform: translateX(0); /* GSAP will animate this */ -} - -.search-bar { - font-family: {font}; - font-size: BAR_FONT_SIZE; - font-weight: BAR_FONT_WEIGHT; - color: {textColor}; - letter-spacing: BAR_LETTER_SPACING; + white-space: nowrap; /* text must stay on one line for the camera math */ } - .search-bar .text { - /* Width grows as more characters reveal */ display: inline-block; overflow: hidden; vertical-align: bottom; } - .search-bar .cursor { - display: inline-block; + display: inline-block; /* inline sibling of the text, NOT absolutely positioned — + absolute positioning misaligns with the camera math */ width: CURSOR_WIDTH; margin-left: CURSOR_GAP; background: {accentColor}; height: CURSOR_HEIGHT_EM; vertical-align: bottom; - /* No `animation: blink` CSS keyframe here — HF renders by seeking a paused - timeline, and CSS animation clocks are NOT synced to that seek. A CSS - blink will flicker non-deterministically. Drive cursor blink as a finite - yoyo tween on the GSAP timeline instead — see GSAP Timeline section. */ + /* no CSS blink animation — CSS clocks don't sync to seek; blink is a GSAP tween below */ } ``` -## GSAP Timeline - -```html - - +```js +// Pre-measure the target text width to compute tracking distance. +// Measure SYNCHRONOUSLY — no fonts.ready gate (see Critical Constraints). +const textEl = document.getElementById("reveal-text"); +const targetCursorScreenX = CURSOR_TARGET_FRACTION * VIEWPORT_WIDTH; +const fullWidth = textEl.scrollWidth; // total text width after full reveal +const trackingDelta = Math.max(0, VIEWPORT_PAD_LEFT + fullWidth - targetCursorScreenX); + +// Phase 1 — text reveals progressively; camera holds. maxWidth tween +// (width/left/top tweens are forbidden); ease "none" = linear typing rate. +tl.fromTo( + ".search-bar .text", + { maxWidth: 0 }, + { maxWidth: fullWidth, duration: REVEAL_DUR, ease: "none" }, + REVEAL_START, +); + +// Phase 2 — camera tracks. Start BEFORE full reveal so the handoff feels +// continuous (Math.min form above makes it mathematically continuous). +tl.to(".world", { x: -trackingDelta, duration: TRACK_DUR, ease: "power2.inOut" }, TRACK_START); + +// Cursor blink — finite GSAP yoyo (never CSS @keyframes; CSS animation clocks +// aren't synced to HF's seek and flicker non-deterministically). +const blinkRepeats = Math.ceil(SCENE_DURATION / BLINK_HALF_PERIOD) - 1; +tl.to( + ".search-bar .cursor", + { opacity: 0, duration: BLINK_HALF_PERIOD, ease: "steps(1)", yoyo: true, repeat: blinkRepeats }, + 0, +); ``` -### Variations - -- **Centered → Center-Tracked**: set `.viewport { justify-content: center; padding: 0; }`. Camera tracks once the focal point crosses the midline (`CURSOR_TARGET_FRACTION = 0.5`). -- **Left-Aligned → Right-Tracked**: as written above. Best when content exceeds viewport width from the start. -- **Continuous typing driver**: replace the `maxWidth` tween with an `onUpdate` typing clock (`charsTyped = Math.floor(progress)`) plus per-frame `measureNodeWidth` to drive the cursor screen X. Required when the typed text is consumed elsewhere in the scene (e.g. by a parent strip's camera offset). - -## How to Choose Values - -- **VIEWPORT_PAD_LEFT** — left-edge padding of the world inside the viewport (Phase 1 anchor X). - - Range: 0 → ~10% of viewport width - - Effects: 0 hugs the left edge; larger inset feels more like a centered hero frame - - Constraints: must match the CSS `padding-left` on `.viewport` or the camera math drifts -- **VIEWPORT_WIDTH** — the composition's `data-width` in CSS pixels. - - Constraints: must equal the scene root's `data-width`; never tweened -- **CURSOR_TARGET_FRACTION** — fraction of viewport width where the focal point locks during Phase 2. - - Range: 0.5 (center-tracked) → 0.75 (right-leaning, more text visible behind cursor) - - Effects: lower values leave less revealed text in frame; higher values delay tracking -- **BAR_FONT_SIZE** — hero element font size. - - Range: ~8-12% of viewport height; below ~6% reads as a UI widget rather than a cinematic element -- **BAR_FONT_WEIGHT** — weight of the search-bar text. - - Range: discrete; 400 for neutral demo text, 700 for hero / headline framing -- **BAR_LETTER_SPACING** — `letter-spacing` for the bar text. - - Range: slight negative (tighter, more cinematic) → 0 (default) -- **CURSOR_WIDTH** — visual cursor stroke width in CSS pixels. - - Range: ~4-10 px at 1080p; thinner reads as typed text, thicker reads as a block caret -- **CURSOR_GAP** — `margin-left` between text and cursor. - - Range: a few px of breathing room; do not exceed the cursor width or it visually detaches -- **CURSOR_HEIGHT_EM** — cursor height as an em multiple of the font. - - Range: 0.85-1.0; matches the visual height of the typed glyphs -- **REVEAL_START** — when Phase 1 typing begins. - - Constraints: typically 0; if preceded by another phase, ≥ that phase's end + small buffer -- **REVEAL_DUR** — duration of the Phase 1 reveal tween. - - Range: scale with character count (target an average per-character cadence in the 0.05-0.15s range) - - Constraints: must end before `SCENE_DURATION` and ideally overlap slightly with the tracking phase -- **TRACK_START** — when Phase 2 camera motion begins. - - Range: usually before reveal completes so the handoff feels continuous; can equal `REVEAL_START` if the focal point is already past the target at t=0 - - Constraints: `TRACK_START < REVEAL_START + REVEAL_DUR` for a smooth crossfade -- **TRACK_DUR** — duration of the camera pan. - - Range: 0.8-2.0s; under 0.5s reads as a snap, over 2.5s drags -- **SCENE_DURATION** — must match the scene root's `data-duration`. - - Constraints: feeds the blink repeat count; mismatch causes blinks to truncate or run past the end -- **BLINK_HALF_PERIOD** — half-period of the cursor blink (one on-state OR one off-state). - - Range: 0.2-0.4s; 0.3s reads as a natural caret blink - - Constraints: derived value `Math.ceil(SCENE_DURATION / BLINK_HALF_PERIOD) - 1` must be ≥ 0 -- **Ease choices** — discrete: - - Camera pan: `power2.inOut` or `power3.inOut` for cinematic settle; avoid `back.out` (overshoot reads as UI bounce, not camera) - - Reveal: `"none"` for linear typing; any easing distorts the per-keystroke cadence - - Blink: `"steps(1)"` for hard on/off; any easing fades the cursor and breaks the caret feel - -## Key Principles - -- **Measure with `getBoundingClientRect()` / probe nodes**, not by character count × font-size. Proportional fonts have variable glyph widths. -- **`white-space: nowrap`** on the world — text must stay on one line for camera math to work -- **Pre-allocate the world width** by setting `maxWidth` at full target width — prevents layout shift mid-tween -- **Eased camera** (`power2.inOut` / `power3.inOut`), not linear — natural pan feel -- **Spring-like via easing**, not via stiffness/damping params — GSAP doesn't have a built-in spring, but `back.out(${BOUNCE_FACTOR})` or `power4.out` approximate the settling feel +## Variations -## Critical Constraints +- **Centered → center-tracked**: `.viewport { justify-content: center; padding: 0; }`, `CURSOR_TARGET_FRACTION = 0.5` — tracks once the focal point crosses the midline. +- **Left-aligned → right-tracked**: as written; best when content exceeds viewport width from the start. +- **Continuous typing driver**: replace the `maxWidth` tween with an `onUpdate` typing clock (`charsTyped = Math.floor(progress)`) plus per-frame `measureNodeWidth` driving the cursor screen X — required when the typed text is consumed elsewhere in the scene (e.g. by a parent strip's camera offset). + +## Values -- **Build the timeline SYNCHRONOUSLY, no fonts.ready gate** — HF renders frames in parallel workers, each a fresh browser. If you wrap the timeline build in `document.fonts.ready.then(...)`, some workers will seek frames BEFORE the Promise resolves and find no timeline registered → those frames render at CSS initial state (e.g. `max-width: 0` ⇒ empty text), other workers render correctly → visible flicker between empty and filled. Register `window.__timelines[id] = tl` at script-parse time, even if fonts haven't loaded yet — the camera math can tolerate a few percent width error from fallback-font measurement, but worker-race flicker is unacceptable. -- **If precise post-font measurement matters**, re-measure inside the tween's `onUpdate` (still deterministic per-frame seek), not via a Promise gate. Or set `font-display: block` on the @font-face to force the browser to wait for the font before painting any text. -- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()` -- **Registry key = `data-composition-id`**: `window.__timelines["tracking-scene"]` must match scene root -- **Continuous math at phase boundary**: the world's `x` at the moment tracking starts must equal the static-phase offset. The `Math.min(INITIAL_OFFSET, trackingOffset)` formulation guarantees this; do NOT switch to a hard `if (typingProgress > threshold)` branch or the camera will visibly jump. -- **Inline cursor, not absolutely positioned**: cursor should be a sibling of the text (inline-block) so it follows text flow naturally — absolute positioning misaligns with the camera math -- **`overflow: hidden` on `.viewport`**: clip the world's left edge as it pans off-screen -- **Cursor blink via GSAP, NOT CSS `@keyframes ... infinite`** — HF renders by seeking the paused timeline; CSS animation clocks are NOT synchronized with that seek, so any CSS-driven blink will flicker non-deterministically across frames. Always drive blink as a finite yoyo tween on the paused GSAP timeline (repeat count computed from scene length). +| token | range | notes | +| ---------------------- | --------------------------- | ------------------------------------------------------------------------------- | +| VIEWPORT_PAD_LEFT | 0 → ~10% of viewport width | must match the CSS `padding-left` or the camera math drifts | +| VIEWPORT_WIDTH | = the root's `data-width` | never tweened | +| CURSOR_TARGET_FRACTION | 0.5–0.75 | lower = less revealed text in frame; higher delays tracking | +| CURSOR_WIDTH / GAP | 4–10 px / a few px | gap ≤ cursor width or it visually detaches | +| CURSOR_HEIGHT_EM | 0.85–1.0 em | matches the typed glyph height | +| REVEAL_DUR | chars × 0.05–0.15s | ease `"none"` — any easing distorts the per-keystroke cadence | +| TRACK_START | < REVEAL_START + REVEAL_DUR | overlap the reveal so the handoff feels continuous | +| TRACK_DUR | 0.8–2.0s | `power2.inOut`/`power3.inOut`; `back.out` reads as UI bounce, not camera | +| BLINK_HALF_PERIOD | 0.2–0.4s | `steps(1)` hard on/off; repeats derived from SCENE_DURATION (= `data-duration`) | -## Combinations +## Critical Constraints -- [context-sensitive-cursor.md](context-sensitive-cursor.md) — change cursor color/style per text segment during typing -- [discrete-text-sequence.md](discrete-text-sequence.md) — non-linear text reveals that pair with this camera +- **Build the timeline SYNCHRONOUSLY — no `fonts.ready` gate.** HF renders frames in parallel workers, each a fresh browser. A `document.fonts.ready.then(...)` wrapper means some workers seek frames BEFORE the Promise resolves and find no timeline → those frames render at CSS initial state (`max-width: 0` ⇒ empty text) while others render correctly → visible flicker. Register the timeline at script-parse time: the camera math tolerates a few percent width error from fallback-font measurement; worker-race flicker is unacceptable. If precise post-font measurement matters, re-measure inside the tween's `onUpdate` (still deterministic per-frame), or set `font-display: block` on the @font-face. +- **Measure with `getBoundingClientRect()` / `scrollWidth` / probe nodes**, never character count × font-size — proportional fonts have variable glyph widths. +- **Continuous math at the phase boundary** — the `Math.min(INITIAL_OFFSET, trackingOffset)` form, never a hard threshold branch. +- **`white-space: nowrap` on the world** and pre-allocated width (tween `maxWidth` to the full target width) — prevents layout shift mid-tween. +- **Cursor is an inline sibling of the text**, and blinks via a finite GSAP yoyo — never CSS `@keyframes … infinite`. +- **`overflow: hidden` on `.viewport`** — clips the world as it pans. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + tween API -- `/hyperframes-core` — composition wiring + `data-*` attributes -- `/hyperframes-cli` — `hyperframes lint` to validate the registry key + duration +[context-sensitive-cursor.md](context-sensitive-cursor.md) (cursor color per text segment) · [discrete-text-sequence.md](discrete-text-sequence.md) (non-linear text reveals under this camera). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/card-morph-anchor.md b/plugins/visual-content/skills/hyperframes-animation/rules/card-morph-anchor.md index 16ce3f9..3ae8ae1 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/card-morph-anchor.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/card-morph-anchor.md @@ -7,261 +7,128 @@ metadata: # Card Morph Anchor -A container smoothly transforms its width, height, border-radius, and (optionally) background between two visual states. The morph itself **IS the shot transition** — no separate transition effect needed. The viewer's eye tracks the morphing container as the anchor between shots. +A free-floating container morphs apparent size, corner radius, and surface treatment between two shots — the morph itself IS the transition; the viewer's eye tracks the persistent container. Distinct from [anchored-layout-expand.md](anchored-layout-expand.md) (an edge-pinned live layout participant that grows along one axis and reflows neighbors — here nothing is pushed) and [theme-crossfade-morph.md](theme-crossfade-morph.md) (a whole-theme reskin under a fixed anchor — here a single container changes shape). ## How It Works -A single GSAP tween animates multiple container properties simultaneously (width / height / border-radius / background). At the same time: +Since `width`/`height` tweens are forbidden, **substitute uniform `scale` for apparent size**; the remaining morph channels are **paint-only**: `borderRadius`, `background`, `boxShadow`. All channels ride ONE tween (one ease, one duration) so the shape morphs in lockstep. Content choreography: old content fades out during the first ~40% of the morph, new content fades in during the last ~40% — the shape-only gap between is the natural "blink." Optionally the morph card itself fades at the very end, revealing the real next-shot element rendered behind it. -1. **Old content** fades out during the first ~40% of the morph -2. **New content** fades in during the last ~40% of the morph -3. **Optional final fade** — the morph container itself fades to 0, revealing the actual next-shot element rendered behind it - -The persistent container provides visual continuity even as content and shape change. - -## HTML +## Recipe ```html -
- -
-
-

{shotOneHeadline}

-

{shotOneSubcopy}

-
-
- logo -
-
- - -
- anchor -
+ + +
anchor
+
+
{shotOneContent}
+
{shotTwoContent}
``` -## CSS (hero-frame layout) - -Card starts as a wide rectangle (shot 1 state). All properties present from the start; only opacities differ: - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; -} - .morph-card { - position: relative; - width: {SHOT_ONE_W}px; - height: {SHOT_ONE_H}px; - border-radius: {SHOT_ONE_RADIUS}px; + width: SHOT_ONE_W; + height: SHOT_ONE_H; /* shot-1 geometry; the morph is scale, never width/height */ + border-radius: SHOT_ONE_RADIUS; background: {surfaceShotOne}; - overflow: hidden; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + overflow: hidden; /* content must clip during the shape change */ display: grid; place-items: center; + will-change: transform; } - .content-old, .content-new { position: absolute; inset: 0; display: grid; place-items: center; - padding: 32px; -} - -.content-old { - opacity: 1; } .content-new { - opacity: 0; + opacity: 0; /* author its inner sizes at apparent-size ÷ END_SCALE — it scales with the card */ } - .next-shot-anchor { position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - opacity: 0; /* GSAP fades this in as morph card fades out */ - /* Use DOM ORDER for stacking — render .next-shot-anchor BEFORE .morph-card - in markup so the morph card is naturally on top. Do NOT use z-index: -1 - and then snap it positive mid-fade — that causes a visible pop. */ + opacity: 0; /* fades in as the morph card fades out */ } ``` -## GSAP Timeline - -```html - - +```js +const END_SCALE = SHOT_TWO_W / SHOT_ONE_W; // uniform — keep the two shots aspect-matched + +// Hold shot 1 for HOLD_BEAT first — an instant morph reads as glitchy. + +// One tween, all channels: uniform scale + paint-only properties. +tl.to( + ".morph-card", + { + scale: END_SCALE, + borderRadius: SHOT_TWO_RADIUS / END_SCALE, // borderRadius is pre-scale — divide to land the APPARENT radius + background: "{surfaceShotTwo}", + boxShadow: "{shadowShotTwo}", + duration: MORPH_DUR, + ease: "power2.inOut", + }, + MORPH_START, +); + +tl.to( + ".content-old", + { opacity: 0, duration: MORPH_DUR * OLD_FADE_FRAC, ease: "power1.in" }, + MORPH_START, +); +tl.to( + ".content-new", + { opacity: 1, duration: MORPH_DUR * NEW_FADE_FRAC, ease: "power1.out" }, + MORPH_START + MORPH_DUR * (1 - NEW_FADE_FRAC), +); + +// Optional handoff — card fades out over the pixel-identical real anchor. +tl.to( + ".morph-card", + { opacity: 0, duration: MORPH_DUR * FINAL_FADE_FRAC, ease: "power1.in", immediateRender: false }, + MORPH_START + MORPH_DUR * (1 - FINAL_FADE_FRAC), +); +tl.to( + ".next-shot-anchor", + { opacity: 1, duration: MORPH_DUR * FINAL_FADE_FRAC, ease: "power1.out" }, + MORPH_START + MORPH_DUR * (1 - FINAL_FADE_FRAC), +); ``` -## Key Properties to Morph +## Morph channels -| Property | Shape of change | Visual effect | -| ------------------ | -------------------------------------------------------------- | ---------------------------- | -| `width` / `height` | `SHOT_ONE_W × SHOT_ONE_H` → `SHOT_TWO_W × SHOT_TWO_H` | wide card shrinks to an icon | -| `borderRadius` | `SHOT_ONE_RADIUS` → `SHOT_TWO_RADIUS` (≤ half of smaller side) | rectangle becomes a circle | -| `background` | `{surfaceShotOne}` → `{surfaceShotTwo}` (solid or gradient) | container identity shifts | -| `boxShadow` | base shadow → accent glow token | emphasis changes | +| channel | how | +| -------------- | ---------------------------------------------------------------------------------------------- | +| apparent size | uniform `scale` — the substitution for the forbidden `width`/`height` tween; aspect preserved | +| `borderRadius` | paint-only; pre-scale units — tween to `APPARENT_RADIUS / END_SCALE`, ≤ half the smaller side | +| `background` | paint-only; gradients interpolate only with equal stop counts (solid→solid: `backgroundColor`) | +| `boxShadow` | paint-only; base shadow → accent glow shifts emphasis | -GSAP tweens all of these simultaneously when included in one `tl.to(...)` call. +## Variations -## How to Choose Values +- **Landing on a non-centered target** (dock icon, sidebar slot): add `x`/`y` to the same tween, computed as the FLIP-style delta between the card's and the target's rects — `getBoundingClientRect()` both at build time (single-scene only, per the contract) and tween the difference. Don't hand-compute from CSS values: paddings, borders, and parent transforms compound, and center-vs-edge arithmetic is the classic off-by-half bug. +- **Aspect change between shots**: uniform scale preserves aspect — morph to the nearest uniform fit and let the crossfade/handoff absorb the small delta, or drop the handoff and hold the card's final state. -- **HOLD_BEAT** — pre-morph dwell so the viewer registers shot 1 before it changes - - Range: 0.6-1.5 s - - Effects: low end feels rushed / glitchy; high end stalls pacing - - Constraints: must be ≥ shot 1's content entry settle time -- **MORPH_START** — when the container morph begins - - Range: equal to `HOLD_BEAT` in the canonical pattern - - Constraints: must be > any shot-1 entry tween end -- **MORPH_DUR** — full length of the simultaneous container morph - - Range: 0.6-1.2 s - - Effects: low end reads as a snap; high end loses momentum - - Constraints: short morphs (<0.5s) cannot fit both old-fade and new-fade -- **SHOT_TWO_W / SHOT_TWO_H** — final container dimensions - - Range: 80-400 px when handing off to an icon-sized anchor - - Constraints: if handing off (`.next-shot-anchor`), MUST match the anchor's dimensions exactly to avoid a visible pop -- **SHOT_TWO_RADIUS** — final corner radius (use to read as circle / pill / soft-rect) - - Range: 0 to `min(SHOT_TWO_W, SHOT_TWO_H) / 2` - - Effects: half-of-smaller-side = perfect circle; smaller = soft rect - - Constraints: > half is visually clamped — wastes the tween -- **OLD_FADE_FRAC** — fraction of `MORPH_DUR` over which shot-1 content fades out, starting at `MORPH_START` - - Range: 0.3-0.5 - - Effects: low end clips shot 1 too early; high end overlaps with shot 2 content - - Constraints: `OLD_FADE_FRAC + NEW_FADE_FRAC ≤ 1` (gap between is the "shape-only" moment) -- **NEW_FADE_FRAC** — fraction of `MORPH_DUR` over which shot-2 content fades in, ending at `MORPH_START + MORPH_DUR` - - Range: 0.3-0.5 - - Effects: symmetric to OLD_FADE_FRAC -- **FINAL_FADE_FRAC** — optional tail fraction during which the morph container itself fades to 0 for handoff - - Range: 0 (no handoff) or 0.1-0.2 - - Constraints: only use when `.next-shot-anchor` matches the morph's final visual exactly -- **Ease family** — discrete choice - - Options: `power2.inOut` (canonical, balanced), `power3.inOut` (snappier), `expo.inOut` (most cinematic but can feel sluggish at low durations) - - Avoid `back.out` / `elastic.out` on the morph itself — overshoot fights the dimensional change +## Values -CSS-side placeholders (`SHOT_ONE_W`, `SHOT_ONE_H`, `SHOT_ONE_RADIUS`, `{surfaceShotOne}`, `{surfaceShotTwo}`) take real values in the example. Pick `{surfaceShotOne}` and `{surfaceShotTwo}` so the gradient/solid stops counts match (GSAP can interpolate background gradients only when stop counts agree). - -## Key Principles - -- **All target properties in one tween** — they share a single ease and duration so they morph in lockstep -- **Old content fades early, new content fades late** — the container shape change happens between, providing a natural "blink" moment -- **Final fade is optional** — use it when the next shot has a real anchor element to hand off to (e.g. avatar that the icon morphed into "is") -- **Same easing for shape and crossfade** — avoid mixing `power2.inOut` morph with `bounce.out` content, looks unsynchronized -- **❗ If you use `.next-shot-anchor` for handoff, its visuals must be pixel-identical to `.morph-card`'s final state** — same `width` / `height`, same `border-radius`, same `background`, same `box-shadow`, same internal icon dimensions. Any visual delta between the two = visible pop during the crossfade. If you can't match exactly, **drop the handoff** and just hold the morph card at its final state (add a breath if needed for life). +| token | range | notes | +| ----------------- | ------------------------- | ------------------------------------------------------------------------------------ | +| HOLD_BEAT | 0.6–1.5s | ≥ shot 1's entry settle; the viewer must register shot 1 first | +| MORPH_DUR | 0.6–1.2s | < 0.5s can't fit both content fades | +| END_SCALE | SHOT_TWO_W / SHOT_ONE_W | icon-sized handoffs typically land at 80–400px apparent width | +| SHOT_TWO_RADIUS | ≤ min(W, H)/2 apparent | half the smaller side = perfect circle; beyond is clamped | +| OLD/NEW_FADE_FRAC | 0.3–0.5 each, sum ≤ 1 | the gap between is the shape-only "blink" | +| FINAL_FADE_FRAC | 0 (no handoff) or 0.1–0.2 | only when a pixel-identical anchor exists | +| ease | `power2.inOut` canonical | `power3`/`expo.inOut` OK; never `back`/`elastic` — overshoot fights the shape change | ## Critical Constraints -- **`overflow: hidden`** on the morph container — content must clip during shape change, otherwise content overflows the morphing border radius -- **Hold a beat before morphing** — let the viewer register shot 1's content before morphing; instant morph reads as glitchy -- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()` -- **Registry key = `data-composition-id`**: `window.__timelines["morph-scene"]` must match scene root -- **Use `background` tween, not `background-color`**: gradients need `background` (GSAP supports gradient interpolation when targets are gradients with same number of stops). For solid → solid, `backgroundColor` works. -- **`borderRadius` should be ≤ half the smaller dimension** at end state — otherwise the radius is visually clamped and the morph looks abrupt at the boundary -- **❗ Don't snap `z-index` mid-fade** — if you need `.next-shot-anchor` to appear from behind the morph card, use **DOM order** (render `.next-shot-anchor` BEFORE `.morph-card` so the morph card is naturally on top), then crossfade their opacities. A `tl.set({ zIndex: ... })` call during an active opacity tween causes a visible flicker as the stacking order flips before the opacity transition finishes. - -## Variation: Morphing to a target element's position - -When shot 2 isn't centered (e.g. the morph card "lands" on a specific icon in a dock, sidebar, or grid), compute the target `top` / `left` from the **target element's element-position**, not its visual center. Common mistake: subtracting `height/2` to get center, then applying that to the morph-card's `top` — but if `.morph-card` uses absolute positioning with `top` + `margin: 0` (no transform-centering), `top` represents the **element top edge**, not the center. - -Math template (example: morph card lands on icon at bottom dock): - -``` -target_element_top = viewport_height − dock_bottom_offset − dock_padding_y − icon_height - = 1080 − 60 − 22 − 110 = 888 px -``` - -Then tween `.morph-card { top: 888 }` so its element-top aligns with the target icon's element-top. If you mistakenly tween to `888 + icon_height/2 = 943` you'll land below; tweening to a "center" value like `top: 933` (off-by-arithmetic) will be even worse. - -Always **measure the target element with `getBoundingClientRect()`** before the timeline starts, and use those numbers — don't hand-compute from CSS values, since paddings, borders, and parent transforms compound. - -## Combinations - -- [scale-swap-transition.md](scale-swap-transition.md) — simpler morph without dimension change (just scale + content swap) -- [sine-wave-loop.md](sine-wave-loop.md) — gentle breathing on the final state (e.g. final small circular icon idles with a breath) +- **❗ Uniform-scale substitution** — never tween `width`/`height`; `scale` + the paint-only channels (`borderRadius`, `background`, `boxShadow`) are the ONLY morph properties. +- **❗ Handoff anchor must be pixel-identical to the card's final state** — same apparent size, radius, background, shadow, inner icon dimensions. Any delta = a visible pop during the crossfade. Can't match exactly? Drop the handoff and hold the morph card. +- **❗ Stacking by DOM order, never a z-index snap mid-fade** — render the anchor before the card; a `tl.set({ zIndex })` during an active opacity tween flips stacking before the fade finishes and flickers. +- **`overflow: hidden`** on the card — content must clip as the radius changes. +- **Hold a beat before morphing**; same ease family for shape and crossfade (mixed eases read unsynchronized). -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + multi-property tween reference -- `/hyperframes-core` — composition wiring, `data-*` attributes -- `/hyperframes-cli` — `hyperframes lint` to verify scene structure +`anchored-layout-expand` (edge-pinned one-axis growth with reflow) · `theme-crossfade-morph` (whole-theme reskin under a fixed anchor) · `scale-swap-transition` (content swap without shape change) · `sine-wave-loop` (a breath on the final state). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/center-outward-expansion.md b/plugins/visual-content/skills/hyperframes-animation/rules/center-outward-expansion.md index 1c0a291..0b6c106 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/center-outward-expansion.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/center-outward-expansion.md @@ -7,51 +7,24 @@ metadata: # Center-Outward Expansion -Elements begin at a shared center point and radiate outward to their final positions. The expansion can be the entry beat itself, or **driven by another animation's progress** (e.g. a counting number growing) for coordinated motion. +Elements begin at one shared center point and radiate outward to their final positions — the entry beat itself, or motion driven by another animation's progress (a counting number, a beat). Flat 2D cousin of [depth-scatter-assemble.md](depth-scatter-assemble.md) (per-element 3D cloud): here every element shares the SAME origin. ## How It Works -Each element has a `targetX/Y` (its final layout position) and a shared `centerX/Y`. A `progress` value (0→1) interpolates each element between center and target: +Each element carries its final offset as `data-target-x/y`. Its position lerps between center and target: `x = targetX × progress`. Self-centering is baked as `xPercent/yPercent: -50` so the tweened `x`/`y` are pure offsets from the stage center. Standalone burst = per-item staggered `fromTo`; driven burst = one shared proxy (see Variations). -```js -const x = centerX + (targetX - centerX) * progress; -const y = centerY + (targetY - centerY) * progress; -``` - -When `progress = 0` all elements overlap at the center; when `progress = 1` they're at their final spots. - -## HTML +## Recipe ```html -
-
-
{itemA}
-
{itemB}
-
{itemC}
-
{itemD}
-
{itemE}
-
{itemF}
-
+ +
+
{itemA}
+
{itemB}
+
{itemC}
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; -} .burst-wrap { position: relative; width: 100%; @@ -61,167 +34,55 @@ When `progress = 0` all elements overlap at the center; when `progress = 1` they } .burst-item { position: absolute; - /* Items start at the wrap center via the absolute + 50% trick. - We tween translate offsets via GSAP, not left/top. */ top: 50%; - left: 50%; - transform: translate(-50%, -50%); - - width: {itemSize}; - height: {itemSize}; - display: grid; - place-items: center; - background: {itemBgColor}; - border-radius: 28px; - font-family: {font}; - font-weight: 900; - font-size: 96px; - color: {textColor}; + left: 50%; /* GSAP xPercent/yPercent -50 bakes the centering; x/y tween the offset */ will-change: transform; } ``` -## GSAP Timeline - -```html - - -``` - -## How to Choose Values - -- **ITEM_COUNT** — number of elements in the burst - - Range: 3–8 - - Effects: 3 = sparse; 8 = busy. > 8 causes visual chaos where cards overlap mid-expansion - - Constraints: at low counts, prefer wider angular spread (target positions further apart) - -- **EXPAND_DUR** — duration of each item's center → target tween - - Range: 1.0–1.8 s - - Effects: shorter = snappy burst; longer = floats outward - - Constraints: if driven by a counter, must equal the counter's duration (chord) - -- **EXPAND_EASE** — shared ease across all items - - Discrete choice: `power2.out`, `power3.out`, `expo.out` - - Selection: `power3.out` is the default — fling out then settle. `power2.out` is gentler. `expo.out` makes them stop dramatically. Avoid `in` easings (they read as items being sucked back in mid-air). - - Constraint: if driven by another animation, must be identical to the driver's ease - -- **STAGGER** — gap between successive items' start times - - Range: 0.04–0.08 s - - Effects: < 0.04 = simultaneous chord; > 0.08 feels lazy / arpeggiated - - Constraints: ITEM_COUNT × STAGGER must be < EXPAND_DUR or the last items still moving when others have landed reads as ragged - -- **ENTRY_AT** — offset applied to the whole burst start - - Range: 0 – 0.5 s - - Effects: > 0 gives a beat of compositional quiet before the burst - -- **START_PROGRESS** — fraction of the center→target path where items begin (for partially-spread variant) - - Range: 0 (exact center) – 0.5 - - Effects: 0 = full cluster, dramatic spread; 0.3 = avoids initial pile-up at center - -## Variations - -### Synced expansion (driven by a counter) - -If the burst should mirror a counting animation's progress: - ```js -// Counter tween defines a state.value 0 → TARGET over COUNT_DUR -const counterState = { value: 0 }; -const burstState = { p: 0 }; - -// Shared tween — same duration, same ease — visually a "chord" -tl.to( - counterState, - { - value: COUNT_TARGET, - duration: COUNT_DUR, - ease: COUNT_EASE, - onUpdate: () => (counterEl.textContent = Math.round(counterState.value).toLocaleString()), - }, - 0, -); - -tl.to( - burstState, - { - p: 1, - duration: COUNT_DUR, - ease: COUNT_EASE, - onUpdate: () => - items.forEach((el) => { - const tx = Number(el.dataset.targetX) * burstState.p; - const ty = Number(el.dataset.targetY) * burstState.p; - el.style.transform = `translate(-50%, -50%) translate(${tx}px, ${ty}px)`; - }), - }, - 0, -); +document.querySelectorAll(".burst-item").forEach((el, i) => { + tl.fromTo( + el, + { xPercent: -50, yPercent: -50, x: 0, y: 0, scale: 0.6, opacity: 0 }, + { + x: Number(el.dataset.targetX), + y: Number(el.dataset.targetY), + scale: 1, + opacity: 1, + duration: EXPAND_DUR, + ease: EXPAND_EASE, + }, + ENTRY_AT + i * STAGGER, + ); +}); ``` -### Starting partially-spread - -To avoid the initial clustered mess (6+ elements stacked at center), start at `START_PROGRESS`: - -```js -{ x: targetX * START_PROGRESS, y: targetY * START_PROGRESS, scale: 0.4, opacity: 0 } -``` - -### Idle micro-float at final position +## Variations -Pair with `sine-wave-loop` after expansion lands — keeps elements alive instead of frozen. +- **Synced to a driver (chord)**: when the burst shadows a counter / beat, drop the stagger and drive all items from ONE 0→1 proxy tween with the driver's exact duration AND ease; `onUpdate` writes `translate(-50%,-50%) translate(targetX*p, targetY*p)` per item — the two read as one beat. +- **Partially-spread start**: with 6+ items the full cluster piles up — start from `{ x: targetX * START_PROGRESS, ... }`. +- **Idle micro-float**: hand off to [sine-wave-loop.md](sine-wave-loop.md) after landing instead of freezing. -## Key Principles +## Values -- **Driver vs driven** — if the burst stands on its own, use a per-item stagger; if it shadows another animation (counter, audio beat), share the same eased progress so they read as one beat -- **Stagger inside the 0.04-0.08 s band** — too tight and the cluster never separates visually, too loose and the burst feels lazy -- **Out-easing for the expansion** — out-easing makes items "fling" out then settle. In-easing looks like they're sucked back in mid-air -- **Element count: 3-8** — fewer feels empty, more causes visual chaos at the center where cards overlap mid-expansion -- **❗ Don't put a label below the burst as the "real headline"** — if you do, the eye snaps to the label and ignores the burst. The burst IS the beat. If a label is needed, use big block-caps and reveal it post-burst, in the same stacked layout. +| token | range | notes | +| -------------- | -------------------- | ---------------------------------------------------------------- | +| ITEM_COUNT | 3–8 | > 8 = visual chaos mid-expansion; low counts want wider spread | +| EXPAND_DUR | 1.0–1.8s | must equal the driver's duration in the synced variant | +| EXPAND_EASE | `power3.out` default | `power2.out` gentler, `expo.out` dramatic stop; NEVER `in` eases | +| STAGGER | 0.04–0.08s | tighter = chord; looser = lazy arpeggio | +| ENTRY_AT | 0–0.5s | a beat of compositional quiet before the burst | +| START_PROGRESS | 0–0.5 | 0 = dramatic full cluster; ~0.3 avoids the pile-up | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **Use translate, not left/top** — translating composes cleanly with the centering `translate(-50%, -50%)` trick; mutating `left`/`top` fights the centering and causes pixel jitter -- **`will-change: transform`** on burst items — many simultaneous transforms benefit from compositor hints -- **No `position: absolute` parents inside `burst-wrap` other than items themselves** — sibling absolute elements would steal the centered baseline - -## Combinations - -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — counter peak drives the burst peak (chord) -- [sine-wave-loop.md](sine-wave-loop.md) — idle motion after the burst lands -- [card-morph-anchor.md](card-morph-anchor.md) — burst out of a morphed card +- **Tween `x`/`y` over the baked `xPercent/yPercent: -50`** — mutating `left`/`top` fights the centering and causes pixel jitter. +- **Out-easing only** — `in` easings read as items being sucked back mid-air. +- **No other absolute-positioned siblings inside `.burst-wrap`** — they'd steal the centered baseline. +- **❗ The burst IS the beat** — don't park a "real headline" label below it (the eye snaps to the label and ignores the burst). If a label is needed, reveal it post-burst in the same stack. +- Synced variant: identical duration + ease as the driver, or the chord falls apart. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + stagger -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`counting-dynamic-scale` (the classic chord driver) · `depth-scatter-assemble` (3D per-element cloud) · `card-morph-anchor` (burst out of a morphed card) · `sine-wave-loop` (post-landing life). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/chart-scrub-readout.md b/plugins/visual-content/skills/hyperframes-animation/rules/chart-scrub-readout.md new file mode 100644 index 0000000..527878f --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/chart-scrub-readout.md @@ -0,0 +1,151 @@ +--- +name: chart-scrub-readout +description: A cursor/playhead scrubs an already-drawn chart — one driver moves a vertical tracking line and marker along a baked data polyline while a date/value tooltip steps through the data array; a second series can activate on cross. Deterministic data, readout writes only on index change. +metadata: + tags: chart, scrub, readout, tooltip, tracking-line, data, cursor, playhead +--- + +# Chart Scrub Readout + +The chart is already ON screen — this rule **interrogates** it. A vertical tracking line rides the scrub position, a marker dot follows the series, and a live tooltip reads out `date: value` per position, values flickering past like an odometer. It's the "this data is real — look closer" beat: the scrub proves the chart is an instrument, not a picture. + +Boundary with its neighbors: [stat-bars-and-fills.md](stat-bars-and-fills.md) owns the chart's ARRIVAL; [counting-dynamic-scale.md](counting-dynamic-scale.md) owns a single number swelling in place. This rule assumes the graphic already exists and adds a **read head** moving across it. The three chain naturally: the line draws in (svg-path-draw / stat-bars), this rule scrubs it, and the landing value hands off to a count-up lockup. + +## How It Works + +1. **Data baked at setup** — a literal `DATA` array of `{ d, v }` points (or a pure index formula). The polyline's `points` attribute is computed ONCE from `DATA` by pure mapping functions: chart and readout share one source of truth. The argument of the shot is "this data is real" — a random walk regenerated per render breaks both determinism and the rhetorical claim. +2. **One driver tween** `p: 0 → 1` derives everything in its `onUpdate`: tracking-line x, marker x/y, tooltip position. Every output is a pure function of `p` — any seek lands the identical frame. Parallel tweens that merely share timing drift apart under rounding and read as chart chrome, not a read head. +3. **The marker rides the polyline** — its y interpolates between the two neighboring baked points, from the same arrays that built the chart; a separately-keyframed marker inevitably floats off the line. +4. **The readout is threshold-stepped** — the nearest data index derives from `p`, and `textContent` is written ONLY when that index changes (last-index guard). Transforms glide per frame (compositor-cheap); text steps per data point — no per-frame DOM text thrash. The guard is an optimization, not state: any seek recomputes the same index and the same text. + +## Recipe + +```html + +
+ + + + + + +
+ {firstDate} + {firstValue} +
+
+``` + +```css +.tooltip { + position: absolute; + top: 0; + left: 0; + min-width: TIP_MIN_WIDTH; /* fixed — the box must not resize as values change length */ +} +#tip-value { + font-variant-numeric: tabular-nums; /* MANDATORY — digits flicker past; widths must not */ +} +``` + +```js +// Data baked at setup — literal values. +const DATA = [ + { d: "{date1}", v: V1 }, + // ... N points, chronological ... +]; + +// Pure mapping functions — geometry derives from DATA once. +const PAD = CHART_PAD; +const PLOT_W = CHART_W - PAD * 2; +const PLOT_H = CHART_H - PAD * 2; +const vals = DATA.map((p) => p.v); +const V_MIN = Math.min(...vals); +const V_MAX = Math.max(...vals); +const X = (i) => PAD + (i / (DATA.length - 1)) * PLOT_W; +const Y = (v) => PAD + PLOT_H * (1 - (v - V_MIN) / (V_MAX - V_MIN)); + +document + .getElementById("series-a") + .setAttribute("points", DATA.map((p, i) => `${X(i)},${Y(p.v)}`).join(" ")); + +const line = document.getElementById("track-line"); +const marker = document.getElementById("marker"); +const tooltip = document.getElementById("tooltip"); +const tipDate = document.getElementById("tip-date"); +const tipValue = document.getElementById("tip-value"); + +// Tooltip pops in as the scrub begins — a small fromTo scale/opacity spring at SCRUB_AT. + +// ONE driver — line, marker, and tooltip are all projections of p. +const scrub = { p: 0 }; +let lastIdx = -1; +tl.to( + scrub, + { + p: 1, + duration: SCRUB_DUR, + ease: SCRUB_EASE, + onUpdate: () => { + const f = scrub.p * (DATA.length - 1); // fractional index + const i = Math.min(DATA.length - 2, Math.floor(f)); + const t = f - i; + const x = X(i) + (X(i + 1) - X(i)) * t; + const y = Y(DATA[i].v) + (Y(DATA[i + 1].v) - Y(DATA[i].v)) * t; + + // Transforms glide every frame (cheap, deterministic) + line.setAttribute("x1", x); + line.setAttribute("x2", x); + marker.setAttribute("cx", x); + marker.setAttribute("cy", y); + tooltip.style.transform = `translate(${x + TIP_DX}px, ${y - TIP_DY}px)`; + + // Text steps only when the nearest data point changes + const idx = Math.round(f); + if (idx !== lastIdx) { + tipDate.textContent = DATA[idx].d; + tipValue.textContent = `${DATA[idx].v.toLocaleString()} {unitLabel}`; + lastIdx = idx; + } + }, + }, + SCRUB_AT, +); +// End hold: the driver finishes before the scene does — the landed value reads. +``` + +## Variations + +- **Peak stop** — the scrub is the wind-up, the landing is the stat: `SCRUB_EASE: "power3.out"` decelerates onto the final/peak point, then pop the emphasis at landing (`fromTo` marker `scale: 1 → PEAK_POP_SCALE` at `SCRUB_AT + SCRUB_DUR`). Pair with a pill tooltip that springs to its final label ([spring-pop-entrance.md](spring-pop-entrance.md)) — the classic "line breaks above the band" climax. +- **Second-series activation on cross** — series B sits dimmed; at `SCRUB_AT + SCRUB_DUR * CROSS_P` tween its stroke to the lit color (0.25s, `power2.out`), and in the driver's `onUpdate` read from B's array once `scrub.p ≥ CROSS_P` (still index-guarded). The color flip lands ON the cross — same-frame causality. +- **Two-chart glide** — two scrub beats: sweep chart A, glide the cursor/tooltip group across the gutter (a plain `x` tween, no readout — dead travel, not data), then chart B activates with its own driver. One driver per chart. +- **Cursor-led scrub** — an oversized cursor is the visible actor: another projection of the SAME driver (positioned from `x` in the same `onUpdate`, tip at the tracking line's head) — never a second tween that merely matches timing. Cursor look and click grammar from [cursor-click-ripple.md](cursor-click-ripple.md). +- **Playhead form** — no cursor; the tracking line IS the actor (timeline scrubbers, audio waves, session replays). `ease: "none"` — mechanical playback, not a hand. + +## Values + +| token | range / default | notes | +| ----------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| N (data points) | 10–40 | <10 reads as a slideshow; >40 blurs into texture. The flicker is the point — only first and final values must be legible | +| SCRUB_DUR | 1.5–3s | shorter = confident sweep; longer = inspection. Leave ≥0.8s of scene after the driver ends so the landed value holds | +| SCRUB_EASE | `power1.inOut` default | `"none"` playhead form; `power3.out` peak stop. Never `back.out` — a read head that overshoots and re-reads looks broken | +| CROSS_P | 0.55–0.75 | earlier and A never establishes; later and B's readout has no time to live | +| TIP_DX / TIP_DY | 16–48px, up-and-right | flip the sign near the chart's right edge so the tooltip never exits the frame | +| MARKER_R / stroke width | r 6–12 / 4–8px | the marker must dominate the line it rides | +| TIP_MIN_WIDTH | ≥ longest `date: value` state | without it the box breathes as digits change | + +## Critical Constraints + +- **`DATA` is literal at setup**; polyline points derive from it via pure functions — chart and readout share one source of truth. +- **Seed at setup** — call the scrub applier once with `p = 0` right after building (à la `3d-camera-flight`'s `applyCamera()`), or a seek to t=0 before the driver runs shows the tracking line/marker at their HTML-default positions. +- **Single driver** — one `p` tween; all scrub outputs (line, marker, tooltip, any cursor) computed in its `onUpdate`, each a pure function of `p`. +- **Readout writes guarded by index change** — `onUpdate` stays O(1): a few attribute sets, one transform, text only on step. +- **SVG `viewBox` units = CSS pixels** (`viewBox="0 0 W H"` with matching `width`/`height`) — one coordinate space must serve the SVG internals and the HTML tooltip's transform. +- **`tabular-nums` + fixed `min-width`** on the tooltip value. +- **The chart pre-exists** — draw-in belongs to `svg-path-draw` / `stat-bars-and-fills`; sequence it BEFORE the scrub, don't blend them. +- **Land the read** — hold the final value ≥0.8s (or hand off to a count-up lockup). + +## See also + +`svg-path-draw` (the series draws in first) · `stat-bars-and-fills` (surrounding dashboard chrome) · `spring-pop-entrance` (peak dot + pill pop at the landing) · `counting-dynamic-scale` (closing stat lockup) · `cursor-click-ripple` / `context-sensitive-cursor` (the cursor-led form's actor) · `control-target-sync` (the sibling WRITE direction — there a control edits a target; here a scrub reads a dataset). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/chromatic-glitch.md b/plugins/visual-content/skills/hyperframes-animation/rules/chromatic-glitch.md new file mode 100644 index 0000000..c35713a --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/chromatic-glitch.md @@ -0,0 +1,150 @@ +--- +name: chromatic-glitch +description: RGB-split / slice glitch that snaps sharp — offset color copies jitter on a deterministic hash of quantized timeline time (never Math.random), or horizontal slices displace and converge; a brief vibration, then a clean resolve. Entrance or emphasis punctuation; finite, seek-safe. +metadata: + tags: glitch, rgb-split, chromatic, slice, jitter, stutter, text, snap, distortion +--- + +# Chromatic Glitch + +Digital interference as punctuation: for a fraction of a second the element **breaks** — offset color copies shudder behind it, or horizontal slices displace sideways — then it **snaps sharp** and holds clean. The payoff is the resolve; the glitch exists to make the clean state land harder. Two forms: an **RGB-split jitter** (warm + cool ghost copies vibrating behind the base) and a **slice displacement** (horizontal bands that arrive offset and converge). + +Boundaries: [motion-blur-streak.md](motion-blur-streak.md) is velocity blur tied to **travel** — its element is going somewhere fast. A glitching element is **in place**; the disturbance is temporal, not directional. [hacker-flip-3d.md](hacker-flip-3d.md) substitutes **glyphs** (a decode); here the glyphs are fixed and only displaced copies of them move. + +## How It Works + +The subject is stacked: the **base copy on top** (full legibility at every frame), ghost copies behind. All motion comes from one finite **amplitude-envelope** tween read by an `onUpdate`: + +1. **Quantized time** — `const step = Math.floor(tl.time() / JITTER_STEP)`. The stutter comes from offsets that hold for `JITTER_STEP` and then jump. Smoothly interpolated offsets read as wobble, not glitch — **the quantization IS the digital texture**. +2. **Deterministic hash** — offsets are a pure function of `(step, layerIndex)`: + + ```js + const glitchHash = (n) => { + const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; + return x - Math.floor(x); // 0..1, pure — a scrub to any t recomputes the same frame + }; + ``` + +3. **Amplitude envelope** — a proxy tween carries `amp: 1 → 0` over `GLITCH_DUR`. Per-frame offset = `amp × (glitchHash(step * 13 + layer * 7) * 2 − 1) × MAX_SPLIT`. When the envelope hits zero the copies sit at exactly 0 — the snap-sharp is built into the math, and a final `tl.set` clamps the rest state so the hold is bit-exact. + +The **slice form** swaps color copies for `SLICE_COUNT` full copies, each clipped to a horizontal band via `clip-path: inset()`; per-band `x` (and optional `scaleX` stretch) start at hash-derived offsets and converge to 0 under a stepped ease. + +## Recipe + +```html + + +
+ + + {glitchText} +
+``` + +```css +.glitch-stack { + display: grid; /* all copies share one cell — pixel-identical boxes */ +} +.glitch-base, +.glitch-copy { + grid-area: 1 / 1; +} +.glitch-base { + z-index: 2; /* grid items take z-index without position */ + color: {textColor}; +} +.glitch-copy { + z-index: 1; + opacity: 0; /* raised only while the envelope is live */ + will-change: transform; /* updates every frame while live */ + mix-blend-mode: screen; /* additive on dark bg; drop to normal (and lower opacity) on light */ +} +.glitch-copy.warm { + color: {warmSplit}; /* classic: red/orange */ +} +.glitch-copy.cool { + color: {coolSplit}; /* classic: cyan/blue */ +} +``` + +```js +// Form A: RGB-split jitter — envelope snaps to full amplitude, decays to zero. +// All per-frame state derives from tl.time() + the envelope: pure, replays on seek. +const copies = gsap.utils.toArray("#glitch-stack .glitch-copy"); +const amp = { a: 0 }; +tl.set(amp, { a: 1 }, GLITCH_START); +tl.set(copies, { opacity: SPLIT_OPACITY }, GLITCH_START); +tl.to( + amp, + { + a: 0, + duration: GLITCH_DUR, + ease: "power3.in", // most of the violence up front, dying fast + onUpdate: () => { + const step = Math.floor(tl.time() / JITTER_STEP); // quantized — the stutter + copies.forEach((el, layer) => { + const jx = (glitchHash(step * 13 + layer * 7) * 2 - 1) * MAX_SPLIT * amp.a; + const jy = (glitchHash(step * 29 + layer * 11) * 2 - 1) * MAX_SPLIT * 0.35 * amp.a; + gsap.set(el, { x: jx, y: jy }); + }); + }, + }, + GLITCH_START, +); +// The clean resolve: clamp ghosts to exact rest — never rely on the decay +// landing on zero. A ghost left 1px off reads as a bug every frame after. +tl.set(copies, { x: 0, y: 0, opacity: 0 }, GLITCH_START + GLITCH_DUR); + +// Form B: slice displacement — N band copies of the same content converge. +const slices = gsap.utils.toArray("#slice-stack .slice"); +const bandH = 100 / slices.length; +slices.forEach((el, i) => { + gsap.set(el, { clipPath: `inset(${i * bandH}% 0 ${100 - (i + 1) * bandH}% 0)` }); + const dir = glitchHash(i * 3 + 1) > 0.5 ? 1 : -1; + tl.fromTo( + el, + { + x: dir * (SLICE_OFFSET_MIN + glitchHash(i * 5 + 2) * (SLICE_OFFSET_MAX - SLICE_OFFSET_MIN)), + scaleX: 1 + glitchHash(i * 7 + 3) * SLICE_STRETCH, + opacity: 1, + }, + { x: 0, scaleX: 1, duration: SLICE_RESOLVE_DUR, ease: "steps(SLICE_STEPS)" }, + SLICE_START + glitchHash(i * 11 + 4) * SLICE_JITTER_LAG, + ); +}); +``` + +## Variations + +- **Glitch-stretch entrance** — the element ENTERS glitching: layer `fromTo(stack, { scaleX: STRETCH_FROM, opacity: 0 }, { scaleX: 1, opacity: 1, duration: GLITCH_DUR, ease: "power4.out" })` (`STRETCH_FROM` 1.3–1.8) on the whole stack while the envelope runs. Stretch, split, and envelope all die at the same frame — the word is simply _there_, sharp. +- **Emphasis burst on a held word** — a spasm, not an arrival: 2–3 short envelopes (`GLITCH_DUR` ~0.12–0.2s each) separated by clean gaps of ~0.2–0.4s, each its own `set(amp)/to(amp)/set(rest)` triplet. The clean frames between bursts make it read as energy instead of a rendering fault. +- **Slice reveal** — Form B as the arrival itself: bands start opaque but displaced, converge under the stepped ease. Drop the color copies for the monochrome version — the restrained enterprise read of this rule. +- **Card / non-text glitch** — the stacked-copy machinery is content-agnostic (logo lockup, small card). Keep `MAX_SPLIT` proportional (~1% of element width) — oversized splits read as broken layout, not interference. + +## Values + +| token | range | notes | +| -------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- | +| MAX_SPLIT | 4–14px at headline sizes (~0.06–0.1em) | vertical ~35% of horizontal; base must stay legible at peak | +| JITTER_STEP | 1/30–1/12 s | shorter = frantic buzz, longer = VHS stutter; **≥ one render frame** or quantization vanishes | +| GLITCH_DUR | 0.25–0.6s entrance; 0.12–0.2s burst | ≥ ~1s stops reading as an event and starts reading as a broken render | +| SPLIT_OPACITY | 0.5–0.9 (screen on dark) | 0.35–0.6 unblended on light — screen on white is invisible | +| SLICE_COUNT | 4–10 | more = finer tear, diminishing past ~10 | +| SLICE_OFFSET_MIN / MAX | 12–60px | derive per-band values from `glitchHash(i)`, never uniform — equal offsets read mechanical | +| SLICE_STRETCH | 0–0.5 | 0 pure displacement; ~0.3 stretched-scanline read | +| SLICE_RESOLVE_DUR / SLICE_STEPS / JITTER_LAG | 0.2–0.4s / 3–6 / ≤0.08s per band | the stepped ease keeps the settle digital | +| {warmSplit} / {coolSplit} | — | classic red/cyan; any opposing warm+cool brand pair survives | + +## Critical Constraints + +- **Quantize time — the stutter IS the effect.** Offsets hold for `JITTER_STEP` then jump; if the glitch looks like jelly, you interpolated. `JITTER_STEP` ≥ one render frame or the quantization silently disappears. +- **Pure functions of (quantized time, index)** — every per-frame value comes from `glitchHash`; the hash inputs use `tl.time()`, nothing else. +- **Clamp the rest state** — `tl.set({ x: 0, y: 0, opacity: 0 })` on the ghosts at envelope end; never rely on the decay landing exactly on zero. +- **Base on top, always legible** — ghosts vibrate _behind_ the base; a glitch that destroys legibility for more than ~2 frames is a tear-down, not an accent. +- **Brief, then clean** — the clean hold after the snap is the actual beat; `GLITCH_DUR` well under half the element's screen time. Emphasis bursts are separate finite triplets. +- **No CSS `@keyframes` glitch loops** — the classic CSS glitch snippet runs on the wall clock and desyncs from seek; every displacement goes through the timeline's `onUpdate`. +- **Match the register** — RGB-split is a loud consumer/tech gesture; the monochrome slice variant is the only form that belongs in a restrained enterprise composition. + +## See also + +`kinetic-beat-slam` (one beat lands with the glitch-stretch entrance) · `spring-pop-entrance` (pop clean, burst on the stress beat) · `gradient-text-sweep` (gradient carries the hold after the resolve) · `discrete-text-sequence` (state swap masked at max amplitude) · `motion-blur-streak` (the traveling sibling — if it's moving fast, blur it there). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/context-sensitive-cursor.md b/plugins/visual-content/skills/hyperframes-animation/rules/context-sensitive-cursor.md index 5a2625b..a75f6da 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/context-sensitive-cursor.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/context-sensitive-cursor.md @@ -7,251 +7,134 @@ metadata: # Context-Sensitive Cursor -In a typewriter sequence, the cursor's color (and optionally height/blink rate) matches the **active text segment**. If the typewriter is currently typing a brand name, the cursor is the brand accent color; on a placeholder, it dims to gray. Enhances visual cohesion vs a single fixed cursor color across all text states. +In a typewriter sequence, the cursor's color (and optionally height / blink behavior) matches the **active text segment** — brand accent while typing the brand name, dim on placeholders, success color on the completion mark. The eye lands on the keyword being typed because the cursor shifts with it; a fixed single-color cursor is visual noise by comparison. Layers on top of [discrete-text-sequence](discrete-text-sequence.md)'s SEQUENCE pattern. ## How It Works -The text is authored as a SEQUENCE of `{text, t, segment}` entries where `segment` is a string identifier ('main' / 'highlight' / 'brand' / 'success'). The driver tween's onUpdate determines the current segment based on `time`, then sets the cursor's CSS color (and optionally other props) to match that segment's palette. +The text is authored as a SEQUENCE of `{ t, text, segment, color }` entries; a linear driver's `onUpdate` reverse-searches for the current entry and writes both the visible text and the cursor's `background` (the cursor is a colored block, so `background`, NOT `color`). A second linear tween sweeps a phase `p` through `2π × BLINK_CYCLES_PER_SCENE` and gates cursor opacity on `sin(p) > 0` — a deterministic square-wave blink on the timeline. -## HTML +## Recipe ```html -
-
-
$
-
- _ -
+ +
+
$
+
+ _
``` -## CSS - -Placeholders: `{monoFont}` is the project's monospace stack (proportional fonts cause cursor drift mid-segment); `{bgColor}` is the dark backdrop; `{textColor}` is the readable foreground; `{promptColor}` is the segment-default color for the leading prompt glyph. - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; - font-family: {monoFont}; -} .terminal { + font-family: {monoFont}; /* proportional fonts drift the cursor mid-segment */ display: flex; align-items: baseline; - gap: 24px; - font-size: 72px; - font-weight: 800; - color: {textColor}; - white-space: pre; -} -.prompt { - color: {promptColor}; -} -.text-wrap { - display: inline-flex; - align-items: baseline; - min-width: 1200px; + white-space: pre; /* preserve trailing spaces — cursor sits at segment end */ } .text { - color: {textColor}; white-space: pre; } -/* Cursor highlights based on active segment via per-frame background swap */ .cursor { - display: inline-block; + display: inline-block; /* inline ignores width/height */ width: {cursorWidth}px; height: {cursorHeight}px; background: {textColor}; /* default — overridden per segment in onUpdate */ - margin-left: {cursorGap}px; - vertical-align: {cursorBaselineFix}px; + vertical-align: {cursorBaselineFix}px; /* small negative — anchor to baseline, not line-height */ } ``` -## GSAP Timeline - -```html - - + }, + 0, +); ``` ## Variations -### Non-blinking during active typing - -When letters are being added (driver moved forward in the last `TYPING_GRACE` seconds), suppress blink — cursor stays solid. When no typing activity (`driver.t - lastChangeTime > TYPING_GRACE`), resume blink. +- **Non-blinking during active typing** — suppress blink while letters are appearing (solid cursor), resume on idle. This MUST be a pure function of the driver's time: tracking a mutable `lastChangeTime` in `onUpdate` is not reverse-seek-safe (scrubbing backwards leaves the stale forward-pass value behind and the cursor blinks — or holds solid — at the wrong frames). Bake the change times from the SEQUENCE instead — every entry whose `text` differs from its predecessor is a typing event: ```js -let lastChangeTime = 0, - lastText = ""; -// In onUpdate: -if (entry.text !== lastText) { - lastChangeTime = driver.t; - lastText = entry.text; -} -const isTyping = driver.t - lastChangeTime < TYPING_GRACE; +// Baked once at build time — no runtime state. +const CHANGE_TIMES = SEQUENCE.filter((e, i) => i > 0 && e.text !== SEQUENCE[i - 1].text).map( + (e) => e.t, +); +// In onUpdate — identical result at any seek, either direction: +const isTyping = CHANGE_TIMES.some((t) => t <= driver.t && driver.t - t < TYPING_GRACE); cursorEl.style.opacity = isTyping ? "1" : Math.sin(blink.p) > 0 ? "1" : "0"; ``` -### Cursor HEIGHT shifts on segment - -Larger cursor on brand segment for emphasis (`cursorHeightEmphasis > cursorHeight`): - -```js -cursorEl.style.height = - entry.segment === "brand" ? `${cursorHeightEmphasis}px` : `${cursorHeight}px`; -``` - -### Cursor reverses contrast on dark text - -If a segment is rendered DARK text on light bg, cursor should swap to dark too. Manage via `entry.color` as the SOURCE OF TRUTH and read from there. - -## Key Principles - -- **Cursor color shifts make brand moments POP** — eye lands on the brand name because the cursor color shifts to brand accent. Without it, cursor is visual noise. -- **`background` property on the cursor div** — NOT `color` (cursor is a colored block, not a glyph) -- **Deterministic blink via sin** — never CSS `@keyframes blink`. HF seek will desync. -- **Cursor `display: inline-block`** — `display: inline` ignores width/height. -- **`vertical-align: -8px`** (or similar) — visually anchor cursor to text baseline, not full line-height. -- **`white-space: pre`** on text and parent — preserve trailing spaces so cursor sits at end of segment, not after collapsed space. -- **Color palette aligned with brand system** — 3-4 colors max for segments (main / brand / cmd / success). More and the segmentation reads as random. +- **Cursor HEIGHT shifts on segment** — larger cursor on the brand segment: `cursorEl.style.height = entry.segment === "brand" ? cursorHeightEmphasis : cursorHeight` (1.1–1.25×; more reads as glitch). +- **Contrast reversal** — a dark-text-on-light segment needs a dark cursor too; keep `entry.color` as the single source of truth and read from it. -## How to Choose Values +## Values -- **DURATION** — total scene length in seconds - - Range: 4-8 s for a single typed line; longer if the line is long - - Effects: too short truncates the typing; too long leaves a dead tail after the success state - - Constraints: must be `≥ SEQUENCE[last].t + (closing dwell)` - - Reference: see the corresponding blueprint's example HTML - -- **SEQUENCE entry `t` values** — absolute seconds where each new visible text + segment kicks in - - Range: monotonically increasing; spacing 0.2-0.5 s between micro-additions (per-word or per-token), longer between segment swaps - - Effects: too-tight spacing collapses the typing feel into a slideshow; too-loose drags - - Constraints: ordered ascending; entries do not need uniform spacing — slow down on highlights - - Reference: see the corresponding blueprint's example HTML - -- **Segment palette: mainColor / brandColor / cmdColor / successColor** — the cursor-fill swatches - - Range: 3-4 discrete colors max; each should be distinguishable at small cursor width - - Effects: too many segments and the swaps read as random; too few and the brand moment loses pop - - Constraints: `brandColor` and `successColor` may be similar in hue but should differ in saturation/luminance so a brand→success transition is visible - - Reference: see the corresponding blueprint's example HTML - -- **cursorWidth / cursorHeight / cursorGap / cursorBaselineFix** — cursor block geometry - - Range: cursorWidth 8-24 px; cursorHeight ≈ 0.85-1.0 × fontSize; cursorGap 4-12 px; cursorBaselineFix small negative number to drop below the baseline - - Effects: too-thin cursor disappears in render compression; too-tall cursor visually outranks the text - - Constraints: must use `display: inline-block` (a `width` on `display: inline` is ignored) - - Reference: see the corresponding blueprint's example HTML - -- **cursorHeightEmphasis** (Variations) — height when the active segment is the brand - - Range: 1.1-1.25 × `cursorHeight` - - Effects: subtle bump reads as emphasis; large bump reads as glitch - - Constraints: `cursorHeightEmphasis > cursorHeight` - - Reference: see the corresponding blueprint's example HTML - -- **BLINK_CYCLES_PER_SCENE** — how many full blink cycles span `DURATION` - - Range: choose so the period `DURATION / BLINK_CYCLES_PER_SCENE` ≈ 0.6-1.2 s; e.g. an 8-second scene with ~1 s period uses BLINK_CYCLES_PER_SCENE = 8 - - Effects: short period (many cycles) reads as glitchy / agitated; long period reads as terminal-idle - - Constraints: must be a whole number when `DURATION` is fixed — the sin sweep ends mid-cycle otherwise and the cursor pops on the last frame - - Reference: see the corresponding blueprint's example HTML - -- **TYPING_GRACE** (Variations) — seconds after a text change during which blink is suppressed - - Range: 0.15-0.3 s - - Effects: low end still blinks while letters are still appearing; high end keeps cursor solid through long holds - - Constraints: must be smaller than the shortest dwell between two adjacent SEQUENCE entries — otherwise the cursor never blinks - - Reference: see the corresponding blueprint's example HTML +| token | range | notes | +| ---------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- | +| DURATION | 4–8s per typed line | `≥ SEQUENCE[last].t + closing dwell` | +| entry `t` spacing | 0.2–0.5s micro-additions | ascending, non-uniform — slow down on highlights | +| segment palette | 3–4 colors max | more reads as random; brand vs success should differ in saturation/luminance | +| cursorWidth / Height | 8–24px / 0.85–1.0× fontSize | too thin vanishes in render compression; too tall outranks the text | +| cursorBaselineFix | small negative px | drop the block to the text baseline | +| BLINK_CYCLES_PER_SCENE | period ≈ 0.6–1.2s | **whole number** — otherwise the sin sweep ends mid-cycle and the cursor pops on the last frame | +| TYPING_GRACE | 0.15–0.3s | **< shortest dwell between adjacent entries** — otherwise the cursor never blinks | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS animation** on cursor — must be timeline-driven (blink + color) -- **Cursor `display: inline-block`** — required for width/height -- **`white-space: pre`** on text container and text — preserve trailing space -- **Monospace font** — proportional fonts cause cursor to drift mid-segment - -## Combinations - -- [discrete-text-sequence.md](discrete-text-sequence.md) — uses the same SEQUENCE array pattern; this rule adds the cursor styling layer -- [camera-cursor-tracking.md](camera-cursor-tracking.md) — camera tracks the cursor across the typing -- [press-release-spring.md](press-release-spring.md) — after typing completes, a button press confirms the command +- **Cursor color goes on `background`** — it's a colored block, not a glyph. +- **Blink is timeline-driven sin, pure of any mutable tracker** — the typing-grace variation shows the seek-safe form. +- **`white-space: pre` on text and container** — collapsed trailing spaces park the cursor in the wrong column. +- **Monospace font + `display: inline-block` cursor** — proportional faces drift the cursor mid-segment; inline ignores the block geometry. +- **BLINK_CYCLES_PER_SCENE is a whole number** for the fixed DURATION. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — onUpdate driving cursor color + sin blink -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`discrete-text-sequence` (the underlying SEQUENCE pattern) · `camera-cursor-tracking` (camera follows the cursor) · `press-release-spring` (post-typing confirm press). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/control-target-sync.md b/plugins/visual-content/skills/hyperframes-animation/rules/control-target-sync.md new file mode 100644 index 0000000..d521589 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/control-target-sync.md @@ -0,0 +1,131 @@ +--- +name: control-target-sync +description: The live-sync couple — a scrubbed/typed/picked control drives a second element's property in the SAME beat. Readout tween + target transform tween share one timeline label (continuous scrub), or one threshold state array carries both sides (discrete steps). Makes "change this, watch it change" read as causality. +metadata: + tags: control, scrub, live-sync, mirror, panel, editor, couple, readout, ui +--- + +# Control-Target Sync + +THE live-editing move: an inspector/editor control is manipulated — a value scrubbed, a field retyped, a dropdown picked — and a **bound second element answers in the same frame**. The button rotates WHILE the rotation value scrubs; icons resize PER KEYSTROKE. The persuasion is causality — one gesture, two surfaces changing together — and this rule is the coupling contract that produces it. + +Nearest precedent is [reactive-displacement.md](reactive-displacement.md): that rule also derives two elements' motion from one source, but it is **collision physics** — an entering intruder displaces an exiting victim, once, as a transition, and the victim leaves. This rule is a **live editing mirror**: the control is manipulated repeatedly across several beats, the target answers every time, and both sides hold the stage throughout. The numeric readout rides [counting-dynamic-scale.md](counting-dynamic-scale.md)'s proxy pattern; discrete steps ride [discrete-text-sequence.md](discrete-text-sequence.md)'s threshold pattern — what this rule adds is the law that binds either of them to the target. + +## How It Works + +An **edit beat** is a set of concurrent tweens at ONE timeline label: `tl.addLabel("edit1", …)`, then the **readout tween** (numeric proxy + `onUpdate` writing `textContent` only) and the **target transform tween** (`rotation` / `x` / `y` / `scale` to the same endpoint), both placed at the label with the same **duration** and **ease**. The two motions are two projections of one gesture — value at 40% ⇒ target at 40%, on every frame, under any seek. That mathematical lockstep reads as "the panel is editing the page," not "two animations happen to overlap." + +For **discrete edits** (per-keystroke retypes, dropdown picks, unit snaps) the couple steps instead of glides: a single threshold state array carries BOTH sides — each state holds the readout text AND the target's property value — and one driver applies whichever state is active. Both sides read from the same state object, so they cannot desync. + +Chain 2–4 edit beats with short holds between, and end on a **landed** edit — the last value applied and holding, never a tooltip with the dropdown unopened. + +## Recipe + +```html + +
+
{buttonLabel}
+
+
{iconA}
+ … +
+
+
+
+ Rotation +
+
+ Classtext-1xl +
+
+``` + +```js +// ---- Continuous couple: ONE label; both tweens share duration AND ease ---- +tl.addLabel("edit1", EDIT1_AT); +const rotState = { v: 0 }; +const rotReadout = document.getElementById("rotation-readout"); +tl.to( + rotState, + { + v: ROT_TARGET, + duration: SCRUB_DUR, + ease: SCRUB_EASE, + onUpdate: () => { + rotReadout.textContent = `${Math.round(rotState.v)}°`; + }, + }, + "edit1", +); +tl.to( + "#target-button", + { rotation: ROT_TARGET, duration: SCRUB_DUR, ease: SCRUB_EASE }, + "edit1", // same label — the mirror answers in the same frame +); + +// ---- Discrete couple: ONE state array carries BOTH sides ---- +const STEPS = [ + { t: 0.0, text: "text-1xl", scale: 1.0 }, // must equal the initial state + { t: 0.4, text: "text-4xl", scale: 1.9 }, + { t: 1.0, text: "text-xl", scale: 0.85 }, // backspace + { t: 1.35, text: "text-2xl", scale: 1.3 }, // lands +]; +const stepAt = (time) => [...STEPS].reverse().find((s) => time >= s.t) ?? STEPS[0]; + +tl.addLabel("edit3", EDIT3_AT); +const classReadout = document.getElementById("class-readout"); +const stepDriver = { t: 0 }; +let lastStep = null; +tl.to( + stepDriver, + { + t: STEPS_TOTAL, + duration: STEPS_TOTAL, + ease: "none", + onUpdate: () => { + const s = stepAt(stepDriver.t); + if (s !== lastStep) { + classReadout.textContent = s.text; // control steps + gsap.set(".preview-icon", { scale: s.scale }); // target steps — same state object + lastStep = s; + } + }, + }, + "edit3", +); +``` + +## Variations + +- **Dropdown pick → instant conversion (self-conversion)** — the pick converts the panel's own readout in place (`tl.set("#padding-readout", { textContent: "6 px" }, "pick")`); control and target collapse into one element. Compose the dropdown from neighbors: menu pops via [spring-pop-entrance.md](spring-pop-entrance.md), row hover-stepping via [dynamic-content-sequencing.md](dynamic-content-sequencing.md). The conversion must be an INSTANT snap — tweening between unit strings reads as broken, and instantness is the feature being sold. +- **Easing-handle drag → target re-animates (deferred mirror)** — the edit authors a _behavior_, so the mirror is a **replay**, not a concurrent transform: beat 1 drags the handle (handle tween + coords readout), then at a later label the target performs its motion with the newly-authored curve (`tl.fromTo("#toggle-knob", { x: 0 }, { x: KNOB_TRAVEL, duration: REPLAY_DUR, ease: AUTHORED_EASE }, "replay")`), often under a zoom-out ([viewport-change.md](viewport-change.md)). The one sanctioned case where the response is not in the gesture's beat; the replay must still be unmistakably the edited parameter. +- **Read-sync mirror (reverse direction)** — the gesture happens ON the target (hovering swatches, selecting an element) and the PANEL readout is the bound side. Same discrete contract — one state array of `{ t, hoverTarget, readout }` drives both the highlight and the text. +- **Color couple** — the readout counts (`0 → 80`) while the target's `backgroundColor` tweens between two palette stops at the same label. Keep it two fixed stops (GSAP interpolates); never derive per-frame hex strings by hand. + +## Values + +| token | range | notes | +| -------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| SCRUB_DUR | 0.8–1.6 s | the viewer must see BOTH sides move — under ~0.6 s the mirror registers subconsciously at best | +| SCRUB_EASE | `power1.inOut` / `power2.inOut` | shared verbatim by both tweens. Never `back.out` / `elastic.out` — an overshooting value reads as a broken hinge; the readout is data | +| edit endpoints | visible but plausible | −10° tilt, 38 px shift, 1xl → 4xl → 2xl; a 2° rotation doesn't demo anything | +| HOLD_BETWEEN | 0.3–0.8 s | each landed value gets a breath; below 0.3 s the beats smear into one gesture | +| BEAT_COUNT | 2–4 | one edit is a moment, not a demo; past 4 the shot reads as a settings tour | +| STEP gaps (discrete) | 0.15–0.5 s | keystroke pacing per discrete-text-sequence; first state must equal the on-load state | +| VALUE_MIN_WIDTH | ≥ longest value's width | without it the panel edge jitters as digit counts change | + +## Critical Constraints + +- **One label, one gesture** — readout tween and target tween share position, duration, AND ease; never sequence readout-then-target, and never stagger the target behind the readout even by 0.1 s — a delayed response reads as an animation following an edit, not a bound surface. A mismatched ease desyncs the mirror mid-tween even when endpoints agree. +- **Discrete steps share one state object** — both sides read the same array entry, so desync is impossible by construction; first entry mirrors the initial DOM state. +- **The readout is data** — no overshoot, no bounce on the settle; the target may carry the gesture's ease but lands exactly on the edited value. +- **Co-visibility is load-bearing** — control and target share the frame for every edit beat; a camera move must never crop the mirror out (punch-and-return around the beats, not through them). +- **`tabular-nums` + fixed `min-width`** on every scrubbed readout; `onUpdate` is O(1) — text writes only, discrete drivers guard writes with a last-state check. +- **End on a landed edit** — the final beat resolves with the value applied and holding (or the deferred-mirror replay); never mid-gesture or on an unopened menu. +- **The gesture's actor is a separate rule** — cursor glide, grab-cursor flip, and click feedback come from the cursor rules; this rule owns only the couple. + +## See also + +`cursor-click-ripple` / `context-sensitive-cursor` (the hand performing the gesture) · `counting-dynamic-scale` (the readout half alone, when there is no bound target) · `discrete-text-sequence` (retypes inside the control field) · `spring-pop-entrance` (dropdowns/chrome around the couple) · `multi-phase-camera` (punch-and-return framing) · `chart-scrub-readout` (the sibling READ direction — a scrub interrogates a chart instead of editing a target). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/coordinate-target-zoom.md b/plugins/visual-content/skills/hyperframes-animation/rules/coordinate-target-zoom.md index 083add1..a28e80f 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/coordinate-target-zoom.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/coordinate-target-zoom.md @@ -11,32 +11,26 @@ A simple `scale > 1` on a wrapper pushes off-center content OFF the visible canv ## How It Works -Two nested wrappers, separated concerns: +Two nested wrappers, separated concerns — never scale and translate on the SAME element (`translate * scale` ≠ `scale * translate` in CSS transform composition): -1. **Outer wrapper** applies `scale` (the zoom) +1. **Outer wrapper** applies `scale` (the zoom) around `transform-origin: 50% 50%` 2. **Inner wrapper** applies `translate(x, y)` (the counter-shift) -The translate is the **negation** of the target's offset from center. The inner translate moves the target back to the outer's transform-origin BEFORE the outer scale fires, so the scale around center maps the target to 0. +The counter-translate is the **negation** of the target's offset from viewport center: ``` T = -offset ``` -Derivation (outer scales the inner-translated content): +Derivation: the inner translate moves the target to `offset + T` in pre-scale units; the outer scale S (around center) maps that to `S × (offset + T)`; landing at center means `S × (offset + T) = 0` → **`T = -offset`**. The formula does NOT depend on S — the translate is identical at 1.5×, 2×, or 3×. A common wrong intuition is `T = -offset × (S - 1)`: it coincidentally matches at S = 2 and is wrong at every other scale. -1. Inner translate moves target by T in pre-scale units → target at `offset + T` -2. Outer scale S (around center 0,0) maps that to `S × (offset + T)` -3. For target to land at viewport center: `S × (offset + T) = 0` → **`T = -offset`** - -Note: the formula does NOT depend on S. The translate amount is the same whether you zoom 1.5×, 2×, or 3× — as long as the OUTER is the scale and the INNER is the translate, and scale uses `transform-origin: 50% 50%`. +⚠️ **This is the NESTED-wrapper formula.** The single-wrapper camera in [viewport-change.md](viewport-change.md) puts `translate(x,y) scale(S)` on ONE element, where CSS applies scale first — there the counter-translate is **`T = -offset × S`**. The two formulas are not interchangeable; match the formula to the wrapper structure. ## Getting the offset `T = -offset` is only as good as `offset`. The #1 way this pattern ships broken is hand-computing `offset` from a layout formula, getting the **sign** or magnitude wrong, and letting the zoom amplify a small error off-screen. **Default to measuring the target's real laid-out center; reserve the formula for symmetric rows.** -### Default — measure the target's actual center (works for ANY layout) - -Read where the target actually is, once, at setup. This is immune to sign errors because it's derived from the rendered DOM, not a mental model: +**Default — measure the actual center (works for ANY layout).** Immune to sign errors because it reads the rendered DOM, not a mental model: ```js await document.fonts.ready; // metrics final; fallback fonts are 10–30px off → tens of px after a 3×+ zoom @@ -45,87 +39,52 @@ const W = 1920, const r = document.getElementById("target-card").getBoundingClientRect(); const TARGET_OFFSET_X = r.left + r.width / 2 - W / 2; const TARGET_OFFSET_Y = r.top + r.height / 2 - H / 2; -// bake these; feed counterX/Y = -TARGET_OFFSET_X/Y to the inner tween ``` -This `getBoundingClientRect` runs **once at setup**, before timeline registration — NOT per-frame (per-frame DOM reads desync under the renderer's parallel sampling; see SKILL universal constraints). Because the measurement is async (`fonts.ready`), build and register the timeline inside the same `async` setup so the baked offset is ready before `window.__timelines[id]` is published. - -### Shortcut — symmetric equal-width row ONLY +Measure **once at setup** and bake — never per-frame in `onUpdate`. Because the measurement is async (`fonts.ready`), build and register the timeline inside the same `async` setup so the baked offset is ready before `window.__timelines[id]` is published. -If (and only if) the target is one of N **equal-width** cards in a centered row with uniform gaps, you may skip measurement: +**Shortcut — symmetric equal-width row ONLY:** ```js const index_offset = targetIndex - (N - 1) / 2; const TARGET_OFFSET_X = index_offset * (CARD_WIDTH + CARD_GAP); ``` -⚠️ This assumes every sibling is the **same width**. The moment the row is asymmetric — a wide companion label beside a narrow chip, a wordmark flanked by unequal elements — it gives the wrong answer, often the wrong **sign**: the heavier side shifts the centered target the _opposite_ way you'd guess. (A real example: `companion(220) + gap + wordmark + gap + chip(110)` puts the wordmark ~55px **right** of center, but the "chip − companion" intuition says left.) For anything but equal cards, **measure**. +⚠️ This assumes every sibling is the **same width**. The moment the row is asymmetric, it gives the wrong answer — often the wrong **sign**: the heavier side shifts the centered target the _opposite_ way you'd guess (e.g. `companion(220) + gap + wordmark + gap + chip(110)` puts the wordmark ~55px **right** of center, but "chip − companion" intuition says left). For anything but equal cards, **measure**. -### Headroom budget — cap the scale from the measured size - -A zoom multiplies any centering error, so leave margin. Keep the target ≤ ~88% of the canvas at peak; derive the cap from the measured size instead of picking a round number by feel: +**Headroom budget — cap the scale from the measured size.** A zoom multiplies any centering error; keep the target ≤ ~88% of the canvas at peak: ```js const maxScale = Math.min((0.88 * W) / r.width, (0.88 * H) / r.height); const ZOOM_SCALE = Math.min(DESIRED_SCALE, maxScale); ``` -A target that fills 97%+ of the frame reads as cut-off the instant its center is even slightly off — and a hand-baked offset always is. (The perception gate flags this as `primary-offscreen`, and `data-layout-allow-overflow` does **not** exempt it.) +A target filling 97%+ of the frame reads as cut-off the instant its center is slightly off — and a hand-baked offset always is. (The perception gate flags this as `primary-offscreen`; `data-layout-allow-overflow` does **not** exempt it.) -## HTML +## Recipe ```html -
-
-
-
- -
-
{label1}
-
{price1}
-
-
-
{label2}
-
{price2}
-
-
-
{targetLabel}
-
{targetPrice}
-
{targetTagline}
-
-
-
{label4}
-
{price4}
-
-
+
+
+
+
{other}
+
{target}
+
{other}
``` -## CSS - ```css .scene { - position: relative; - width: 100%; - height: 100%; - overflow: hidden; /* REQUIRED — see Critical Constraints */ - background: {bgGradient}; + overflow: hidden; /* REQUIRED — at zoom > 1 the scaled content leaks past the frame */ } .zoom-outer { width: 100%; height: 100%; display: grid; place-items: center; - transform-origin: 50% 50%; + transform-origin: 50% 50%; /* center scaling is what the counter-translate math assumes */ will-change: transform; } .zoom-inner { @@ -133,200 +92,47 @@ A target that fills 97%+ of the frame reads as cut-off the instant its center is place-items: center; will-change: transform; } -.content { - display: flex; - gap: CARD_GAP; -} -.card { - width: CARD_WIDTH; - padding: CARD_PADDING; - border-radius: CARD_RADIUS; - background: {cardBg}; - border: 1px solid {cardBorder}; - text-align: center; - font-family: {font}; -} -.card.target { - background: {targetCardBg}; /* slightly brighter than .card */ - border: 2px solid {targetBorder}; - box-shadow: {targetGlow}; -} -.label { - font-size: LABEL_FONT_SIZE; - font-weight: 800; - letter-spacing: 6px; - text-transform: uppercase; - color: {labelColor}; -} -.price { - font-size: PRICE_FONT_SIZE; - font-weight: 900; - color: {textColor}; - margin: 16px 0; - font-variant-numeric: tabular-nums; -} -.tag { - font-size: TAG_FONT_SIZE; - font-weight: 700; - letter-spacing: 4px; - color: {accentColor}; - opacity: 0; -} ``` -## GSAP Timeline - -```html - - +```js +// TARGET_OFFSET_X/Y and ZOOM_SCALE come from "Getting the offset" — measured +// at setup (after fonts.ready), baked. Counter-translation = -offset. +const counterX = -TARGET_OFFSET_X; +const counterY = -TARGET_OFFSET_Y; + +// Scale and counter-translate MUST share position, duration, AND ease — +// otherwise the target visibly wanders mid-zoom. +tl.to("#zoom-outer", { scale: ZOOM_SCALE, duration: ZOOM_DUR, ease: "power3.inOut" }, ZOOM_AT); +tl.to( + "#zoom-inner", + { x: counterX, y: counterY, duration: ZOOM_DUR, ease: "power3.inOut" }, + ZOOM_AT, +); ``` ## Variations -### Dynamic target lookup via `getBoundingClientRect` - -This is now the **default**, not a variation — see [Getting the offset](#getting-the-offset). Always `await document.fonts.ready` before measuring (fallback-font metrics are off by 10–30px, which a 3×+ zoom magnifies into tens of visible px) and measure **once at setup**, never per-frame. +- **Zoom out (target → wide view)**: reverse the phases — start zoomed-in, then tween to `scale: 1` + `x: 0, y: 0`; the "reveal" beat is the panorama. +- **Multi-target zoom sequence**: chain zooms (target A → pause → target B → pull back); each segment needs its own counter-translation pair. -### Zoom out (target → wide view) +## Values -Reverse the phases — start at zoomed-in, then `scale: 1` + `x: 0, y: 0` to pull back. The "reveal" beat is the panorama. - -### Multi-target zoom sequence - -Chain multiple zooms: target A (1.5-2.5s) → pause → target B (3-4s) → pull back (4.5-5s). Each segment needs its own counter-translation pair. - -## How to Choose Values - -### Layout - -- **CARD_WIDTH / CARD_GAP / CARD_PADDING / CARD_RADIUS** — geometric layout. - - Constraints: `N × CARD_WIDTH + (N-1) × CARD_GAP < viewportWidth` so all cards fit pre-zoom - - Effects: smaller cards → more siblings on screen → busier composition; larger cards → fewer siblings, more emphasis per card -- **LABEL_FONT_SIZE / PRICE_FONT_SIZE / TAG_FONT_SIZE** — typographic hierarchy. - - Range: tag < label < price (price is the focal element after zoom; sizing it largest reinforces this) - -### Reveal phase - -- **REVEAL_START** — when the cards begin fading in. - - Constraints: typically a small offset (~0.2s) for a beat of black before content appears -- **REVEAL_DUR** — per-card fade-up duration. - - Range: 0.4-0.8s -- **REVEAL_Y** — initial vertical offset of each card before fade-up (in px). - - Range: 16-48 px; bigger feels "thrown in," smaller feels gentle -- **REVEAL_STAGGER** — delay between consecutive card reveals. - - Range: 0.06-0.15s; calibrated so all cards finish before `ZOOM_START` - -### Zoom phase - -- **ZOOM_START** — when the zoom begins. - - Constraints: `≥ REVEAL_START + REVEAL_DUR + (N-1) × REVEAL_STAGGER + viewer-scan-time` (give viewer 0.5-1.5s to read the layout before zooming) -- **ZOOM_DUR** — duration of the zoom tween. - - Range: 1.0-2.0s; under 0.8s feels like a teleport, over 2.5s drags - - Constraints: scale tween + counter-translate tween MUST share this duration AND ease -- **ZOOM_SCALE** — final magnification. - - Range: 1.5× (modest emphasis) → 3× (dominant focus) → 5×+ (cinematic extreme) - - Constraints: card content must remain crisp at this scale; raster source media needs `sourceResolution ≥ rendered × ZOOM_SCALE` - - **Headroom budget**: cap from the measured target size so the target stays ≤ ~88% of the canvas at peak — `ZOOM_SCALE = Math.min(DESIRED, 0.88×W/r.width, 0.88×H/r.height)`. Picking a round number by feel (e.g. 3.2× on a 585px wordmark → 1872px = 97% of 1920) leaves no margin, so any centering slop cuts the text off. - -### Target reveal + dwell - -- **TAG_REVEAL_START** — when the target's hidden tag fades in. - - Constraints: `≥ ZOOM_START + ZOOM_DUR` (only reveal after the zoom settles, so viewer's eye is already on the target) -- **TAG_REVEAL_DUR** — tag fade-in duration. - - Range: 0.3-0.6s -- **DWELL_DUR** — post-zoom hold so the viewer reads the target. - - Range: ≥ 1.0s after tag reveals (see "Climax dwell" in Key Principles) - -### Color tokens - -- **{bgGradient}** — typically a dark radial gradient to vignette the cards -- **{cardBg} / {cardBorder}** — non-target cards (subtle, recessive) -- **{targetCardBg} / {targetBorder} / {targetGlow}** — target card visually brighter / haloed so the eye lands there before the zoom even fires -- **{labelColor} / {textColor} / {accentColor}** — hierarchical text colors; `{accentColor}` reserved for the tag (pops on reveal) - -## Key Principles - -- **Measure the offset, don't hand-derive it** — for any layout that isn't a symmetric equal-width row, read the target's real center with `getBoundingClientRect` at setup (after `fonts.ready`) and bake it (see [Getting the offset](#getting-the-offset)). Hand-computed offsets silently get the **sign** wrong on asymmetric layouts, and the zoom amplifies the error off-screen — the single most common way this pattern ships broken. -- **Transform order — outer scales, inner translates** — DO NOT put scale and translate on the SAME element. The transform math becomes tangled (`translate * scale` ≠ `scale * translate` in CSS transform composition). Nested wrappers cleanly separate concerns. -- **Counter-translate = -offset** — independent of scale. Derive from: outer scale around center maps `(offset + T)` to `S × (offset + T)`. Setting that to zero gives `T = -offset`. A common wrong intuition is `T = -offset × (S - 1)` — it happens to give the same answer at S=2 but is wrong for any other S. -- **`transform-origin: 50% 50%` on outer wrapper** — non-center origin causes unpredictable inner offset; always center. -- **`overflow: hidden` on `.scene` REQUIRED** — at zoom > 1, the outer-scaled content can leak beyond the 1920×1080 frame. -- **Tween scale and counter-translate together** — they MUST share `duration` and `ease`. Otherwise the target drifts mid-zoom (visible "wandering"). Easiest: pass identical params to both tweens at the same time position. -- **❗ Climax dwell ≥1s after zoom completes** — see SKILL universal constraints. If zoom ends at t=3.0 in a 3.5s comp, viewer barely sees the target; aim for 1.5-2s post-zoom dwell. +| token | range | notes | +| ---------- | --------------------------------------- | ------------------------------------------------------------------------------------------ | +| ZOOM_SCALE | 1.5× modest → 3× dominant → 5×+ extreme | cap via the headroom budget; raster media needs `sourceResolution ≥ rendered × ZOOM_SCALE` | +| ZOOM_DUR | 1.0–2.0s | under 0.8s feels like a teleport, over 2.5s drags; both tweens share it | +| ZOOM_AT | after the layout lands + 0.5–1.5s | give the viewer time to scan the layout before the camera commits | +| DWELL | ≥ 1.0s after the zoom settles | 1.5–2s ideal — the viewer must be able to read the target (climax dwell) | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition` on `.zoom-outer` or `.zoom-inner`** — competes with GSAP -- **`will-change: transform`** on both wrappers — the transforms update every frame during the zoom phase -- **`transform-origin: 50% 50%` on `.zoom-outer`** — center-based scaling is what the counter-translate math assumes -- **Target offset baked once, at setup, from measurement** — measure the target center after `fonts.ready` and bake (see [Getting the offset](#getting-the-offset)); never recompute per-frame in onUpdate, and never hand-estimate the offset for a non-symmetric layout -- **Scale within the headroom budget** — keep the target ≤ ~88% of the canvas at peak, derived from the measured size (`maxScale = 0.88 × W / measuredWidth`); a target that fills the frame is cut off the instant the center is slightly off - -## Combinations - -- [multi-phase-camera.md](multi-phase-camera.md) — multi-phase camera that includes a coordinate-target-zoom phase -- [sine-wave-loop.md](sine-wave-loop.md) — idle breathing on the target AFTER zoom settles -- [discrete-text-sequence.md](discrete-text-sequence.md) — text assembly in the target BEFORE zoom completes +- **Outer scales, inner translates** — never both transforms on one element; nested wrappers keep the math clean. +- **`transform-origin: 50% 50%` on the outer wrapper** — non-center origin breaks the counter-translate derivation. +- **`overflow: hidden` on the scene root** — zoomed content leaks past the frame otherwise. +- **Scale and counter-translate share duration + ease** at the same timeline position, or the target drifts mid-zoom. +- **Offset measured once at setup** (after `fonts.ready`), baked — never recomputed per-frame, never hand-derived for a non-symmetric layout (wrong sign → target shoved off-frame). +- **Scale within the headroom budget** — target ≤ ~88% of the canvas at peak, derived from the measured size. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — two coordinated tweens -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +[viewport-change.md](viewport-change.md) (single-wrapper form, `T = -offset × S`) · [multi-phase-camera.md](multi-phase-camera.md) (a zoom phase inside a phased camera) · [sine-wave-loop.md](sine-wave-loop.md) (idle breathing after the zoom settles) · [discrete-text-sequence.md](discrete-text-sequence.md) (text assembly in the target before the zoom). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/counting-dynamic-scale.md b/plugins/visual-content/skills/hyperframes-animation/rules/counting-dynamic-scale.md index 753923e..5d9da5f 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/counting-dynamic-scale.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/counting-dynamic-scale.md @@ -1,197 +1,83 @@ --- name: counting-dynamic-scale -description: Counter animation where font size grows with the counting value, creating escalating visual weight. +description: Counter animation where the value counts up while transform scale grows to its final size, creating escalating visual weight without per-frame text reflow. metadata: - tags: counter, counting, scale, font-size, number, dynamic, emphasis + tags: counter, counting, scale, transform, number, dynamic, emphasis --- # Counting with Dynamic Scale -A number counts from A → B while its font size simultaneously grows, creating escalating visual weight that reinforces magnitude. +A number counts from A → B while its transform scale grows to the final size — escalating visual weight ("this is impressive") without tweening `font-size` or forcing text layout on every frame. The final font size is static CSS; only the transform changes. ## How It Works -A single eased timeline drives **two synchronized properties**: +Two synchronized tweens at the SAME timeline position with the SAME ease: (1) a proxy value rendered as text via `onUpdate` (`Math.round(...).toLocaleString()`), (2) the counter's transform `scale: START_SCALE → 1`, where `START_SCALE = START_SIZE / END_SIZE`. A suffix (`%`, `×`, `+`) slides in AFTER the count lands — the number gets its own beat — and a label fades in early. -1. The numeric value (rendered as DOM text via `onUpdate`) -2. The font size (tweened from `START_SIZE` → `END_SIZE`) - -As the number gets bigger, the text gets larger — visually communicating "this is impressive." - -## Easing - -Pick by drama desired (the choice is discrete; coefficient is implicit): - -| GSAP ease | Effect | -| ------------ | --------------------------------------------- | -| `power1.out` | Mild — slight deceleration | -| `power2.out` | Default — ease-out, fast start slow end | -| `power3.out` | Strong — dramatic deceleration ⭐ recommended | -| `expo.out` | Very dramatic — almost stops at the end | - -`power3.out` matches the polynomial `1 - (1-x)^k` family at k ≈ 2.5 — number rushes up then slows dramatically at the peak. - -## HTML +## Recipe ```html -
-
- 0{suffix} -
-
{label}
+ +
+ 0{suffix}
+
{label}
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; -} - .counter-wrap { display: flex; align-items: baseline; justify-content: center; - gap: 8px; - /* Fixed-width container prevents layout shift as digit count changes */ - width: {counterContainerWidth}; - text-align: center; + width: {counterContainerWidth}; /* fixed width — no layout shift as digit count changes */ } - .counter { - font-family: {font}; - font-weight: 900; - color: {textColor}; - /* MANDATORY — tabular-nums keeps digits the same width */ - font-variant-numeric: tabular-nums; - /* Initial font-size; GSAP will tween this */ - font-size: {startSize}; - letter-spacing: -2px; - line-height: 1; + font-variant-numeric: tabular-nums; /* MANDATORY — digits keep equal width */ + display: inline-block; + font-size: {endSize}; /* final size is static; GSAP animates scale, not font-size */ + transform-origin: center center; } - .counter-suffix { - font-family: {font}; - font-weight: 800; - color: {accentColor}; - font-size: {suffixSize}; opacity: 0; transform: translateY(20px); } - -.counter-label { - margin-top: 24px; - font-family: {font}; - font-size: {labelSize}; - color: {mutedTextColor}; - text-align: center; -} ``` -## GSAP Timeline - -```html - - +// Label fades in early +tl.from(".counter-label", { opacity: 0, y: 12, duration: LABEL_DUR, ease: "power2.out" }, LABEL_AT); ``` -## How to Choose Values - -- **TARGET_VALUE** — the number the counter lands on - - Effects: 2–3 digits reads best at hero size; 4+ digits requires wider container - - Constraints: must fit horizontally at END_SIZE inside the container - -- **START_SIZE / END_SIZE** — initial and final font size - - Range: START_SIZE ≈ 40–60 % of END_SIZE - - Effects: smaller START_SIZE = more dramatic growth; larger = subtler - - Constraints: END_SIZE × digit count must fit the container width without clipping - -- **COUNT_DUR** — count + scale tween duration - - Range: 1.2–2.5 s - - Effects: shorter = aggressive; longer = settled, gives reading time - - Constraints: must allow the eye to read the digits scrolling past; below ~0.8 s reads as a flash - -- **COUNT_EASE** — shared ease for value AND font-size - - Discrete choice: `power2.out`, `power3.out`, `expo.out` (see table above) - - Constraint: avoid `back.out` / `elastic.out` — overshoot reads as unstable data - -- **SUFFIX_DUR** — duration of the suffix slide-in - - Range: 0.3–0.6 s - - Effects: shorter = snap; longer = floats - - Constraints: must fire after the count lands (started at COUNT_DUR), not during - -- **SUFFIX_BOUNCE_FACTOR** — back.out coefficient on the suffix entry - - Range: 1.4–2.0 - - Effects: 1.4 = small overshoot; 2.0 = bouncy - -- **LABEL_AT / LABEL_DUR** — when and how long the label fades in - - Range: LABEL_AT < COUNT_DUR / 2 (label arrives before count peaks); LABEL_DUR 0.4–0.7 s - ## Variations -### Direct `innerText` tween (no proxy object) - -The GSAP inspector reads `innerText` directly, so a number-only counter can skip the `state` proxy: +- **Direct `innerText` tween (no proxy)** — GSAP can tween `innerText` directly for a number-only counter; keep the proxy form when you need locale formatting or suffix logic. The scale tween stays separate either way: ```js tl.to( @@ -201,83 +87,29 @@ tl.to( ); ``` -`snap: { innerText: 1 }` keeps it integer. Keep the proxy-object `onUpdate` form (above) whenever you must **co-drive** font-size, locale formatting (`toLocaleString`), or a suffix in the same tween — `innerText` alone can't do those, and dynamic scale is the whole point of this rule, so the proxy form is the default here. - -### 3D depth entry - -Combine with `translateZ` for parallax-style depth on entry: - -```js -tl.from( - ".counter", - { - z: -300, - duration: 0.6, - ease: "power2.out", - // requires parent or .counter itself to have perspective set - }, - 0, -); -``` - -CSS prerequisite: +- **3D depth entry** — add a `tl.from(".counter", { z: -300, ... }, 0)` push-in; requires `perspective` on `.counter-wrap` and `transform-style: preserve-3d` on the counter. +- **Multi-stat coordinated reveal** — 3 stats counting in parallel share the SAME ease, duration, and start position so they finish together (a chord, not an arpeggio). Each stat usually also needs a paired graphic (bar / ring / stars) — don't stop at the number; see [stat-bars-and-fills.md](stat-bars-and-fills.md). -```css -.counter-wrap { - perspective: 1000px; -} -.counter { - transform-style: preserve-3d; -} -``` +## Values -### Multi-stat coordinated reveal - -For 3 stats counting in parallel, share the SAME ease and duration so they finish together — visually a chord, not arpeggio. Each stat usually also needs a **paired graphic** (bar / ring / stars) — don't stop at the number; see [stat-bars-and-fills.md](stat-bars-and-fills.md): - -```js -["#stat1", "#stat2", "#stat3"].forEach((sel, i) => { - const obj = { v: 0 }; - tl.to( - obj, - { - v: TARGETS[i], - duration: COUNT_DUR, - ease: COUNT_EASE, - onUpdate: () => (document.querySelector(sel).textContent = Math.round(obj.v)), - }, - 0, - ); // same start position — chord -}); -``` - -## Key Principles - -- **Synchronized value + size in ONE tween** so they share an ease and stay coordinated -- **`font-variant-numeric: tabular-nums` is mandatory** — without it digit-count transitions (e.g. 9 → 10 → 100) cause visible jitter as glyph widths change -- **Fixed-width container** as belt-and-suspenders — even with tabular-nums, glyph shape changes can shift baselines -- **Grow in place, don't bounce** — the number should feel weighty, not springy. `power3.out` ends at exact value; `back.out` overshoots and feels cartoonish -- **Start small enough to grow noticeably** (~50 % of final size); end large enough to feel decisive but not clip viewport -- **Suffix animates AFTER the count, not during** — gives the number its own beat -- **❗ Label is BIG TEXT, not a page-style tiny caption** — for VIDEO, a small paragraph-style caption below a hero-size number reads as visual noise. Use display-size, uppercase, tracked label so the layout is "two-line big-text"; the label is part of the headline, not a footer. +| token | range | notes | +| --------------------- | ------------------------------------------- | ----------------------------------------------------------------------------- | +| TARGET_VALUE | 2–3 digits ideal | 4+ digits needs a wider container; must fit at END_SIZE without clipping | +| START_SIZE / END_SIZE | START ≈ 40–60% of END | design inputs used once for START_SCALE; never tween either | +| COUNT_DUR | 1.2–2.5s | below ~0.8s reads as a flash — the eye must read the digits scrolling past | +| COUNT_EASE | `power2.out` / `power3.out` ⭐ / `expo.out` | shared by value + scale; more `.out` = more dramatic deceleration at the peak | +| SUFFIX_DUR | 0.3–0.6s | fires at `COUNT_DUR`, never during the count | +| SUFFIX_BOUNCE_FACTOR | 1.4–2.0 | overshoot is fine on the suffix (it's punctuation, not data) | +| LABEL_AT / LABEL_DUR | AT < COUNT_DUR/2; 0.4–0.7s | label arrives before the count peaks | ## Critical Constraints -- **`tabular-nums` mandatory** — required CSS for layout stability -- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()` -- **Registry key = `data-composition-id`**: `window.__timelines["counter-scene"]` must match scene root -- **`onUpdate` mutates DOM**: HF runtime seeks the timeline frame-by-frame, so `onUpdate` runs on every seek call. Keep `onUpdate` work O(1) — set text + font-size, no DOM creation -- **`Math.round` not `Math.floor`** — half-way through the final integer should display the final value briefly, not the previous one -- **Avoid `back.out` / `elastic.out`** for the counter itself — overshoot makes the number look unstable (it's data, not decoration) - -## Combinations - -- [stat-bars-and-fills.md](stat-bars-and-fills.md) — **the paired graphic beside the number** (growth bars / progress ring / star wipe). A stat scene is usually BOTH rules: the count-up here + a fill there. Give the fill the same ease and duration so number and graphic land as one beat. -- [svg-path-draw.md](svg-path-draw.md) — icons drawing in around the number -- [center-outward-expansion.md](center-outward-expansion.md) — related icons exploding outward synced to count peak +- **`tabular-nums` mandatory** + fixed-width container as belt-and-suspenders — without them digit-count transitions (9 → 10 → 100) jitter as glyph widths change. +- **Never set `fontSize` in `onUpdate`** — final type size is static CSS; only the transform changes per frame. Keep `onUpdate` O(1): set text only, no style writes or DOM creation. +- **`Math.round`, not `Math.floor`** — halfway through the final integer should already display the final value. +- **Avoid `back.out` / `elastic.out` on the counter itself** — overshoot makes the number look unstable (it's data, not decoration). Grow in place, don't bounce. +- **Label is BIG TEXT, not a page-style caption** — a tiny paragraph under a hero-size number reads as visual noise in video. Display-size, uppercase, tracked: the label is part of the headline. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + `onUpdate` API -- `/hyperframes-core` — composition wiring, `data-*` attributes -- `/hyperframes-cli` — `hyperframes lint` to verify scene +`stat-bars-and-fills` (the paired graphic — give it the same ease/duration so number and fill land as one beat) · `svg-path-draw` (icons drawing in around the number) · `center-outward-expansion` (icons bursting outward at the count peak). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/css-marker-patterns.md b/plugins/visual-content/skills/hyperframes-animation/rules/css-marker-patterns.md index ad497de..97ba6c4 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/css-marker-patterns.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/css-marker-patterns.md @@ -1,18 +1,12 @@ # CSS Patterns for Marker Highlighting -Pure CSS + GSAP implementations of all five MarkerHighlight.js drawing modes. Use these for deterministic rendering in HyperFrames compositions — no external library dependency, full GSAP timeline control. +Pure CSS + GSAP implementations of all five MarkerHighlight.js drawing modes — no external library dependency, full timeline control. Snippets show mechanism DOM only, inside a standard scene clip (hyperframes-core); assume `tl` exists. -## Contents - -- [1. Highlight Mode](#1-highlight-mode) — Yellow marker sweep behind text -- [2. Circle Mode](#2-circle-mode) — Hand-drawn ellipse around text -- [3. Burst Mode](#3-burst-mode) — Radiating lines from text -- [4. Scribble Mode](#4-scribble-mode) — Chaotic scribble over text -- [5. Sketchout Mode](#5-sketchout-mode) — Rough rectangle outline +Shared scaffold for every mode: the wrap is `position: relative; display: inline`; the text copy is `position: relative` and z-indexed **above** the accent (below it for sketchout, where the lines cross the text). ## 1. Highlight Mode -Yellow marker sweep behind text. The most common mode. +Yellow marker sweep behind text — the most common mode. ```html @@ -22,16 +16,9 @@ Yellow marker sweep behind text. The most common mode. ``` ```css -.mh-highlight-wrap { - position: relative; - display: inline; -} .mh-highlight-bar { position: absolute; - top: 0; - left: -6px; - right: -6px; - bottom: 0; + inset: 0 -6px; /* bleed past the text edges */ background: #fdd835; opacity: 0.35; transform: scaleX(0); @@ -39,113 +26,46 @@ Yellow marker sweep behind text. The most common mode. border-radius: 3px; z-index: 0; } -.mh-highlight-text { - position: relative; - z-index: 1; -} ``` ```js -// Sweep in from left tl.to("#hl-1", { scaleX: 1, duration: 0.5, ease: "power2.out" }, 0.6); - -// Optional: skew for hand-drawn feel -// gsap.set("#hl-1", { skewX: -2 }); -``` - -### Multi-line Highlight - -Stagger bars across multiple lines: - -```js -tl.to( - ".mh-highlight-bar", - { - scaleX: 1, - duration: 0.5, - ease: "power2.out", - stagger: 0.3, - }, - 0.6, -); +// Optional hand-drawn skew: gsap.set("#hl-1", { skewX: -2 }); +// Multi-line: tl.to(".mh-highlight-bar", { scaleX: 1, ..., stagger: 0.3 }, 0.6); ``` ## 2. Circle Mode -Hand-drawn circle around text. Use `border-radius: 50%` with a slight rotation for organic feel. +Hand-drawn ellipse around text — `border-radius: 50%` plus a slight rotation for organic feel. ```html - IMPORTANT + IMPORTANT ``` ```css -.mh-circle-wrap { - position: relative; - display: inline; -} -.mh-circle-text { - position: relative; - z-index: 1; -} .mh-circle-ring { position: absolute; top: 50%; left: 50%; - width: 130%; + width: 130%; /* tight (short words): 150%; rounded-rect: 120% + border-radius: 30% */ height: 160%; transform: translate(-50%, -50%) rotate(-3deg) scale(0); border: 3px solid #e53935; border-radius: 50%; - pointer-events: none; z-index: 0; } ``` ```js -// Circle scales in with a wobble -tl.to( - "#circle-1", - { - scale: 1, - rotation: -3, - duration: 0.6, - ease: "back.out(1.7)", - transformOrigin: "center center", - }, - 0.7, -); -``` - -### Variations - -```css -/* Tighter circle (for short words) */ -.mh-circle-ring.tight { - width: 150%; - height: 180%; -} - -/* Squared circle (rounded rectangle) */ -.mh-circle-ring.rounded { - border-radius: 30%; - width: 120%; - height: 140%; -} - -/* Ellipse (wider than tall) */ -.mh-circle-ring.ellipse { - width: 150%; - height: 130%; - border-radius: 50%; -} +tl.to("#circle-1", { scale: 1, rotation: -3, duration: 0.6, ease: "back.out(1.7)" }, 0.7); ``` ## 3. Burst Mode -Radiating lines from text center. Each line is a positioned div rotated to its angle. +Radiating lines from text center — each line a positioned span rotated to its angle. Use ~12 lines at 30° steps and **vary `--len` (40–80px)**; equal lengths look mechanical. ```html @@ -153,36 +73,19 @@ Radiating lines from text center. Each line is a positioned div rotated to its a - - - - - - - - - - + ``` ```css -.mh-burst-wrap { - position: relative; - display: inline; -} -.mh-burst-text { - position: relative; - z-index: 2; -} .mh-burst-container { position: absolute; top: 50%; left: 50%; width: 0; height: 0; - z-index: 1; + z-index: 1; /* text copy at z-index: 2 */ } .mh-burst-line { position: absolute; @@ -199,7 +102,6 @@ Radiating lines from text center. Each line is a positioned div rotated to its a ``` ```js -// All lines burst outward simultaneously with slight stagger tl.fromTo( "#burst-1 .mh-burst-line", { scaleY: 0, opacity: 0 }, @@ -208,11 +110,9 @@ tl.fromTo( ); ``` -**Vary line lengths** (40-80px range) for an organic, hand-drawn feel. Equal lengths look mechanical. - ## 4. Scribble Mode -Wavy SVG underlines and strikethroughs that draw themselves via `stroke-dashoffset`. +Wavy SVG underline that draws itself via `stroke-dashoffset`. ```html @@ -227,22 +127,14 @@ Wavy SVG underlines and strikethroughs that draw themselves via `stroke-dashoffs stroke-linecap="round" /> -
+ ``` ```css -.mh-scribble-wrap { - position: relative; - display: inline; -} -.mh-scribble-text { - position: relative; - z-index: 1; -} .mh-scribble-svg { position: absolute; left: 0; - bottom: -6px; + bottom: -6px; /* strikethrough variant: top: 50%; transform: translateY(-50%) */ width: 100%; height: 24px; z-index: 0; @@ -250,38 +142,17 @@ Wavy SVG underlines and strikethroughs that draw themselves via `stroke-dashoffs ``` ```js -// Measure path length and set initial dash state -var path = document.querySelector("#scribble-1"); -var len = path.getTotalLength(); +const path = document.querySelector("#scribble-1"); +const len = path.getTotalLength(); gsap.set(path, { strokeDasharray: len, strokeDashoffset: len }); - -// Draw the line -tl.to( - "#scribble-1", - { - strokeDashoffset: 0, - duration: 0.8, - ease: "power1.inOut", - }, - 0.7, -); +tl.to("#scribble-1", { strokeDashoffset: 0, duration: 0.8, ease: "power1.inOut" }, 0.7); ``` -### Strikethrough Variant - -Position the SVG at `top: 50%; transform: translateY(-50%)` instead of `bottom: -6px`. - -### Wavy Path Generator - -Scale the path's viewBox width to match text width. The wave pattern `Q x1,y1 x2,y2` alternates between `y=0` and `y=24` for a natural wobble. Adjust the control points for tighter or looser waves: - -- **Tight waves**: smaller x-increments (25px per half-wave) -- **Loose waves**: larger x-increments (50px per half-wave) -- **Amplitude**: change the y range (0-24 for standard, 0-16 for subtle) +Path tuning: the `Q` control points alternate y between 0 and 24 for a natural wobble. Tighter waves = smaller x-increments (~25px per half-wave); looser = ~50px; subtler amplitude = y range 0–16. ## 5. Sketchout Mode -Cross-hatch lines over de-emphasized text. Multiple angled lines create a "crossed out" effect. +Cross-hatch over de-emphasized text — two angled lines create a "crossed out" effect. ```html @@ -294,22 +165,11 @@ Cross-hatch lines over de-emphasized text. Multiple angled lines create a "cross ``` ```css -.mh-sketchout-wrap { - position: relative; - display: inline; -} -.mh-sketchout-text { - position: relative; - z-index: 0; -} .mh-sketchout-lines { position: absolute; - top: 0; - left: -4px; - right: -4px; - bottom: 0; + inset: 0 -4px; overflow: hidden; - z-index: 1; + z-index: 1; /* text at z-index: 0 — the lines cross OVER it */ } .mh-sketchout-line { position: absolute; @@ -320,7 +180,6 @@ Cross-hatch lines over de-emphasized text. Multiple angled lines create a "cross height: 2px; background: #e53935; transform-origin: left center; - transform: scaleX(0); } .mh-sketchout-fwd { transform: scaleX(0) rotate(-12deg); @@ -331,43 +190,19 @@ Cross-hatch lines over de-emphasized text. Multiple angled lines create a "cross ``` ```js -// Forward slash draws first -tl.to( - "#sketchout-1 .mh-sketchout-fwd", - { - scaleX: 1, - duration: 0.3, - ease: "power2.out", - }, - 1.0, -); - -// Backward slash follows -tl.to( - "#sketchout-1 .mh-sketchout-bwd", - { - scaleX: 1, - duration: 0.3, - ease: "power2.out", - }, - 1.15, -); +// Forward slash first, backward follows +tl.to("#sketchout-1 .mh-sketchout-fwd", { scaleX: 1, duration: 0.3, ease: "power2.out" }, 1.0); +tl.to("#sketchout-1 .mh-sketchout-bwd", { scaleX: 1, duration: 0.3, ease: "power2.out" }, 1.15); ``` ## Combining Modes in Captions -Use mode cycling for visual variety across caption groups: +Cycle modes across caption groups for visual variety — every 2-3 groups for high energy, 3-4 for medium, 4-5 for low: ```js -var MODES = ["highlight", "circle", "burst", "scribble"]; - -GROUPS.forEach(function (group, gi) { - var mode = MODES[gi % MODES.length]; - // Apply the mode's CSS pattern to emphasis words in this group - group.emphasisWords.forEach(function (word) { - applyMode(word.el, mode, tl, word.start); - }); +const MODES = ["highlight", "circle", "burst", "scribble"]; +GROUPS.forEach((group, gi) => { + const mode = MODES[gi % MODES.length]; + group.emphasisWords.forEach((word) => applyMode(word.el, mode, tl, word.start)); }); ``` - -Cycle every 2-3 groups for high energy, every 3-4 for medium, every 4-5 for low. diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/cursor-click-ripple.md b/plugins/visual-content/skills/hyperframes-animation/rules/cursor-click-ripple.md index 007ea18..727e141 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/cursor-click-ripple.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/cursor-click-ripple.md @@ -7,80 +7,24 @@ metadata: # Cursor Click Ripple -An animated cursor moves to a target element, performs a click with visual depression, and emits expanding ripple rings from the click point. +An animated cursor moves to a target element, performs a click with visual depression, and emits expanding ripple rings from the click point. Three sequential phases on one timeline: **move** (eased translation to the target's center) → **click** (scale depression on cursor + target together, yoyo back) → **ripple** (1–3 staggered rings expand and fade from the click point). This is a _point event at one location_ — a sustained hold across space is [cursor-drag.md](cursor-drag.md). -## How It Works - -Three sequential phases driven by a single GSAP timeline: - -1. **Move**: eased cursor translation from entry point to the target element's center -2. **Click**: scale depression on both cursor and target (yoyo: shrink then return) -3. **Ripple**: expanding circles radiate outward from the click point with fade-out. 1–3 staggered rings amplify the click feedback - -Use a GSAP timeline because the phase ordering (move → settle → click → ripples) is exactly what timelines express cleanly. - -## HTML +## Recipe ```html -
- - -
- - - -
- - -
-
-
-
+ +
+ +
+
+
``` -## CSS - -Position cursor at the entry point. Button sits at its final position. Ripples are at the click-target center with `scale: 0` and `opacity: 0` so they hold invisible until the timeline trigger: - ```css -.scene { - position: relative; - width: 100%; - height: 100%; -} - -.target-button { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - /* ...button styling (background, color, font from project tokens) */ -} - -.cursor { - position: absolute; - left: 10%; - top: 80%; /* entry corner */ - pointer-events: none; - z-index: 999; -} - .ripple { position: absolute; left: 50%; - top: 50%; /* click target center */ + top: 50%; /* click-target center */ width: 100px; height: 100px; border-radius: 50%; @@ -91,172 +35,70 @@ Position cursor at the entry point. Button sits at its final position. Ripples a } ``` -## GSAP Timeline - -Build a paused timeline. Register it on `window.__timelines` with the same key as `data-composition-id` on the scene root. All tuning values are named constants — see How to Choose Values below. - -```html - - +```js +// Phase 1 — Move: eased, not linear +tl.to(".cursor", { x: TARGET_X, y: TARGET_Y, duration: MOVE_DUR, ease: MOVE_EASE }, 0); + +// Phase 2 — Click: cursor + target depress together, then return +tl.to( + ".cursor", + { scale: CURSOR_PRESS_SCALE, duration: PRESS_DUR, ease: "power2.in", yoyo: true, repeat: 1 }, + CLICK_AT, +); +tl.to( + ".target-button", + { scale: TARGET_PRESS_SCALE, duration: PRESS_DUR, ease: "power2.in", yoyo: true, repeat: 1 }, + CLICK_AT, +); + +// Phase 3 — Ripple burst, N rings staggered from the click point +tl.set([".ripple-1", ".ripple-2", ".ripple-3"], { opacity: 1 }, RIPPLE_AT); +tl.to( + [".ripple-1", ".ripple-2", ".ripple-3"], + { + scale: RIPPLE_SCALE, + opacity: 0, + duration: RIPPLE_DUR, + ease: RIPPLE_EASE, + stagger: RIPPLE_STAGGER, + immediateRender: false, // holds scale 0 / opacity 0 until the click moment + }, + RIPPLE_AT, +); ``` -## How to Choose Values - -- **MOVE_DUR** — cursor travel time from entry to target, in seconds - - Range: 0.4–1.0 s - - Effects: short feels darting; long feels deliberate / "considered click" - - Constraints: must end before `CLICK_AT` — otherwise the click fires while the cursor is still moving and reads as a misclick - - Reference: ../../examples/cta-orbit-collapse.html uses 0.5 s - -- **MOVE_EASE** — easing family for the move tween - - Discrete choice. Options: - - `power2.inOut` — symmetric, calm; good for "the user thoughtfully moves the cursor" - - `back.out()` — overshoot landing; good when the click target is a button you want the cursor to "settle onto" with a tiny visible recoil. Pair with a low overshoot coefficient (~1.2–1.4) — higher reads as cartoonish - - `power3.out` — fast start, soft landing; good for a "decisive" move - - Reference: ../../examples/cta-orbit-collapse.html uses `back.out(1.3)` - -- **CLICK_AT** — time the click fires, in seconds - - Range: must be ≥ `MOVE_DUR` (cursor has settled); typically `MOVE_DUR + 0.0–0.3 s` of "decision pause" - - Effects: zero pause reads as autopilot; >0.3 s of pause reads as hesitation - - Reference: ../../examples/cta-orbit-collapse.html clicks 0.2 s after the cursor settles - -- **PRESS_DUR** — half-duration of the depression (the yoyo runs twice this) - - Range: 0.06–0.12 s - - Effects: short feels crisp; long feels mushy - - Constraints: total press = `2 * PRESS_DUR`; must finish before the next scene phase needs the cursor / target back at normal scale - - Reference: ../../examples/cta-orbit-collapse.html uses 0.08 s - -- **CURSOR_PRESS_SCALE / TARGET_PRESS_SCALE** — how far each compresses during the click - - Range: cursor 0.80–0.90; target 0.92–0.97 - - Effects: smaller numbers = stronger "this click counts" feel; values close to 1 read as a gentle tap - - Constraints: cursor compresses MORE than the target — the cursor is the actor, the target is the recipient - - Reference: ../../examples/cta-orbit-collapse.html uses cursor 0.85 / target 0.95 - -- **RIPPLE_AT** — when the rings start expanding, in seconds - - Range: `CLICK_AT + 0.0–0.08 s` - - Effects: simultaneous with the press feels causal; slight delay feels acoustic ("the click happens, then the wave radiates") - - Reference: ../../examples/cta-orbit-collapse.html starts the ripple at `CLICK_AT` exactly - -- **RIPPLE_DUR** — how long each ring takes to fully expand and fade - - Range: 0.5–1.0 s - - Effects: short rings feel sharp; long rings feel like a soft sonar - - Constraints: must complete before any phase that depends on the ring being gone (e.g. a screen wipe) - - Reference: ../../examples/cta-orbit-collapse.html uses 0.7 s - -- **RIPPLE_SCALE** — final scale of each ring before it fades - - Range: 3–6 - - Effects: 3 keeps the ring near the click site; 6 lets it sweep the surrounding area - - Constraints: if the ring would exit the visible frame before opacity reaches 0, lower the scale or shorten the duration - - Reference: ../../examples/cta-orbit-collapse.html uses 5 - -- **RIPPLE_STAGGER** — delay between consecutive rings - - Range: 0.06–0.12 s (or 0 for a single ring; see Variations) - - Effects: below ~0.06 s reads as one thick ring; above ~0.12 s reads as separate events - - Reference: ../../examples/cta-orbit-collapse.html uses a single ring (no stagger) - -- **RIPPLE_EASE** — easing family for the expansion - - Discrete choice. Options: - - `power2.out` — fast start, soft tail; the standard "ping" feel - - `power3.out` — even sharper attack, longer tail - - `expo.out` — almost-instant expansion with a long quiet fade; reads as a strong, distant pulse - - Reference: ../../examples/cta-orbit-collapse.html uses `power2.out` - -- **TARGET_X / TARGET_Y** — pixel offset of the click target from the cursor's CSS-laid origin - - These are layout-derived, not creative knobs — they must match the visual centroid of the actual click target. A 4 px miss reads as missing the button - - Reference: ../../examples/cta-orbit-collapse.html targets the white button at `CENTER_X + 130, CENTER_Y + 15` - ## Variations -- **Single ring** — keep one `.ripple` element, drop the stagger; reads as more elegant when the rest of the scene is busy -- **Keyframed attack-decay** — replace the simple expand-and-fade with a `keyframes` block that ramps opacity 0 → peak → 0 across the duration; gives a clearer "energy radiates and dissipates" envelope (used in ../../examples/cta-orbit-collapse.html) -- **Multi-ring expanding pulse** — 3 rings with 0.08 s stagger feels richer when the click is the climactic moment of the scene +- **Single ring** — one `.ripple`, no stagger; more elegant when the rest of the scene is busy. +- **Keyframed attack-decay** — a `keyframes` block ramps opacity 0 → peak → 0 across the duration; a clearer "energy radiates and dissipates" envelope. +- **Multi-ring expanding pulse** — 3 rings at 0.08 s stagger when the click is the scene's climactic moment. -## Key Principles +## Values -- **Move before click**: trigger the click only after the move tween has settled — clicking mid-motion reads as unintentional -- **Synchronized depression**: cursor + target depress at the same `position` time with the same duration (and both yoyo back) -- **Ripple from click point**: ripples expand from the exact click location (the button's visual center), not from any element's bounding-box origin -- **Subtle scale**: cursor compresses more than the target — see `CURSOR_PRESS_SCALE` / `TARGET_PRESS_SCALE` -- **High z-index cursor**: cursor renders above all content for the entire sequence +| token | range | notes | +| --------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| MOVE_DUR | 0.4–1.0 s | short darts; long reads as a "considered click." Must end before CLICK_AT or it reads as a misclick | +| MOVE_EASE | discrete choice | `power2.inOut` calm · `power3.out` decisive · `back.out(1.2–1.4)` settles onto the button with a tiny recoil (higher reads cartoonish) | +| CLICK_AT | `MOVE_DUR + 0–0.3 s` | zero pause reads as autopilot; >0.3 s reads as hesitation | +| PRESS_DUR | 0.06–0.12 s (half; yoyo ×2) | short crisp, long mushy; must finish before the next phase needs normal scale | +| CURSOR / TARGET_PRESS_SCALE | 0.80–0.90 / 0.92–0.97 | cursor compresses MORE than the target — the cursor is the actor, the target the recipient | +| RIPPLE_AT | `CLICK_AT + 0–0.08 s` | simultaneous feels causal; slight delay feels acoustic | +| RIPPLE_DUR | 0.5–1.0 s | sharp ping vs soft sonar; must complete before anything that needs the ring gone | +| RIPPLE_SCALE | 3–6 | 3 stays near the click site; if the ring would exit the frame before fading, lower it | +| RIPPLE_STAGGER | 0.06–0.12 s (or 0) | below ~0.06 s reads as one thick ring; above ~0.12 s as separate events | +| RIPPLE_EASE | discrete choice | `power2.out` standard ping · `power3.out` sharper attack · `expo.out` strong distant pulse | +| TARGET_X / TARGET_Y | layout-derived | must match the target's visual centroid — a 4 px miss reads as missing the button | -## Critical Constraints +Reference values: `../examples/cta-orbit-collapse.html` — 0.5 s move on `back.out(1.3)`, click +0.2 s, press 0.08 s at 0.85/0.95, single ring to 5× over 0.7 s `power2.out`. -- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never call `tl.play()` — HyperFrames seeks the timeline frame-by-frame deterministically -- **Registry key = `data-composition-id`**: `window.__timelines[""]` must match the `data-composition-id` on the scene root exactly -- **`immediateRender: false` on the ripple expand**: holds the initial state (`scale: 0`, `opacity: 0`) until the click moment, otherwise the tween pre-renders and the rings appear at the wrong size at t=0 -- **Finite duration**: verify `tl.duration()` matches the scene's `data-duration` -- **`pointer-events: none` on cursor + ripples**: they're purely visual; never block underlying interactivity (matters for hover-able exports) -- **No CSS transitions / animations**: all motion lives in the GSAP timeline so seek stays deterministic - -## Combinations +## Critical Constraints -- [orbit-3d-entry.md](orbit-3d-entry.md) — when the click is the pivot that collapses orbiting elements toward the cursor's target -- [center-outward-expansion.md](center-outward-expansion.md) — the click can be the trigger for an outward burst from the click point -- [press-release-spring.md](press-release-spring.md) for stronger physical feel on the target button -- [scale-swap-transition.md](scale-swap-transition.md) for the button's state change after click (button morphs into success state, next view, etc.) +- **Move before click** — trigger the click only after the move tween settles; clicking mid-motion reads as unintentional. +- **Rings live in DOM from t=0** at the click-target center with `scale: 0` + `opacity: 0` — never conditionally rendered; `immediateRender: false` on the expand so they hold invisible until the trigger. +- **Ripple from the click point** — the button's visual center, not any element's bounding-box origin. +- **Synchronized depression** — cursor + target depress at the same position with the same duration, and both yoyo back. +- **Cursor above all content** (high z-index) for the whole sequence; `pointer-events: none` on cursor + ripples. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + tween API reference (eases, stagger, `immediateRender`, etc.) -- `/hyperframes-core` — composition wiring (`data-*` attributes, scene structure, registration contract) -- `/hyperframes-cli` — `hyperframes lint` to verify the registry key + duration match +`orbit-3d-entry` (click as the pivot that collapses orbiters) · `center-outward-expansion` (click triggers an outward burst) · `press-release-spring` (stronger physical feel on the target) · `scale-swap-transition` (the button's post-click state change). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/cursor-drag.md b/plugins/visual-content/skills/hyperframes-animation/rules/cursor-drag.md new file mode 100644 index 0000000..e249591 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/cursor-drag.md @@ -0,0 +1,141 @@ +--- +name: cursor-drag +description: The drag verb for driven cursors — grab, lift, travel, drop-snap. A semi-transparent ghost chip rides the cursor in exact lockstep and snaps into a placed field with selection chrome; variants cover fill-handle auto-fill down rows, corner-handle proportional resize (uniform scale only), and grab-lift-reorder with the neighbor springing into the vacated slot. +metadata: + tags: cursor, drag, drop, ghost, handle, resize, reorder, snap, interaction, mouse +--- + +# Cursor Drag + +> Cursor look, sizing, off-screen entry, and tip-targeting defer to the **oversized-cursor house doctrine** — this rule owns the drag _mechanics_ only. + +THE held-journey verb: the cursor presses down on a payload, carries it, and releases it somewhere else. The load-bearing law is **lockstep**: the cursor tip and the payload's grip point move as one rigid object for the entire travel — a one-frame drift reads as the chip slipping out of the hand. Distinct from [cursor-click-ripple.md](cursor-click-ripple.md) (move → point event at a single location): a drag is a _sustained hold across space_, and the payload is the co-star. Reuse [physics-press-reaction.md](physics-press-reaction.md) for the grab's press dip (cursor + payload compress together); for N simultaneous actors see [multi-cursor-choreography.md](multi-cursor-choreography.md) — this rule is one protagonist performing a workflow beat. + +## How It Works + +Five beats: **approach** (cursor glides to the source chip, `power2.inOut`) → **grab** (press dip on cursor + chip together; on the down-beat `tl.set` reveals the **ghost** — a pre-rendered semi-transparent clone at the chip's position — plus a small lift `fromTo` to `GHOST_LIFT_SCALE` with a soft shadow, `immediateRender: false`) → **travel** (cursor and ghost move as **matched tweens**) → **drop** (ghost off, placed field pops in with selection chrome) → **adjust / exit** (optional handle resize, then the cursor glides to the next target). + +Matched tweens = same timeline position, same duration, same ease, over straight lines — that keeps the pair rigidly locked at every eased midpoint. A shared `[cursor, ghost]` targets array only works when both need identical deltas; with different start points, use two matched `fromTo`s. Rule-specific corollary of the contract's absolute-values law: a relative `+=` travel on either partner breaks the lockstep under seek. + +Measure chip and slot rects at build time — a 4 px miss on the drop line reads as a failed drag (montage: authored CSS-matched constants, per the contract). `TIP_OFFSET_X/Y` aligns the cursor's TIP (not its bbox) with the grip point. + +## Recipe + +```html + +
⋮⋮ {chipLabel}
+
⋮⋮ {chipLabel}
+
+ {placedLabel} + +
+
+``` + +```js +const chipRect = document.querySelector("#source-chip").getBoundingClientRect(); +const slotRect = document.querySelector("#placed-field").getBoundingClientRect(); +const TRAVEL_DX = slotRect.left - chipRect.left; +const TRAVEL_DY = slotRect.top - chipRect.top; + +// Travel — MATCHED tweens: same position, duration, ease; absolute endpoints. +tl.fromTo( + "#drag-ghost", + { x: 0, y: 0 }, + { x: TRAVEL_DX, y: TRAVEL_DY, duration: TRAVEL_DUR, ease: TRAVEL_EASE, immediateRender: false }, + TRAVEL_AT, +); +tl.fromTo( + "#cursor", + { x: chipRect.left + TIP_OFFSET_X, y: chipRect.top + TIP_OFFSET_Y }, + { + x: chipRect.left + TIP_OFFSET_X + TRAVEL_DX, + y: chipRect.top + TIP_OFFSET_Y + TRAVEL_DY, + duration: TRAVEL_DUR, + ease: TRAVEL_EASE, + immediateRender: false, + }, + TRAVEL_AT, +); + +// Drop is a state commit: ghost off + placed field on at the SAME position. +tl.set("#drag-ghost", { opacity: 0 }, DROP_AT); +tl.fromTo( + "#placed-field", + { opacity: 0, scale: 0.92 }, + { opacity: 1, scale: 1, duration: SNAP_DUR, ease: "power3.out" }, + DROP_AT, +); +tl.fromTo( + [".select-box", ".handle"], + { opacity: 0, scale: 0.6 }, + { opacity: 1, scale: 1, duration: 0.18, ease: "power3.out", stagger: 0.02 }, + DROP_AT + SNAP_DUR * 0.4, +); +``` + +## Variations + +- **Corner-handle proportional resize** — width/height tweens are forbidden, so the resize renders as uniform `scale` with `transform-origin` at the **opposite (anchor) corner**: the anchor stays put, the dragged corner travels. The corner's position is _linear in scale_ (`corner = anchor + scale × (corner₀ − anchor)`), so a cursor tween to the corner's end position with the **same duration and ease** stays glued to the handle exactly: + + ```js + tl.to( + "#placed-field", + { scale: RESIZE_SCALE, transformOrigin: "0% 0%", duration: RESIZE_DUR, ease: "power2.inOut" }, + RESIZE_AT, + ); + tl.to( + "#cursor", + { x: CORNER_END_X, y: CORNER_END_Y, duration: RESIZE_DUR, ease: "power2.inOut" }, + RESIZE_AT, + ); + ``` + + One-axis resizes are `scaleX`/`scaleY` on the same origin logic — stretch-safe boxes only; route to [anchored-layout-expand.md](anchored-layout-expand.md)'s counter-scale when content must stay undistorted. + +- **Fill-handle auto-fill** — the spreadsheet verb: the cursor drags a cell's fill handle straight down on a `"none"` (linear) ease; each row commits via a snapped `tl.set` (never a fade) keyed to the handle's linear progress, so the fill edge and cursor never separate: + + ```js + tl.fromTo( + "#cursor", + { y: HANDLE_Y }, + { y: HANDLE_Y + FILL_DIST, duration: FILL_DUR, ease: "none", immediateRender: false }, + FILL_AT, + ); + gsap.utils.toArray(".fill-cell").forEach((cell, i) => { + tl.set(cell, { opacity: 1 }, FILL_AT + ((i + 1) / CELL_COUNT) * FILL_DUR); + }); + ``` + +- **Grab-lift-reorder** — lift = `y: -LIFT_RISE` + `rotation: LIFT_TILT` (sign from index parity) + shadow on; as the carried item crosses the neighbor's midpoint, the **neighbor springs into the vacated slot** (a `fromTo` translate at `TRAVEL_AT + TRAVEL_DUR * 0.5`, `power3.out`); drop = rotation → 0, shadow off, settle. The neighbor's counter-move sells the reorder — without it the list reads as broken. +- **Component grab between surfaces** — a chip dragged mockup-to-mockup, swapping identity on drop (`tl.set` recolor + label swap at `DROP_AT`, tiny settle pop); the drop chrome is just the identity swap, no handles. + +## Values + +| token | range | notes | +| --------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| approach / press | per cursor-click-ripple | approach 0.4–1.0 s; press-dip halves 0.06–0.12 s; cursor compresses more than the payload | +| GHOST_OPACITY | 0.5–0.75 | below 0.5 vanishes on busy documents; ~1.0 reads as the original moving — then hide `#source-chip` at the grab | +| GHOST_LIFT_SCALE / LIFT_DUR | 1.03–1.08 / 0.12–0.2 s | the shadow is the "off the surface" cue; the scale is garnish | +| TRAVEL_DUR / TRAVEL_EASE | 0.6–1.2 s / `power2.inOut` | a considered drag decelerates into the slot; `power1.inOut` for a calmer carry. `TRAVEL_AT ≥ GRAB_AT + 2×PRESS_DUR + LIFT_DUR` | +| DROP_AT / SNAP_DUR | `TRAVEL_AT + TRAVEL_DUR` exactly / 0.2–0.3 s | a gap between arrival and snap reads as the drop failing | +| RESIZE_SCALE / RESIZE_DUR | by story (≈0.4–0.6) / 0.6–1.0 s | `power2.inOut` | +| LIFT_RISE / LIFT_TILT | 6–12 px / 2–4° | reorder pickup; index-derived tilt sign | + +## Critical Constraints + +- **Lockstep is the law** — matched tweens over straight lines (or one shared tween when deltas are identical); verify at the eased midpoint, not just the endpoints. Absolute endpoints on both partners. +- **The ghost is pre-rendered** — a DOM clone at the source position from t=0, `opacity: 0`, revealed by `tl.set`; placed field and chrome likewise. Never cloned at runtime, never conditionally rendered. +- **Grab has weight** — press dip + lift shadow before any travel; a chip departing without a press reads as telekinesis. +- **Drop is a state commit** — ghost off and placed field on at the same timeline position, `DROP_AT = TRAVEL_AT + TRAVEL_DUR`. +- **Resizes are uniform `scale`, origin at the anchor corner** — never width/height; one-axis stretch on stretch-safe boxes only. +- **Linear ease on the fill-handle travel** — the evenly-spaced `tl.set` reveals depend on it; an eased handle bunches them at the ends. +- **One verb per beat** — drag, then resize, then exit; overlapping a travel with a resize turns choreography into mush. +- **`pointer-events: none`** on cursor, ghost, and chrome. + +## See also + +`physics-press-reaction` (the grab's press dip) · `cursor-click-ripple` (a plain click before/after) · `spring-pop-entrance` (the placed field's snap-settle) · `waterfall-entry` (kinetic fill cascade) · `multi-phase-camera` (the zoom-breathing carrier shot golden drag demos ride) · `multi-cursor-choreography` (this verb inside an ensemble). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/depth-of-field-blur.md b/plugins/visual-content/skills/hyperframes-animation/rules/depth-of-field-blur.md index 702fbc4..e1b974a 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/depth-of-field-blur.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/depth-of-field-blur.md @@ -7,168 +7,64 @@ metadata: # Depth-of-Field Blur (Selective Focus / Rack Focus) -Pulls the eye to one focal element by **blurring** (and slightly **dimming**) everything around it while the focal layer stays sharp — the camera's depth-of-field falling off the background, or a rack-focus shifting which plane is in focus. The motion is `filter: blur(Npx)` plus a small `opacity` dim, tweened from sharp(0) to blurred over the focus-shift window — both seek-safe, since `filter` and `opacity` are paint-only properties HF interpolates correctly frame-by-frame. - -This is the backing rule for the focus-falloff beat the blueprints keep reaching for: the outer nodes blurring during the push-in (`constellation-hub`), the rack-focus across a parallax card stack (`cursor-ui-demo`), and the non-highlighted cards dimming + blurring to spotlight the hero metric (`dataviz-countup`). Each of those flags "no backing rule" for the DoF half of the move — this is it. +Pulls the eye to one focal element by **blurring** (and slightly **dimming**) everything around it while the focal layer stays sharp — the camera's depth-of-field falling off the background, or a rack-focus shifting which plane is in focus. `filter` and `opacity` are paint-only, so both tween seek-safe. This is the backing rule for the focus-falloff beat the blueprints reach for: outer nodes blurring during a push-in (`constellation-hub`), rack-focus across a parallax card stack (`cursor-ui-demo`), non-highlighted cards dimming to spotlight a hero metric (`dataviz-countup`). ## How It Works -Every layer carries a `--dof` custom property (px of blur), read by `filter: blur(var(--dof))`, plus its own `opacity`. A single GSAP tween advances each layer's `--dof` from `0` to its target blur and its opacity from `1` to a dim level, over the focus-shift window. The focal layer's tween targets `--dof: 0` (stays sharp); the off-focus layers target a positive blur. +Every layer carries a `--dof` custom property (px of blur), read by `filter: blur(var(--dof))`, plus its own `opacity`. A GSAP tween advances each layer's `--dof` from `0` to its target blur and its opacity from `1` to a dim level over the focus-shift window. The focal layer's `--dof` stays `0`. Per-layer targets derive from `data-depth` / index, so the falloff is identical on every seek. Three mechanics, same primitive: 1. **Focal pull** — one window: off-focus layers go sharp(0) → blurred while the focal layer holds at 0. The eye is pulled to the only thing still crisp. -2. **Rack focus** — two adjacent windows on the same property: focus releases plane A (its blur ramps 0 → max) at the same position plane B's blur ramps max → 0. State continuity matters exactly as in `press-release-spring`: A's resting blur after the rack must be the value B held before it, so authoring the two as adjacent tweens on the same `--dof` is what makes the hand-off seamless. -3. **Blur-the-cluster-while-pushing-in** — the DoF tween runs concurrently with a camera push-in (`multi-phase-camera` / `coordinate-target-zoom`): the surrounding cluster blurs + dims on the SAME timeline position as the camera scales toward the focal core, so "the world recedes" and "we push in" read as one move. - -Because the blur is a tween target (not a CSS `transition`), the renderer can land it at any frame — and because each layer's target is derived from its index / a data attribute (never `Math.random`), the falloff is identical on every seek. +2. **Rack focus** — two adjacent windows on the same property: plane A's blur ramps 0 → max at the same position plane B's ramps max → 0. State continuity matters exactly as in `press-release-spring`: A's resting blur after the rack must equal what B held before it — author both as tweens on the same `--dof` at the same position so the hand-off is seamless. +3. **Blur-the-cluster-while-pushing-in** — the DoF tween runs at the SAME timeline position as a camera push-in (`multi-phase-camera` / `coordinate-target-zoom`): "the world recedes" and "we push in" read as one move. -## HTML +## Recipe ```html -
-
- -
{FocalLabel}
- - -
{Context A}
-
{Context B}
-
{Context C}
-
+
+ +
{FocalLabel}
+ +
{Context A}
+
{Context B}
+
{Context C}
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgGradient}; -} .world { - /* Single wrapper so a concurrent camera push-in (multi-phase-camera) - transforms everything together; DoF is independent of the camera. */ + /* single wrapper so a concurrent camera push-in transforms everything + together; DoF is independent of the camera */ position: relative; width: 100%; height: 100%; transform-origin: 50% 50%; } .layer { - /* --dof is the px of blur; filter reads it. Starts sharp. */ - --dof: 0px; + --dof: 0px; /* px of blur; filter reads it — starts sharp */ filter: blur(var(--dof)); - /* will-change: filter — promotes the layer so the blur is cheap to - re-rasterize each frame. See the perf note in Key Principles. */ - will-change: filter; - font-family: {font}; - font-weight: 900; - color: {textColor}; + will-change: filter; /* promotes the layer so per-frame re-rasterization is cheap */ } .focal { - /* Sits above the context layers and never blurs. */ - z-index: 2; - font-size: FOCAL_FONT_SIZE; + z-index: 2; /* sharp layer must sit ABOVE the blurred ones, or its crisp + edges read as bleeding into the haze */ } .ctx { - /* The off-focus plane(s). Smaller / grouped so the blur radius can - stay modest yet still read — blurring a small layer is cheap. */ z-index: 1; - font-size: CTX_FONT_SIZE; - opacity: 1; } ``` -## GSAP Timeline - -```html - - -``` - -## Variations - -### Rack focus between two depth planes (foreground ⇄ background) - -Two adjacent tweens on the same `--dof` per plane — focus leaves plane A as it lands on plane B. State continuity: B's _resting_ blur before the rack equals what A holds after, so the hand-off has no jump. - ```js -// Start: A sharp, B pre-blurred (set BEFORE the rack so there's no pop). -gsap.set("#planeA", { "--dof": "0px", opacity: 1 }); -gsap.set("#planeB", { "--dof": `${MAX_BLUR}px`, opacity: DIM_LEVEL }); - -// Rack: A defocuses while B comes into focus, same position + duration. -tl.to( - "#planeA", - { "--dof": `${MAX_BLUR}px`, opacity: DIM_LEVEL, duration: RACK_DUR, ease: "power2.inOut" }, - RACK_START, -); -tl.to( - "#planeB", - { "--dof": "0px", opacity: 1, duration: RACK_DUR, ease: "power2.inOut" }, - RACK_START, -); -``` - -### Blur the cluster while pushing in (DoF + camera, one beat) - -Run the focal-pull tween at the **same timeline position** as a camera push-in so the surrounding cluster recedes into blur exactly as the camera scales toward the core. The camera transforms `.world`; the DoF tweens the layers — independent properties, no conflict. - -```js -// Camera push-in toward the focal core (see multi-phase-camera / coordinate-target-zoom). -tl.to( - "#world", - { scale: PUSH_SCALE, x: PUSH_X, y: PUSH_Y, duration: FOCUS_DUR, ease: "power2.inOut" }, - FOCUS_START, -); -// Cluster blurs + dims on the SAME position — "the world recedes as we push in." -ctx.forEach((el) => { +// Mechanic 1 — FOCAL PULL. Blur scales with data-depth so far planes blur +// more than near ones; the focal layer (--dof: 0, opacity: 1) is untouched. +gsap.utils.toArray(".ctx").forEach((el) => { const depth = Number(el.dataset.depth) || 1; tl.to( el, { "--dof": `${BLUR_PER_DEPTH * depth}px`, - opacity: DIM_LEVEL, + opacity: DIM_LEVEL, // dim, not gone duration: FOCUS_DUR, ease: "power2.inOut", }, @@ -177,137 +73,40 @@ ctx.forEach((el) => { }); ``` -### Spotlight a hero metric in a card grid (dim + blur the rest) - -The `dataviz-countup` beat: a subset of grid cards stays sharp (the hero metric) while the remainder dim + blur. Tag the hero(es) and skip them; everything else defocuses on one shared window. - -```js -gsap.utils.toArray(".card:not(.hero)").forEach((el) => { - tl.to( - el, - { "--dof": `${GRID_BLUR}px`, opacity: DIM_LEVEL, duration: FOCUS_DUR, ease: "power2.out" }, - FOCUS_START, - ); -}); -``` - -### Refocus / settle (release the blur before the scene ends) - -If the beat resolves back to "everything visible" (or hands off to a crossfade that needs a clean outgoing frame), ramp the blur back to 0 over the tail so the scene settles sharp instead of mid-defocus. - -```js -ctx.forEach((el) => - tl.to( - el, - { "--dof": "0px", opacity: 1, duration: REFOCUS_DUR, ease: "power2.inOut" }, - REFOCUS_START, - ), -); -``` - -### Bounded focus-breathing on the focal layer (optional) - -For a subtle "rack settling" feel, let the focal layer's blur breathe a hair around 0 during its hold — a _finite_ `ease:"none"` driver writing `sin()` into `--dof` (never `repeat:-1`, never a CSS animation). Keep the amplitude well under 1px or it reads as "still focusing." - -```js -const drift = { p: 0 }; -tl.to( - drift, - { - p: Math.PI * 2 * BREATH_CYCLES, - duration: BREATH_DUR, - ease: "none", - onUpdate: () => { - const b = Math.max(0, Math.sin(drift.p)) * FOCAL_BREATH_PX; // ≤ ~0.6px - document.getElementById("focal").style.setProperty("--dof", `${b}px`); - }, - }, - BREATH_START, -); -``` - -## How to Choose Values - -### Geometry / layout - -- **FOCAL_FONT_SIZE / CTX_FONT_SIZE** — focal vs context sizing. - - Range: focal is the visual lead; context layers smaller so a modest blur radius still reads as "out of focus." - - Effects: small context layers let you use a smaller `BLUR_PER_DEPTH` (cheaper) yet still look soft. -- **z-index** — focal `z-index: 2`, context `z-index: 1`. - - Constraints: the sharp focal layer must sit **above** the blurred ones, or its crisp edges read as bleeding into the haze. - -### Blur amounts - -- **BLUR_PER_DEPTH** — px of blur added per depth step (`data-depth`). - - Range: 3-6 px per step (a 3-plane stack tops out at ~9-18 px) - - Effects: low → gentle DoF; high → strong miniature/tilt-shift falloff - - Constraints: keep **per-layer blur ≤ ~24 px on large layers** — radius cost grows with both blur and area; large radius over a full-frame element is the expensive case (see Key Principles) -- **MAX_BLUR** — terminal blur for a fully-defocused plane (rack / focal-pull peak). - - Range: 8 (soft) → 16 (default) → 24 (heavy) px - - Constraints: above ~24 px on a big surface, prefer scaling the layer down or grouping its contents so the blurred footprint shrinks -- **GRID_BLUR** — blur on dimmed grid cards (spotlight variation). - - Range: 6-12 px — enough to push them back without losing the grid's shape - -### Dim amounts - -- **DIM_LEVEL** — opacity of off-focus layers at full defocus. - - Range: 0.4 (strong push-back) → 0.55 (default) → 0.7 (subtle) - - Effects: lower → context recedes hard / near-spotlight; higher → still legibly present, just secondary - - Constraints: rarely below 0.35 — fully dark off-focus layers read as "removed," not "defocused" - -### Timing - -- **FOCUS_START / FOCUS_DUR** — when the focal pull begins and how long the rack takes. - - Range: `FOCUS_DUR` 0.5-1.2 s — a rack/pull is a deliberate move, not a snap - - Effects: shorter → urgent "snap focus"; longer → languid cinematic rack -- **RACK_START / RACK_DUR** — rack-focus window (foreground ⇄ background). - - Constraints: both planes' tweens share `RACK_START` and `RACK_DUR` so they cross at the midpoint; `gsap.set` the pre-blurred plane BEFORE `RACK_START` -- **REFOCUS_START / REFOCUS_DUR** — settle-back window. - - Constraints: `REFOCUS_START + REFOCUS_DUR ≤ DURATION` so the scene actually reaches sharp before it ends / hands off -- **PUSH_SCALE / PUSH_X / PUSH_Y** (cluster-while-pushing-in variation) — camera move on `.world`. - - Constraints: shares `FOCUS_START` + `FOCUS_DUR` with the DoF tween so move and defocus read as one beat; counter-translate math lives in `coordinate-target-zoom` / `viewport-change` -- **BREATH_CYCLES / BREATH_DUR / FOCAL_BREATH_PX** (focus-breathing variation). - - Range: `FOCAL_BREATH_PX ≤ 0.6` px; period 2-3 s; this is a barely-there nicety, default to omitting it +## Variations -### Tokens +- **Rack focus between two depth planes** — `gsap.set` plane B pre-blurred BEFORE the rack (no pop), then two tweens sharing `RACK_START` + `RACK_DUR`: A → `MAX_BLUR` + `DIM_LEVEL`, B → `0px` + `1`. Shared window makes them cross at the midpoint. +- **Blur the cluster while pushing in** — run the focal-pull tweens at the same position + duration as a camera tween on `#world` (`scale/x/y`, `power2.inOut`). Camera transforms the world; DoF tweens the layers — independent property channels, no conflict. +- **Spotlight a hero metric in a card grid** — `gsap.utils.toArray(".card:not(.hero)")` all defocus (`GRID_BLUR` + `DIM_LEVEL`) on one shared window; heroes are skipped. +- **Refocus / settle** — if the beat resolves back to "everything visible" (or hands off to a crossfade needing a clean outgoing frame), ramp all `--dof` back to `0px` / opacity 1 over the tail (`REFOCUS_START + REFOCUS_DUR ≤ DURATION`). +- **Bounded focus-breathing on the focal layer (optional)** — a finite `ease:"none"` driver writes `Math.max(0, Math.sin(p)) * FOCAL_BREATH_PX` into the focal `--dof` during a hold. Keep it ≤ ~0.6px or it reads as "still focusing"; default to omitting it. -- **{bgGradient}** — typically dark so the sharp focal layer reads as lit and forward -- **{textColor}** — high-contrast on `{bgGradient}`; the blur softens edges, so don't rely on hairline contrast -- **{font}** — display weight; blurred copy needs heavy weight to stay shape-legible when defocused +## Values -## Key Principles +| token | range | notes | +| --------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| BLUR_PER_DEPTH | 3–6 px per depth step | a 3-plane stack tops out ~9–18 px; low = gentle DoF, high = tilt-shift falloff | +| MAX_BLUR | 8 soft → 16 default → 24 heavy px | terminal blur for a fully-defocused plane; above ~24 px on a big surface, shrink/group the layer instead | +| GRID_BLUR | 6–12 px | pushes cards back without losing the grid's shape | +| DIM_LEVEL | 0.4 strong → 0.55 default → 0.7 subtle | rarely below 0.35 — fully dark reads as "removed," not "defocused" | +| FOCUS_DUR | 0.5–1.2 s | a rack/pull is a deliberate move, not a snap; shorter = snap focus, longer = languid | +| RACK_START / RACK_DUR | shared by both planes | `gsap.set` the pre-blurred plane BEFORE `RACK_START` | +| FOCAL_BREATH_PX | ≤ 0.6 px, period 2–3 s | barely-there nicety | +| FOCAL vs CTX sizing | context smaller / grouped | small context layers let a modest radius still read as "out of focus" — and blur cheaply | -- **`--dof` drives the blur; tween the variable, never a CSS `transition`.** Reading `filter: blur(var(--dof))` and animating `--dof` on the GSAP timeline keeps the blur on the HF seek clock. A CSS `transition` on `filter` interpolates on the browser's own clock and flickers/desyncs under frame-by-frame seek. -- **Blur the SMALL / GROUPED layers, not the giant one.** Filter-blur cost scales with both radius and the blurred element's pixel area. A 20 px blur on a full-frame background is the worst case; the same blur on a smaller context card, or on a single grouped wrapper, is cheap. Prefer pushing the focal plane _forward and sharp_ over cranking the background blur radius. -- **`will-change: filter`** on every layer that animates its blur — promotes it to its own layer so the re-rasterization each frame is cheap. Drop it once the blur settles if the layer also does heavy transform work. -- **Keep the radius modest.** ≤ ~24 px on large surfaces; lean on the `opacity` **dim** to do the "push it back" work alongside a smaller blur, rather than blur alone. Dim + modest blur reads more like real DoF than blur cranked to the max. -- **Focal layer stays genuinely sharp** — its `--dof` is `0` and untouched (or breathes ≤0.6 px). Any visible blur on the focal element kills the "this is the thing" read. -- **State continuity on a rack** — the plane coming OUT of focus must start the rack at the blur the incoming plane _was_ holding, and vice-versa; author both as tweens on the same `--dof` at the same position so the cross is seamless (same rule as `press-release-spring`'s press↔release). -- **DoF is independent of the camera** — blur the layers, transform `.world` for the push-in. They're different property channels, so they compose without fighting. Don't try to fake DoF with the camera transform or vice-versa. -- **Settle sharp before a hand-off** — if the next beat is a crossfade/push, refocus to `--dof:0` in the tail so the outgoing frame is crisp; handing off mid-defocus reads as "the render glitched." +Tokens: dark `{bgGradient}` so the sharp focal layer reads as lit and forward; heavy display `{font}` weight — blurred copy needs it to stay shape-legible. ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition`** on `filter` / `opacity` — animate `--dof` and `opacity` on the timeline instead -- **No `repeat` / `yoyo` / infinite tweens** — the focus pull is a finite tween; any breathing is a bounded `onUpdate` reading the driver phase (or a finite tween), never `repeat:-1` -- **No `Math.random` / `Date.now`** — per-layer blur is derived from `data-depth` / element index so every seek is identical -- **Tween `filter` (blur) + `opacity` only here** — both paint-only and seek-safe. Use GSAP transform aliases (`x`, `y`, `scale`) for any concurrent camera move; never tween `width` / `height` / `left` / `top` -- **`will-change: filter`** on layers whose blur animates; keep the blurred footprint small -- **Per-layer blur radius ≤ ~24 px on large surfaces** — beyond that the cost (and visible banding) climbs; shrink/group the layer instead - -## Combinations - -- [multi-phase-camera.md](multi-phase-camera.md) — the push-in / push-through whose focus-falloff this rule supplies; run the DoF tween at the same position as the PUSH phase -- [coordinate-target-zoom.md](coordinate-target-zoom.md) — zoom onto the focal core while the off-center layers blur (the `constellation-hub` hook) -- [viewport-change.md](viewport-change.md) — pan across a tilted card plane with a rack-focus between near and far cards (the `cursor-ui-demo` focus-pull) -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — the hero metric counts up sharp while the surrounding cards dim + blur (the `dataviz-countup` spotlight) -- [3d-page-scroll.md](3d-page-scroll.md) — the parallax card stack whose planes you rack focus between -- [sine-wave-loop.md](sine-wave-loop.md) — the focal layer idle-breathes after the rack settles (keep idle amplitude and focus-breath both tiny) +- **Tween the `--dof` variable on the timeline** — reading `filter: blur(var(--dof))` keeps the blur on the HF seek clock. +- **Blur the SMALL / GROUPED layers, not the giant one.** Filter cost scales with radius × pixel area; a 20 px blur on a full-frame background is the worst case. Keep per-layer radius ≤ ~24 px on large surfaces and lean on the `opacity` **dim** to do the push-back work — dim + modest blur reads more like real DoF than blur cranked to the max. +- **`will-change: filter`** on every layer whose blur animates (drop it after settle if the layer also does heavy transform work). +- **Focal layer stays genuinely sharp** — `--dof: 0`, untouched (or breathing ≤ 0.6 px). Any visible blur on the focal element kills the "this is the thing" read. +- **State continuity on a rack** — the outgoing plane starts at the blur the incoming plane was holding, and vice-versa; adjacent tweens on the same `--dof` at the same position. +- **DoF is independent of the camera** — blur the layers, transform `.world` for the push-in; don't fake DoF with the camera transform or vice-versa. +- **Settle sharp before a hand-off** — refocus to `--dof: 0` in the tail if the next beat is a crossfade/push; handing off mid-defocus reads as "the render glitched." +- **Sharp focal layer above blurred layers** (`z-index`). -## Pairs with HF skills +## See also -- `/hyperframes-animation` — tweening a CSS custom property + multi-tween coordination -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +[multi-phase-camera.md](multi-phase-camera.md) (the push-in this rule's falloff accompanies) · [coordinate-target-zoom.md](coordinate-target-zoom.md) (zoom onto the focal core — the `constellation-hub` hook) · [viewport-change.md](viewport-change.md) (pan + rack across a tilted card plane) · [counting-dynamic-scale.md](counting-dynamic-scale.md) (hero metric counts up sharp — the `dataviz-countup` spotlight) · [3d-page-scroll.md](3d-page-scroll.md) (the parallax stack to rack between) · [sine-wave-loop.md](sine-wave-loop.md) (post-rack idle; keep both amplitudes tiny). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/depth-scatter-assemble.md b/plugins/visual-content/skills/hyperframes-animation/rules/depth-scatter-assemble.md index ffe9424..8d19680 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/depth-scatter-assemble.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/depth-scatter-assemble.md @@ -7,297 +7,133 @@ metadata: # Depth Scatter ↔ Assemble -N elements (glyphs, cards, icons, logo fragments) fly in from a rotating 3D depth-cloud and lock into a clean on-screen layout — or the reverse. Each element starts at a **deterministic** 3D offset (translateZ depth + rotateX/rotateY + an x/y scatter derived from its index), then tweens to its assembled flat position (`z: 0, rotation: 0`). Because every scattered position is computed by trig on the element's index — never `Math.random` — it renders identically every frame. - -Distinct from `orbit-3d-entry` (flip-in then a continuous orbit) and `center-outward-expansion` (a flat 2D burst from one shared center): here each element has its **own** point in a 3D cloud, and the resolve is a flat assembled layout, not an orbit or a radial spray. +N elements (glyphs, cards, logo fragments) fly in from a rotating 3D depth-cloud and lock into a flat layout — or the reverse. Each element has its OWN index-derived point in the cloud (translateZ depth + rotateX/Y tumble + x/y scatter). Distinct from `orbit-3d-entry` (flip-in then continuous orbit) and `center-outward-expansion` (flat burst from one shared center): here the resolve is a flat assembled layout. ## How It Works -Each element resolves to a flat layout position (`targetX/Y`, set once in CSS or via `data-*`). Its **scattered** state is derived from its index `i`: +Each element's flat target lives in `data-target-x/y`; its scattered state is pure trig on its index — golden-angle spread, stepped depth — so the cloud is byte-identical every render with no `Math.random`: ```js -const GOLDEN = Math.PI * (3 - Math.sqrt(5)); // ~2.39943 rad — even angular spread, no clumping -const a = i * GOLDEN; // this element's angle in the cloud -const scatterX = Math.cos(a) * RADIUS; // index-derived, deterministic +const GOLDEN = Math.PI * (3 - Math.sqrt(5)); // ~2.39943 rad — even spread, no clumping +const a = i * GOLDEN; +const scatterX = Math.cos(a) * RADIUS; const scatterY = Math.sin(a) * RADIUS; -const scatterZ = Z_NEAR - (i / (n - 1)) * (Z_NEAR - Z_FAR); // stepped depth across the cloud -const rotX = Math.sin(a) * TUMBLE; // tumble orientation, also from the angle +const scatterZ = Z_NEAR - (i / (n - 1)) * (Z_NEAR - Z_FAR); // stepped depth +const rotX = Math.sin(a) * TUMBLE; const rotY = Math.cos(a) * TUMBLE; ``` -A single 0→1 `progress` proxy interpolates each element between scattered and assembled (lerp every channel). At `progress = 0` the elements form the depth-cloud; at `progress = 1` they sit flat in the layout. Run it forward and it's **assemble**; the cloud itself slowly rotates (a stage `rotateY` tween) so the scatter has life before it locks. - -Requires `perspective` on the stage and `transform-style: preserve-3d` on the stage AND each element, or the z-depth and tumble flatten to a 2D scale. +Elements are PARKED at their scatter points (`gsap.set`, opacity 0) before any tween, then each tweens to its flat target while the whole stage slowly rotates so the scatter has life before it locks. Requires `perspective` on the scene root and `preserve-3d` on the stage AND each element, or depth + tumble flatten to a 2D scale. -## HTML +## Recipe ```html -
- -
-
{glyph1}
-
{glyph2}
-
{glyph3}
-
{glyph4}
-
{glyph5}
-
+ +
+
{glyph1}
+
{glyph2}
+
``` -For a logo lockup, `targetX/Y` describe the parts' resting layout; for kinetic type, one `.frag` per glyph (inject spans from the phrase string at setup so width is exact — see Variations). - -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; +.scene-root { display: grid; place-items: center; - background: {bgColor}; - perspective: 1400px; /* REQUIRED — without it, z-depth + tumble read as flat 2D scale */ + perspective: 1400px; /* REQUIRED */ } .cloud-stage { position: relative; - width: 100%; - height: 100%; display: grid; place-items: center; - transform-style: preserve-3d; /* REQUIRED — preserves child 3D context */ + transform-style: preserve-3d; will-change: transform; } .frag { position: absolute; - /* Live at stage center; GSAP translates each one to its layout / cloud point. */ top: 50%; left: 50%; - display: grid; - place-items: center; - font-family: {font}; - font-weight: 900; - font-size: 120px; - color: {textColor}; - transform-style: preserve-3d; /* each fragment keeps its own 3D context */ + transform-style: preserve-3d; backface-visibility: hidden; /* hides the mirrored face mid-tumble */ will-change: transform, opacity; } ``` -## GSAP Timeline - -```html - - +}); ``` ## Variations -### Tumble-swap (mid-shot hand-off between two phrases) - -The signature for `kinetic-type-beats` beat changes: one phrase's glyphs scatter **into** the cloud at the same moment the next phrase's glyphs assemble **out** of it — a 3D hand-off between two states, never an empty frame. Two glyph sets share the cloud; drive both with one shared 0→1 `progress` so they cross deterministically. - -```js -// outgoing[] and incoming[] are two glyph arrays, each with precomputed scatter[] (above). -const swap = { p: 0 }; -tl.to( - swap, - { - p: 1, - duration: SWAP_DUR, - ease: "power2.inOut", - onUpdate: () => { - const p = swap.p; - outgoing.forEach((el, i) => { - // 1 → 0: layout → cloud (scatters AWAY) - const s = outScatter[i]; - const tx = Number(el.dataset.targetX); - const ty = Number(el.dataset.targetY); - el.style.opacity = String(1 - p); - el.style.transform = - `translate(-50%,-50%) translate3d(${tx + (s.x - tx) * p}px,${ty + (s.y - ty) * p}px,${s.z * p}px)` + - ` rotateX(${s.rotationX * p}deg) rotateY(${s.rotationY * p}deg)`; - }); - incoming.forEach((el, i) => { - // 0 → 1: cloud → layout (assembles IN) - const s = inScatter[i]; - const tx = Number(el.dataset.targetX); - const ty = Number(el.dataset.targetY); - el.style.opacity = String(p); - el.style.transform = - `translate(-50%,-50%) translate3d(${s.x + (tx - s.x) * p}px,${s.y + (ty - s.y) * p}px,${s.z * (1 - p)}px)` + - ` rotateX(${s.rotationX * (1 - p)}deg) rotateY(${s.rotationY * (1 - p)}deg)`; - }); - }, - }, - SWAP_AT, -); -``` - -Inject a per-glyph span set for each phrase at setup (so `targetX` per glyph is the exact laid-out advance width — measure after `document.fonts.ready`), and hide each set's opacity to 0 until its window. - -### Radial letter-explode → resolve - -A flat-plane special case (the `kinetic-type-beats` "letters explode radially then resolve" GAP): set `Z_NEAR = Z_FAR = 0` and `TUMBLE` small so the cloud is a 2D ring, then reverse the assemble for the explode — fragments fling out to `scatter[i]` then snap back to layout. Pure in-plane, no depth. - -### Scatter-OUT (final-frame exit only) - -Reverse the assemble (layout → cloud, opacity 1→0) ONLY as the composition's last beat. A scatter-out mid-shot reads as an exit and breaks the shot — keep entrances and hand-offs as assemble or tumble-swap. - -### Parallax depth slide-in (logo lockup) - -For `logo-assemble-lockup`, give back layers a larger `|Z_FAR|` and a longer `ASSEMBLE_DUR`, foreground parts a shallower depth and shorter duration — parts at different depths slide in at different apparent speeds (parallax) and lock into the lockup. - -## How to Choose Values - -- **n (ELEMENT_COUNT)** — fragments / glyphs in the cloud - - Range: 4–14 (glyph sets follow the word length; for fragments/cards stay 4–9) - - Effects: few reads as deliberate assembly; many reads as a dense swarm condensing - - Constraints: above ~14 the cloud crowds the center and individual paths stop reading - -- **RADIUS** — cloud spread in the x/y plane, px - - Range: 250–700 px - - Effects: small = a tight knot that barely separates; large = fragments arrive from the frame edges - - Constraints: keep the farthest scatter inside frame at the chosen `perspective`, or fragments pop in from off-screen with no travel read - -- **Z_NEAR / Z_FAR** — depth band of the cloud, px (front / back) - - Range: Z_NEAR +150 to +450; Z_FAR −150 to −500 - - Effects: a wide band (e.g. +400 / −400) gives strong fly-toward / recede-from camera depth; a narrow band keeps it nearly flat - - Constraints: very large `|z|` against a short `perspective` over-distorts (fragments smear huge then tiny) — widen `perspective` to match - -- **TUMBLE** — peak rotateX/rotateY of scattered fragments, deg - - Range: 40–110° - - Effects: low = fragments drift in nearly upright; high = they tumble through space and rotate upright on arrival - - Constraints: with `backface-visibility: hidden`, glyphs past 90° show blank mid-tween (intended for the tumble); for cards with content on one face, cap near 80° - -- **ASSEMBLE_DUR** — per-fragment cloud → layout tween, s - - Range: 0.7–1.4 s - - Effects: short = snappy lock-in; long = a floating condense - - Constraints: `(n − 1) × STAGGER + ASSEMBLE_DUR` must fit the scene's assembly window - -- **ASSEMBLE_EASE** — shared ease across fragments - - Discrete choice: `power3.out`, `expo.out`, `back.out(1.4)` - - Selection: `power3.out` default (fly in, settle). `expo.out` snaps hard at the end. `back.out` adds a small overshoot as parts seat. Avoid `in` easings — fragments look sucked backward into the cloud mid-air. - -- **STAGGER** — gap between successive fragments' assembly starts, s - - Range: 0.03–0.09 s - - Effects: < 0.03 = a single chord (whole cloud collapses at once); > 0.09 = a slow drip that loses the "swarm" read - - Constraints: `n × STAGGER` should stay below `ASSEMBLE_DUR` so the cloud is collapsing as one motion, not a queue - -- **CLOUD_SPIN_DEG / CLOUD_SPIN_DUR** — stage rotateY over the assembly, deg / s - - Range: 15–60° over a duration ≥ `ASSEMBLE_DUR` - - Effects: a gentle spin gives the scatter life so it doesn't read as a frozen explosion diagram; too fast competes with the assembly - - Constraints: keep finite and ending by settle — no `repeat` - -- **SWAP_DUR / SWAP_AT** (tumble-swap) — hand-off length / when it fires, s - - Range: SWAP_DUR 0.5–1.0 s; SWAP_AT on the beat boundary - - Effects: shorter = a hard cross; longer = a visible dissolve-through-cloud - - Constraints: outgoing and incoming MUST share one `progress` (one tween) so they cross at the same instant - -## Key Principles - -- **`perspective` on the scene root + `preserve-3d` on stage AND each fragment** — without all three, z-depth and tumble collapse to a flat scale -- **Every scattered value is index-derived** — `cos/sin(i × GOLDEN)`, stepped `z` by `i/(n−1)`. The golden angle spreads points evenly with no clumps and (critically) **no `Math.random`**, so the cloud is byte-identical every render -- **`gsap.set` the cloud BEFORE adding tweens** — park each fragment at its scatter point with `opacity: 0` first; the assemble tweens FROM there. Skipping the set leaves frame 0 showing the assembled layout, then a teleport when the first tween starts -- **Resolve flat** — the settled state is `z: 0, rotationX: 0, rotationY: 0` in the layout. A cloud that resolves still-tilted reads as unfinished -- **Assemble / hand-off only; scatter-OUT is an exit** — fragments leaving for the cloud mid-shot reads as the shot ending. Use forward assemble for entrances, tumble-swap for beat changes; reserve scatter-out for the final frame -- **Depth ordering is automatic** — inside `preserve-3d`, paint order follows actual Z, so nearer fragments correctly occlude farther ones with no manual z-index (unlike the orbit case, where the orbit is faked in 2D and needs capped z-index) +- **Tumble-swap** (the beat-change hand-off): two glyph sets share the cloud; ONE shared 0→1 progress tween drives both in its `onUpdate` — outgoing lerps layout→cloud with `opacity: 1−p`, incoming lerps cloud→layout with `opacity: p`. Two separate tweens drift out of phase under seek and the cross stops reading as one hand-off. Inject per-glyph spans per phrase at setup (measure advance widths after `document.fonts.ready` — single-scene only). +- **Radial letter-explode → resolve**: flat-plane special case — `Z_NEAR = Z_FAR = 0`, small `TUMBLE`; reverse the assemble for the explode. Pure in-plane. +- **Scatter-OUT**: reverse assemble (layout → cloud, opacity 1→0) ONLY as the composition's final beat — mid-shot it reads as the shot ending. +- **Parallax lockup**: back layers get deeper `|Z_FAR|` + longer `ASSEMBLE_DUR`, foreground shallower/shorter — depth-speeded slide-in that locks into the logo. + +## Values + +| token | range | notes | +| ---------------------- | --------------------- | ----------------------------------------------------------------------------- | +| n | 4–14 (fragments 4–9) | above ~14 individual paths stop reading | +| RADIUS | 250–700px | keep the farthest scatter in frame or fragments pop in with no travel | +| Z_NEAR / Z_FAR | +150…+450 / −150…−500 | large `\|z\|` needs a wider `perspective` or fragments smear | +| TUMBLE | 40–110° | past 90° glyphs show blank mid-tween (intended); cap ~80° for one-faced cards | +| ASSEMBLE_DUR | 0.7–1.4s | | +| ASSEMBLE_EASE | `power3.out` default | `expo.out` snaps, `back.out(1.4)` seats with overshoot; never `in` | +| STAGGER | 0.03–0.09s | `n × STAGGER < ASSEMBLE_DUR` — one collapsing motion, not a queue | +| CLOUD_SPIN_DEG / \_DUR | 15–60° over ≥ dur | gentle life; too fast competes with the assembly | +| SWAP_DUR | 0.5–1.0s | on the beat boundary; shorter = hard cross | ## Critical Constraints -- **No `Math.random` / `Date.now`** — derive every scatter coordinate from the index (golden-angle trig + stepped depth). This is the whole point of the rule: a randomized cloud renders differently each frame and the seek breaks -- **No CSS `transition`** — all motion is GSAP tweens on the paused timeline -- **No `repeat` / `yoyo` / infinite** — the cloud spin and every assemble are finite, one-shot tweens that end before settle -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **Transform aliases only** — `x`, `y`, `z`, `scale`, `rotation`/`rotationX`/`rotationY`. Never `width`/`height`/`left`/`top`; `x`/`y` compose with the `xPercent/yPercent -50` self-centering -- **`will-change: transform`** on stage + fragments — many simultaneous 3D transforms benefit from compositor hints -- **In tumble-swap, one shared `progress` for both glyph sets** — two separate tweens can drift out of phase under seek and the cross stops looking like a single hand-off - -## Combinations - -- [orbit-3d-entry.md](orbit-3d-entry.md) — alternative 3D entrance (settles into a continuous orbit instead of a flat lockup); shares the `perspective` + `preserve-3d` stage setup -- [hacker-flip-3d.md](hacker-flip-3d.md) — per-glyph 3D flip/decode as the fragments seat; layer for a "letters tumble in AND decode on arrival" read -- [3d-text-depth-layers.md](3d-text-depth-layers.md) — give the assembled wordmark a stacked extrusion once it locks -- [center-outward-expansion.md](center-outward-expansion.md) — flat 2D cousin (single shared center, no depth) when perspective isn't wanted -- [press-release-spring.md](press-release-spring.md) — a spring settle on the assembled lockup once the cloud resolves -- [sine-wave-loop.md](sine-wave-loop.md) — idle breathe on the resolved layout instead of a frozen hold +- **Every scattered value is index-derived** — `cos/sin(i × GOLDEN)` + stepped `z`. The golden angle spreads points evenly with no clumps and no `Math.random`. +- **`gsap.set` the cloud BEFORE adding tweens** — skipping it leaves frame 0 showing the assembled layout, then a teleport when the first tween starts. +- **`perspective` + `preserve-3d` on stage AND each fragment** — missing any one flattens the depth. +- **Resolve flat** — settled state is `z: 0`, rotations 0; a still-tilted resolve reads unfinished. +- **Tumble-swap: one shared progress for both glyph sets.** +- **Depth ordering is automatic** inside `preserve-3d` (paint order follows actual Z) — no manual z-index, unlike the orbit case's capped band. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + `onUpdate` API (the shared-progress tumble-swap) -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`orbit-3d-entry` (settles into a continuous orbit instead) · `hacker-flip-3d` (glyphs decode on arrival) · `3d-text-depth-layers` (extrude the locked wordmark) · `center-outward-expansion` (flat 2D cousin) · `sine-wave-loop` (idle breathe on the resolved layout). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/discrete-text-sequence.md b/plugins/visual-content/skills/hyperframes-animation/rules/discrete-text-sequence.md index 3045b3a..2d3ba11 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/discrete-text-sequence.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/discrete-text-sequence.md @@ -7,149 +7,99 @@ metadata: # Discrete Text Sequence -Instead of character-by-character typewriter, replace entire string states at time thresholds. Enables non-linear effects (typos, bulk additions, pauses, "thinking" gaps) that smooth per-char typing can't achieve. +Instead of character-by-character typewriter, replace entire string states at time thresholds — enabling non-linear effects (typos, backspaces, bulk paste, "thinking" gaps) that smooth per-char typing can't achieve. If your effect is "type each character, no edits", this rule is overkill — use the smooth-slice variation below. ## How It Works -An array of `{ text, t }` pairs where `t` is a time in seconds. On every onUpdate, scan the array for the latest entry whose `t` has passed and render that text. The display jumps between states; no animation between them. +The typing is authored as a sparse array of `{ t, text }` states; on every `onUpdate` a **reverse search** finds the latest entry whose `t` has passed and renders its text. Display jumps between states with no animation between them — the realism comes from the schedule shape: fast keystroke clusters (0.06–0.20s apart), pauses at word breaks (0.3–0.6s), a typo, backspaces peeling back to the fork, then a bulk paste replacing many chars in one entry. A block cursor blinks via a deterministic sin square wave on the same timeline. -For continuous per-char typewriter (no pauses, no edits), use the **smooth-slice** variation at the bottom. - -## HTML +## Recipe ```html -
-
-
$
-
- | - _ -
+ +
+
$
+
+ _
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; - font-family: {monoFont}; /* monospace is required — see Critical Constraints */ -} .terminal { + font-family: {monoFont}; /* monospace required — proportional jitters even in a fixed box */ display: flex; align-items: baseline; - gap: GUTTER; - font-weight: 800; font-size: TERMINAL_FONT_SIZE; - color: {textColor}; -} -.prompt { - color: {accentColor}; } .text-wrap { display: inline-flex; align-items: baseline; - /* Fixed-width container prevents the right side from jittering as - content changes length. Choose width ≥ longest state's width. */ - min-width: TEXT_WRAP_MIN_WIDTH; + min-width: TEXT_WRAP_MIN_WIDTH; /* ≥ widest state — stops right-edge jitter */ white-space: nowrap; } -.text { - color: {textColor}; -} .cursor { - display: inline-block; + display: inline-block; /* inline ignores width */ width: CURSOR_WIDTH; - color: {accentColor}; - margin-left: CURSOR_GAP; } ``` -## GSAP Timeline + Discrete State Logic - -```html - - + }, + 0, +); ``` ## Variations -### Smooth character slice (continuous typewriter — no pauses, no edits) - -For straight-forward typewriter without the non-linear chaos: +- **Smooth character slice** (continuous typewriter — no pauses, no edits): faster to author but uniformly "machine-typed", missing the human realism: ```js const fullText = "{fullPhrase}"; @@ -168,106 +118,29 @@ tl.to( ); ``` -This is faster to author but produces a uniform "machine-typed" feel — missing the human-typing realism. +- **Thinking pause** — hold one state for `THINK_HOLD_DUR` (0.8–2.0s; under 0.5s reads as a stutter, not thought) simply by leaving a gap before the next entry's `t`. +- **State pulse on completion** — when the final state lands, `tl.to(".text", { scale: 1.03–1.08, duration: 0.15–0.3, yoyo: true, repeat: 1 }, T_DONE)`. +- **Per-state color shift** — in `onUpdate`, branch on `driver.t` vs the milestones: success color after `T_DONE`, dim mid-edit, normal while typing. -### Thinking pause (extended hold on a key state) +## Values -Insert a state that holds for `THINK_HOLD_DUR` seconds without changes — feels like the user paused to think: - -```js -{ t: T_PRE_PAUSE, text: '{partialPhrase}' }, // last state before the pause -// ... no entries for THINK_HOLD_DUR seconds ... -{ t: T_PRE_PAUSE + THINK_HOLD_DUR, text: '{resumedPhrase}' }, -``` - -### State pulse on completion - -When the final state lands (e.g. "✓"), pulse-scale the line briefly for emphasis: - -```js -tl.to( - ".text", - { scale: COMPLETION_PULSE_SCALE, duration: COMPLETION_PULSE_DUR, yoyo: true, repeat: 1 }, - T_DONE, -); -``` - -### Per-state color shift - -Color-code states by phase (e.g. dim during edit, success color after the completion marker, optional warning color on typo): - -```js -// In onUpdate after setting textContent: -if (driver.t > T_DONE) textEl.style.color = "{successColor}"; -else if (driver.t < T_K2) - textEl.style.color = "{textColor}"; // normal typing -else textEl.style.color = "{mutedColor}"; // mid-edit dim -``` - -## How to Choose Values - -### Layout - -- **TERMINAL_FONT_SIZE** — font size of the typing line. - - Range: 48-96 px for full-bleed compositions; smaller for terminal-style detail - - Constraints: combined with `TEXT_WRAP_MIN_WIDTH` must fit within viewport -- **TEXT_WRAP_MIN_WIDTH** — fixed-width container holding the text. - - Constraints: must be `≥ widthOf(longest SEQUENCE state) at TERMINAL_FONT_SIZE`. Measure with a hidden probe after `document.fonts.ready` if unsure - - Effects: too small → right edge jitters as states change length; too large → unused horizontal whitespace pads the composition -- **GUTTER** — flex gap between prompt glyph (`$`, `>`) and text. - - Range: ~0.3-0.5× `TERMINAL_FONT_SIZE` -- **CURSOR_WIDTH / CURSOR_GAP** — block cursor dimensions. - - Range: width ~0.3× `TERMINAL_FONT_SIZE`; gap small (single-digit px) so the cursor feels attached to the text - -### Sequence timing - -- **TOTAL_DURATION** — composition length. - - Constraints: must be ≥ `T_DONE` + ~1s climax dwell so viewer sees the completion marker -- **T_K1 / T_K2 / T_BS / T_BULK / T_DONE** — milestone timestamps within the SEQUENCE. - - Range: keystrokes 0.06-0.20s apart for "human typing"; pauses 0.3-0.6s at natural word breaks; bulk paste jumps multiple characters in a single entry - - Constraints: monotonically increasing; `T_DONE ≤ TOTAL_DURATION - dwell` -- **TYPE_DUR** (smooth-slice variation) — total typing duration for continuous typewriter. - - Range: `chars × 0.06s` (fast) to `chars × 0.12s` (relaxed) -- **THINK_HOLD_DUR** (thinking-pause variation) — hold time between two SEQUENCE states. - - Range: 0.8-2.0s; under 0.5s reads as a stutter rather than thought -- **COMPLETION_PULSE_SCALE / COMPLETION_PULSE_DUR** (pulse variation). - - Range: scale 1.03-1.08 (subtle), duration 0.15-0.30s - -### Cursor - -- **BLINK_CYCLES** — number of full blink cycles across `TOTAL_DURATION`. - - Range: `TOTAL_DURATION / 0.8s ≤ BLINK_CYCLES ≤ TOTAL_DURATION / 0.5s` (cycle every 0.5-0.8s reads as a natural cursor) - -### Color tokens - -- **{bgColor} / {textColor} / {accentColor} / {successColor} / {mutedColor}** — discrete choices, not numeric ranges. Pick from the composition's palette; the prompt + cursor share `{accentColor}` so they read as the same "system" element. - -## Key Principles - -- **Threshold sequence drives realism** — group fast successive keystrokes (0.1-0.2s apart), then pause on word breaks (0.3-0.5s), bulk-paste in single jumps (one entry replaces many chars), include a typo or two for human-typing feel -- **Reverse-search the array each frame** — O(n) per frame, where n is small (≤30 typical). Don't try to index by frame; the sequence is sparse -- **Fixed-width container is mandatory** — without `min-width`, the right edge of the text wrap jitters as state length changes. Set width ≥ longest expected state -- **Cursor must be deterministic** — sin-based or sequence-driven blink, NOT a CSS animation. HF seeks frame-by-frame; CSS animations desync -- **No `transition` on the text element** — discrete jumps should be INSTANT. A CSS transition turns the jump into a smear and ruins the "typing" feel -- **❗ Distinguish discrete from smooth** — if your effect is "type each character, no edits" → use the smooth-slice variation. Discrete sequence is overkill for that case. Use discrete only when you need non-linear states (typos, pauses, bulk paste) +| token | range | notes | +| ------------------- | -------------------------------------------- | ---------------------------------------------------------------------- | +| TERMINAL_FONT_SIZE | 48–96px | full-bleed comps; smaller for terminal-style detail | +| TEXT_WRAP_MIN_WIDTH | ≥ widest state | measure with a hidden probe after `document.fonts.ready` if unsure | +| milestone `t`s | keystrokes 0.06–0.20s apart; pauses 0.3–0.6s | monotonically increasing; `T_DONE ≤ TOTAL_DURATION − ~1s` climax dwell | +| TYPE_DUR (smooth) | `chars × 0.06–0.12s` | fast → relaxed | +| BLINK_CYCLES | one cycle per 0.5–0.8s | `TOTAL_DURATION / 0.8 ≤ BLINK_CYCLES ≤ TOTAL_DURATION / 0.5` | +| CURSOR_WIDTH | ~0.3× font size | gap to text single-digit px so the cursor feels attached | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition`** on the text or any of its parents -- **Cursor `display: inline-block`** — `display: inline` ignores width/transform -- **Monospace font** for terminal-style effects — proportional fonts cause visual jitter even with fixed-width container -- **Whitespace: nowrap** on text wrap — wrapping mid-state breaks the illusion - -## Combinations - -- [3d-text-depth-layers.md](3d-text-depth-layers.md) — discrete text rendered with layered depth (heavy, dramatic) -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — discrete text for the LABEL while counter animates smoothly -- [press-release-spring.md](press-release-spring.md) — after the sequence completes, the line "presses" like a button confirming success +- **Reverse-search the array each frame** — O(n) with small n (≤30 typical); don't index by frame, the sequence is sparse. +- **`min-width` on the text wrap is mandatory** — without it the right edge jitters as state length changes. +- **Discrete jumps must be INSTANT** — any transition on the text turns the jump into a smear and kills the "typing" feel. +- **Cursor blink is sin/sequence-driven on the timeline**, `display: inline-block`, monospace font, `white-space: nowrap` (wrapping mid-state breaks the illusion; trailing spaces must survive). +- **Discrete vs smooth** — use discrete only for non-linear states (typos, pauses, bulk paste); plain typing takes the smooth-slice variation. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — onUpdate-driven discrete state lookup -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`context-sensitive-cursor` (same SEQUENCE pattern + segment-colored cursor) · `3d-text-depth-layers` (discrete text with layered depth) · `counting-dynamic-scale` (discrete label beside a smooth counter) · `press-release-spring` (post-completion press beat). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/dynamic-content-sequencing.md b/plugins/visual-content/skills/hyperframes-animation/rules/dynamic-content-sequencing.md index 14ab6ea..4f281af 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/dynamic-content-sequencing.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/dynamic-content-sequencing.md @@ -7,301 +7,143 @@ metadata: # Dynamic Content Sequencing -A utility pattern (not a motion rule in itself) for scenes that show a SEQUENCE of items (cards, phrases, stats). Each item's duration is calculated from its content length + a per-item config; the sequencer assigns absolute start/end times automatically. Distinct from [discrete-text-sequence](discrete-text-sequence.md) (which is one text element changing states) — this rule swaps between distinct content blocks. +A utility pattern (not a motion rule in itself) for scenes that show a SEQUENCE of items (cards, phrases, stats): each item's duration is computed from its content length + per-item config, and the sequencer assigns absolute start/end times automatically — no hardcoded offsets per item. Distinct from [discrete-text-sequence](discrete-text-sequence.md) (one text element changing states) — this rule swaps between distinct content blocks. ## How It Works -1. Define a content array — each entry has `{ text, speedFactor, hold }` (or arbitrary fields) -2. Pre-compute absolute start times: `start[i] = sum of durations 0..i-1` -3. In onUpdate, find which entry is active (last entry whose `start ≤ time`) and render it +A content array of `{ eyebrow, title, body, speedFactor, hold }` entries is reduced once at build time into a flat `TIMELINE` of `{ …entry, start, end }` — duration per entry is `BASE_DURATION + body.length × SEC_PER_CHAR + hold`, so longer text earns more reading time. A single linear driver's `onUpdate` reverse-searches the active entry and swaps the DOM **only on transitions** (a `lastTitle` guard — per-frame `textContent` writes flicker in render); an optional progress bar fills 0→100% across the whole run. -The "dynamic" part: items with longer text get more screen time (formula: `baseDuration + textLength * msPerChar`). No hardcoded `from` / `durationInFrames` per item. - -## HTML +## Recipe ```html -
-
-
{eyebrow}
-
-
-
-
-
— {Brand}
+ +
+
+
+
+
``` -## CSS - -Placeholders: `{font}` is the project sans-serif stack; `{bgColor1}`/`{bgColor2}` make the dark backdrop gradient; `{accentColor}` highlights the eyebrow / brand / progress fill; `{textColor}` is the primary readable foreground. - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: radial-gradient(ellipse at center, {bgColor1} 0%, {bgColor2} 70%); - font-family: {font}; -} -.display { - display: flex; - flex-direction: column; - align-items: center; - gap: 32px; - text-align: center; - max-width: 1400px; -} -.eyebrow { - font-size: 32px; - font-weight: 800; - letter-spacing: 14px; - color: {accentColor}; - text-transform: uppercase; -} -.title { - font-size: 120px; - font-weight: 900; - letter-spacing: -2px; - line-height: 1; - color: {textColor}; -} .body { - font-size: 48px; - font-weight: 500; - line-height: 1.4; - color: {accentColor}; - opacity: 0.9; - min-height: 160px; /* reserve space so layout doesn't jump */ -} -.progress-bar { - width: 600px; - height: 4px; - background: {accentColor}26; /* ~15% alpha */ - border-radius: 2px; - margin-top: 16px; - overflow: hidden; + min-height: 160px; /* reserve space — content height varies; without this, layout jumps */ } .progress-fill { height: 100%; - background: linear-gradient(90deg, {accentColor} 0%, {accentColor2} 100%); width: 0%; } -.brand { - position: absolute; - bottom: 80px; - left: 50%; - transform: translateX(-50%); - font-size: 32px; - font-weight: 900; - letter-spacing: 12px; - color: {accentColor}; -} ``` -## GSAP Timeline - -```html - - + }, + 0, +); ``` ## Variations -### Crossfade between items (not hard cut) - -Add `overlap` to the find function — return BOTH the previous and next entry during the overlap window, render with crossfade opacity: - -```js -function activeEntries(time, overlap = 0.3) { - const result = []; - TIMELINE.forEach((e) => { - if (time >= e.start - overlap && time <= e.end + overlap) result.push(e); - }); - return result; -} -``` - -Then render the two adjacent entries with computed opacities based on distance from boundary. - -### Per-item motion variation +- **Crossfade between items** — return BOTH adjacent entries during an overlap window (`time ≥ e.start − overlap && time ≤ e.end + overlap`, overlap ≈ 0.3s) and render them with opacities computed from distance to the boundary. +- **Per-item motion variation** — map an `entry.style` key to an existing rule per chapter (e.g. `3d-text-depth-layers` → `hacker-flip-3d` → `counting-dynamic-scale`); the sequencer only orchestrates timing. +- **Auto-extend composition duration** — you can set `data-duration` from the computed `TOTAL_DURATION` in script, but HF reads `data-duration` at composition load and setting it after init may not take effect — author the duration manually from a rough total. -Each entry has its own motion style. Map `entry.style` to one of the existing rules: chapter 1 uses [3d-text-depth-layers](3d-text-depth-layers.md), chapter 2 uses [hacker-flip-3d](hacker-flip-3d.md), chapter 3 uses [counting-dynamic-scale](counting-dynamic-scale.md). The sequencer just orchestrates timing; per-entry rendering uses the appropriate rule. +### Accelerating cadence (geometric hold decay) -### Auto-extend composition duration - -If you don't know upfront how long the sequence will be (dynamic content count), bind `data-duration` to the computed `TOTAL_DURATION`. Do this in script BEFORE the timeline registers: +For rhetorical escalation — "everyone says…", a roll-call, a praise flurry — the beat grid itself accelerates: early entries hold ~1s (read speed), then windows shrink geometrically into a ~0.15–0.3s flurry, braking on an emphasis state before the resolve. The acceleration is pre-computed into the same flat `TIMELINE` — still content-driven, still deterministic, no speed-up tween anywhere: ```js -document - .querySelector("[data-composition-id]") - .setAttribute("data-duration", String(Math.ceil(TOTAL_DURATION))); +// Geometric decay on the hold, clamped at a flurry floor; the brake state holds longest. +const HOLDS = CONTENT.map((entry, i) => Math.max(FLURRY_FLOOR, HOLD_START * Math.pow(DECAY, i))); +HOLDS[CONTENT.length - 1] = HOLD_FINAL; + +let cumulative = 0; +const TIMELINE = CONTENT.map((entry, i) => { + // Past ~0.5s states are glanced as motion texture, not read — + // drop the per-char term or you never reach flurry speed. + const readable = HOLDS[i] >= READ_THRESHOLD; + const dur = HOLDS[i] + (readable ? entry.body.length * SEC_PER_CHAR : 0); + const start = cumulative; + cumulative += dur; + return { ...entry, start, end: cumulative }; +}); ``` -(Caveat: HF reads `data-duration` at composition load; setting after init may not take effect — author the duration manually based on a rough TOTAL calc.) - -## Key Principles - -- **Pre-compute timeline once, not per-frame** — building absolute start/end at script init means onUpdate is O(log n) reverse-search, not O(n²). -- **Per-item duration formula: `BASE_DURATION + body.length × SEC_PER_CHAR + hold`** — longer text needs more reading time. The formula is the load-bearing teaching of this rule; ranges for each const are in How to Choose Values. -- **Reserve `min-height` on body element** — content height varies per item; without reservation, layout jumps and downstream elements (progress bar, brand) jitter. -- **DOM update on transition, not every frame** — track `lastTitle` (or whatever key) and only call `textContent =` when it changes. Per-frame textContent assignment causes flicker in HF render. -- **Optional progress indicator** — a thin bar at the bottom showing 0-100% completes the "this is a sequence" framing. -- **Climax dwell longer than mid-sequence dwell** — the outro's `hold` (HOLD_FINAL) should exceed the in-sequence `hold` (HOLD_MID) so the final brand/CTA lands. - -## How to Choose Values - -- **BASE_DURATION** — minimum visible time of an entry regardless of content length - - Range: 0.6-1.5 s - - Effects: low end snaps through short entries too fast for the eye; high end stalls on short titles - - Constraints: ensures even one-word entries have time to read - - Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule) +Worked example — **praise-chip flurry**: ~16 short quotes hard-cut through a chip beside a pinned wordmark. First 3 states at `HOLD_START = 1.0` (each reads fully); `DECAY = 0.8` shrinks every following window until `FLURRY_FLOOR = 0.2` catches it (≈12 states over ~2.5s — a churn of acclaim, individually glanced); the longest phrase takes `HOLD_FINAL ≈ 1.6` as the brake before the closing lockup. -- **SEC_PER_CHAR** — extra time added per body character - - Range: 0.03-0.06 s/char (≈ 17-33 chars/sec read pace for video) - - Effects: low end feels rushed for paragraph-style bodies; high end feels slow when bodies are short - - Constraints: should be uniform across the sequence so the pace reads as one engine; for languages with wider characters, lean to the high end - - Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule) +Values: `HOLD_START` 0.8–1.2s; `DECAY` 0.75–0.88 (higher = longer runway before the flurry bites); `FLURRY_FLOOR` 0.15–0.3s (below ~0.15s swaps strobe); `READ_THRESHOLD` ~0.5s; brake ≥ 4× the floor or the stop doesn't register as a beat. The 3–6 entry guidance relaxes here — 12–18 states are legal precisely because flurry states aren't individually read. The hard-cut discipline (`lastTitle` guard, instant swaps) is what lets 0.2s states render clean. -- **HOLD_MID** — dwell after the typing of a non-final entry completes - - Range: 0.5-1.0 s - - Effects: low end feels rushed; high end feels lazy - - Constraints: `HOLD_MID < HOLD_FINAL` - - Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule) +## Values -- **HOLD_FINAL** — dwell on the last entry (outro / climax) - - Range: 1.0-2.0 s - - Effects: low end truncates the closing beat; high end overstays - - Constraints: must exceed HOLD_MID by a clear margin so the close reads as a beat, not another mid-sequence pause - - Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule) +| token | range | notes | +| ------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------- | +| BASE_DURATION | 0.6–1.5s | minimum per entry regardless of length — even one-word entries get read time | +| SEC_PER_CHAR | 0.03–0.06 s/char | ≈17–33 chars/sec; uniform across the sequence so the pace reads as one engine; lean high for wide-character languages | +| HOLD_MID | 0.5–1.0s | dwell on a non-final entry; `< HOLD_FINAL` | +| HOLD_FINAL | 1.0–2.0s | climax dwell — must exceed HOLD_MID by a clear margin so the close reads as a beat | +| SPEED_FACTOR | 0.5–2.0 (default 1.0) | per-entry only; if every entry shares a factor, fold it into SEC_PER_CHAR | +| TAIL_PAD | 0.0–1.0s | quiet beat after the last entry; prefer 0 when the next composition owns the breath | +| CONTENT N | 3–6 entries | <3 isn't a sequence; >6 drags (accelerating cadence relaxes this — see above) | -- **SPEED_FACTOR** — per-entry pacing multiplier - - Range: 0.5-2.0 (default 1.0) - - Effects: <1 stretches an entry's body-driven duration (good for high-density passages); >1 compresses it - - Constraints: discrete choice — use 1.0 unless one entry needs special pacing; if every entry uses the same factor, fold it into SEC_PER_CHAR instead - - Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule) - -- **TAIL_PAD** — seconds added to `TOTAL_DURATION` after the last entry's `end` - - Range: 0.0-1.0 s - - Effects: 0 ends the driver exactly at the last `hold` completion; >0 leaves a quiet beat (useful before a transition to the next composition) - - Constraints: if downstream is another composition, prefer 0 and handle the breath at the composition seam - - Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule) - -- **CONTENT length (N)** — number of entries in the sequence - - Range: 3-6 entries - - Effects: <3 isn't a sequence (use a static scene); >6 drags - - Constraints: each entry's `title` must fit one line at the chosen `.title` fontSize; bodies should fit within `min-height` after wrapping - - Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule) +Reference: `../examples/messaging-multi-phrase.html`. ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **Pre-compute the TIMELINE array** — don't recompute in onUpdate -- **`min-height` on body** for layout stability -- **DOM swap only on entry transition** — use lastTitle/lastKey guard -- **Sequential only** — for parallel tracks, use a different reduction (this rule is sequential) - -## Combinations - -- [discrete-text-sequence.md](discrete-text-sequence.md) — per-entry typewriter on the body -- [context-sensitive-cursor.md](context-sensitive-cursor.md) — cursor color per chapter segment -- [vertical-spring-ticker.md](vertical-spring-ticker.md) — animated word transitions between items (instead of hard cut) -- [scale-swap-transition.md](scale-swap-transition.md) — visual morph between entries +- **Pre-compute the TIMELINE once at build** — never recompute in `onUpdate`; the reverse search over the flat array is the whole per-frame cost. +- **DOM swap only on entry transition** (`lastTitle`/key guard) — per-frame `textContent` assignment flickers in HF render. +- **`min-height` on the body element** — without reservation, downstream elements (progress bar, brand) jitter as content height varies. +- **Sequential only** — for parallel tracks use a different reduction. +- **Titles fit one line at the chosen size; bodies fit inside `min-height` after wrapping.** -## Pairs with HF skills +## See also -- `/hyperframes-animation` — single driver, reverse-search dispatch -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`discrete-text-sequence` (per-entry typewriter on the body) · `context-sensitive-cursor` (cursor color per chapter) · `vertical-spring-ticker` (animated word swap instead of hard cut) · `scale-swap-transition` (visual morph between entries). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/gradient-text-sweep.md b/plugins/visual-content/skills/hyperframes-animation/rules/gradient-text-sweep.md new file mode 100644 index 0000000..e633dad --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/gradient-text-sweep.md @@ -0,0 +1,136 @@ +--- +name: gradient-text-sweep +description: A gradient tweened THROUGH letterforms — background-clip:text + a backgroundPosition tween. Three forms: a continuous horizontal sweep inside a held headline, a traveling word-to-word highlight, and a hue-sweep that settles to a solid. Glyphs never move; finite, deterministic, seek-safe. +metadata: + tags: gradient, text, sweep, background-clip, highlight, hue, typography, headline +--- + +# Gradient Text Sweep + +Color that lives **inside the glyphs**: the headline's fill is an oversized gradient clipped into the letterforms (`background-clip: text`), and the motion is the gradient sliding **through** the type — the letters never move. Three forms: a **continuous sweep** across a held title card, a **word-to-word highlight** that lights a line left→right, and a **hue-sweep** that settles to a solid. + +Boundaries: [asr-keyword-glow.md](asr-keyword-glow.md) is word-timed emphasis railed to ASR timestamps — this rule is a design beat with no audio rail. [ambient-glow-bloom.md](ambient-glow-bloom.md)'s traveling sweep is a sheen riding **over a surface**; here the gradient is masked **into the type** (its "Shimmer sweep" variation is this mechanism re-aimed as a working-state loop). [css-marker-patterns.md](css-marker-patterns.md) draws accents _around_ text, never fills. + +## How It Works + +The text carries a gradient background **wider than its own box** (`background-size: SWEEP_SPAN 100%`, e.g. `300% 100%`) clipped into the glyphs, so tweening `backgroundPosition` slides the gradient through the visible letterforms. Two gotchas own this rule: + +- **`background-position` percentages only produce travel when `background-size` exceeds 100%** — at 100% the image is pinned and the tween is a silent no-op. +- **The percent axis runs opposite to the perceived travel** — tweening `"100% 50%"` → `"0% 50%"` moves the highlight left→right through the text. + +1. **Continuous sweep (held title card)** — one long **linear** `backgroundPosition` tween spanning the hold. First and last color stops equal, so the travel has no visible seam and reads as endless while remaining a single finite tween. +2. **Word-to-word highlight** — each word is two pixel-identical stacked copies: a base copy in the resting color and a gradient-clipped copy at `opacity: 0`. A per-word opacity envelope (rise, then fall as the next word rises) passes the highlight along on an index-derived stagger — an **envelope, not a moving mask**: no per-word position measurement. +3. **Hue-sweep → solid** — the gradient holds position while a `filter: hue-rotate()` tween sweeps its hues; the settle is a stacked-copy crossfade to a solid twin — never a color-stop tween (gradients with different stops don't interpolate reliably). + +## Recipe + +```html + + +
+

{headlineText}

+

{headlineText}

+
+ + +

+ {word1}{word1} + {word2}{word2} +

+``` + +```css +.headline-stack, +.word { + display: grid; /* twins share one cell — pixel-identical boxes */ +} +.headline, +.w-base, +.w-hot { + grid-area: 1 / 1; +} +.gradient-fill, +.w-hot { + background-image: {gradient}; /* {sweepGradient} A/C, {highlightGradient} B */ + background-size: SWEEP_SPAN 100%; /* MUST exceed 100% or the position tween is dead */ + background-position: 100% 50%; /* start; tween toward 0% for left→right travel */ + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.solid-twin { + color: {settleColor}; +} +.w-base { + color: {restColor}; +} +.w-hot { + opacity: 0; /* the envelope raises it as the highlight passes */ +} +``` + +```js +// Form A: continuous sweep. 100% → 0% reads left→right (percent axis inverted); +// ease "none" — an eased sweep reads as an object, not light. +tl.fromTo( + "#headline", + { backgroundPosition: "100% 50%" }, + { backgroundPosition: "0% 50%", duration: SWEEP_DUR, ease: "none" }, + SWEEP_START, +); + +// Form B: traveling highlight — per-word rise/fall envelopes, index stagger. +gsap.utils.toArray(".w-hot").forEach((el, i) => { + const at = HIGHLIGHT_START + i * WORD_LAG; + tl.fromTo(el, { opacity: 0 }, { opacity: 1, duration: HOT_RISE, ease: "power2.out" }, at); + tl.to(el, { opacity: 0, duration: HOT_FALL, ease: "power2.in" }, at + WORD_LAG); +}); + +// Form C: hue-sweep, then crossfade to the solid twin (never tween color stops). +tl.fromTo( + "#headline", + { filter: "hue-rotate(0deg)" }, + { filter: `hue-rotate(${HUE_RANGE}deg)`, duration: HUE_DUR, ease: "power1.inOut" }, + HUE_START, +); +tl.to( + "#headline", + { opacity: 0, duration: SETTLE_SNAP_DUR, ease: "power2.in" }, + HUE_START + HUE_DUR, +); +``` + +## Variations + +- **Title-card crawl** — Form A stretched across a long terminal hold (3–8s end card): seamless-ended gradient, `ease: "none"`, `SWEEP_DUR` = the whole hold. One tween, no loop. +- **One-pass sheen inside type** — gradient is the resting fill everywhere except one narrow highlight band (≤ ~25% of the span); one `backgroundPosition` pass carries the band through and the text returns to rest with no crossfade. +- **Karaoke settle** — Form B with the fall tweens skipped: the line lights cumulatively left→right and holds fully lit; settle color = the hot state, base copies start dimmer. +- **Gradient climax word** — one emphasized word (often ~-8° rotated) carries the gradient while the line stays solid; static gradient + a short Form C hue shift on landing, settling to the brand accent. Pairs with a `kinetic-beat-slam` arrival. + +## Values + +| token | range | notes | +| ------------------- | ---------------------- | ------------------------------------------------------------------------------------ | +| SWEEP_SPAN | 200–400% | must exceed 100%; wider = softer/slower feel, narrower = busier color per glyph | +| SWEEP_DUR | 1.2–3s | match the card's hold exactly; slower than ~4s stops registering as motion | +| WORD_LAG | 0.25–0.5s | HOT_FALL starts exactly WORD_LAG after the rise so envelopes cross — a gap = a blink | +| HOT_RISE / HOT_FALL | 0.15–0.3s / 0.25–0.45s | fall slightly longer — the highlight "trails" | +| HUE_RANGE / HUE_DUR | 40–180° / 0.8–1.6s | past ~180° the palette dissociates from itself mid-sweep | +| SETTLE_SNAP_DUR | 0.1–0.35s | the goldens snap (~0.15s) | +| {settleColor} | — | one of the gradient's own stops (or the brand ink) so the settle reads as resolution | + +## Critical Constraints + +- **`background-size` > 100%** on any element whose `backgroundPosition` is tweened — otherwise the tween is a silent no-op. +- **Percent axis is inverted** — left→right perceived travel is `100% → 0%`. +- **Both `-webkit-background-clip: text` AND `background-clip: text`, with `color: transparent`** — missing the prefix renders a solid gradient block over the text in the capture browser. +- **`ease: "none"` on position sweeps** — this is supposed to read as light, not an accelerating object. +- **Seamless ends for a crawl** — first and last stops equal, or the wrap point flashes a hard edge mid-hold. +- **Stacked copies pixel-identical** — same box, font, weight, tracking, one grid cell; any metric drift makes the crossfade a double-exposure. +- **`data-layout-allow-occlusion` on the twin** — pixel-identical stacked copies trip `hyperframes check`'s `text_occluded` gate by construction; the flag is the sanctioned waiver for this mechanism. +- **Settle by crossfade, never by tweening stops**; and the glyphs never move — if the type must travel, that's a separate rule on the wrapper. +- **No CSS `@keyframes` shimmer** — wall-clock animation desyncs from seek; every sweep is a timeline tween. + +## See also + +`kinetic-beat-slam` (slam lands the climax word, hue settle finishes it) · `spring-pop-entrance` (pop in solid, sweep after) · `discrete-text-sequence` (swap-slot under a riding crawl) · `ambient-glow-bloom` (surface-level sibling) · `css-marker-patterns` (strokes around text; fills here). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/gsap-effects.md b/plugins/visual-content/skills/hyperframes-animation/rules/gsap-effects.md index 44b9566..0dcf695 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/gsap-effects.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/gsap-effects.md @@ -1,33 +1,26 @@ # GSAP Effects for HyperFrames -Drop-in animation patterns. Each effect is self-contained (HTML + CSS + JS) and follows the HyperFrames seek-driven contract — deterministic, no randomness, timeline registered on `window.__timelines`. +Drop-in animation patterns. Snippets show mechanism only, inside a standard scene clip (hyperframes-core); assume `tl` exists. -## Index - -- [Typewriter](#typewriter) — character-by-character text reveal with optional cursor / backspace / word rotation +- [Typewriter](#typewriter) — character-by-character reveal with optional cursor / backspace / word rotation - [Audio Visualizer](#audio-visualizer) — pre-extract audio data, drive Canvas/DOM rendering from the timeline ---- - ## Typewriter -Reveal text character by character using GSAP's TextPlugin. - -### Required Plugin +Requires GSAP's TextPlugin alongside the core script: ```html - ``` -### Basic Typewriter +### Basic ```js const text = "Hello, world!"; -const cps = 10; // chars per second: 3-5 dramatic, 8-12 conversational, 15-20 energetic +const cps = 10; // chars per second — see timing table tl.to( "#typed-text", { text: { value: text }, duration: text.length / cps, ease: "none" }, @@ -35,13 +28,9 @@ tl.to( ); ``` -### With Blinking Cursor +### Blinking Cursor -Three rules: - -1. **One cursor visible at a time** — hide previous before showing next. -2. **Cursor must blink when idle** — after typing, during pauses. -3. **No gap between text and cursor** — elements must be flush in HTML. +Three rules: **one cursor visible at a time** (hide previous before showing next); **cursor must blink when idle** (after typing, during holds); **no gap between text and cursor** (elements flush in HTML). ```html | @@ -70,7 +59,7 @@ Three rules: } ``` -Pattern: blink → solid (typing starts) → type → solid → blink (typing done). +Pattern: blink → solid (typing starts) → type → blink (typing done): ```js tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], startTime); @@ -78,9 +67,11 @@ tl.to("#typed-text", { text: { value: text }, duration: dur, ease: "none" }, sta tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], startTime + dur); ``` +Multi-line handoff: hide previous cursor → blink new → brief pause (~0.5s) → solid when typing. Never go `hidden → solid` (skips the idle blink). + ### Backspacing -TextPlugin removes from front — wrong for backspace. Use manual substring removal: +TextPlugin removes from the front — wrong for backspace. Use manual substring removal: ```js function backspace(tl, selector, word, startTime, cps) { @@ -88,9 +79,7 @@ function backspace(tl, selector, word, startTime, cps) { const interval = 1 / cps; for (let i = word.length - 1; i >= 0; i--) { tl.call( - () => { - el.textContent = word.slice(0, i); - }, + () => (el.textContent = word.slice(0, i)), [], startTime + (word.length - i) * interval, ); @@ -101,72 +90,26 @@ function backspace(tl, selector, word, startTime, cps) { ### Spacing With Static Text -When a typewriter word sits next to static text, use `margin-left` on a wrapper span. Don't use flex `gap` (it spaces the cursor from the text) and don't put a trailing space in the static text (it collapses when the dynamic span is empty). - -```html -
- Ship something - | -
-``` +A typewriter word next to static text (`Ship something|` in a baseline-aligned flex row): use `margin-left` on the wrapper span. Don't use flex `gap` (it spaces the cursor from the text) and don't put a trailing space in the static text (it collapses when the dynamic span is empty). ### Word Rotation -Type → hold → backspace → next word. Cursor blinks during every idle moment (holds, after backspace). +Type → hold → backspace → next word; cursor blinks during every idle moment: ```js let offset = 0; words.forEach((word, i) => { const typeDur = word.length / 10; - tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], offset); + // cursor: solid while typing, blink during holds (same call pattern as above) tl.to("#typed-text", { text: { value: word }, duration: typeDur, ease: "none" }, offset); - tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], offset + typeDur); offset += typeDur + 1.5; // hold - - if (i < words.length - 1) { - tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], offset); - const clearDur = backspace(tl, "#typed-text", word, offset, 20); - tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], offset + clearDur); - offset += clearDur + 0.3; - } + if (i < words.length - 1) offset += backspace(tl, "#typed-text", word, offset, 20) + 0.3; }); ``` ### Appending Words -Build a sentence word-by-word into the same element: - -```js -let accumulated = ""; -let offset = 0; -words.forEach((word) => { - const target = accumulated + (accumulated ? " " : "") + word; - const newChars = target.length - accumulated.length; - tl.to("#typed-text", { text: { value: target }, duration: newChars / 10, ease: "none" }, offset); - accumulated = target; - offset += newChars / 10 + 0.3; -}); -``` - -### Multi-Line Cursor Handoff - -Handing off between typewriter lines: hide previous → blink new → pause → solid when typing. Never go `hidden → solid` (skips the idle blink). - -```js -tl.call( - () => { - prevCursor.classList.replace("cursor-blink", "cursor-hide"); - nextCursor.classList.replace("cursor-hide", "cursor-blink"); - }, - [], - handoffTime, -); - -const typeStart = handoffTime + 0.5; // brief blink pause -tl.call(() => nextCursor.classList.replace("cursor-blink", "cursor-solid"), [], typeStart); -tl.to("#next-text", { text: { value: text }, duration: dur, ease: "none" }, typeStart); -tl.call(() => nextCursor.classList.replace("cursor-solid", "cursor-blink"), [], typeStart + dur); -``` +Build a sentence word-by-word into the same element: keep an `accumulated` string, each step tweens `text: { value: accumulated + " " + word }` with `duration: newChars / cps`, then advances the offset. ### Timing Guide @@ -177,59 +120,40 @@ tl.call(() => nextCursor.classList.replace("cursor-solid", "cursor-blink"), [], | 15-20 | Fast, energetic | Tech demos, code | | 30+ | Near-instant | Filling long blocks | ---- - ## Audio Visualizer -Pre-extract audio data, drive Canvas / DOM rendering from a single `tl.call(...)` per frame. **Do not** use the Web Audio API at render time — there's no playback during seek. +Pre-extract audio data, drive Canvas / DOM rendering from the timeline. **Do not use the Web Audio API at render time** — there's no playback during seek. ### Extract Audio Data -Use the bundled extractor (requires `ffmpeg` and Python `numpy`): +Bundled extractor (requires `ffmpeg` + Python `numpy`): ```bash python skills/hyperframes-creative/scripts/extract-audio-data.py audio.mp3 -o audio-data.json python skills/hyperframes-creative/scripts/extract-audio-data.py video.mp4 --fps 30 --bands 16 -o audio-data.json ``` -### Data Format +Output: `{ "fps": 30, "totalFrames": 5415, "frames": [{ "time": 0.0, "rms": 0.42, "bands": [0.8, 0.6, 0.3] }] }` — `rms` (0-1) is overall loudness; `bands[]` (0-1) are frequency magnitudes, index 0 = bass, each band normalized independently. -```json -{ - "fps": 30, - "totalFrames": 5415, - "frames": [{ "time": 0.0, "rms": 0.42, "bands": [0.8, 0.6, 0.3] }] -} -``` - -- **`rms`** (0-1) — overall loudness, normalized across the track. -- **`bands[]`** (0-1) — frequency magnitudes. Index 0 = bass, higher index = treble. Each band normalized independently. +### Loading (Synchronously) -### Loading the Data (Synchronously) +Inline the JSON for small files (< ~500 KB), or sync XHR for large ones: ```js -// Option A — inline (small files, under ~500 KB) -var AUDIO_DATA = { - /* paste audio-data.json contents */ -}; - -// Option B — sync XHR (large files; must be synchronous for deterministic timeline construction) -var xhr = new XMLHttpRequest(); -xhr.open("GET", "audio-data.json", false); +const xhr = new XMLHttpRequest(); +xhr.open("GET", "audio-data.json", false); // synchronous — deliberate xhr.send(); -var AUDIO_DATA = JSON.parse(xhr.responseText); +const AUDIO_DATA = JSON.parse(xhr.responseText); ``` -**Do NOT use async `fetch()`.** HyperFrames reads `window.__timelines` synchronously after page load — building the timeline inside `.then()` means the timeline isn't ready when capture starts. +**Do NOT use async `fetch()`** — HyperFrames reads `window.__timelines` synchronously after page load; building the timeline inside `.then()` means it isn't ready when capture starts. ### Driving the Timeline -**Canvas 2D** — most common (bars, waveforms, circles, gradients): +Canvas 2D is the workhorse (bars, waveforms, circles, gradients) — one `tl.call` per frame: ```js -const canvas = document.getElementById("viz"); -const ctx = canvas.getContext("2d"); - +const ctx = document.getElementById("viz").getContext("2d"); for (let f = 0; f < AUDIO_DATA.totalFrames; f++) { tl.call( () => { @@ -243,9 +167,7 @@ for (let f = 0; f < AUDIO_DATA.totalFrames; f++) { } ``` -**WebGL / Three.js** — HyperFrames patches `THREE.Clock` for deterministic time. Update uniforms from audio data each frame. - -**DOM elements** — fine for fewer than ~20 elements, slower than Canvas for many. +WebGL / Three.js: HyperFrames patches `THREE.Clock` for deterministic time — update uniforms from audio data each frame. DOM elements: fine under ~20 elements, slower than Canvas beyond that. ### Smoothing @@ -254,46 +176,21 @@ let prev = null; const smoothing = 0.25; // 0.1-0.2 snappy, 0.3-0.5 flowing function smooth(f) { const raw = AUDIO_DATA.frames[f]; - if (!prev) { - prev = { rms: raw.rms, bands: [...raw.bands] }; - return prev; + if (!prev) prev = { rms: raw.rms, bands: [...raw.bands] }; + else { + prev = { + rms: prev.rms * smoothing + raw.rms * (1 - smoothing), + bands: raw.bands.map((b, i) => prev.bands[i] * smoothing + b * (1 - smoothing)), + }; } - prev = { - rms: prev.rms * smoothing + raw.rms * (1 - smoothing), - bands: raw.bands.map((b, i) => prev.bands[i] * smoothing + b * (1 - smoothing)), - }; return prev; } ``` -### Spatial Mapping +### Design Guide -- **Horizontal**: bass left, treble right (iterate bands left-to-right) -- **Vertical**: bass bottom, treble top -- **Circular**: bass at 12 o'clock, wrap clockwise; mirror for a full circle - -### Motion Principles - -- **Bass drives big moves** — scale, glow, position shifts. -- **Treble drives detail** — shimmer, flicker, edge effects. -- **RMS drives globals** — background brightness, overall energy. -- Pick 2-3 properties to animate. More looks noisy. -- Keep minimums above zero — quiet sections still need life. - -### Band Count - -| Bands | Detail | Good for | -| ----- | --------- | -------------------------- | -| 4 | Low | Background glow, pulsing | -| 8 | Medium | Bar charts, basic spectrum | -| 16 | High | Detailed EQ (default) | -| 32 | Very high | Dense radial layouts | - -### Layering - -Layer multiple canvases with CSS `z-index` for depth — a background layer driven by bass/rms and a foreground layer driven by individual bands creates depth without per-element complexity. - -```html - - -``` +- **Spatial mapping** — horizontal: bass left, treble right; vertical: bass bottom; circular: bass at 12 o'clock, wrap clockwise (mirror for a full circle). +- **Bass drives big moves** (scale, glow, position); **treble drives detail** (shimmer, flicker, edges); **RMS drives globals** (background brightness, overall energy). +- Pick 2-3 animated properties — more looks noisy. Keep minimums above zero so quiet sections still have life. +- **Band count**: 4 = background glow/pulse, 8 = bar charts, 16 = detailed EQ (default), 32 = dense radial layouts. +- **Layering**: stack canvases with `z-index` — a background layer driven by bass/rms under a foreground layer driven by individual bands gives depth without per-element complexity. diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/hacker-flip-3d.md b/plugins/visual-content/skills/hyperframes-animation/rules/hacker-flip-3d.md index f831dce..e6db1e4 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/hacker-flip-3d.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/hacker-flip-3d.md @@ -7,217 +7,117 @@ metadata: # Hacker Flip 3D Reveal -Characters flip down from 90° in 3D while cycling through random glyphs, then settle on the target character. Creates a "decryption" or airport flap-display reveal. +Characters flip down from 90° in 3D while cycling through pseudo-random glyphs, then settle on the target character — a "decryption" / airport flap-display reveal. Resolves to a short target word (typically a brand or label). ## How It Works -Each character gets its own per-char tween from `rotateX: 90deg` (hidden) to `rotateX: 0deg` (revealed), staggered across the word. During the flip: +Each character gets its own per-char tween from `rotateX: 90deg` (hidden, hinged at the bottom edge) to `0deg` (upright), staggered across the word. Below `REVEAL_THRESHOLD` progress the char displays a seeded pseudo-random glyph that reshuffles every few frames; past it, the real target character clicks into place — so the eye catches the right letter just as the flip settles. A hidden ghost copy of the full word reserves layout width so narrow flicker glyphs never shift the line. -1. **Phase A (0 → ~`REVEAL_THRESHOLD` progress)**: character displays a randomly-substituted glyph that flickers (changes every `FLICKER_RATE` frames) -2. **Phase B (`REVEAL_THRESHOLD` → 1.0 progress)**: character displays the REAL target character, settling into its final upright position - -The `REVEAL_THRESHOLD` separates "scrambled" from "revealed" — by the time the flip is mostly done, viewer sees the correct letter clicking into place. - -## HTML +## Recipe ```html -
-
- -
+ +
+
``` -`{phrase}` is the target word the flip resolves to (typically a brand or short label). - -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; - perspective: 1500px; /* REQUIRED — without this rotateX renders flat */ -} - +/* the scene root (or nearest 3D ancestor) MUST set perspective: 1500px */ .hacker-text-wrap { - font-family: {monoFont}; /* monospace recommended so flicker glyphs hold width */ + font-family: {monoFont}; /* monospace so flicker glyphs hold width */ font-weight: 900; font-size: HACKER_FONT_SIZE; - color: {textColor}; - letter-spacing: 4px; - display: flex; - /* Ghost / live chars are absolutely stacked; container reserves layout width */ - position: relative; + position: relative; /* ghost stacks absolutely behind the live row */ } - .hacker-char { display: inline-block; - /* Hinge at the bottom edge — flap-display look */ - transform-origin: bottom; + transform-origin: bottom; /* flap-display hinge */ transform-style: preserve-3d; - /* Will-change improves render perf */ - will-change: transform, opacity; } - -/* Ghost placeholder is hidden but reserves width for variable-glyph fonts. - Without this, narrow target glyphs collapse width when displayed and - characters shift horizontally during flicker. */ .hacker-ghost { opacity: 0; pointer-events: none; + position: absolute; + inset: 0 auto auto 0; } ``` -## GSAP Timeline + Random Glyph Logic +```js +const wrap = document.getElementById("hacker-text"); +const targetWord = wrap.dataset.target; +const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*"; + +// Ghost row (reserves width) + live per-char spans +const ghost = document.createElement("div"); +ghost.className = "hacker-ghost"; +ghost.textContent = targetWord; +wrap.appendChild(ghost); +const charEls = [...targetWord].map((ch) => { + const span = document.createElement("span"); + span.className = "hacker-char"; + span.textContent = ch === " " ? " " : ch; + span.dataset.target = ch; + wrap.appendChild(span); + return span; +}); + +// Index-seeded hash — same frame always yields the same glyph +function pseudoGlyph(seed) { + const h = ((seed * 9301 + 49297) % 233280) / 233280; + return GLYPHS[Math.floor(h * GLYPHS.length)]; +} -```html - - + }, + i * CHAR_STAGGER, + ); +}); ``` -## How to Choose Values - -- **HACKER_FONT_SIZE** — font-size of the flip text in px. - - Range: 6-10% of viewport min-dimension; the flip text is the focal beat, scale accordingly - - Constraints: ghost row must use the identical size so layout width stays stable mid-flicker - - Reference: ../../examples/proof-logo-chain.html uses `163px` at 1920×1080 -- **FLIP_DURATION** — per-character flip tween duration. - - Range: 0.4-1.0s; under 0.4s the random-glyph phase has no time to flicker, over 1.0s drags - - Effects: shorter feels snappy and modern; longer feels mechanical / typewriter - - Reference: ../../examples/proof-logo-chain.html uses `0.55s` -- **CHAR_STAGGER** — delay between consecutive characters starting their flips, in seconds. - - Range: 0.03-0.08s; too fast and chars overlap visually, too slow and the effect feels labored - - Constraints: total decode time = `CHAR_STAGGER × (charCount − 1) + FLIP_DURATION`; ensure this fits the phase budget - - Reference: ../../examples/proof-logo-chain.html uses `0.033s` (≈2 frames at 60fps) -- **REVEAL_THRESHOLD** — progress at which a glyph swaps from random → real. - - Range: 0.5-0.7; lower reveals too early (no decode tension), higher feels like a hard reveal at the end - - Effects: this is a discrete tuning of when the eye locks onto the real letter - - Reference: ../../examples/proof-logo-chain.html uses `0.6` -- **FLICKER_RATE** — frames between glyph reshuffles during the random phase. - - Range: 3-6; lower than 3 looks like noise, higher than 6 looks like discrete typing instead of flicker - - Constraints: must be ≥ ~3 frames (see Critical Constraints) - - Reference: ../../examples/proof-logo-chain.html uses an equivalent of `3` (one shuffle every 3 internal-clock frames) -- **{bgColor} / {textColor}** — stage background and live-character color tokens. -- **{monoFont}** — monospace family preferred so flicker glyphs don't change width per swap; if a proportional font is required, the ghost placeholder makes the cost recoverable. -- **{phrase}** — the target word the flip resolves to. Length feeds the total decode duration via `CHAR_STAGGER`. - ## Variations -- **Top-down hinge** — swap `transform-origin: bottom` to `top` for a falling-flap look. +- **Top-down hinge** — `transform-origin: top` for a falling-flap look. - **Center spin** — `transform-origin: center` reads as a barrel roll, not a flap. - **Number-only pool** — restrict `GLYPHS` to digits for a price / countdown decode. -- **Two-pass decode** — chain two `FLIP_DURATION` tweens with different glyph pools (e.g. symbols → letters → real) for a longer reveal. +- **Two-pass decode** — chain two `FLIP_DURATION` tweens with different glyph pools (symbols → letters → real) for a longer reveal. -## Key Principles +## Values -- **Threshold at ~`REVEAL_THRESHOLD`** for swap from random → real glyph — close enough to settled that viewer's eye catches the right letter -- **Hinge at `transform-origin: bottom`** for flap-display look (vs `top` for top-down, vs `center` for spin) -- **Deterministic random** via seeded hash — HF runtime seeks frame-by-frame, so the same frame must show the same glyph (no `Math.random()`) -- **Ghost placeholder** sits behind the live chars with identical content + same font, reserving width — without it, narrow glyphs shift the layout mid-flicker -- **Stagger in the 0.04-0.08s range** per char — too fast and chars overlap visually, too slow and effect feels labored -- **Center the flip dead-center via `display: grid; place-items: center;`** on the scene root — and DO NOT add decorative headers/footers (timestamp lines, "// AUTH" tags, small status dots). The flip text IS the focal beat; surrounding clutter dilutes it. If a secondary label is necessary, promote it to BIG typography in the same stacked layout (56-72px caps + tracking), not a tiny corner annotation. +| token | range | notes | +| ---------------- | ------------------------------- | ---------------------------------------------------------------------------------- | +| HACKER_FONT_SIZE | 6–10% of viewport min-dimension | the flip IS the focal beat; ghost must use the identical size | +| FLIP_DURATION | 0.4–1.0s | under 0.4s the flicker phase has no time; over 1.0s drags | +| CHAR_STAGGER | 0.03–0.08s | total decode = `CHAR_STAGGER × (chars − 1) + FLIP_DURATION` — fit the phase budget | +| REVEAL_THRESHOLD | 0.5–0.7 | lower reveals too early (no tension); higher reads as a hard end-reveal | +| FLICKER_RATE | 3–6 frames per glyph swap | <3 looks like noise; >6 looks like discrete typing | -## Critical Constraints - -- **`perspective` on scene root REQUIRED** — without parent perspective, `rotateX` looks like a 2D scale, not a 3D flip -- **`transform-style: preserve-3d` on each char** — keeps 3D context intact when chars have their own transforms -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **Deterministic randomness**: don't use `Math.random()`. Use a seed derived from char index + frame group so seek determinism holds -- **`onUpdate` writes to DOM**: HF seeks every frame, so this runs many times — keep work O(1) per char per frame -- **Flicker rate ≥ ~3 frames per glyph swap**: faster looks like noise, slower looks like discrete typing +Reference: `../examples/proof-logo-chain.html` (163px, 0.55s, 0.033s, 0.6). -## Combinations +## Critical Constraints -- [card-morph-anchor.md](card-morph-anchor.md) — pair: hacker-flip reveals a phrase, then card morphs into the next shot -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — counterpart for numeric reveals (text vs number) +- **`perspective` on the scene root REQUIRED** — without parent perspective, `rotateX` renders as a 2D squash, not a 3D flip; `transform-style: preserve-3d` on each char. +- **Ghost placeholder** with identical content + font must back the live chars — without it, narrow glyphs shift the layout mid-flicker (monospace preferred; the ghost makes a proportional face recoverable). +- **Flicker seed = char index + quantized progress** — the same frame must show the same glyph. +- **Flicker rate ≥ ~3 frames per swap**; `onUpdate` work stays O(1) per char per frame. +- **Center the flip dead-center and add NO decorative chrome** (timestamp lines, "// AUTH" tags, status dots) — the flip is the beat. A necessary secondary label is BIG typography (56–72px caps + tracking) in the same stack, never a tiny corner annotation. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + per-char stagger + `onUpdate` -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`card-morph-anchor` (flip reveals a phrase, card morphs into the next shot) · `counting-dynamic-scale` (the numeric counterpart). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/kinetic-beat-slam.md b/plugins/visual-content/skills/hyperframes-animation/rules/kinetic-beat-slam.md index 5ada237..ad6562c 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/kinetic-beat-slam.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/kinetic-beat-slam.md @@ -7,43 +7,25 @@ metadata: # Kinetic Beat Slam -Short phrases hit one at a time on a **steady beat**, each with a _different_ entrance, then stack into a locked finale. This is the recipe for "punchy / rhythmic" text-forward pieces (taglines, manifestos, hype intros). The difference between generic and rhythmic is (1) one shared **onset array** driving every element, (2) **distinct** entrances per phrase rather than one reused helper, and (3) optional **rhythm chrome** that visibly keeps the beat. +Short phrases hit one at a time on a **steady beat**, each with a _different_ entrance, then stack into a locked finale — the recipe for "punchy / rhythmic" text-forward pieces (taglines, manifestos, hype intros). The difference between generic and rhythmic is (1) one shared **onset array** driving every element, (2) **distinct** entrances per phrase rather than one reused helper, and (3) optional **rhythm chrome** that visibly keeps the beat. ## How It Works -1. **Define the beat once.** A single `BEATS = [t0, t1, t2, …]` array (seconds) is the rhythmic spine. Every phrase entrance, accent, and chrome tick reads its time from this array — so the whole piece locks to one pulse instead of drifting hand-tuned offsets. -2. **Vary the entrances.** Phrase 1 slams (scale + blur), phrase 2 snaps from the side, phrase 3 rises and rotates. Same _energy_, different _form_ — reusing one `punchIn()` for all three reads as flat. -3. **Land a finale.** All phrases lock into a left-aligned or centered stack; an accent underline sweeps in; optionally a continuous low-amplitude pulse holds the last beat. +A single tempo grid — `PULSE` seconds per sub-beat, `BEATS = [t0, t1, t2, …]` on that grid — is the rhythmic spine; every phrase entrance, accent, and chrome tick reads its time from it, so the piece locks to one pulse instead of drifting hand-tuned offsets. Each phrase gets a different transform axis (scale+blur slam / side snap / rise+rotate) with short attacks (0.35–0.6s on the hit), then the stack holds with a finite low-amplitude breath. -## Beat & Easing - -Pick the entrance easing by attack character (the choice is discrete): - -| GSAP ease | Attack feel | -| ------------- | ------------------------------------------- | -| `power4.out` | Hard slam, fast settle ⭐ default for a hit | -| `expo.out` | Hardest snap (side-snaps, whip-ins) | -| `back.out(2)` | Overshoot pop — accents, not body words | -| `circ.out` | Heavy rise with momentum | - -Use **at least 3 distinct easings** across the piece (entrances are its "tone of voice"). Keep durations short — 0.35–0.6s on the hit, ≤0.25s on the exit — so the beat stays percussive. - -## HTML +## Recipe ```html -
-
-
Notice more.
-
Decide faster.
-
Act now.
-
- - -
+ +
+
Notice more.
+
Decide faster.
+
Act now.
+
+ + ``` -## CSS - ```css .kbs-stage { position: absolute; @@ -51,9 +33,7 @@ Use **at least 3 distinct easings** across the piece (entrances are its "tone of display: flex; flex-direction: column; justify-content: center; - gap: 8px; padding: 120px 160px; /* title-safe margin */ - box-sizing: border-box; } .kbs-line { font-family: "Archivo Black", "League Gothic", sans-serif; /* embedded display face */ @@ -61,11 +41,10 @@ Use **at least 3 distinct easings** across the piece (entrances are its "tone of line-height: 0.96; letter-spacing: -0.03em; color: #f5f5f5; - will-change: transform, filter, opacity; } .kbs-line .verb { - color: #ff5b2e; -} /* one accent hue */ + color: #ff5b2e; /* exactly one accent hue */ +} .kbs-metronome { position: absolute; bottom: 64px; @@ -82,102 +61,77 @@ Use **at least 3 distinct easings** across the piece (entrances are its "tone of } ``` -## GSAP Timeline - -```html - - +```js +// ONE tempo grid drives everything — phrases AND the metronome read it. +const PULSE = 0.4; // seconds per sub-beat +const BEATS = [PULSE * 1, PULSE * 5, PULSE * 9]; // phrase onsets, on the grid + +// Distinct entrances per phrase (NOT one reused helper). +tl.fromTo( + "#p1", + { scale: 1.5, filter: "blur(16px)", opacity: 0 }, + { scale: 1, filter: "blur(0px)", opacity: 1, duration: 0.5, ease: "power4.out" }, + BEATS[0], +); +tl.fromTo( + "#p2", + { x: -320, opacity: 0 }, + { x: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, + BEATS[1], +); +tl.fromTo( + "#p3", + { y: 90, rotation: 6, opacity: 0 }, + { y: 0, rotation: 0, opacity: 1, duration: 0.55, ease: "circ.out" }, + BEATS[2], +); + +// Rhythm chrome: each tick flashes on the SAME grid, not a magic offset. +gsap.utils.toArray(".kbs-metronome i").forEach((tick, i) => { + tl.to(tick, { opacity: 1, duration: 0.08, yoyo: true, repeat: 1, ease: "none" }, PULSE * (i + 1)); +}); + +// Finale hold: floor (not ceil) so the repeat never overshoots data-duration; +// max(0,…) so a short hold never yields a negative repeat (GSAP reads negative as -1 = infinite). +const holdStart = BEATS[2] + 0.7, + cycle = 1.6, + holdDur = SCENE_DURATION - holdStart; +tl.to( + ".kbs-stage", + { + scale: 1.01, + duration: cycle / 2, + ease: "sine.inOut", + yoyo: true, + repeat: Math.max(0, Math.floor(holdDur / cycle) - 1), + }, + holdStart, +); ``` -## How to Choose Values +## Variations -- **BEATS spacing** — 1.2–1.8s between hits reads as a confident beat; <0.8s feels frantic, >2.5s loses the pulse. Keep spacing even (it's a _beat_). -- **Entrance duration** — 0.35–0.6s. The hit must resolve before the next beat. -- **Distinct entrances** — assign a different transform axis per phrase (scale / x / y+rotate). Reuse the _ease family_, vary the _motion_. -- **Accent hue** — exactly one (the verbs). The rest is mono white/near-black. -- **Rhythm chrome** — optional but high-impact for "rhythmic": a 5-tick metronome, a center beat bar, or a `// label` monospace tag pulsing on-beat. Mark any decorative that must survive a shader transition per `../../transitions/overview.md` rules. +- **Entrance easing by attack character** — `power4.out` hard slam ⭐ default hit · `expo.out` hardest snap (side-snaps, whip-ins) · `back.out(2)` overshoot pop (accents only, not body words) · `circ.out` heavy rise with momentum. Use **at least 3 distinct easings** across the piece. +- **Rhythm chrome alternatives** — a center beat bar or a `// label` monospace tag pulsing on-beat instead of the 5-tick metronome; mark any decorative that must survive a shader transition per `../transitions/overview.md`. +- **Finale dressing** — stack + accent underline sweep ([css-marker-patterns](css-marker-patterns.md)); don't just leave the last phrase sitting. -## Key Principles +## Values -- **One beat array, not scattered offsets** — every element times off `BEATS[]`. This is the single biggest lever for "rhythmic." -- **Different entrance per phrase** — a reused `punchIn()` for all lines is the flat-but-competent tell. -- **Short attacks** — percussive means fast in, brief, decisive. Long fades kill the beat. -- **One accent hue, heavy weight** — embedded display faces (Archivo Black, League Gothic, Oswald) at 150px+; see `hyperframes-creative/references/typography.md`. -- **Finale earns the hold** — stack + underline sweep + optional breath; don't just leave the last phrase sitting. +| token | range | notes | +| ----------------- | -------------------- | -------------------------------------------------------------------------------------------- | +| BEATS spacing | 1.2–1.8s | <0.8s frantic, >2.5s loses the pulse; keep spacing even — it's a beat | +| entrance duration | 0.35–0.6s | the hit must resolve before the next beat; exits ≤0.25s | +| accent hue | exactly 1 | the verbs; the rest mono white / near-black | +| display face | 150px+, heavy weight | Archivo Black / League Gothic / Oswald — see `hyperframes-creative/references/typography.md` | ## Critical Constraints -- **Timeline paused**: `gsap.timeline({ paused: true })`. Never `tl.play()`. -- **No infinite repeats** on the hold/chrome — use `repeat: Math.max(0, Math.floor(dur / cycle) - 1)` (no `repeat: -1`). Use **`Math.floor`, not `Math.ceil`** — `ceil` overshoots `data-duration` and trips the `gsap_repeat_ceil_overshoot` lint rule; the `Math.max(0, …)` guards against a negative repeat (which GSAP reads as `-1` = infinite = non-deterministic) when the hold is shorter than two cycles. -- **No banned exit animations** between scenes — if this is one of several scenes, the _transition_ is the exit (see `../../transitions/overview.md`); only a final scene may fade out. -- **Display font must be embedded** or it silently falls back at render (Anton/Bebas-as-literal are NOT embedded — `Bebas Neue` aliases to League Gothic; verify in `typography.md`). -- **Registry key = `data-composition-id`** on the root. - -## Combinations - -- [3d-text-depth-layers.md](3d-text-depth-layers.md) — extruded depth on the slammed words -- [css-marker-patterns.md](css-marker-patterns.md) — underline sweep / circle on the finale -- [sine-wave-loop.md](sine-wave-loop.md) — the finale breath/pulse +- **One beat array, not scattered offsets** — every element times off `BEATS[]` / `PULSE`; this is the single biggest lever for "rhythmic". +- **Different entrance per phrase** — a reused `punchIn()` for all lines is the flat-but-competent tell. Vary the motion axis, reuse the ease _family_. +- **Finale repeat math**: `repeat: Math.max(0, Math.floor(dur / cycle) - 1)` — `Math.ceil` overshoots `data-duration` and trips the `gsap_repeat_ceil_overshoot` lint rule; a negative repeat is read by GSAP as `-1` (infinite). +- **No banned exit animations between scenes** — in a montage the _transition_ is the exit (`../transitions/overview.md`); only a final scene may fade out. +- **Display font must be embedded** or it silently falls back at render — Anton / Bebas-as-literal are NOT embedded (`Bebas Neue` aliases to League Gothic; verify in `typography.md`). -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + easing vocabulary (`../../adapters/gsap-easing-and-stagger.md`) -- `/hyperframes-creative` — `references/video-composition.md` (foreground rhythm chrome), `references/typography.md` (embedded display fonts) -- `/hyperframes-core` — composition wiring, determinism (finite repeats) +`3d-text-depth-layers` (extruded depth on the slammed words) · `css-marker-patterns` (finale underline/circle) · `sine-wave-loop` (the finale breath) · `../adapters/gsap-easing-and-stagger.md` (easing vocabulary). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/motion-blur-streak.md b/plugins/visual-content/skills/hyperframes-animation/rules/motion-blur-streak.md index f94f61b..a914eca 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/motion-blur-streak.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/motion-blur-streak.md @@ -7,322 +7,124 @@ metadata: # Motion-Blur Streak -Real per-frame motion blur isn't available to a seeked renderer (it integrates over shutter time, which a paused timeline has no concept of), so this rule **fakes** it for a fast element fly-in or a hard camera push-through. The blur **peaks at maximum velocity and resolves to 0 at the settle** — the element reads as streaking in then snapping sharp on arrival. The whole point is the _coupling_: the blur envelope rides the same ease and window as the position tween, so peak-blur lands exactly on peak-speed, and the element is razor-sharp the instant it stops. +Real motion blur isn't available to a seeked renderer (it integrates over shutter time), so this rule **fakes** it for a fast fly-in or hard camera push-through. The whole point is the _coupling_: the blur envelope rides the **same ease and window** as the position tween, so peak blur lands exactly on peak speed and the element is razor-sharp the instant it stops. Two paths: -Two implementation paths, both finite, deterministic, and seek-safe: +- **(A) Directional SVG blur** — inline `` (X on the motion axis, 0 across it), tweened via a proxy. Cleanest; a true directional smear. +- **(B) Echo / ghost trail** — 2–4 duplicates at decreasing opacity, offset backward along the motion vector, collapsing into the lead as it settles. No filter cost; a stylized "speed-line" trail. -- **(A) Directional SVG blur** — an inline `` with `` (X on the axis of motion, 0 across it). GSAP tweens `X` from high → 0 through a proxy that calls `setAttribute` each frame. Cleanest, one element, true directional smear. -- **(B) Echo / ghost trail** — 2–4 duplicate copies at decreasing opacity, offset backward along the motion vector, collapsing into the lead element as it settles. No filter cost; reads as a "speed-line" stutter trail. Better when you want the streak _colored_ or _stylized_ rather than a literal optical blur. - -This is for **entrances and mid-shot moves only** — a fast arrival, a beat that zooms past camera, a card slamming into a grid slot, a logo punching through into a lockup. **Never an exit on a non-final frame** (a blurred element fleeing off-frame mid-composition reads as a glitch, and a hard exit between scenes is the transition's job, not a per-element blur). +**Entrances and mid-shot moves only — never a mid-composition exit.** A blurred element fleeing off-frame mid-composition reads as a glitch; a hard exit between scenes is the transition's job (`../transitions/overview.md`). One sanctioned scope extension: the envelope may ride the **camera wrapper** during a travel leg — see the Camera-Travel Carve-Out. ## How It Works -A fast move has a velocity profile: it accelerates off the start, peaks, then decelerates into the settle. An `out` ease (`expo.out`, `power4.out`) front-loads that — velocity is highest right at the start and bleeds to zero at the end. The fake works by mapping a **blur (or echo) envelope onto that same curve**: - -1. **Position tween** — the element travels from an off-frame / pushed-back start to its resting transform (`x`/`y` for a fly-in, `scale` for a push-through) on a fast `out` ease over `MOVE_DUR`. -2. **Blur envelope** — in lockstep over the **same window and ease**, the smear goes from `PEAK_BLUR` → `0`. Because the ease front-loads velocity and the envelope shares it, max blur coincides with max speed, and blur hits exactly `0` as the element lands. -3. **Settle is sharp** — by `MOVE_START + MOVE_DUR` the element is at its resting transform with blur `0` (path A) or all echoes collapsed onto the lead (path B). It then holds, fully crisp, for the climax dwell. - -Path A tweens the filter's `stdDeviation` attribute (a non-DOM-style numeric attribute) via a **proxy object** — GSAP can't tween an SVG attribute directly, so you tween a plain `{ v: PEAK_BLUR }` and write it back with `setAttribute` in `onUpdate`. Path B places the ghosts at deterministic backward offsets (`i * ECHO_STEP_PX`) and fades/collapses them on the same envelope. - -## HTML +A fast `out`-eased move front-loads velocity — fastest off the start, bleeding to zero at the settle. Map the blur/echo envelope onto that same curve: position travels from an off-frame / pushed-back start to rest over `MOVE_DUR`; in lockstep on the same window and ease the smear goes `PEAK_BLUR → 0` (A) or the ghosts collapse onto the lead (B). By the settle the element is fully crisp and dwells ≥1 s — the contrast between violent streak and still, sharp settle IS the effect. GSAP can't tween an SVG attribute directly: tween a plain `{ v }` proxy and write `setAttribute("stdDeviation", …)` in `onUpdate`, seeding it once at setup so a seek to t=0 shows the streaked start. -### Path A — directional SVG blur (recommended default) +## Recipe ```html -
- - - -
-
{phrase}
-
-
+ + +
{phrase}
+ ``` -### Path B — echo / ghost trail - -```html -
-
- - - - -
{phrase}
-
-
-``` - -## CSS - -```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {sceneBg}; - font-family: {font}; - overflow: hidden; /* the smear/echo extends past the resting position before settling */ -} -.streak-stage { - position: relative; - display: grid; - place-items: center; -} -.streak-el { - position: relative; - z-index: 2; - font-size: EL_FONT_SIZE; - font-weight: 900; - letter-spacing: EL_TRACKING; - color: {textColor}; - /* Path A only — reference the directional filter. (Omit for Path B.) */ - filter: url(#streak); - will-change: transform, filter; -} - -/* Path B ghosts — identical glyphs behind the lead, decreasing opacity */ -.streak-ghost { - position: absolute; - inset: 0; - display: grid; - place-items: center; - z-index: 1; - font-size: EL_FONT_SIZE; - font-weight: 900; - letter-spacing: EL_TRACKING; - color: {textColor}; - opacity: 0; - will-change: transform, opacity; - pointer-events: none; -} -``` - -## GSAP Timeline - -### Path A — directional SVG blur - -```html - - +}); ``` -### Path B — echo / ghost trail +## Variations -```html - - -``` +- **Envelope follows the leg's ease.** An `out` leg (dive, final push) uses the base recipe unchanged. An `inOut` repositioning leg peaks mid-leg: split the envelope at the velocity peak — `0 → PEAK` on the in-half ease over the first half, `PEAK → 0` on the out-half over the second. Seed the proxy at **0** for these (the streaked state lives mid-leg, not at t=0; seed-at-`PEAK_BLUR` belongs to the entrance shape, where the first frame IS the fastest). +- **Filter placement.** 2D camera: `filter: url(#streak)` on the `.world` wrapper. 3D flight: on the **perspective stage** above the 3D context — a `filter` on a `preserve-3d` element flattens it and collapses every `translateZ`. Never per-element inside the world: one frame-wide envelope, not N desynced ones. +- **Full-frame blur is heavy** — cap `PEAK_BLUR` ~18–20 at wrapper level (vs 30 for one element); a brief whip may touch ~24. Axis rule as usual: `"X 0"` for a lateral whip/pan, `"B B"` for a dive/push. -## Variations - -### Vertical streak (rise / drop-in) +### Whip sweep (named composition) -Swap the motion axis: use `y` instead of `x` for the position tween, `stdDeviation="0 Y"` for Path A (blur on Y, 0 on X), and `ENTER_FROM_Y` / vertical echo offsets for Path B. A phrase that streaks _up_ into place pairs with `kinetic-type-beats`' rise-rotate beat. +The heavily-blurred lateral whip that resolves into the next region — two rules on one window: -### Camera push-through (scale streak into a lockup) +1. **Position** — [nudge-curve.md](nudge-curve.md)'s three-phase chain on the camera state, tuned burst-dominant (tail still ≥3× ramp-in in time). +2. **Blur** — `0 → PEAK` across the ramp-in, held at `PEAK` through the linear burst (constant velocity = constant smear), `PEAK → 0` across the tail. -Instead of translating, the element rushes the camera: `scale: SCALE_FROM → 1` on the fast `out` ease, with a **radial / zoom blur** feel approximated by a symmetric `stdDeviation="B B"` envelope (blur on both axes since the smear is depth-wise, not directional). This is the `logo-assemble-lockup` push-through — the wordmark punches forward out of soft focus and snaps crisp at the lock. +Swap or reveal the next region's content DURING the burst — the smear masks the change; the `power4.out` tail lands it sharp. Reveal during the burst, read after the tail. ```js -tl.fromTo( - "#streak-el", - { scale: SCALE_FROM, opacity: 0 }, - { scale: 1, opacity: 1, duration: MOVE_DUR, ease: MOVE_EASE }, - MOVE_START, +tl.to(cam, { x: WHIP_X * 0.1, duration: 0.12, ease: "power3.in", onUpdate: applyCamera }, WHIP_AT); +tl.to( + cam, + { x: WHIP_X * 0.75, duration: 0.1, ease: "none", onUpdate: applyCamera }, + WHIP_AT + 0.12, ); tl.to( - blurProxy, - { - v: 0, - duration: MOVE_DUR, - ease: MOVE_EASE, - onUpdate: () => blurNode.setAttribute("stdDeviation", `${blurProxy.v} ${blurProxy.v}`), - }, - MOVE_START, + cam, + { x: WHIP_X, duration: 0.35, ease: "power4.out", onUpdate: applyCamera }, + WHIP_AT + 0.22, ); -``` -### Staggered grid streak-in (cards assemble) - -For `grid-card-assemble`: each card streaks into its slot from its own backward offset, staggered. Drive every card off the same ease/window with a per-index delay; derive the entrance offset and start time from the card's index (no `Math.random`). Each card is sharp the instant it lands in its slot. - -```js -gsap.utils.toArray(".grid-card").forEach((card, i) => { - const at = MOVE_START + i * CARD_STAGGER; - tl.fromTo( - card, - { x: ENTER_FROM_X, opacity: 0 }, - { x: 0, opacity: 1, duration: MOVE_DUR, ease: MOVE_EASE }, - at, - ); - // + a per-card blur proxy tween at the same `at` (Path A), or per-card ghosts (Path B) -}); +tl.to(blurProxy, { v: PEAK_BLUR, duration: 0.12, ease: "power3.in", onUpdate: writeBlur }, WHIP_AT); +// blur holds at PEAK through the linear burst (no tween needed — value rests at PEAK) +tl.to(blurProxy, { v: 0, duration: 0.35, ease: "power4.out", onUpdate: writeBlur }, WHIP_AT + 0.22); ``` -### Hold-the-streak (whip emphasis on a single beat) - -For a single kinetic phrase that "zooms past," keep the streak slightly visible a frame or two longer by easing the blur on a marginally _slower_ curve than the position (e.g. position `expo.out`, blur `power3.out`) — the element arrives, then the last wisp of smear resolves. Use sparingly; the default is locked envelopes. +## Values -## How to Choose Values - -### Motion - -- **MOVE_EASE** — shared ease for position and blur/echo. - - Range: `expo.out` (hardest snap), `power4.out` (hard slam, default), `power3.out` (firm but softer) - - Effects: harder `out` → velocity more front-loaded → blur reads as a sharper streak that resolves later in the window - - Constraints: must be an `out`-family ease (velocity front-loaded). An `inOut` or `in` ease puts peak speed mid/late and the blur-speed coupling breaks. **Position and blur must use the SAME ease** (except the deliberate Hold-the-streak variation). -- **MOVE_DUR** — travel + blur-resolve duration. - - Range: 0.25–0.6 s - - Effects: shorter → more violent whip; longer → a glide, the streak loses punch - - Constraints: a streak is _fast_ — over ~0.7 s it stops reading as velocity blur and looks like a focus pull -- **MOVE_START** — timeline position of the entrance. - - Constraints: leave **≥1 s of dwell** after `MOVE_START + MOVE_DUR` before the composition ends (climax dwell — a streak that lands at `t = DURATION − 0.2 s` reads as "flashed and gone") -- **ENTER_FROM_X / ENTER_FROM_Y** — off-frame start offset along the motion axis. - - Range: 40–120% of the element's own dimension on that axis (far enough to read as "came from off-frame") - - Effects: larger → longer travel → the streak has more runway to read; too small and there's no sense of speed - -### Path A — SVG blur - -- **PEAK_BLUR** — `stdDeviation` at maximum velocity (start of the window). - - Range: 8 (subtle) → 18 (default) → 30 (extreme whip) - - Effects: higher → heavier smear at peak speed; too high erases the glyph entirely at the start frame - - Constraints: ≤ ~30 — beyond that the element is unreadable for the first several frames and reads as "missing then appearing"; the filter region (`x/y/width/height` on ``) must be large enough (≥`-50% … 200%`) or the smear clips at the box edge -- **SCALE_FROM** (push-through variation) — starting scale for a camera push. - - Range: 1.3 (gentle push) → 2.5 (aggressive punch-through) - -### Path B — echo trail - -- **N (ghost count)** — number of ghosts behind the lead (set by how many `.streak-ghost` you author). - - Range: 2–4 - - Effects: more ghosts → a longer, smoother smear; >4 reads as a stutter / strobe rather than a streak -- **ECHO_STEP_PX** — backward offset per ghost along the motion vector. - - Range: 12–40 px - - Effects: larger → a more spread-out, visible trail; smaller → a tight blur-like cluster - - Constraints: `(N) × ECHO_STEP_PX` should be ≲ `ENTER_FROM_X` so the furthest ghost still starts within the travel runway -- **GHOST_BASE_OPACITY** — opacity of the nearest ghost (`i = 1`); falls off as `BASE / i`. - - Range: 0.3 (faint) → 0.6 (pronounced) - - Constraints: ≤ ~0.6 — opaque ghosts read as duplicate elements, not a trail - -### Layout & type - -- **EL_FONT_SIZE / EL_TRACKING** — the streaking element's type weight (when it's a phrase). - - Constraints: heavy display weight (≥120 px at 1080p, ≥800 weight) so the smear has mass to streak; thin type smears into invisibility -- **CARD_STAGGER** (grid variation) — delay between consecutive cards. - - Range: 0.05–0.12 s — tight enough to read as one assembling wave, not separate arrivals - -### Tokens - -- **{sceneBg}** — background; a streak reads best against a solid / low-detail field (a busy bg fights the smear) -- **{font}** — typographic stack (embedded display face if the streaking element is text — see typography reference) -- **{textColor}** — element color; for Path B the ghosts inherit this, so a slightly desaturated trail can be had by tinting `.streak-ghost` separately -- **{phrase}** — the word / glyph / wordmark that streaks in - -## Key Principles - -- **Blur peaks at peak speed, resolves to 0 at the settle** — this is the whole rule. Share the ease and window between the position tween and the blur/echo envelope so they're locked. A blur that lingers after the element stops, or peaks after it's already slow, reads as a focus pull, not velocity. -- **`out`-family ease, always** — velocity must be front-loaded (fast off the start, decelerating in). `expo.out` / `power4.out` / `power3.out`. An `in` or `inOut` ease puts peak speed in the wrong place and the coupling falls apart. -- **Directional blur on the motion axis** (Path A) — `stdDeviation="X 0"` for horizontal, `"0 Y"` for vertical, `"B B"` only for a depth/scale push. A symmetric blur on a sideways move looks like defocus, not speed. -- **Tween a proxy, write the attribute** (Path A) — GSAP tweens the plain `{ v }` object; `onUpdate` calls `setAttribute("stdDeviation", …)`. You cannot tween the SVG attribute directly, and you must **seed it once at setup** so a seek to `t=0` shows the streaked start. -- **Ghosts are deterministic, by index** (Path B) — offset `i * ECHO_STEP_PX`, opacity `BASE / i`. Never `Math.random` for the trail; index drives all per-ghost variation so every seek is identical. -- **Entrances only, never a mid-composition exit** — a streak is an _arrival_. A blurred element leaving on a non-final frame reads as a glitch; scene-to-scene exits are the transition's job (see `../../transitions/overview.md`). -- **Earn the sharp hold** — after the snap, the crisp element must dwell ≥1 s. The contrast between the violent streak and the still, sharp settle _is_ the effect. -- **Heavy element, solid background** — thin type or a busy backdrop both swallow the smear. Big bold mass on a clean field reads. +| token | range | notes | +| ------------------ | -------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| MOVE_EASE | `expo.out` / `power4.out` (default) / `power3.out` | `out`-family ONLY — `in`/`inOut` puts peak speed in the wrong place; position and blur share it | +| MOVE_DUR | 0.25–0.6s | over ~0.7s reads as a focus pull, not velocity | +| ENTER_FROM_X/Y | 40–120% of the element's own dimension | enough runway for the streak to read | +| PEAK_BLUR | 8–30 (default 18) | >30 erases the glyph at the start; ~18–20 cap at wrapper level | +| SCALE_FROM | 1.3–2.5 | push-through variation | +| N (ghosts) | 2–4 | >4 reads as strobe, not streak | +| ECHO_STEP_PX | 12–40px | `N × step ≲ ENTER_FROM` so the furthest ghost starts inside the runway | +| GHOST_BASE_OPACITY | 0.3–0.6 | opaque ghosts read as duplicate elements | +| CARD_STAGGER | 0.05–0.12s | one assembling wave, not separate arrivals | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()`. -- **Registry key = `data-composition-id`** on the root. -- **No CSS `transition`** on the streaking element (or ghosts) — it interpolates independently of HF seek and causes flicker. Only GSAP drives the move and the blur. -- **No `repeat` / `yoyo` / infinite** — a streak is a single finite arrival. Finite tweens only. -- **No `Math.random` / `Date.now`** — ghost offsets/opacities and any stagger derive from the element index; deterministic every seek. -- **GSAP transform aliases only**: `x`, `y`, `scale`, `rotation`. Never tween `width` / `height` / `left` / `top`. Tweening `filter` (the proxy → `stdDeviation`) and `opacity` is seek-safe and fine. -- **Seed the SVG `stdDeviation` at setup** (Path A) — write it once before play so a seek to the first frame renders the streaked start, not a momentarily-sharp pre-frame. -- **Filter region must be generous** (Path A) — `` so the smear doesn't clip at the element's box edge. -- **`overflow: hidden` on the scene** — the smear / furthest ghost extends past the resting position during travel; contain it so it doesn't bleed outside the frame. - -## Combinations - -- [kinetic-beat-slam.md](kinetic-beat-slam.md) — use this streak as the entrance for one phrase in a beat sequence (the scale-slam beat _is_ a motion-blur fly-in); reads its onset from the shared `BEATS[]` array -- [center-outward-expansion.md](center-outward-expansion.md) — the grid streak-in is center-expansion with a velocity-blur envelope on each element's travel -- [3d-text-depth-layers.md](3d-text-depth-layers.md) — extruded depth on the phrase that streaks in (depth layers ride the lead's transform) -- [scale-swap-transition.md](scale-swap-transition.md) — alternative for a SAME-footprint state swap (this rule is for a fast ARRIVAL from off-frame / depth, not a morph) +- Blur peaks at peak speed and resolves to 0 at the settle — share the ease and window between position and envelope. A blur that lingers after the stop reads as a focus pull. +- Entrances / mid-shot arrivals only — never a mid-composition exit; wrapper-level use only per the carve-out. +- Seed `stdDeviation` at setup: at `PEAK_BLUR` for the entrance shape, at 0 for a whip / `inOut` leg. +- Generous filter region (`x="-50%" y="-50%" width="200%" height="200%"`) or the smear clips at the element's box edge. +- Directional axis: `"X 0"` horizontal, `"0 Y"` vertical, `"B B"` only for a depth/scale move — symmetric blur on a sideways move looks like defocus. +- Dwell ≥1 s sharp after the snap; a streak landing at the last beat reads as "flashed and gone". +- Heavy element on a solid field — thin type (< ~120px / 800 weight) or a busy backdrop swallows the smear. +- `overflow: hidden` on the scene — the smear / furthest ghost extends past the resting position during travel. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — `out`-family easing, proxy-driven `onUpdate` attribute tweens, and locked-envelope coordination (`../../adapters/gsap-easing-and-stagger.md`) -- `/hyperframes-creative` — `references/typography.md` (embedded display face for a text streak), `references/video-composition.md` (solid field behind the smear) -- `/hyperframes-core` — composition wiring, determinism (finite tweens, no `Math.random`) -- `/hyperframes-cli` — `hyperframes lint` / `hyperframes validate` (validate catches a missing `#streak-blur` node or an unreferenced filter) +`kinetic-beat-slam` (streak as one beat's entrance) · `center-outward-expansion` (grid streak-in) · `scale-swap-transition` (same-footprint morph — not an arrival) · `nudge-curve` (the whip sweep's position half) · `3d-camera-flight` / `viewport-change` (the carve-out's wrappers). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/multi-cursor-choreography.md b/plugins/visual-content/skills/hyperframes-animation/rules/multi-cursor-choreography.md new file mode 100644 index 0000000..bd58955 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/multi-cursor-choreography.md @@ -0,0 +1,133 @@ +--- +name: multi-cursor-choreography +description: N labeled independent cursor actors work one canvas simultaneously (collaborative-canvas ambience) — per-cursor deterministic waypoint schedules, name-tag pills in distinct colors, grab/drop actions on an interleaved beat grid so paths and actions never collide; the camera stays locked, the liveness itself is the message. +metadata: + tags: cursor, multi-cursor, collaboration, ensemble, canvas, name-tag, choreography, ambient, teamwork +--- + +# Multi-Cursor Choreography + +> **The camera never chases anyone.** No real camera — any "pan" is the canvas group translating inside a static frame. And per the motion doctrine's idle-motion ban, every cursor must **perform**: travel to a target, act, then rest still. Scheduled rest is stillness; aimless wander loops are wobble. + +THE ensemble primitive: **two to four labeled cursor actors** — each an arrow plus a name-tag pill in its own color — work one shared canvas at the same time. No single interaction is the subject; the **simultaneous liveness is** ("a team is in here, working"), usually as ambience under a headline building over the top. Distinct from [cursor-click-ripple.md](cursor-click-ripple.md) and [cursor-drag.md](cursor-drag.md): those are **one protagonist** the viewer follows click-by-click; here the actors are chorus, not lead — each action smaller and quieter than a solo cursor's, the value in the interleaving. Also distinct from [camera-cursor-tracking.md](camera-cursor-tracking.md): that locks the _viewport_ to one focal cursor; this rule forbids exactly that — the frame is static and the eye roams freely. + +## How It Works + +Everything hangs off one data table: + +1. **The actor table** — a literal `ACTORS` array: per actor a name, a color, and a **waypoint schedule** (`{ x, y, at, dur }` legs plus action beats). All coordinates and times are hand-authored constants — the choreography is data: deterministic, seekable, and auditable for collisions before a single frame renders. +2. **Legs as explicit `fromTo`s** — each leg tweens the actor wrapper from the previous waypoint to the next at an absolute position. Gaps between legs are **rests**: the cursor sits still exactly where it landed. +3. **Actions** — a leg can end in a grab (press dip; the payload rides the next leg in lockstep — [cursor-drag.md](cursor-drag.md) mechanics at chorus intensity), a drop (`tl.set` identity swap + tiny settle pop), or a hover (a highlight fades in under the tip, once, then holds). +4. **The interleaved beat grid** — actions land on **alternating beats** (~1.2 / 2.6 / 4.0 s): at any moment at most one action lands while the others glide or rest. Each actor owns a home **zone** of the canvas; only one actor at a time leaves its zone, so paths never cross near-simultaneously. (Short specimens under ~5s can compress beat spacing to ~0.3–0.9s — zones still prevent collisions; the ≥1s spacing is for ambience-length shots.) +5. **Ambience staging** — cursors may already be mid-canvas at t=0 (the team was working before we arrived — the collaborative-canvas idiom), or enter off-frame on staggered starts. The canvas group may slowly translate-pan under the ensemble (element translate, not a camera). + +## Recipe + +```html + +
+
{mockupA}
+
{chipLabel}
+
+
+ + {actorName1} +
+``` + +```js +// The choreography IS this table — all literals; read the `at` columns to +// verify beats interleave. Each actor owns a zone. +const ACTORS = [ + { + id: "#actor-1", // zone: left mockup + legs: [ + { from: { x: 180, y: 420 }, to: { x: 320, y: 300 }, at: 0.2, dur: 0.9 }, + { to: { x: 340, y: 480 }, at: 2.0, dur: 0.8 }, // rest 0.9s between legs + ], + }, + { + id: "#actor-2", // zone: center mockup + legs: [ + { from: { x: 900, y: 200 }, to: { x: 820, y: 360 }, at: 0.6, dur: 1.0 }, + { to: { x: 980, y: 380 }, at: 3.4, dur: 0.7 }, + ], + }, + { + id: "#actor-3", // zone: right panel — enters from off-frame + legs: [{ from: { x: 1980, y: 520 }, to: { x: 1560, y: 460 }, at: 1.4, dur: 1.1 }], + }, +]; + +ACTORS.forEach((actor) => { + let prev = actor.legs[0].from; + tl.set(actor.id, { x: prev.x, y: prev.y }, 0); // on stage (or off) from t=0 + actor.legs.forEach((leg) => { + tl.fromTo( + actor.id, + { x: prev.x, y: prev.y }, + { x: leg.to.x, y: leg.to.y, duration: leg.dur, ease: "power2.inOut", immediateRender: false }, + leg.at, + ); + prev = leg.to; + }); +}); + +// Actions at chorus intensity — actor 1 grabs the chip: press dip, then the +// chip rides leg 2 in lockstep (matched tween: same position, duration, ease). +tl.to("#actor-1", { scale: 0.88, duration: 0.07, ease: "power2.in", yoyo: true, repeat: 1 }, 1.1); +tl.fromTo( + "#chip-1", + { x: 0, y: 0 }, + { x: CHIP_DX, y: CHIP_DY, duration: 0.8, ease: "power2.inOut", immediateRender: false }, + 2.0, // = actor-1 leg 2 `at` and `dur`, exactly +); +// Drop: identity swap + tiny settle — quieter than a solo cursor's snap +tl.set("#chip-1", { backgroundColor: "{chipSwapColor}" }, 2.8); +tl.fromTo( + "#chip-1", + { scale: 1.06 }, + { scale: 1, duration: 0.2, ease: "power3.out", immediateRender: false }, + 2.8, +); + +// Optional ambient canvas pan (element translate, NOT a camera) +tl.fromTo("#canvas-group", { x: 0 }, { x: PAN_DX, duration: 6.0, ease: "none" }, 0.3); +``` + +## Variations + +- **Ambient collaborative canvas (the Hook register)** — the default: actors mid-canvas at t=0, canvas slowly panning, a headline building over the top ([waterfall-entry.md](waterfall-entry.md)). The demo is set-dressing for the words; keep every action small and the beat grid loose. +- **One labeled editor (N = 1, still ensemble-styled)** — a single labeled teammate cursor performs one visible edit (deletes and retypes a headline word via [discrete-text-sequence.md](discrete-text-sequence.md), or drops one component). The name tag is the point: _a person_ did this. +- **Featured beat inside the ensemble** — one actor briefly becomes the lead: full [cursor-drag.md](cursor-drag.md) grab-carry-drop with chrome while the others explicitly REST for that window. Freeze the chorus; two things moving with intent at once splits the eye. +- **Staggered entrances** — cursors enter from off-frame at `ENTER_AT + i * ENTER_STAGGER`, each gliding to its zone ("the team assembles"); entry vectors from different edges, per the house cursor entry law. + +## Values + +| token | range | notes | +| ------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| ACTOR_COUNT | 2–4 | one is a solo rule's job; five+ reads as noise — no viewer tracks five pointers | +| leg `dur` | 0.6–1.2 s, `power2.inOut` | human, considered mouse movement; sub-0.5 s across long distances reads as a teleport | +| rest gaps | 0.5–1.5 s | rests make the ensemble read as people; zero-rest actors read as screensavers | +| action beat spacing | ≥ 1.0 s | while one acts, others may glide but must not act — audit by sorting all `at` values | +| zones | one per actor | only the acting actor crosses zones; two cursors within ~80 px reads as a glitch — check waypoint pairs at overlapping times | +| PAN_DX | ~40–80 px, linear | parallax life, not a camera move; omit for busier ensembles | +| tag / arrow size | smaller than a solo lead | the oversized-cursor treatment is for protagonists; tags must stay legible at render resolution | +| colors | one saturated hue each | from the palette's accent range; tag pill and arrow fill share the hue | + +## Critical Constraints + +- **The table is the choreography** — all waypoints, times, and actions are literal data. If you can't verify non-collision by reading the `at` columns, the schedule is too clever. +- **Every leg is an explicit `fromTo`** with the previous waypoint as the from-state, `immediateRender: false` on all but each actor's initial placement — chained `.to()`s on shared properties capture stale starts under seek. +- **Interleave, never chord** — at most one action landing at any moment; simultaneous travel is fine (that's the liveness), simultaneous _payoffs_ compete. +- **Chorus intensity** — every action is a quieter version of its solo rule: smaller dips, subtler snaps, no ripple bursts; save full treatment for a featured beat. +- **Rest is stillness** — between legs a cursor holds exactly where it landed: no idle drift, no yoyo wander on any actor. +- **Payload lockstep** — a carried chip's tween matches its actor's leg exactly (position, duration, ease), per the cursor-drag law. +- **The wrapper moves, never the parts** — arrow + name tag are one element; tweening them separately shears the actor apart under seek. +- **Camera locked** — no viewport zoom/pan tweens; the only large-scale motion is the linear canvas-group translate. Never zoom to an actor (that's a solo-cursor shot). +- **Actors are people** — human-speed glides, pauses, one thing at a time; `pointer-events: none` on all actors. Check `tl.duration()` — ensembles accumulate long tails from late rests. + +## See also + +`cursor-drag` (full-treatment featured beat) · `cursor-click-ripple` (chorus click — press only, skip the ripple) · `discrete-text-sequence` (a labeled actor's retype edit) · `viewport-change` (the canvas-group translate math) · `spring-pop-entrance` (components popping in as drop results). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/multi-phase-camera.md b/plugins/visual-content/skills/hyperframes-animation/rules/multi-phase-camera.md index 3bbe742..9b03ce7 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/multi-phase-camera.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/multi-phase-camera.md @@ -7,192 +7,79 @@ metadata: # Multi-Phase Camera -A camera wrapper around the entire scene that progresses through discrete zoom phases at scripted triggers. Continuous sine-driven micro-drift overlays so the camera never feels static between phases. Distinct from a single linear zoom — multi-phase creates "cinematic pacing" (anticipation → reveal → settle). +A camera wrapper around the ENTIRE scene that progresses through discrete zoom phases at scripted triggers, with continuous sine-driven micro-drift overlaid so the camera never feels static between phases. Distinct from a single linear zoom — multi-phase creates cinematic pacing (anticipation → reveal → settle). ## How It Works -The camera is a single wrapping `
` whose `transform: scale() translate(x, y)` is driven by: +The camera is one wrapping `
` whose `transform: scale() translate(x, y)` is composed from two channels inside a single `onUpdate` writer: -1. **Phase scale** — a stepwise scale value that advances through phases at trigger times (e.g. `PHASE_1_SCALE` at t=0 → `PHASE_2_SCALE` at PHASE_2_AT → `PHASE_3_SCALE` at PHASE_3_AT) -2. **Drift offset** — a continuous sine-based `translateX` / `translateY` (small amplitude, slow frequency) ADDED to the phase transform +1. **Phase scale** — a proxy object `{ scale }` stepped through phases at trigger times (`PHASE_1_SCALE` at t=0 → `PHASE_2_SCALE` at `PHASE_2_AT` → `PHASE_3_SCALE` at `PHASE_3_AT`). +2. **Drift offset** — a continuous sine-based `translateX` / `translateY` (small amplitude, slow frequency) ADDED to the phase transform. X and Y run at slightly different frequencies (`DRIFT_FREQ_RATIO ≈ 1.3`) — equal frequencies produce a perfect diagonal that reads mechanical; ~1.3 gives an organic Lissajous. -Both run inside the GSAP timeline so HF seeks frame-by-frame deterministically. - -## HTML +## Recipe ```html -
-
-
-
{Brand}
-
{tagline}
-
{ctaText}
-
+
+
+
{Brand}
+
{tagline}
+
{ctaText}
``` -## CSS - ```css .scene { - position: relative; - width: 100%; - height: 100%; - overflow: hidden; - background: {sceneBgColor}; + overflow: hidden; /* REQUIRED — any phase scale < 1 exposes the content's edges */ + background: {sceneBgColor}; /* background on .scene, NOT .camera — a camera-borne + background warps/translates with the transform and reveals the outer void */ } .camera { position: absolute; inset: 0; display: grid; place-items: center; - transform-origin: 50% 50%; + transform-origin: 50% 50%; /* off-center origin creates phase-to-phase drift */ will-change: transform; } -.content { - display: flex; - flex-direction: column; - align-items: center; - gap: 32px; - text-align: center; -} -.hero { - font-family: {font}; - font-weight: 900; - font-size: {heroSize}; - letter-spacing: 8px; - color: {textColor}; - text-transform: uppercase; -} -.tagline { - font-family: {font}; - font-weight: 600; - font-size: {taglineSize}; - color: {accentColor}; -} -.cta { - font-family: {monoFont}; - font-weight: 700; - font-size: {ctaSize}; - letter-spacing: 6px; - color: {accentColor}; - text-transform: uppercase; -} ``` -## GSAP Timeline - -```html - - +// Content reveals happen INSIDE the camera frame (hero/tagline/cta beats). ``` -## How to Choose Values - -- **PHASE_1_SCALE / PHASE_2_SCALE / PHASE_3_SCALE** — three-step zoom values - - Range: PHASE_1 0.88–0.96; PHASE_2 0.98–1.02; PHASE_3 1.04–1.15 - - Effects: tighter spread = subtler camera; wider = more cinematic - - Constraints: at PHASE_1_SCALE < 1, `.scene` MUST have `overflow: hidden` or the inner content's edges leak outside the frame - -- **PHASE_2_AT / PHASE_2_DUR** — when the focus phase starts and how long it takes - - Range: PHASE_2_AT 0.3–1.0 s; PHASE_2_DUR 1.0–1.8 s - - Effects: longer DUR = slower settle, more cinematic - -- **PHASE_3_AT / PHASE_3_DUR** — when the push phase starts and how long it takes - - Range: PHASE_3_AT 2.0–4.0 s; PHASE_3_DUR 1.0–2.0 s - - Constraints: PHASE_3_AT must be ≥ PHASE_2_AT + PHASE_2_DUR (otherwise focus is preempted) - -- **PHASE_2_EASE / PHASE_3_EASE** — ease per transition - - Discrete choice: `power2.out`, `power3.out`, `power2.inOut` - - Selection: cinematic feel; spring/back easing on a camera feels uncomfortable. Each later phase should imply more settling than the previous (longer dur OR more out-easing). - -- **TOTAL_DURATION** — composition's total runtime (matches `data-duration`) - - Reference: the drift tween must span the whole composition - -- **DRIFT_CYCLES** — number of sine cycles across TOTAL_DURATION - - Range: 1–3 - - Effects: 1 = one slow breath; 3 = noticeably busier - - Constraints: high values read as mechanical wobble rather than organic drift - -- **DRIFT_AMP_X / DRIFT_AMP_Y** — peak drift offset in pixels - - Range: DRIFT_AMP_X 2–8 px; DRIFT_AMP_Y 1–4 px - - Effects: per-frame imperceptible, visible over time. If drift is a discrete shake, it's too much. - -- **DRIFT_FREQ_RATIO** — multiplier on the Y-axis sine frequency - - Range: 1.2–1.5 - - Effects: 1.0 = perfect diagonal (reads mechanical); ~1.3 = organic Lissajous - -- **HERO_AT / TAGLINE_AT / CTA_AT** — content reveal beats - - Constraints: HERO_AT should land AFTER PHASE_1 settles via PHASE_2 (otherwise the hero feels like it's flying away while camera is still pulling back) - ## Phase Patterns -| Pattern | Scale Sequence (Phase 1 → 2 → 3) | Feel | When to use | +| Pattern | Scale sequence (1 → 2 → 3) | Feel | When to use | | ------------------- | --------------------------------- | ------------------------------- | ----------------------------- | | **Focus-in** | back → neutral → slight push | Approach → settle → slight push | Default product reveal | | **Dramatic reveal** | push → neutral → pull | Wide → focus → settle back | Hero shot with breathing room | @@ -201,73 +88,42 @@ Both run inside the GSAP timeline so HF seeks frame-by-frame deterministically. ## Variations -### Phase trigger by content beat (not time) - -If the composition has content phases (e.g. an entry completes, then orbit starts), align the camera tween start time with the content tween's end time rather than using a fixed clock value. - -### Camera shake (panic / impact) - -For a brief shake instead of drift, replace the drift tween with a higher-amplitude, higher-frequency one over a short window: - -```js -tl.to( - drift, - { - p: Math.PI * 2 * SHAKE_CYCLES, - duration: SHAKE_DUR, - ease: "none", - onUpdate: () => { - const dx = Math.sin(drift.p) * SHAKE_AMP_X; - const dy = Math.sin(drift.p * SHAKE_FREQ_RATIO) * SHAKE_AMP_Y; - camera.style.transform = `scale(${phase.scale}) translate(${dx}px, ${dy}px)`; - }, - }, - SHAKE_AT, -); -``` - -### Targeted zoom into off-center element - -If the climax should zoom into a non-centered element, combine scale with counter-translation. Compute the offset so the target ends at viewport center after scale: +- **Phase trigger by content beat**: align a camera tween's start with a content tween's end (entry completes → push begins) rather than a fixed clock value. +- **Camera shake (panic / impact)**: a brief higher-amplitude, higher-frequency drift tween over a short window — same `drift` mechanism with `SHAKE_AMP` / `SHAKE_CYCLES` / `SHAKE_DUR` at `SHAKE_AT`. +- **Targeted zoom into an off-center element**: combine scale with counter-translation so the target lands at viewport center — divide the measured offset by the current scale before feeding it into the writer: ```js -const target = document.querySelector(".cta"); -const tRect = target.getBoundingClientRect(); -const viewportCenter = { x: STAGE_W / 2, y: STAGE_H / 2 }; -const offsetX = (viewportCenter.x - (tRect.left + tRect.width / 2)) / phase.scale; -const offsetY = (viewportCenter.y - (tRect.top + tRect.height / 2)) / phase.scale; +const tRect = document.querySelector(".cta").getBoundingClientRect(); +const offsetX = (STAGE_W / 2 - (tRect.left + tRect.width / 2)) / phase.scale; +const offsetY = (STAGE_H / 2 - (tRect.top + tRect.height / 2)) / phase.scale; // then in onUpdate: translate(offsetX + dx, offsetY + dy) ``` -## Key Principles +(Full counter-translate doctrine: [coordinate-target-zoom.md](coordinate-target-zoom.md).) -- **Drift is imperceptible per-frame, visible over time** — if drift reads as discrete shake, the amplitude is too high -- **Drift X and Y at slightly different frequencies** — `DRIFT_FREQ_RATIO ≈ 1.3` prevents perfect-diagonal motion, which reads as mechanical -- **Phase springs softer than UI springs** — `power2.inOut` or `power3.out` for cinematic feel; spring/back easing on a camera feels uncomfortable -- **Each later phase settles "deeper"** — phase 2 ease should imply more settling than phase 1 (longer duration OR more out-easing). Wakes up → settles → settles deeper -- **Camera wraps EVERYTHING in the scene** — applying camera per-element creates parallax bugs and breaks "this is one viewpoint" -- **❗ overflow: hidden on .scene** — phases that pull back (`scale < 1`) reveal edges of the inner content. Without `overflow: hidden`, those edges leak outside the stage frame and HF renders them as visible content -- **❗ Hero reveal starts AFTER initial pullback ease lands** — if the camera is still pulling back when the headline fades in, the headline feels like it's flying away +## Values -## Critical Constraints - -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition` on `.camera`** — competes with the GSAP transform -- **`transform-origin: 50% 50%`** on camera — off-center origin creates unpredictable phase-to-phase drift -- **`will-change: transform`** on `.camera` — the camera transform updates every frame -- **`overflow: hidden` on `.scene`** — required when any phase scale < 1 -- **Scene background on `.scene`, not `.camera`** — if background is on camera, scaling/translating it reveals the outer void +| token | range | notes | +| --------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| PHASE_1 / 2 / 3_SCALE | 0.88–0.96 / 0.98–1.02 / 1.04–1.15 | tighter spread = subtler camera; scale < 1 REQUIRES `overflow: hidden` on `.scene` | +| PHASE_2_AT / PHASE_2_DUR | 0.3–1.0s / 1.0–1.8s | longer DUR = slower settle, more cinematic | +| PHASE_3_AT / PHASE_3_DUR | 2.0–4.0s / 1.0–2.0s | PHASE_3_AT ≥ PHASE_2_AT + PHASE_2_DUR or focus is preempted | +| PHASE_2_EASE / PHASE_3_EASE | `power2.out` `power3.out` `power2.inOut` | spring/back easing on a camera feels uncomfortable; each later phase settles deeper | +| TOTAL_DURATION | = `data-duration` | the drift tween must span the whole composition | +| DRIFT_CYCLES | 1–3 | 1 = one slow breath; high values read as mechanical wobble | +| DRIFT_AMP_X / DRIFT_AMP_Y | 2–8 px / 1–4 px | imperceptible per-frame, visible over time — if it reads as a shake, it's too much | +| DRIFT_FREQ_RATIO | 1.2–1.5 | 1.0 = perfect diagonal (mechanical); ~1.3 = organic Lissajous | +| HERO_AT (etc.) | after Phase-2 settle lands | a hero fading in mid-pull-back feels like it's flying away | -## Combinations +## Critical Constraints -- [orbit-3d-entry.md](orbit-3d-entry.md) — orbit motion inside a slowly drifting camera -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — climax phase push synced to counter peak -- [3d-text-depth-layers.md](3d-text-depth-layers.md) — depth-stacked hero with cinematic camera moves -- [sine-wave-loop.md](sine-wave-loop.md) — element idle inside the camera (compound motion) +- **Camera wraps EVERYTHING in the scene** — a per-element camera creates parallax bugs and breaks the "one viewpoint" read. +- **One writer**: phase scale and drift compose inside the single drift `onUpdate`; nothing else touches `camera.style.transform`. +- **`overflow: hidden` on `.scene`** — required whenever any phase scale < 1. +- **`transform-origin: 50% 50%` on `.camera`** — off-center origin creates unpredictable phase-to-phase drift. +- **Scene background on `.scene`, not `.camera`** — otherwise scaling/translating reveals the outer void. +- **Hero reveal starts AFTER the initial pull-back ease lands** — otherwise the headline feels like it's flying away. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — multi-phase tween + drift onUpdate -- `/hyperframes-core` — composition wiring, scene wrapper -- `/hyperframes-cli` — `hyperframes lint` +[coordinate-target-zoom.md](coordinate-target-zoom.md) (counter-translate math for the targeted variation) · [orbit-3d-entry.md](orbit-3d-entry.md) (orbit inside a drifting camera) · [counting-dynamic-scale.md](counting-dynamic-scale.md) (climax push synced to counter peak) · [3d-text-depth-layers.md](3d-text-depth-layers.md) (depth-stacked hero under camera moves) · [sine-wave-loop.md](sine-wave-loop.md) (element idle inside the camera). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/nudge-curve.md b/plugins/visual-content/skills/hyperframes-animation/rules/nudge-curve.md new file mode 100644 index 0000000..25e5526 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/nudge-curve.md @@ -0,0 +1,47 @@ +--- +name: nudge-curve +description: Slow-fast-slow three-phase group slide — reposition a composed group (word rows, card stacks, lists) to reveal content or make room. No single built-in ease produces it; chain power3.in ramp → linear burst → power4.out tail (10/65/25 distance, tail ≥3× ramp-in in time). +metadata: + tags: slide, reposition, group-motion, easing, nudge, slow-fast-slow, reveal, layout +--- + +# Nudge Curve + +Slow-fast-slow repositioning of a composed group (word rows, card stacks, lists) to +reveal content or make room. **In-scene group slide — not a seam.** No single built-in +ease produces it — `power4.inOut` smacks to a stop. Chain three tweens on one property: + +| Phase | Ease | Distance | Time | Feel | +| --------- | --------------- | -------- | ---- | ---------------------------------------- | +| 1 ramp-in | `power3.in` | ~10% | ~20% | barely moves — motion registers, no jolt | +| 2 burst | `none` (linear) | ~65% | ~18% | ~2× average px/frame — purposeful | +| 3 tail | `power4.out` | ~25% | ~62% | decaying creep to rest — kills the smack | + +## Rules + +- The tail is ≥3× the ramp-in in TIME. If it still smacks: extend the tail's time (not + distance) or use `power5.out`. +- Phase 2 stays linear — easing it loses the burst contrast. +- Reveal new content DURING phase 2 — the burst masks its appearance. +- Same ratios vertical; scale distances proportionally, keep the time ratios. +- A cascade arrival usually precedes this slide — see [waterfall-entry.md](waterfall-entry.md). + +## JS + +Reference values for a 270px leftward slide (0.57s total). Scale distances +proportionally for other travels; preserve the TIME ratios; tail ≥3× ramp-in. + +```js +var t = /* start after content settles */; +tl.to(".text-row", { x: -30, duration: 0.12, ease: "power3.in" }, t); // ramp-in: 11% dist / 21% time +tl.to(".text-row", { x: -210, duration: 0.10, ease: "none" }, t + 0.12); // burst: 67% dist / 18% time +tl.to(".text-row", { x: -270, duration: 0.35, ease: "power4.out" }, t + 0.22); // tail: 22% dist / 61% time +// vertical: same ratios on y. 150px variant: -15 / -115 / -150 at the same times. +``` + +## Anti-patterns + +| Don't | Instead | +| -------------------------------------------------------- | ---------------------------------------- | +| Single ease for a group slide (`power4.inOut`, `slow()`) | The three-phase chain above | +| Nudge tail shorter than 3× the ramp-in | Extend the tail's TIME, not its distance | diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/orbit-3d-entry.md b/plugins/visual-content/skills/hyperframes-animation/rules/orbit-3d-entry.md index 8d6203f..ef46b77 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/orbit-3d-entry.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/orbit-3d-entry.md @@ -7,295 +7,142 @@ metadata: # Orbit with 3D Entry -Elements flip in from 3D space (rotateX + rotateY + translateZ) then transition into a continuous elliptical orbit around a focal point. Distinct from one-shot reveals — the orbit keeps running. +Elements flip in from 3D space (`rotateX` + `rotateY` + negative `z`) then settle into a continuous elliptical orbit around a center label. Distinct from one-shot reveals — the orbit keeps running, driven by a 0→1 progress tween INSIDE the timeline (never rAF). ## How It Works -Two phases per element: +Per element, two phases: (1) a `back.out` flip from a hidden 3D orientation to flat — **in place at its orbital starting position** (see Critical Constraints); (2) a continuous orbit where `onUpdate` computes `x/y` from `cos/sin(initialAngle + p·2π)` on the ellipse. The stage needs `perspective` on the scene root and `preserve-3d` on stage + items, or the flip flattens to a 2D scale. -1. **Entry (per element)**: GSAP tween from hidden 3D orientation (`rotateX`, `rotateY`, negative `z`) to flat (`rotateX: 0, rotateY: 0, z: 0`). Spring-like ease (`back.out`) for the flip-in. -2. **Orbit (after entry)**: Continuous trigonometric position around a center point. The element's `x` and `y` translate are driven by `cos(t)` and `sin(t)` at a slow angular speed. - -The orbit runs **inside the timeline** — not via `requestAnimationFrame` — so HF seek-by-frame stays deterministic. - -## HTML +## Recipe ```html -
-
-
{glyph1}
-
{glyph2}
-
{glyph3}
-
{glyph4}
-
{glyph5}
-
{glyph6}
-
{centerLabel}
-
+ +
+
{glyph1}
+
{glyph2}
+ +
{centerLabel}
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; +.scene-root { display: grid; place-items: center; - background: {sceneBackground}; - perspective: 1800px; /* REQUIRED — without perspective, rotateX/Y flatten */ + perspective: 1800px; /* REQUIRED */ } .orbit-stage { position: relative; - width: 1000px; - height: 700px; display: grid; place-items: center; transform-style: preserve-3d; } .orbit-item { position: absolute; - /* Items live at stage center; GSAP translates them along the orbit. */ top: 50%; left: 50%; - width: 140px; - height: 140px; - display: grid; - place-items: center; - background: {accentColor}; - border-radius: 50%; - font-family: {font}; - font-weight: 900; - font-size: 64px; - color: {itemTextColor}; transform-style: preserve-3d; will-change: transform; - box-shadow: 0 12px 36px {accentShadowColor}; } .orbit-center { position: relative; - z-index: 5; - font-family: {font}; - font-weight: 900; - font-size: 96px; - letter-spacing: 8px; - color: {centerTextColor}; - text-transform: uppercase; + transform: translateZ(220px); /* wins paint order inside preserve-3d */ + z-index: 9999; } ``` -## GSAP Timeline - -```html - - -``` - -## How to Choose Values - -- **RADIUS_X** — horizontal radius of the orbit ellipse, in px - - Range: 300–900 px - - Effects: small radius reads as a tight cluster; large radius spreads the ring across the frame and lets a large center element breathe - - Constraints: must clear the center element horizontally at every angle — see Key Principles for the `RADIUS_X * min(|cos(θ)|) ≥ L_w + I_w + breathing_room` rule - - Reference: ../../examples/cta-orbit-collapse.html uses 480 - -- **Y_TO_X_RATIO** — `RADIUS_Y / RADIUS_X`, the orbit's perspective flattening - - Range: 0.4–0.7 - - Effects: low values read as a near-horizontal disc seen from above; values approaching 1 read as a flat plane facing the camera - - Constraints: keep < 1 — the orbit should look like a tilted ring, not a frontal halo - - Reference: ../../examples/cta-orbit-collapse.html uses ≈ 0.58 - -- **ORBIT_DURATION** — seconds for one full revolution - - Range: 4–25 s (longer for ambient backdrop, shorter for active feature motion) - - Effects: short durations look frenetic; long durations read as drifting / calm - - Constraints: must be ≥ the time the orbit is on screen, otherwise the tween ends and items stop - - Reference: ../../examples/cta-orbit-collapse.html uses ~25 s effective (orbit speed 0.25 rad/s) - -- **ENTRY_DUR** — per-element flip-in duration - - Range: 0.4–0.8 s - - Effects: short feels punchy; long feels stately - - Constraints: must be ≤ the gap between the first and last element's start so the cascade doesn't overlap to incoherence - - Reference: ../../examples/cta-orbit-collapse.html uses 0.55 s - -- **STAGGER** — delay between consecutive element entries - - Range: 0.06–0.12 s - - Effects: below ~0.06 s reads as "popcorn"; above ~0.12 s reads as plodding - - Constraints: total cascade `(n - 1) * STAGGER` should still complete before the next scene phase begins - - Reference: ../../examples/cta-orbit-collapse.html uses 0.10 s - -- **FLIP_BACK** — `back.out()` overshoot for the flip-in - - Range: 1.2–2.0 - - Effects: low end is a soft arrive; high end snaps with visible overshoot - - Constraints: pair with a calmer `CENTER_BACK` if both fire close together — competing overshoots cancel each other - - Reference: ../../examples/cta-orbit-collapse.html uses 1.4 - -- **CENTER_BACK** — `back.out()` overshoot for the center label fade-in - - Range: 1.2–1.8 - - Effects: low end keeps the label calm under the busy orbit; high end gives it a small "pop" of arrival - - Reference: ../../examples/cta-orbit-collapse.html uses 1.4 - -- **CENTER_FADE_AT** — when the center label fades in, in seconds - - Range: just after the first 2–4 elements have landed - - Effects: too early competes with the cascade; too late leaves a hole at the center of the orbit - - Reference: ../../examples/cta-orbit-collapse.html starts the center brand near the front of the scene - -- **ROTATE_X_FROM / ROTATE_Y_FROM / Z_FROM / SCALE_FROM** — initial 3D orientation - - Range: rotateX ±60° to ±120°; rotateY ±45° to ±120°; z −200 to −400; scale 0.2–0.6 - - Effects: higher absolute rotation + deeper negative z = more dramatic "card flipping out of depth"; lower = subtle reorientation - - Constraints: pick a direction consistent with the scene's perspective; mixing positive and negative rotateY across items reads as noise - - Reference: ../../examples/cta-orbit-collapse.html uses rotateX 90, rotateY −45, z −100, scale 0 - -## Variations - -### Collapse to center - -To reverse — orbit then collapse inward — interpolate `RADIUS_X` and `RADIUS_Y` to 0 in a final phase by multiplying both radii by a 1→0 driver: + // 3) Continuous orbit — each item gets its OWN progress tween (own initialAngle) + const orbit = { p: 0 }; + tl.to( + orbit, + { + p: 1, + duration: ORBIT_DURATION, + ease: "none", + onUpdate: () => { + const a = a0 + orbit.p * Math.PI * 2; + const x = Math.cos(a) * RADIUS_X; + const y = Math.sin(a) * RADIUS_Y; + // capped z-index band [1, 50] — see center-label clearance below + el.style.zIndex = String(1 + Math.round(((y + RADIUS_Y) / (2 * RADIUS_Y)) * 49)); + el.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px)`; + }, + }, + i * STAGGER + ENTRY_DUR, + ); +}); -```js -const collapse = { r: 1 }; -tl.to( - collapse, - { - r: 0, - duration: COLLAPSE_DUR, - ease: "power3.inOut", - onUpdate: () => - items.forEach((el) => { - const a = (Number(el.dataset.angle) / 360) * Math.PI * 2; - const x = Math.cos(a) * RADIUS_X * collapse.r; - const y = Math.sin(a) * RADIUS_Y * collapse.r; - el.style.transform = `translate(-50%,-50%) translate(${x}px,${y}px) scale(${collapse.r})`; - }), - }, - COLLAPSE_AT, +tl.from( + ".orbit-center", + { opacity: 0, scale: 0.6, duration: ENTRY_DUR, ease: `back.out(${CENTER_BACK})` }, + CENTER_FADE_AT, ); ``` -### Tilted orbit plane - -For a more dramatic 3D orbit, rotate the entire `.orbit-stage` on the X axis: - -```css -.orbit-stage { - transform: rotateX(25deg); -} -``` +## Variations -Items rendered above/below the equator visually arc through the plane. +- **Collapse to center**: a final 1→0 driver multiplies both radii (and item scale) in `onUpdate` — the ring condenses into the center element; pairs with a CTA "click" igniting the collapse. +- **Tilted orbit plane**: `rotateX(25deg)` on `.orbit-stage` — items visibly arc through the plane. -## Key Principles +## Values -- **`perspective` on scene root REQUIRED** — without it, rotateX/Y read as 2D scale and the flip-in looks flat -- **`transform-style: preserve-3d`** on both the stage and each item — preserves the 3D context as items have their own transforms -- **Stagger entries** — cascade reads as "swarm forming," simultaneous reads as "popcorn." See `STAGGER` in How to Choose Values -- **Element count 4-12** — fewer feels empty, more crowds the center -- **❗ Center label clearance — translateZ + capped item z-index** — `z-index` ALONE is unreliable inside a `transform-style: preserve-3d` stage (paint order follows Z position, not stacking-context z-index). For the orbit to NEVER occlude the headline: - 1. Push the center label forward: `transform: translateZ(220px); z-index: 9999;` - 2. Cap orbit-item dynamic z-index in `[1, 50]` so bottom-of-orbit items still read as "in front of" top-of-orbit items, but **never above the center label**. e.g.: `el.style.zIndex = String(1 + Math.round((y + RADIUS_Y) / (2 * RADIUS_Y) * 49));` - 3. **Choose `RADIUS_X` so items also clear the center label HORIZONTALLY at all angles.** If the label's half-width is `L_w` and the item's half-width is `I_w`, then `RADIUS_X` must satisfy `RADIUS_X * min(|cos(θ_minimum)|) ≥ L_w + I_w + breathing_room`. For a 6-item orbit with 60° angular spacing, the worst case is `cos(30°) ≈ 0.866` between items. Scale `RADIUS_X` with the center label's width — a heavier wordmark needs a wider ring. -- **❗ Center element is the headline** — the orbit is ornamental motion around it. If the orbit dominates the eye, increase center element size or fade orbit items down +| token | range | notes | +| ----------------------- | ----------------------------- | ------------------------------------------------------------------- | +| RADIUS_X | 300–900px | must also clear the center label horizontally (see below) | +| Y_TO_X_RATIO | 0.4–0.7 | keep < 1 — a tilted ring, not a frontal halo | +| ORBIT_DURATION | 4–25s per revolution | ≥ time on screen, or the tween ends and items freeze | +| ENTRY_DUR | 0.4–0.8s | | +| STAGGER | 0.06–0.12s | below reads "popcorn", above reads plodding | +| FLIP_BACK / CENTER_BACK | 1.2–2.0 / 1.2–1.8 | calm the center pop if both fire close together | +| CENTER_FADE_AT | after 2–4 items land | too early competes; too late leaves a hole | +| ROTATE_X/Y_FROM, Z_FROM | ±60–120°, ±45–120°, −200…−400 | one consistent rotation direction across items; mixed signs = noise | +| SCALE_FROM | 0.2–0.6 | | +| item count | 4–12 | fewer feels empty, more crowds the center | ## Critical Constraints -- **No `requestAnimationFrame`** — orbit must run inside the timeline so HF seeks frame-by-frame deterministically -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **Each item gets its OWN orbit tween** — don't share one tween with `targets: '.orbit-item'` because each starts at a different `initialAngle` -- **`will-change: transform`** — many simultaneous orbital transforms benefit from compositor hints -- **Don't animate `left`/`top`** — use `translate()` (composes with `translate(-50%, -50%)` centering) -- **❗ Entry must flip IN PLACE at orbital position, NOT at center** — a fromTo whose "from" and "to" both have `x: 0, y: 0` keeps the item at the stage center during phase 1, so it collides with the center label during flip-in (and then snaps to orbit on phase 2 start — a visible teleport). - - The correct pattern (see GSAP Timeline above) is to `gsap.set()` each item at `(cos(initialAngle)*RADIUS_X, sin(initialAngle)*RADIUS_Y)` with `opacity: 0` BEFORE adding tweens, then have phase 1 animate only rotation/opacity/scale — NOT translate. The item fades in IN PLACE at its orbital starting point, and phase 2 picks up the orbit smoothly from there. - -## Combinations - -- [center-outward-expansion.md](center-outward-expansion.md) — alternative entry pattern (burst, not orbit); also the reversed driver for an orbit-collapse finish -- [cursor-click-ripple.md](cursor-click-ripple.md) — pairs naturally when the center element is a CTA the user "clicks" to trigger the collapse -- [sine-wave-loop.md](sine-wave-loop.md) — per-item idle wobble layered on top of the orbit +- **❗ Entry must flip IN PLACE at the orbital position, NOT at center** — `gsap.set` each item at `(cos(a0)·RADIUS_X, sin(a0)·RADIUS_Y)` with `opacity: 0` BEFORE adding tweens, then phase 1 animates only rotation/opacity/scale. A fromTo that keeps `x/y: 0` flips at the stage center, collides with the center label, then teleports to the orbit when phase 2 starts. +- **❗ Center-label clearance** — `z-index` alone is unreliable inside `preserve-3d` (paint order follows actual Z): push the label forward with `translateZ(220px)` + `z-index: 9999`, cap item z-index to `[1, 50]`, AND size the ring so items clear the label horizontally at every angle: `RADIUS_X × min|cos(θ)| ≥ L_w + I_w + breathing_room` (label/item half-widths; for 6 items the worst case is `cos(30°) ≈ 0.866`). A heavier wordmark needs a wider ring. +- **Each item gets its OWN orbit tween** — a shared `targets: ".orbit-item"` tween can't carry per-item `initialAngle`. +- **The center element is the headline** — the orbit is ornament; if it dominates, grow the center or fade the items down. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + `onUpdate` API -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`center-outward-expansion` (burst entry; reversed driver = the collapse finish) · `cursor-click-ripple` (the click that triggers a collapse) · `depth-scatter-assemble` (3D entrance that resolves flat instead of orbiting). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/particle-burst.md b/plugins/visual-content/skills/hyperframes-animation/rules/particle-burst.md new file mode 100644 index 0000000..01a96e4 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/particle-burst.md @@ -0,0 +1,149 @@ +--- +name: particle-burst +description: Deterministic particle / confetti events — a confetti pop that bursts up and drifts down (optionally instant-shrinking away), a dot burst from behind text, or a glyph dissolving to particles. Every particle's state is a pure ballistic function of timeline time from index-seeded values, so a scrub to any t shows the correct mid-flight frame. +metadata: + tags: particles, confetti, burst, dissolve, celebration, ballistic, deterministic, punctuation +--- + +# Particle Burst + +Discrete flying particles as a one-shot event: a **confetti pop** that erupts upward and drifts back down on gravity, a **dot burst** radiating from behind a landing word, or a **glyph dissolve** where text breaks into particles that scatter and die. Particles are ephemeral garnish — born from a beat, fly, gone; they never become layout. + +Boundaries: [css-marker-patterns.md](css-marker-patterns.md)'s burst mode is radiating **drawn lines** — a static accent, no flight. [press-release-spring.md](press-release-spring.md)'s release burst is **one blurred radial layer** faking an explosion — enough when a single glow pop will do. [center-outward-expansion.md](center-outward-expansion.md) moves **real layout elements** to final resting slots; particles have no destination, only physics and a death. + +## How It Works + +The whole event is **one driver tween and one formula**: + +1. **Seeded setup** — a fixed pool of `PARTICLE_COUNT` small divs is created once at composition setup (a deterministic loop — setup-time generation is fine; per-frame DOM creation is not). Each particle `i` derives everything from a pure hash: + + ```js + // angle, speed, size, spin, color (palette[i % palette.length]) — all from prand(i * k) + const prand = (n) => { + const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; + return x - Math.floor(x); // 0..1, pure function of n + }; + ``` + +2. **Ballistic formula** — a proxy tween advances `T: 0 → 1` over `FLIGHT_DUR` with `ease: "none"`; `onUpdate` positions every particle as a **pure function of T**: + + ``` + x(T) = vx · T·FLIGHT_DUR + y(T) = vy · T·FLIGHT_DUR + ½ · G · (T·FLIGHT_DUR)² + rot(T) = spin · T·FLIGHT_DUR + ``` + + Gravity `G` supplies the rise-decelerate-fall arc for free. Because position is computed from `T` (never accumulated per frame), a seek to any moment renders the exact mid-flight state — this is what makes DOM particles seek-safe. The driver's `ease: "none"` is load-bearing: the physics lives in the formula; an eased driver warps gravity and the arc stops reading as thrown objects. + +3. **Death** — an opacity tail inside the same formula (fade over the last `FADE_FRAC` of flight), or the confetti signature: a separate **instant-shrink** tween scaling the pool to 0 in a blink at flight end. Either way the particles end invisible and stay invisible. + +## Recipe + +```html + +
+
+
{heroWord}
+
+``` + +```css +/* .burst-stage: position: relative; display: grid; place-items: center. + .burst-hero: z-index: 2 — particles fly BEHIND the word. */ +.particle-field { + position: absolute; + z-index: 1; + left: 50%; + top: 50%; /* the launch origin — offset to taste (e.g. the word's baseline) */ + width: 0; + height: 0; +} +.particle { + position: absolute; + left: 0; + top: 0; + border-radius: 2px; /* confetti chip; 50% for dots */ + opacity: 0; /* invisible until the event fires */ + will-change: transform, opacity; +} +``` + +```js +// Setup: deterministic pool, generated ONCE. +const field = document.getElementById("particle-field"); +const palette = ["{accentA}", "{accentB}", "{accentC}"]; // 3-5 brand tokens +const parts = []; +for (let i = 0; i < PARTICLE_COUNT; i++) { + const el = document.createElement("div"); + el.className = "particle"; + const size = SIZE_MIN + prand(i * 3 + 1) * (SIZE_MAX - SIZE_MIN); + el.style.width = `${size}px`; + el.style.height = `${size * 0.7}px`; // slightly oblong = confetti chip + el.style.background = palette[i % palette.length]; + field.appendChild(el); + // Index-seeded launch parameters — the particle's whole life, fixed here. + const angle = -Math.PI / 2 + (prand(i * 5 + 2) * 2 - 1) * CONE; // upward cone + const speed = SPEED_MIN + prand(i * 7 + 3) * (SPEED_MAX - SPEED_MIN); + parts.push({ + el, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, // negative = up + spin: (prand(i * 11 + 4) * 2 - 1) * SPIN_MAX, + }); +} + +// Confetti pop — one driver, pure ballistic formula. +const drive = { T: 0 }; +tl.fromTo( + drive, + { T: 0 }, + { + T: 1, + duration: FLIGHT_DUR, + ease: "none", // physics lives in the formula, not the ease + onUpdate: () => { + const t = drive.T * FLIGHT_DUR; // seconds of flight — pure function of T + const fade = Math.min(1, (1 - drive.T) / FADE_FRAC); // opacity tail + parts.forEach((p) => { + const x = p.vx * t; + const y = p.vy * t + 0.5 * G * t * t; // rise, stall, drift down + p.el.style.transform = `translate(${x}px, ${y}px) rotate(${p.spin * t}deg)`; + p.el.style.opacity = String(drive.T === 0 ? 0 : fade); // T===0 guard covers seeks before the event + }); + }, + }, + BURST_AT, +); +``` + +## Variations + +- **Confetti pop, then instant-shrink** — the playful signature: full burst, gravity drift, then every chip scales to 0 in a blink: `FADE_FRAC` near 0, plus `tl.to(".particle", { scale: 0, duration: SHRINK_DUR, ease: "power2.in" }, BURST_AT + FLIGHT_DUR - SHRINK_DUR)` with `SHRINK_DUR` 0.15–0.25s. Keep the whole event tiny relative to the subject — a garnish measured in a few dozen pixels, not a screen-filling cannon. +- **Dot burst behind a landing word** — radial instead of a cone: `angle = prand(i) * Math.PI * 2`, `G` near 0, short flight (0.4–0.7s), round dots (`border-radius: 50%`), pool z-indexed behind the word. Fire at the word's settle frame. +- **Glyph dissolve** — seed each particle's **origin** across the glyph block's box (`ox = (prand(i*13) - 0.5) * BLOCK_W`, same for `oy`, added inside the transform), gentle outward drift with low `G`; text fades out over the first ~30% of flight while particles fade in from its silhouette. Color every particle `{textColor}` so the swarm reads as the text's own material. (True per-pixel dissolves are Canvas-2D territory — `techniques.md`; this DOM version sells it up to ~40 particles.) +- **Two-stage burst (pop + stragglers)** — split the pool: 70% on the main driver, 30% on a second driver ~0.12s later with lower speeds; the split is index-derived (`i % 10 < 3`). Same formula, two windows. + +## Values + +| token | range | notes | +| --------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- | --- | ----------------------- | +| PARTICLE_COUNT | 10–18 pop/dots; 24–40 dissolve | **cap ~40** — per-frame style writes; past that, seek perf and register degrade | +| G | 900–1600 px/s² confetti; 0–200 dots/dissolve | natural fall vs drift | +| SPEED_MIN / SPEED_MAX | 250–700 px/s | per-particle via `prand`, never uniform | +| CONE | 0.35–0.8 rad (~20–45°) | wider = splash, narrower = fountain | +| FLIGHT_DUR | 0.7–1.4s | arc should peak ~35–45% of flight: check ` | vy | / G ≈ 0.4 × FLIGHT_DUR` | +| SIZE_MIN / SIZE_MAX | 5–14px chips; 4–8px dots | on a 1080p frame | +| SPIN_MAX | 180–720 deg/s confetti; 0 dots | tumble | +| FADE_FRAC | 0.2–0.35 | near 0 when using instant-shrink | +| BURST_AT | on a cause | the word's settle, a click, a lockup completing — an uncaused burst is noise | + +## Critical Constraints + +- **Position is a pure function of time, driver ease `"none"`** — `x(T)`, `y(T)`, `rot(T)` computed from the driver value every frame, never accumulated (`+=`) per tick (accumulation breaks the moment the renderer seeks); gravity is the ease — an eased driver bends the parabola. +- **Fixed pool, no per-frame DOM** — all particles exist after setup with `opacity: 0`; the event only writes `transform` / `opacity`. **`PARTICLE_COUNT ≤ ~40`** — per-frame style writes scale linearly; keep the event cheap. +- **Particles start AND end at `opacity: 0`** — the `drive.T === 0` guard covers seeks to before the event; the tail/shrink covers after. A chip frozen mid-air at driver end is a bug every subsequent frame. +- **Particles are punctuation** — one event per beat, fired on a cause, small relative to the subject, dead before the next beat; z-ordered behind or around the word it celebrates, never over it. A persistent particle system is a background, and that's not this rule. + +## See also + +`spring-pop-entrance` (confetti fires on the hero's settle frame) · `kinetic-beat-slam` (one beat earns the confetti payoff) · `press-release-spring` (single-layer glow alternative, or compose both) · `css-marker-patterns` (drawn-line burst when the accent should feel hand-annotated) · `scale-swap-transition` (glyph dissolve covers the exit). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/physics-press-reaction.md b/plugins/visual-content/skills/hyperframes-animation/rules/physics-press-reaction.md index 8eb79e9..9fec017 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/physics-press-reaction.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/physics-press-reaction.md @@ -7,344 +7,95 @@ metadata: # Physics Press Reaction (Cursor + Element Synced) -Models a real click: a cursor approaches a button, lands, and both compress IN SYNC, then release together. Two distinct timing events (down-frame and up-frame) bound by spring forces. Distinct from [press-release-spring](press-release-spring.md) (which has no cursor — just a press happening); this rule is the COMBINED cursor + element behavior. +Models a real click: a cursor approaches a button, lands, and both compress IN SYNC, then release together. Distinct from [press-release-spring.md](press-release-spring.md) (no cursor — just a press happening); this rule is the COMBINED cursor + element behavior. A single `PRESS_INTENSITY` drives both: press down compresses both to `1 - PRESS_INTENSITY` via **one targets array**, release springs both back to 1.0 with overshoot. The cursor translates to the button's center BEFORE the press starts; after release it may move on or hold. -## How It Works - -A single `PRESS_INTENSITY` value drives both cursor and button together: - -- **press down**: both compress to `1 - PRESS_INTENSITY` -- **release**: both spring back to 1.0 with overshoot - -The cursor ALSO translates to the button's center during the approach phase BEFORE press starts. After release, the cursor may move on (next interaction) or hold. - -## HTML +## Recipe ```html -
-
- -
{Brand}
-
- - - - -
-``` - -## CSS - -```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {sceneBg}; - font-family: {font}; - overflow: hidden; -} -.stack { - display: flex; - flex-direction: column; - align-items: center; - gap: STACK_GAP; -} -.btn { - display: flex; - align-items: center; - gap: BTN_INNER_GAP; - padding: BTN_PADDING_V BTN_PADDING_H; - background: {btnBg}; - border: none; - border-radius: BTN_RADIUS; - color: {btnTextColor}; - font-family: {font}; - font-weight: 900; - font-size: BTN_FONT_SIZE; - letter-spacing: BTN_TRACKING; - text-transform: uppercase; - cursor: pointer; - box-shadow: {btnRestingShadow}; - transform-origin: 50% 50%; - will-change: transform; -} -.btn-icon { - font-size: BTN_ICON_SIZE; - line-height: 1; -} -.brand { - font-size: BRAND_SIZE; - font-weight: 800; - letter-spacing: BRAND_TRACKING; - color: {brandColor}; - text-transform: uppercase; -} -/* Cursor — absolute, positioned by GSAP */ -.cursor { - position: absolute; - width: CURSOR_SIZE; - height: CURSOR_SIZE; - pointer-events: none; - z-index: 100; - /* initial position is set by gsap.set() */ - transform-origin: 0 0; /* arrow point is the click point */ - filter: {cursorDropShadow}; -} -``` - -## GSAP Timeline - -```html - - + + + ``` -## Variations - -### Multiple-element chain press - -Cursor presses button A → button A triggers swap → cursor moves to button B → presses again. Each press is one full down-release sub-routine. +```js +gsap.set("#cursor", { x: CURSOR_START_X, y: CURSOR_START_Y }); // off-screen / far corner -### Hold press (continuous pressure) +// Phase 1 — approach +tl.to( + "#cursor", + { x: BUTTON_CENTER_X, y: BUTTON_CENTER_Y, duration: APPROACH_DUR, ease: "power2.inOut" }, + APPROACH_START, +); -Insert a `HOLD_DUR` window between press-down and release. Cursor scale stays at `1 - PRESS_INTENSITY`, button scale stays at `1 - PRESS_INTENSITY`, inner glow stays on. Suggests "thinking" or "loading." +// Phase 2 — coordinated press down: ONE targets array, same scale +tl.to( + ["#btn", "#cursor"], + { scale: 1 - PRESS_INTENSITY, duration: PRESS_DOWN_DUR, ease: "power1.in" }, + PRESS_DOWN_AT, +); -### Synchronized inner-glow pulse +// Phase 3 — release: both spring back together +tl.to( + ["#btn", "#cursor"], + { scale: 1, duration: RELEASE_DUR, ease: `back.out(${BOUNCE_FACTOR})` }, + RELEASE_AT, +); -During the hold phase, the inner glow pulses (sin-driven). Suggests "processing": +// Phase 4 — inner glow during press, resting shadow on release (contact confirmation) +tl.to( + "#btn", + { boxShadow: "{btnPressedShadow}", duration: PRESS_DOWN_DUR, ease: "power1.in" }, + PRESS_DOWN_AT, +); +tl.to( + "#btn", + { boxShadow: "{btnRestingShadow}", duration: RELEASE_DUR, ease: "power2.out" }, + RELEASE_AT, +); -```js -const holdGlow = { p: 0 }; +// Cursor optionally exits after the press settles tl.to( - holdGlow, - { - p: Math.PI * GLOW_PULSE_CYCLES * 2, - duration: HOLD_DUR, - ease: "none", - onUpdate: () => { - const alpha = GLOW_BASE_ALPHA + Math.sin(holdGlow.p) * GLOW_PULSE_AMP; - document.getElementById("btn").style.boxShadow = - `inset 0 0 GLOW_BLUR rgba(255, 255, 255, ${alpha})`; - }, - }, - HOLD_START_AT, + "#cursor", + { x: CURSOR_EXIT_X, y: CURSOR_EXIT_Y, duration: CURSOR_EXIT_DUR, ease: "power2.out" }, + CURSOR_EXIT_AT, ); ``` -## How to Choose Values - -### Timing (seconds) - -- **APPROACH_START** — when the cursor begins moving toward the button. - - Range: 0-0.3 s (small lead-in is fine; long delays read as a dead frame) -- **APPROACH_DUR** — cursor approach duration. - - Range: 0.7-1.3 s; faster reads as urgent, slower as deliberate -- **PRESS_DOWN_AT** — when the press fires. - - Constraints: MUST equal `APPROACH_START + APPROACH_DUR` so the cursor arrives exactly when the press begins (avoids "tapping on air") -- **PRESS_DOWN_DUR** — compression duration. - - Range: 0.1-0.25 s -- **RELEASE_AT** — when the release fires. - - Constraints: must be > `PRESS_DOWN_AT + PRESS_DOWN_DUR`; an optional brief hold (0.05-0.4 s, or `HOLD_DUR` for the Hold-press variation) for "thinking" interactions -- **RELEASE_DUR** — release spring duration. - - Range: 0.4-0.7 s (long enough for the overshoot to settle) -- **BRAND_REVEAL_AT** — when the brand line fades in. - - Constraints: must be < `PRESS_DOWN_AT` (context precedes interaction) -- **BRAND_REVEAL_DUR** — brand fade-in duration. - - Range: 0.4-0.8 s -- **CURSOR_EXIT_AT / CURSOR_EXIT_DUR** — optional outbound cursor motion after release. - - Constraints: `CURSOR_EXIT_AT` must be ≥ `RELEASE_AT + RELEASE_DUR` so the cursor exits AFTER the press settles, not during - -### Physics - -- **PRESS_INTENSITY** — how deep the press compression goes. - - Range: 0.05 (subtle) - 0.10 (standard) - 0.15 (heavy) - - Applied as `scale: 1 - PRESS_INTENSITY` on both cursor and button (single GSAP target array) -- **BOUNCE_FACTOR** — `back.out(${BOUNCE_FACTOR})` overshoot on the release. - - Range: 1.6 (soft) - 2.0 (firm) - 2.4 (cartoony) - -### Positioning - -- **CURSOR_START_X / CURSOR_START_Y** — initial cursor position in composition coordinates. - - Constraints: off-screen or in a corner far from the button so the approach reads as motion-in, not a teleport -- **BUTTON_CENTER_X / BUTTON_CENTER_Y** — the button's measured screen-space center. - - Source: measured at composition coordinates; for `place-items: center` at 1920×1080 this is `(960, 540)` -- **CURSOR_EXIT_X / CURSOR_EXIT_Y** — where the cursor moves after release (if used). - - Range: any off-stage or out-of-the-way position -- **BRAND_REVEAL_Y_PX** — brand initial y offset. - - Range: 8-20 px - -### Layout / typography - -- **STACK_GAP** — gap between button and brand line. - - Range: 40-96 px -- **BTN_PADDING_V / BTN_PADDING_H** — button padding. - - Range: V 24-40 px, H 60-100 px (horizontal padding 2-3× vertical reads as pill-shaped CTA) -- **BTN_INNER_GAP** — gap between icon and label inside the button. - - Range: 16-32 px -- **BTN_RADIUS** — button corner radius. - - Range: 20-40 px, or `BTN_PADDING_V + BTN_FONT_SIZE/2` for fully rounded ends -- **BTN_FONT_SIZE / BTN_ICON_SIZE** — typographic sizes inside the button. - - Range: font 60-100 px at 1080p; icon ~1.0-1.1× font size -- **BTN_TRACKING** — letter-spacing on uppercase button text. - - Range: 4-12 px -- **BRAND_SIZE / BRAND_TRACKING** — brand line typography. - - Range: 40-60 px, tracking 8-16 px -- **CURSOR_SIZE** — cursor SVG size. - - Range: 48-96 px at 1080p - -### Hold-press variation - -- **HOLD_DUR** — hold window between press down and release. - - Range: 0.3-0.8 s -- **HOLD_START_AT** — when the glow pulse begins. - - Constraints: typically equal to `PRESS_DOWN_AT + PRESS_DOWN_DUR` -- **GLOW_PULSE_CYCLES** — number of full sine cycles across `HOLD_DUR`. - - Range: 1-4 (more cycles read as faster "processing") -- **GLOW_BASE_ALPHA** — center of the alpha pulse. - - Range: 0.15-0.3 -- **GLOW_PULSE_AMP** — peak deviation from `GLOW_BASE_ALPHA`. - - Range: 0.1-0.2; must satisfy `GLOW_BASE_ALPHA - GLOW_PULSE_AMP ≥ 0` -- **GLOW_BLUR** — inset glow blur radius (px). - - Range: 24-48 px - -### Tokens - -- **{sceneBg}** — background gradient/color -- **{font}** — typographic stack -- **{btnBg}** — button background (typically gradient toward an accent hue) -- **{btnTextColor}** — button text color -- **{btnRestingShadow}** / **{btnPressedShadow}** — outer + inset box-shadow strings for the resting and pressed states -- **{brandColor}** — accent brand color -- **{cursorFill}** / **{cursorStroke}** — cursor SVG fill and stroke -- **{cursorDropShadow}** — `filter: drop-shadow(...)` value for cursor depth -- **{Brand}** — brand line copy -- **{ctaCopy}** / **{ctaIcon}** — button label and inline icon glyph - -## Key Principles +## Variations -- **Same press scale on cursor AND button** — physical synchronicity. If only the button scales, the cursor appears to "tap on air"; if only the cursor scales, the button feels disconnected. -- **Cursor arrives BEFORE press starts** — there must be a clear moment of "cursor over target" before scale change. Otherwise the press is unattributed. -- **`back.out(${BOUNCE_FACTOR})` for release** — both elements need spring overshoot together. Linear release loses the tactile feel. -- **Inner glow appears DURING press, fades on release** — visual confirmation of contact. Outer shadow shrinks (pushed-in), inner glow appears (energy concentrated). -- **Cursor `pointer-events: none`** — the cursor is decorative; if it captures events, hover/click behaviors on button below break. -- **Cursor `transform-origin: 0 0`** — the arrow's tip is the click point, not its center. Scale around the tip keeps the click point stable. -- **Climax dwell ≥1 s** — after release, the comp must continue ≥1 s. The press is a beat; viewer needs time to see the result. +- **Multiple-element chain press** — press button A → A triggers a swap → cursor moves to button B → presses again; each press is one full down-release sub-routine. +- **Hold press (continuous pressure)** — insert a `HOLD_DUR` window between press-down and release: both scales stay at `1 - PRESS_INTENSITY`, inner glow stays on. Suggests "thinking" or "loading." +- **Synchronized inner-glow pulse** — during the hold, pulse the inset glow with a sine driver: a `{ p: 0 }` proxy tweened to `Math.PI * GLOW_PULSE_CYCLES * 2` on `ease: "none"`, `onUpdate` writing `boxShadow` with `alpha = GLOW_BASE_ALPHA + sin(p) * GLOW_PULSE_AMP`. Suggests "processing." + +## Values + +| token | range / rule | notes | +| ------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------- | +| APPROACH_START | 0–0.3 s | long delays read as a dead frame | +| APPROACH_DUR | 0.7–1.3 s | faster = urgent, slower = deliberate | +| PRESS_DOWN_AT | `= APPROACH_START + APPROACH_DUR` | cursor arrives exactly as the press begins — avoids "tapping on air" | +| PRESS_DOWN_DUR | 0.1–0.25 s | | +| RELEASE_AT | > `PRESS_DOWN_AT + PRESS_DOWN_DUR` | optional 0.05–0.4 s hold (or `HOLD_DUR` 0.3–0.8 s) for "thinking" interactions | +| RELEASE_DUR | 0.4–0.7 s | long enough for the overshoot to settle | +| PRESS_INTENSITY | 0.05 subtle · 0.10 standard · 0.15 heavy | applied to both cursor and button via the single targets array | +| BOUNCE_FACTOR | 1.6 soft · 2.0 firm · 2.4 cartoony | | +| CURSOR_START / EXIT | off-screen or far corner | the approach must read as motion-in, not a teleport; exit ≥ `RELEASE_AT + RELEASE_DUR` | +| BUTTON_CENTER | measured | for `place-items: center` at 1920×1080: `(960, 540)` | +| BRAND_REVEAL_AT | < `PRESS_DOWN_AT` | context precedes interaction | +| glow pulse | 1–4 cycles; base α 0.15–0.3; amp 0.1–0.2 | `GLOW_BASE_ALPHA − GLOW_PULSE_AMP ≥ 0` | +| CURSOR_SIZE | 48–96 px at 1080p | | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition`** on either cursor or button — competes with GSAP -- **Cursor SVG with `pointer-events: none`** -- **`will-change: transform`** on button (and cursor if desired) -- **`up-frame > down-frame`** — release MUST come after press; otherwise the comp shows release without press -- **Don't use real `mouseenter` / `click` events** — HF is a render context, not a UI; everything must run via the timeline - -## Combinations - -- [press-release-spring.md](press-release-spring.md) — the BUTTON-only press variant; this rule layers cursor on top -- [cursor-click-ripple.md](cursor-click-ripple.md) — adds a ripple effect at the click point -- [scale-swap-transition.md](scale-swap-transition.md) — the press TRIGGERS the swap +- **Same press scale on cursor AND button** (one targets array) — only the button scaling makes the cursor "tap on air"; only the cursor scaling makes the button feel disconnected. +- **Cursor arrives BEFORE the press starts** — a clear "cursor over target" moment, or the press is unattributed. +- **`back.out(BOUNCE_FACTOR)` on the release, for both together** — a linear release loses the tactile feel; release MUST come after press. +- **Inner glow appears DURING press, fades on release** — outer shadow shrinks (pushed in), inner glow appears (energy concentrated). +- **Cursor `transform-origin: 0 0`** — the arrow's tip is the click point; scale around the tip keeps it stable. `pointer-events: none` on the cursor. +- **Climax dwell ≥ 1 s** — after release the composition must continue ≥ 1 s; the press is a beat, the viewer needs time to see the result. +- **No real `mouseenter` / `click` events** — HF is a render context; everything runs via the timeline. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — coordinated multi-target tweens via array -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`press-release-spring` (the BUTTON-only press; this rule layers the cursor on top) · `cursor-click-ripple` (adds a ripple at the click point) · `scale-swap-transition` (the press TRIGGERS the swap). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/press-release-spring.md b/plugins/visual-content/skills/hyperframes-animation/rules/press-release-spring.md index 4e6fb0a..3c6eac9 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/press-release-spring.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/press-release-spring.md @@ -7,290 +7,102 @@ metadata: # Press-Release Spring Chain -Separates input (linear compression) from output (spring recovery) to create tactile feel. The overshoot is a natural byproduct of the spring config, not manually coded. Pairs with secondary motion (shadow shrink, release burst, background glow) layered on the same trigger frame. +Separates input (linear compression) from output (spring recovery) to create tactile feel: the overshoot is a natural byproduct of the spring config, not manually coded, with secondary motion (shadow shrink, release burst, background glow) layered on the same trigger frame. This is a **reaction on an element already resting on screen** — an arrival that springs in from nothing is [spring-pop-entrance.md](spring-pop-entrance.md); add a visible cursor actor and it becomes [physics-press-reaction.md](physics-press-reaction.md). -## How It Works - -Two distinct phases split at the **release** moment: +Two phases split at the **release**: 1. **Press**: linear ease → compression (`scale: 1 → PRESS_SCALE`, shadow shrinks). Linear, not spring — the dip must read as instant/tactile, not squishy. -2. **Release**: `back.out(${BOUNCE_FACTOR})` spring → elastic pop back to `1.0` (overshoot proportional to `BOUNCE_FACTOR`). Optional burst glow ring expands behind the button; optional background environmental glow fades in. +2. **Release**: `back.out(BOUNCE_FACTOR)` spring back to 1.0. Optional burst glow ring expands behind the button; optional environmental glow fades in. -State continuity is critical: the release tween's start value MUST equal the press tween's end value, or the spring snaps to a different position. GSAP threads this automatically when both tweens target the same property at adjacent positions on the same timeline. +State continuity is critical: the release tween's start value MUST equal the press tween's end value, or the spring snaps to a different position. GSAP threads this automatically when both tweens target the same property at **adjacent positions** — `RELEASE_START = PRESS_START + PRESS_DUR`; a gap or overlap breaks it. -## HTML +## Recipe ```html -
-
-
-
- -
+
+
+ +
+
``` -## CSS - -```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; -} -.press-stage { - position: relative; - display: grid; - place-items: center; -} -.btn { - position: relative; - z-index: 2; - /* Visual weight: ≥4% of canvas for the press to read on a 1080p frame */ - width: BTN_WIDTH; - height: BTN_HEIGHT; - background: {btnBg}; - border: none; - border-radius: BTN_RADIUS; - font-family: {font}; - font-weight: 900; - font-size: BTN_FONT_SIZE; - letter-spacing: BTN_LETTER_SPACING; - color: {btnTextColor}; - text-transform: uppercase; - /* Anchor compression on the center — see Critical Constraints */ - transform-origin: 50% 50%; - /* Initial floating shadow — large + diffuse */ - box-shadow: {btnRestShadow}; -} -.burst { - /* Sits BEHIND the button, same footprint */ - position: absolute; - z-index: 1; - inset: 0; - width: BTN_WIDTH; - height: BTN_HEIGHT; - background: {burstGradient}; - filter: blur(BURST_BLUR); - opacity: 0; - transform: scale(1); - pointer-events: none; -} -.bg-glow { - /* Full-stage radial — extends beyond the stage with negative inset */ - position: absolute; - inset: BG_GLOW_INSET; - background: {bgGlowGradient}; - opacity: 0; - pointer-events: none; -} -``` - -## GSAP Timeline - -```html - - -``` - -## Variations - -### Subtle press (status save / muted CTA) - -Less compression, gentler overshoot, smaller burst. `PRESS_SCALE` toward the high end of its range (~0.96), `BOUNCE_FACTOR` toward the low end (~1.4), `BURST_PEAK_SCALE` and `BURST_PEAK_OPACITY` reduced. - -### Dramatic press (hero CTA / "ship it" moment) - -Deeper compression, more overshoot, larger burst. `PRESS_SCALE` toward the low end (~0.88), `BOUNCE_FACTOR` toward the high end (~2.5), `BURST_PEAK_SCALE` and `BURST_PEAK_OPACITY` maxed. - -### Color shift during press - -Darken the button mid-press, return on release. Same timeline positions as the scale tweens — interpolated `backgroundColor` on `#btn`. State continuity rule still applies: the release-color tween's start equals the press-color tween's end. - ```js -tl.to("#btn", { backgroundColor: "{btnPressedColor}", duration: PRESS_DUR }, PRESS_START); -tl.to("#btn", { backgroundColor: "{btnRestColor}", duration: RELEASE_DUR }, RELEASE_START); -``` +// Phase 1 — press (linear compression) +tl.to( + "#btn", + { scale: PRESS_SCALE, boxShadow: "{btnPressedShadow}", duration: PRESS_DUR, ease: "power1.in" }, + PRESS_START, +); -### State change at release (approve / confirm pattern) +// Phase 2 — release (spring back; start scale == PRESS_SCALE by adjacency) +tl.to( + "#btn", + { + scale: 1, + boxShadow: "{btnRestShadow}", + duration: RELEASE_DUR, + ease: `back.out(${BOUNCE_FACTOR})`, + }, + RELEASE_START, +); -When the press signals confirmation, swap the button's resting color to a success token at `RELEASE_START` (instead of returning to `{btnRestColor}`), then pop a checkmark via a separate `back.out(${CHECK_BOUNCE})` tween at the same position. The button is now in its terminal state — no further presses expected. +// Phase 3 — burst glow pops behind the button, then fades +tl.fromTo( + "#burst", + { scale: 1, opacity: 0 }, + { + scale: BURST_PEAK_SCALE, + opacity: BURST_PEAK_OPACITY, + duration: BURST_GROW_DUR, + ease: "power2.out", + }, + RELEASE_START, +); +tl.to("#burst", { opacity: 0, duration: BURST_FADE_DUR, ease: "power2.in" }, BURST_FADE_START); -```js -tl.to("#btn", { backgroundColor: "{successColor}", duration: RELEASE_DUR }, RELEASE_START); +// Phase 4 — environmental glow fades in after release tl.to( - ".btn-check", - { scale: 1, duration: CHECK_POP_DUR, ease: `back.out(${CHECK_BOUNCE})` }, + "#bg-glow", + { opacity: BG_GLOW_PEAK_OPACITY, duration: BG_GLOW_FADE_DUR, ease: "power2.out" }, RELEASE_START, ); ``` -## How to Choose Values - -### Geometry - -- **BTN_WIDTH / BTN_HEIGHT** — button footprint. - - Range: button area ≥ 3-5% of canvas (a 320×68 button at 1080p is ~1% and reads as visually insignificant) - - Effects: smaller → press barely reads; larger → press dominates the frame - - Constraints: `BTN_WIDTH × BTN_HEIGHT / (canvasW × canvasH) ≥ 0.03` -- **BTN_RADIUS** — corner radius. - - Range: `BTN_HEIGHT × 0.15` (sharp/modern) → `BTN_HEIGHT / 2` (pill) -- **BTN_FONT_SIZE / BTN_LETTER_SPACING** — typographic weight. - - Range: `BTN_FONT_SIZE ≈ BTN_HEIGHT × 0.4-0.5`; letter-spacing 4-10 px reads as "actionable label" - -### Press dynamics - -- **PRESS_SCALE** — compression depth. - - Range: 0.88 (dramatic) → 0.92 (default) → 0.96 (subtle) - - Effects: lower → more tactile / weightier; higher → barely-there acknowledgment - - Constraints: never <0.85 (button feels broken) or >0.98 (no perceptible dip) -- **PRESS_DUR** — compression duration. - - Range: 0.10-0.30 s - - Effects: shorter → snappier / "instant-feeling"; longer → slow squish - - Constraints: shorter than `RELEASE_DUR` (input is faster than spring recovery) -- **RELEASE_DUR** — spring recovery duration. - - Range: 0.40-0.90 s - - Effects: shorter → tight pop; longer → loose, wobbly settle -- **BOUNCE_FACTOR** — `back.out(BOUNCE_FACTOR)` overshoot strength. - - Range: 1.4 (soft) → 2.0 (firm pop) → 2.8 (cartoony) - - Effects: low end barely overshoots; high end reads as cartoonish; tune by feel - - Alternative: switch to `elastic.out(amplitude, period)` for a rubbery oscillation instead of a single overshoot -- **PRESS_START / RELEASE_START** — timeline positions. - - Constraints: `RELEASE_START = PRESS_START + PRESS_DUR` (state continuity — see Critical Constraints) - -### Burst glow - -- **BURST_PEAK_SCALE** — radial pop max scale. - - Range: 3 (subtle) → 6 (default) → 8 (dramatic) - - Constraints: ≤ ~8 — beyond that the radial gradient pixelates visibly -- **BURST_PEAK_OPACITY** — burst max opacity. - - Range: 0.4 (subtle) → 0.8 (default) → 1.0 (dramatic) -- **BURST_GROW_DUR / BURST_FADE_DUR** — grow vs. fade timing. - - Range: 0.4-0.7 s each; default grow ≈ fade -- **BURST_BLUR** — gaussian blur on the burst layer. - - Range: 40-100 px; smaller reads as a hard ring, larger as ambient haze - -### Background glow - -- **BG_GLOW_PEAK_OPACITY** — peak environmental glow. - - Range: 0.1 (subtle) → 0.25 (default) → 0.45 (dramatic) - - Constraints: ≤ 0.45 — higher washes the whole composition -- **BG_GLOW_FADE_DUR** — fade-in duration. - - Range: 0.6-1.0 s -- **BG_GLOW_INSET** — negative inset so the radial extends past the stage edges. - - Range: typically `-300` to `-500` px on a 1920×1080 canvas - -### Optional "approve" variation - -- **CHECK_BOUNCE** — checkmark pop overshoot. - - Range: 1.4-2.0; firmer than the button's main `BOUNCE_FACTOR` to read as a punctuating "stamp" -- **CHECK_POP_DUR** — checkmark scale-up duration. - - Range: 0.3-0.6 s +## Variations -### Tokens +- **Subtle press** (status save / muted CTA): `PRESS_SCALE` ~0.96, `BOUNCE_FACTOR` ~1.4, burst scale/opacity reduced. +- **Dramatic press** (hero CTA / "ship it"): `PRESS_SCALE` ~0.88, `BOUNCE_FACTOR` ~2.5, burst maxed. +- **Color shift during press** — darken mid-press, return on release; interpolated `backgroundColor` at the same timeline positions as the scale tweens. Same state-continuity rule. +- **State change at release** (approve / confirm) — instead of returning to the rest color, swap to `{successColor}` at `RELEASE_START` and pop a checkmark via a separate `back.out(CHECK_BOUNCE)` tween (1.4–2.0, firmer than the button's bounce — a punctuating "stamp"; pop 0.3–0.6 s) at the same position. The button is now terminal — no further presses expected. -- **{btnBg} / {btnRestColor} / {btnPressedColor}** — primary button surface; pressed darker than rest -- **{btnRestShadow} / {btnPressedShadow}** — rest shadow is large + diffuse; pressed is small + tight (the button "sinks toward the surface") -- **{burstGradient}** — radial; saturated near center, fading to transparent (color should be darker + more saturated than `{btnBg}` — same-color glow looks washed out) -- **{bgGlowGradient}** — full-stage radial, low-opacity tint of `{btnBg}`'s hue family -- **{successColor}** — confirmation green / brand-success for the approve variation +## Values -## Key Principles +| token | range | notes | +| -------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | +| button footprint | ≥ 3–5% of canvas area | a 320×68 button at 1080p is ~1% and the press reads as visually insignificant | +| PRESS_SCALE | 0.88 dramatic · 0.92 default · 0.96 subtle | never <0.85 (broken) or >0.98 (no perceptible dip) | +| PRESS_DUR | 0.10–0.30 s | shorter = snappier; must be shorter than `RELEASE_DUR` (input faster than spring recovery) | +| RELEASE_DUR | 0.40–0.90 s | shorter = tight pop; longer = loose, wobbly settle | +| BOUNCE_FACTOR | 1.4 soft · 2.0 firm · 2.8 cartoony | or `elastic.out(amplitude, period)` for a rubbery oscillation instead of one overshoot | +| RELEASE_START | `= PRESS_START + PRESS_DUR` | adjacency = automatic state continuity | +| BURST_PEAK_SCALE | 3 subtle · 6 default · 8 max | beyond ~8 the radial gradient pixelates visibly | +| BURST_PEAK_OPACITY | 0.4–1.0 | grow ≈ fade, 0.4–0.7 s each; blur 40–100 px (hard ring → ambient haze) | +| BG_GLOW_PEAK_OPACITY | 0.1 subtle · 0.25 default · 0.45 max | higher washes the whole composition; fade-in 0.6–1.0 s; inset −300…−500 px at 1080p | -- **State continuity** — release start value MUST exactly match press end value. With a GSAP timeline, the first tween's end value automatically becomes the second tween's start when they target the same property at adjacent times. -- **Visual weight** — button area should be **≥3-5% of canvas**. Smaller and the press reads as visually insignificant. -- **Linear press, spring release** — the compression is `power1.in/out`, the recovery is `back.out`. Both spring → squishy; both linear → mechanical / no overshoot punch. -- **Anchor compression on center** — `transform-origin: 50% 50%` (default). Otherwise the button collapses asymmetrically. -- **Burst behind, not in front** — burst `z-index: 1`, button `z-index: 2`. If burst sits in front, it occludes the button at peak opacity. -- **Glow color darker + more saturated than element** — bright surface → dark, saturated glow. Same-color glow looks washed out. -- **Don't tween `boxShadow` and `filter` together on the same element** — they compete in the layout pipeline; pick one. Shadow on the button, blur on a separate burst layer. -- **Climax beats need dwell time** — after the burst peak + label/wordmark reveal, the composition must run for **≥1s more** (≥2s for "dramatic" variants) before ending. A reveal at `t=DURATION−0.2s` reads as "flashed and gone." +Color tokens: pressed surface darker than rest; rest shadow large + diffuse, pressed small + tight (the button "sinks toward the surface"); burst gradient darker + more saturated than `{btnBg}` — same-color glow looks washed out; bg glow a low-opacity tint of the button's hue family. ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition`** on the button — those interpolate independently of HF seek and cause flicker -- **`will-change: transform`** if the button compounds with other animation layers -- **`RELEASE_START = PRESS_START + PRESS_DUR`** — adjacency on the same property is what makes state continuity automatic; gap or overlap breaks it -- **Burst max scale ≤ ~8** — beyond that the radial gradient pixelates visibly -- **Background glow `opacity ≤ 0.45`** — higher and it washes the whole composition -- **GSAP transform aliases only**: `x`, `y`, `scale`, `rotation`. Never tween `width` / `height` / `left` / `top`. - -## Combinations - -- [sine-wave-loop.md](sine-wave-loop.md) — idle micro-float on the button BEFORE the press (slight breathing, sells "ready") -- [center-outward-expansion.md](center-outward-expansion.md) — burst of badges outward synced to the press release -- [cursor-click-ripple.md](cursor-click-ripple.md) — cursor click that triggers the press +- **State continuity** — release start value exactly equals press end value; enforced by same-property adjacency at `RELEASE_START = PRESS_START + PRESS_DUR`. +- **Linear press, spring release** — both spring → squishy; both linear → mechanical, no overshoot punch. +- **Anchor compression on center** (`transform-origin: 50% 50%`) or the button collapses asymmetrically. +- **Burst behind, not in front** — burst `z-index: 1`, button `z-index: 2`; in front it occludes the button at peak opacity. +- **Don't tween `boxShadow` and `filter` on the same element** — they compete in the layout pipeline; shadow on the button, blur on the separate burst layer. +- **Climax dwell** — after the burst peak + reveal, the composition must run ≥ 1 s more (≥ 2 s for dramatic variants); a reveal at `t = DURATION − 0.2 s` reads as "flashed and gone." -## Pairs with HF skills +## See also -- `/hyperframes-animation` — `back.out` ease + multi-tween coordination -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`spring-pop-entrance` (the ENTRANCE counterpart — arrival, not reaction) · `physics-press-reaction` (this press with a visible cursor actor) · `cursor-click-ripple` (the cursor click that triggers the press) · `sine-wave-loop` (idle micro-float BEFORE the press) · `center-outward-expansion` (badge burst synced to the release). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/reactive-displacement.md b/plugins/visual-content/skills/hyperframes-animation/rules/reactive-displacement.md index d8c0b0b..ae4b84f 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/reactive-displacement.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/reactive-displacement.md @@ -7,271 +7,86 @@ metadata: # Reactive Displacement -Exit animation of element A is mathematically DERIVED from the entry spring of element B. Creates a causal link: "A moves _because_ B hit it." Distinct from [scale-swap-transition](scale-swap-transition.md) (which overlaps but isn't causal) and [card-morph-anchor](card-morph-anchor.md) (which uses one container morphing dimensions). +Exit animation of element A is mathematically DERIVED from the entry spring of element B — a causal link: "A moves _because_ B hit it." Distinct from [scale-swap-transition.md](scale-swap-transition.md) (which overlaps but isn't causal) and [card-morph-anchor.md](card-morph-anchor.md) (one container morphing). -## How It Works +A single 0→1 driver tween (the "entry spring") feeds three concurrent derived motions in one `onUpdate`: -A single 0→1 driver tween (the "entry spring") feeds two derived motions: +- **Intruder** (B, entering): position interpolated off-stage → settled over the full driver, plus tilt settling to 0° and a sharp early opacity reveal. +- **Victim** (A, exiting): position interpolated settled → off-stage in the OPPOSITE direction, completing at `VICTIM_FRACTION` (~0.4–0.5) of the driver — NOT 1.0. -- **Intruder** (B, entering): position interpolated from off-stage to settled -- **Victim** (A, exiting): position interpolated from settled to off-stage in the OPPOSITE direction, but completing at a fraction `VICTIM_FRACTION` of the driver (not 1.0) +The victim finishing BEFORE the intruder's entry creates the "hit then settle" rhythm; sharing one eased driver makes the impact moment mathematically synchronized. -The fact that the victim's exit finishes BEFORE the intruder's entry creates the "hit then settle" rhythm. Both motions share the same eased driver, so the impact moment is mathematically synchronized. - -## HTML - -```html -
-
-
-
{victimHeadline}
-
{victimSubline}
-
-
-
{intruderHeadline}
-
{intruderSubline}
-
-
-
-``` - -## CSS - -```css -.scene { - position: relative; - width: 100%; - height: 100%; - overflow: hidden; - background: radial-gradient(ellipse at center, {bgColor} 0%, {bgColorDeep} 70%); - font-family: {font}; -} -.stage { - position: absolute; - inset: 0; - display: grid; - place-items: center; -} -.card { - position: absolute; - /* both at center; transform translates them */ - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 24px; - padding: 64px 80px; - border-radius: 28px; - will-change: transform, opacity; -} -.victim { - background: linear-gradient(160deg, {victimTint} 0%, {bgColorDeep} 70%); - border: 1px solid {victimTint}; - z-index: 1; -} -.intruder { - background: linear-gradient(160deg, {intruderTint} 0%, {bgColorDeep} 70%); - border: 2px solid {intruderBorder}; - box-shadow: 0 28px 96px {intruderTint}; - z-index: 2; -} -.card-title { - font-size: 200px; - font-weight: 900; - color: {textColor}; - line-height: 1; - letter-spacing: -4px; -} -.card-sub { - font-size: 36px; - font-weight: 800; - letter-spacing: 10px; - text-transform: uppercase; - color: {accentColor}; - text-align: center; -} -``` - -## GSAP Timeline - -```html - - -``` - -## How to Choose Values - -- **DRIVER_AT** — when the entry spring begins - - Range: phase-dependent (typically a few seconds in) - - Effects: too early skips setup beats; too late stalls the cut - - Constraints: must allow ≥ DWELL_MIN of climax dwell before composition ends - - Reference: example schedules the displacement after the prior reading beat resolves - -- **DRIVER_DUR** — full intruder entry duration - - Range: 0.6-1.4 s - - Effects: short = zippy/punchy impact; long = heavy/landed impact - - Constraints: tune against `BOUNCE_FACTOR` — higher bounce on long durations reads as floaty - - Reference: see the corresponding blueprint / example - -- **BOUNCE_FACTOR** — `back.out()` coefficient on the intruder spring - - Range: 1.2-2.0 (discrete choice within `back.out` family) - - Effects: low ≈ firm settle; high ≈ overshoot/bounce - - Constraints: ease family stays `back.out` (or upgrade to `elastic.out` if you want oscillation); changing family rewrites the feel - - Reference: examples typically sit between 1.4 and 1.6 - -- **VICTIM_FRACTION** — fraction of `DRIVER_DUR` over which the victim completes its exit - - Range: 0.4-0.5 - - Effects: < 0.4 victim disappears before impact reads; > 0.5 motion feels parallel, not causal - - Constraints: hard upper limit ~0.6; beyond that the collision metaphor breaks - - Reference: this rule's pattern uses ~0.5 - -- **STAGE_W** — stage width in pixels, used to place elements off-stage - - Range: equal to the composition's `data-width` - - Effects: smaller values leave the off-stage element partially visible at start - - Constraints: must be ≥ composition width - - Reference: examples use the project's render width directly - -- **INTRUDER_TILT** — initial rotation (degrees) the intruder rotates from as it settles to 0° - - Range: 5-15° - - Effects: low = clean glide; high = visible "spin-and-plant" - - Constraints: keep sign consistent with entry direction (matches momentum transfer) - - Reference: ~10° is a typical mid-impact tilt - -- **FADE_IN_SHARPNESS** — multiplier controlling how quickly intruder opacity reaches 1 - - Range: 3-8 (intruder reaches opacity 1 at `1/FADE_IN_SHARPNESS` of progress) - - Effects: low = soft fade alongside motion; high = pops in early and reads as solid - - Constraints: > 1; below 1 means intruder is still transparent at center - - Reference: most examples use a sharp early reveal - -- **DWELL_MIN** — minimum climax dwell after the intruder settles - - Range: ≥ 1.0 s - - Effects: shorter feels rushed and unreadable; longer stalls the comp - - Constraints: post-impact dwell is where the new content gets read — do not skip - - Reference: 1.0-1.5 s is typical - -## Variations - -### Impact rotation on victim - -The victim doesn't just slide off — it ALSO rotates from the impact angle: +## Recipe ```js -const victimRot = victimP * -VICTIM_KICK_DEG; // rotates as it slides -victim.style.transform = `translate(-50%, -50%) translateX(${victimX}px) rotate(${victimRot}deg)`; -``` - -`VICTIM_KICK_DEG` is typically 15-25°; pick magnitude to match the perceived intruder weight. - -### Vertical collision - -Intruder enters from top, victim displaced downward. Same math with Y instead of X. Visual feels like "weight dropped on it." - -### Wobble after settle +// Both cards absolutely centered; overflow: hidden on the scene (off-stage travel); +// will-change: transform, opacity on both; intruder z-index ABOVE victim. +const INTRUDER_START_X = STAGE_W; // off-stage right +const VICTIM_END_X = -STAGE_W; // off-stage left — SAME axis, opposite direction -After the intruder centers, a damped sine wobble (`±WOBBLE_AMP_DEG` rotation, decaying over `WOBBLE_DUR`) before stillness. Adds "impact aftermath" before climax dwell. +gsap.set("#victim", { x: 0, opacity: 1, rotation: 0 }); +gsap.set("#intruder", { x: INTRUDER_START_X, opacity: 0, rotation: -INTRUDER_TILT }); -```js -const wobble = { p: 0 }; +const driver = { p: 0 }; tl.to( - wobble, + driver, { - p: Math.PI * WOBBLE_CYCLES * 2, - duration: WOBBLE_DUR, - ease: "none", + p: 1, + duration: DRIVER_DUR, + ease: `back.out(${BOUNCE_FACTOR})`, // the intruder spring onUpdate: () => { - const rot = - Math.sin(wobble.p) * WOBBLE_AMP_DEG * (1 - wobble.p / (Math.PI * WOBBLE_CYCLES * 2)); // linear decay - intruder.style.transform = `translate(-50%, -50%) rotate(${rot}deg)`; + // Intruder: full 0→1 progress maps enter (off-stage → center) + const intruderX = INTRUDER_START_X * (1 - driver.p); + const intruderOpacity = Math.min(1, driver.p * FADE_IN_SHARPNESS); + const intruderRot = -INTRUDER_TILT * (1 - driver.p); // settles to 0° + const intruder = document.getElementById("intruder"); + intruder.style.transform = `translate(-50%, -50%) translateX(${intruderX}px) rotate(${intruderRot}deg)`; + intruder.style.opacity = String(intruderOpacity); + + // Victim: completes its exit at VICTIM_FRACTION of the driver — by the + // time the intruder centers, the victim is already off-stage. + const victimP = Math.min(1, driver.p / VICTIM_FRACTION); + const victimX = VICTIM_END_X * victimP; + const victim = document.getElementById("victim"); + victim.style.transform = `translate(-50%, -50%) translateX(${victimX}px)`; + victim.style.opacity = String(1 - victimP); }, }, - DRIVER_AT + DRIVER_DUR, + DRIVER_AT, ); +// Climax dwell — intruder holds centered for ≥ DWELL_MIN before the scene ends. ``` -### Multi-victim ripple +## Variations -Intruder displaces multiple aligned cards, each victim getting a slightly delayed exit (cascade ripple). Each victim's `victimP` uses a different driver phase offset. +- **Impact rotation on victim** — the victim also rotates as it slides: `const victimRot = victimP * -VICTIM_KICK_DEG;` appended to its transform. `VICTIM_KICK_DEG` 15–25°, magnitude matched to the perceived intruder weight. +- **Vertical collision** — intruder from top, victim displaced downward; same math on Y. Reads as "weight dropped on it." +- **Wobble after settle** — after the intruder centers, a damped sine wobble (`±WOBBLE_AMP_DEG` rotation, linearly decaying over `WOBBLE_DUR` via a second `ease: "none"` driver at `DRIVER_AT + DRIVER_DUR`) before stillness — "impact aftermath." +- **Multi-victim ripple** — the intruder displaces multiple aligned cards, each victim's `victimP` on a slightly offset driver phase (cascade ripple). -## Key Principles +## Values -- **Single driver = single source of truth** — the entry spring drives BOTH motions. Independent tweens for intruder and victim destroy the causal link; they'd just happen to be near each other in time, not collided. -- **Victim completes at a fraction of driver** — by the time the intruder reaches center, the victim is GONE. The "hit" is the moment they overlap; after that the victim is just exiting space the intruder will fill. -- **Directional momentum transfer** — intruder from positive X → victim moves negative X. Same axis. If they move on different axes, it looks like they passed each other, not collided. -- **Intruder z-index ABOVE victim** — during overlap, the intruder should appear in FRONT (it's the "winner" of the collision). Otherwise the victim looks like it tunneled through. -- **Intruder enters with rotation, settles flat** — adds momentum visualization. A small initial tilt → 0° at settle reads as "spinning in then planting." -- **Climax dwell after impact** — the impact is the headline beat. Post-impact dwell is where the new content gets read. +| token | range | notes | +| ----------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- | +| DRIVER_AT | phase-dependent | after the prior reading beat resolves; must leave ≥ DWELL_MIN of climax dwell before the scene ends | +| DRIVER_DUR | 0.6–1.4 s | short = zippy punch, long = heavy landed impact; higher bounce on long durations reads as floaty | +| BOUNCE_FACTOR | 1.2–2.0 (typ. 1.4–1.6) | stay in the `back.out` family (or `elastic.out` for oscillation) — changing family rewrites the feel | +| VICTIM_FRACTION | 0.4–0.5 | <0.4 the victim disappears before the impact reads; >0.5 feels parallel, not causal; hard cap ~0.6 | +| STAGE_W | ≥ composition width | smaller leaves the off-stage element partially visible at start | +| INTRUDER_TILT | 5–15° (typ. ~10°) | low = clean glide, high = "spin-and-plant"; sign consistent with entry direction (momentum transfer) | +| FADE_IN_SHARPNESS | 3–8 | intruder reaches opacity 1 at `1/FADE_IN_SHARPNESS` of progress; must be > 1 or it's transparent at center | +| DWELL_MIN | ≥ 1.0 s (typ. 1.0–1.5) | post-impact dwell is where the new content gets read — do not skip | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **Single driver, multiple derived values in same onUpdate** — don't tween intruder and victim with separate `tl.to()` calls; use ONE driver and compute both inside its onUpdate -- **`overflow: hidden` on `.scene`** — off-stage motion exceeds the frame -- **`will-change: transform, opacity`** on both cards -- **Intruder z-index > victim z-index** — explicit, not relying on DOM order alone - -## Combinations - -- [hacker-flip-3d.md](hacker-flip-3d.md) — intruder text reveals via hacker-flip during the entry phase -- [sine-wave-loop.md](sine-wave-loop.md) — idle breathing on intruder during climax dwell -- [vertical-spring-ticker.md](vertical-spring-ticker.md) — intruder is a ticker that "shoves" the previous content out +- **Single driver = single source of truth** — both motions computed inside ONE driver's `onUpdate`, never separate `tl.to()` calls per element; independent tweens destroy the causal link (they'd merely be near each other in time). +- **Victim completes at a fraction of the driver** — the "hit" is the overlap moment; after it the victim is just vacating space the intruder will fill. +- **Directional momentum transfer** — same axis, opposite directions; different axes read as passing, not colliding. +- **Intruder z-index above victim** — explicit, not DOM order; otherwise the victim looks like it tunneled through. +- **Intruder enters tilted, settles flat** — small initial tilt → 0° reads as "spinning in then planting." +- **Climax dwell after impact** — the impact is the headline beat; hold the settled intruder ≥ DWELL_MIN. +- **`overflow: hidden` on the scene** — off-stage motion exceeds the frame. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — single driver, multi-value onUpdate -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`control-target-sync` (the live-editing mirror — repeated coupled edits, nothing exits) · `hacker-flip-3d` (intruder text reveal during entry) · `sine-wave-loop` (idle breathing during the dwell) · `vertical-spring-ticker` (a ticker that "shoves" the previous content out). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/scale-swap-transition.md b/plugins/visual-content/skills/hyperframes-animation/rules/scale-swap-transition.md index 799e93f..cc35054 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/scale-swap-transition.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/scale-swap-transition.md @@ -7,292 +7,82 @@ metadata: # Scale-Swap Transition -Simulates a "morph" between two DOM elements by overlapping exit and entrance scale animations. Lighter weight than [card-morph-anchor](card-morph-anchor.md) (which morphs container dimensions) and easier than SVG path interpolation. +Simulates a "morph" between two DOM elements by overlapping exit and entrance scale animations. Lighter weight than [card-morph-anchor.md](card-morph-anchor.md) (which morphs container dimensions — use that for SHAPE changes; this rule is for SAME-shape state swaps) and easier than SVG path interpolation. -## How It Works +At a single trigger, two coordinated tweens fire: -At a single trigger time, two coordinated tweens fire: +1. **Outgoing**: scale `1.0 → EXIT_SCALE` + opacity `1 → 0`, fast `power2.in` (rushing away). +2. **Incoming**: scale `EXIT_SCALE → 1.0` + opacity `0 → 1`, `back.out(BOUNCE_FACTOR)` (arriving with weight). -1. **Outgoing element**: scale `1.0 → EXIT_SCALE` + opacity `1 → 0` (fast `power2.in`) -2. **Incoming element**: scale `EXIT_SCALE → 1.0` + opacity `0 → 1` (bouncy `back.out(${BOUNCE_FACTOR})` with overshoot) +A small `OVERLAP` window during which both are mid-tween creates the morph illusion; the incoming sits on top via z-index so the outgoing's fade-tail doesn't bleed through. -A small `OVERLAP` window during which both are mid-tween creates the "morph" illusion. Incoming sits on top via z-index so the outgoing's fade-tail doesn't bleed through. - -## HTML +## Recipe ```html -
-
-
-
-
{outgoingIcon}
-
{outgoingLabel}
-
-
-
{incomingIcon}
-
{incomingLabel}
-
{incomingSubline}
-
-
-
{Brand}
+ +
+
{outgoingIcon} {outgoingLabel}
+
+ {incomingIcon} {incomingLabel} +
{incomingSubline}
``` -## CSS - -```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {sceneBg}; - font-family: {font}; -} -.stack { - display: flex; - flex-direction: column; - align-items: center; - gap: STACK_GAP; -} -.swap-wrap { - position: relative; - width: SWAP_WRAP_W; - height: SWAP_WRAP_H; -} -.card { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: CARD_INNER_GAP; - border-radius: CARD_RADIUS; - padding: CARD_PADDING; - /* Both elements share transform-origin so they "morph" around the same anchor */ - transform-origin: 50% 50%; - will-change: transform, opacity; -} -.card .icon { - font-size: ICON_SIZE; -} -.card .title { - font-size: TITLE_SIZE; - font-weight: 900; - letter-spacing: TITLE_TRACKING; - text-transform: uppercase; -} -.card .sub { - font-size: SUB_SIZE; - font-weight: 700; - color: {accentColor}; - opacity: 0; -} -.outgoing { - z-index: 1; - background: {outgoingBg}; - border: 1px solid {outgoingBorder}; - color: {textColor}; -} -.incoming { - /* Incoming starts hidden + smaller, will pop in */ - z-index: 2; - background: {incomingBg}; - border: 1px solid {incomingBorder}; - color: {textColor}; - opacity: 0; - transform: scale(EXIT_SCALE); -} -.brand { - font-size: BRAND_SIZE; - font-weight: 900; - letter-spacing: BRAND_TRACKING; - text-transform: uppercase; - color: {brandColor}; -} -``` - -## GSAP Timeline - -```html - - -``` - -## Variations - -### Delayed inner content reveal - -The classic pattern: morph the container, then reveal inner text once the container has settled (as in the example above with `.sub`). The 0.2-0.4s gap between morph end and content reveal lets the viewer's eye land on the new container shape before reading the content. - -### Triple swap (3-state cycle) - -Chain: A→B→C with two triggers `TRIGGER_AB` and `TRIGGER_BC`. Each transition needs its own pair of tweens, and the previous incoming becomes the next outgoing. Useful for state evolution narratives (e.g. early-state → mid-state → final-state labels). - ```js -tl.to("#stateA", { scale: EXIT_SCALE, opacity: 0, duration: EXIT_DUR }, TRIGGER_AB); +// Outgoing: shrink + fade fast tl.to( - "#stateB", - { scale: 1.0, opacity: 1, duration: ENTER_DUR, ease: `back.out(${BOUNCE_FACTOR})` }, - TRIGGER_AB + EXIT_DUR - OVERLAP, + "#outgoing", + { scale: EXIT_SCALE, opacity: 0, duration: EXIT_DUR, ease: "power2.in" }, + TRIGGER, ); -tl.to("#stateB", { scale: EXIT_SCALE, opacity: 0, duration: EXIT_DUR }, TRIGGER_BC); + +// Incoming: pops in with overshoot, starting OVERLAP before the exit finishes tl.to( - "#stateC", + "#incoming", { scale: 1.0, opacity: 1, duration: ENTER_DUR, ease: `back.out(${BOUNCE_FACTOR})` }, - TRIGGER_BC + EXIT_DUR - OVERLAP, + TRIGGER + EXIT_DUR - OVERLAP, ); -``` - -### Color-shift transition (no scale) - -For a flat morph between two same-shape states, drop the scale and keep only opacity + a brief background hue tween. Less dramatic but matches a more product-UI tone. - -## How to Choose Values - -### Timing (seconds) - -- **TRIGGER** — when the swap fires. - - Constraints: must be ≥ the outgoing element's settled time + a presence-dwell so the outgoing "lands" before transforming -- **EXIT_DUR** — outgoing shrink + fade duration. - - Range: 0.3-0.5 s -- **ENTER_DUR** — incoming pop-in duration. - - Range: 0.45-0.7 s (longer than `EXIT_DUR` to let the overshoot settle) -- **OVERLAP** — how much the entrance starts before the exit finishes. - - Range: 0.1-0.2 s - - Constraints: too much (>0.3 s) makes both clearly visible together (no morph); too little (<0.05 s) leaves a visible empty gap -- **SUB_REVEAL_DELAY** — gap between incoming settle and subline reveal. - - Range: 0.2-0.4 s; reveals during the morph compete with the swap for attention -- **SUB_REVEAL_DUR** — subline fade-in. - - Range: 0.3-0.5 s -- **BRAND_REVEAL_AT** — when the brand/context line fades in. - - Constraints: must be < `TRIGGER` (brand is context for the swap, not synchronous with it) -- **BRAND_REVEAL_DUR** — brand fade-in duration. - - Range: 0.4-0.8 s - -### Physics -- **EXIT_SCALE** — target scale for outgoing (and starting scale for incoming). - - Range: 0.6-0.8; smaller exits feel more dramatic but risk reading as "vanish" instead of "morph" -- **BOUNCE_FACTOR** — `back.out(${BOUNCE_FACTOR})` overshoot on the incoming. - - Range: 1.4 (soft) - 1.8 (firm) - 2.2 (cartoony) - -### Positioning offsets - -- **SUB_REVEAL_Y_PX** — subline initial y offset (positive = below resting). - - Range: 8-20 px -- **BRAND_REVEAL_Y_PX** — brand initial y offset. - - Range: 10-24 px - -### Layout - -- **STACK_GAP** — gap between swap container and brand line. - - Range: 40-96 px -- **SWAP_WRAP_W / SWAP_WRAP_H** — fixed swap container dimensions; both cards `inset: 0` inside. - - Constraints: pick dimensions that fit both states' content; the wrap does not resize during the swap -- **CARD_INNER_GAP** — gap between icon and title inside a card. - - Range: 16-32 px -- **CARD_RADIUS / CARD_PADDING** — card corner radius and inner padding. - - Range: radius 24-40 px; padding 32-64 px -- **ICON_SIZE / TITLE_SIZE / SUB_SIZE / BRAND_SIZE** — typographic sizes. - - Constraints: titles dominate (~80-120 px at 1080p); sub and brand are accent-sized -- **TITLE_TRACKING / BRAND_TRACKING** — letter-spacing on uppercase labels. - - Range: 4-16 px (uppercase reads better with positive tracking) +// Inner content reveals AFTER the incoming settles +tl.fromTo( + "#sub", + { opacity: 0, y: SUB_REVEAL_Y_PX }, + { opacity: 1, y: 0, duration: SUB_REVEAL_DUR, ease: "power3.out" }, + TRIGGER + EXIT_DUR + SUB_REVEAL_DELAY, +); +``` -### Tokens +## Variations -- **{sceneBg}** — background gradient/color -- **{font}** — typographic stack -- **{textColor}** / **{accentColor}** / **{brandColor}** — semantic color tokens -- **{outgoingBg}** / **{outgoingBorder}** — outgoing card surface + border (typically warm or pre-action hue) -- **{incomingBg}** / **{incomingBorder}** — incoming card surface + border (typically cool or post-action hue) -- **{outgoingIcon}** / **{incomingIcon}** — single glyph/emoji per state -- **{outgoingLabel}** / **{incomingLabel}** — state labels -- **{incomingSubline}** — supporting copy that fades in after the incoming settles -- **{Brand}** — brand line shown beneath the swap +- **Delayed inner content reveal** — the classic pattern above: morph the container, then reveal inner text once it settles; the 0.2–0.4 s gap lets the eye land on the new shape before reading. +- **Triple swap (3-state cycle)** — chain A→B→C with triggers `TRIGGER_AB` / `TRIGGER_BC`; each transition is its own tween pair, the previous incoming becoming the next outgoing. State-evolution narratives (early → mid → final labels). +- **Color-shift transition (no scale)** — for a flat morph between same-shape states, drop the scale and keep opacity + a brief background hue tween; less dramatic, more product-UI tone. -## Key Principles +## Values -- **Incoming z-index ABOVE outgoing** — without this, the outgoing's fade-tail (opacity 0.3-0.5) bleeds through the incoming's lower opacity and creates a "double-exposed" muddy frame -- **Both elements share `transform-origin: 50% 50%`** — different origins make the morph feel like one thing teleporting somewhere else -- **`OVERLAP` in the 0.1-0.2 s window** — too much overlap and both are clearly visible together (no morph); too little and there's a visible empty gap -- **Bouncy ease ONLY for the incoming** — outgoing uses `power2.in` (rushing away), incoming uses `back.out(${BOUNCE_FACTOR})` (arriving with weight). Reverse it and the swap feels mechanical -- **Inner content reveals AFTER container settles** — see `SUB_REVEAL_DELAY`. Reveals during the morph compete for attention and lose -- **Climax dwell ≥1 s after final state lands** — see SKILL universal constraints. After incoming + subline both settle, hold for ≥1 s -- **Brand reveal early, not at the swap** — context (brand, eyebrow) sets the stage; the swap is the headline. If brand reveals AT the swap, it competes +| token | range | notes | +| ---------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| TRIGGER | ≥ outgoing settled + a presence-dwell | the outgoing must "land" before transforming | +| EXIT_DUR | 0.3–0.5 s | | +| ENTER_DUR | 0.45–0.7 s | longer than `EXIT_DUR` so the overshoot can settle | +| OVERLAP | 0.1–0.2 s | >0.3 s both are clearly visible together (no morph); <0.05 s leaves a visible empty gap | +| EXIT_SCALE | 0.6–0.8 | smaller exits feel dramatic but risk reading as "vanish" instead of "morph" | +| BOUNCE_FACTOR | 1.4 soft · 1.8 firm · 2.2 cartoony | | +| SUB_REVEAL_DELAY | 0.2–0.4 s | reveals during the morph compete with the swap for attention | +| BRAND_REVEAL_AT | < TRIGGER | context (brand, eyebrow) sets the stage early; revealed AT the swap it competes with the headline beat | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition`** on either swap element — competes with GSAP -- **`will-change: transform, opacity`** on both swap elements -- **Both elements use `position: absolute; inset: 0`** in the same wrapper — they occupy the same footprint, swap fades one out and pops one in -- **Don't `display: none` the outgoing** after fade — leave it at `opacity: 0` so layout doesn't reflow - -## Combinations - -- [press-release-spring.md](press-release-spring.md) — button press TRIGGERS the swap (cause and effect) -- [sine-wave-loop.md](sine-wave-loop.md) — idle breathing on the final state -- [card-morph-anchor.md](card-morph-anchor.md) — alternative for SHAPE-changing transitions (this rule is for SAME-shape state swaps) +- **Incoming z-index ABOVE outgoing** — otherwise the outgoing's fade-tail (opacity 0.3–0.5) bleeds through and double-exposes the frame. +- **Both elements share `transform-origin: 50% 50%`** — different origins make the morph read as one thing teleporting elsewhere. +- **Bouncy ease ONLY on the incoming** — outgoing `power2.in`, incoming `back.out`; reversed, the swap feels mechanical. +- **Both cards `position: absolute; inset: 0`** in the same fixed-size wrapper (sized to fit both states; the wrap never resizes). +- **Don't `display: none` the outgoing** after the fade — leave it at `opacity: 0` so layout doesn't reflow. +- **Inner content reveals after the container settles**; **climax dwell ≥ 1 s** after the final state + subline land. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — two coordinated tweens with overlap -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`press-release-spring` (a button press TRIGGERS the swap — cause and effect) · `card-morph-anchor` (shape-changing alternative) · `reactive-displacement` (when the replacement should read as a causal collision) · `sine-wave-loop` (idle breathing on the final state). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/sine-wave-loop.md b/plugins/visual-content/skills/hyperframes-animation/rules/sine-wave-loop.md index 81aff5e..6619603 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/sine-wave-loop.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/sine-wave-loop.md @@ -7,272 +7,79 @@ metadata: # Sine Wave Loop (subtle jitter / bounded ambient) -> **Reach for this last.** Per the motion doctrine (`references/motion-language.md`): **circular breathing — scaling text/cards up and down to look "alive" — is cheap, the agent's reflexive cheat, and reads weak.** "I'd rather have NO motion than BAD motion." Before using this rule to keep a held frame alive, prefer (1) **sequential reveal timed to the voiceover** — reveal the next line/element when the VO says it, across the back ~50% of the scene; that, not ambient motion, is what fills a shot. If a frame has genuinely settled and still needs a touch of life, the **sanctioned move is subtle jitter** — a small, low-amplitude jitter (this rule, at the LOW end of its amplitude range). A full breathing loop is reserved for the rare case where a single held hero genuinely needs bounded ambient; keep it de-emphasized and small. +> **Reach for this last.** Per the motion doctrine (`references/motion-language.md`): circular breathing — scaling text/cards up and down to look "alive" — is cheap, the agent's reflexive cheat, and reads weak. "I'd rather have NO motion than BAD motion." First fill the back of a shot with **sequential reveal timed to the VO**; if a frame has genuinely settled and still needs life, the **sanctioned move is subtle jitter** — this rule at the LOW end of its amplitude range. A full breathing loop is the rare last resort on a single held hero, never stamped on every element. -Keeps a settled element from feeling dead — as **subtle jitter** or, rarely, a single bounded ambient breath — using `Math.sin` driven by a finite timeline tween. This is the implementation behind the "subtle jitter" move in the motion vocabulary; it is **not** a license to breathe every hero. +Keeps a settled element from feeling dead using `Math.sin` on the timeline clock. Two forms: -## How It Works +- **Yoyo form** — one `sine.inOut` tween with `yoyo: true` and a **finite** `repeat` count. Preferred when the idle stands alone on a property nothing else touches. +- **onUpdate form** — one long `ease: "none"` tween drives a `phase` proxy `0 → 2π·CYCLES`; `onUpdate` maps `Math.sin(phase)` into the transform. Required when the offset multiplies/adds onto another live value (compound transforms, amplitude envelopes, multi-octave). -A long tween advances a `phase` value from 0 → 2π (or 0 → some multiple thereof). On every onUpdate, the phase feeds into `Math.sin()` to produce a small periodic offset added to the element's transform (`scale`, `translateY`, `rotate`). +Either way, idle begins where the entry settled: at `phase = 0`, `sin(0) = 0` — the offset is zero, so there is no jump from the entry's resting state. -The trick to a "no jump" transition from entry to idle: at `phase = 0`, `sin(0) = 0` — the offset is zero, so the element starts at its post-entry resting state. - -## HTML - -```html -
-
-
{HeroLabel}
-
-
-
-``` - -## CSS - -```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgGradient}; -} -.stack { - display: flex; - flex-direction: column; - align-items: center; - gap: STACK_GAP; -} -.hero { - font-family: {font}; - font-weight: 900; - font-size: HERO_FONT_SIZE; - letter-spacing: HERO_LETTER_SPACING; - color: {textColor}; - text-transform: uppercase; - /* Element gets its post-entry resting transform; idle only ADDS to it */ - will-change: transform; -} -.dot { - width: DOT_SIZE; - height: DOT_SIZE; - border-radius: 50%; - background: {accentColor}; - box-shadow: {accentGlow}; -} -``` - -## GSAP Timeline - -```html - - -``` - -## Variations - -### Multiple offset frequencies (organic multi-octave breathing) - -Combining frequencies feels more alive than pure sine: - -```js -const primary = Math.sin(phase.p) * SCALE_AMP_PRIMARY; -const secondary = Math.sin(phase.p * OCTAVE_RATIO) * SCALE_AMP_SECONDARY; // higher-frequency overlay -const scale = 1 + primary + secondary; -``` - -### Conditional activation (only after entry settles) - -If entry is interactive or skippable, gate the idle: - -```js -const idleActive = entryProgress >= GATE_THRESHOLD; -const scale = idleActive ? 1 + Math.sin((time - IDLE_START_TIME) / PERIOD) * SCALE_AMP : 1; -``` - -### Settle and fade (long-idle gate — strongly recommended when `IDLE_DUR > 6s`) - -Drive amplitude through an envelope that fades to zero over the last ~20% of idle, so the scene visibly settles before the inter-scene transition lands: +## Recipe ```js +// onUpdate form — phase-driven, composable. const phase = { p: 0 }; -const FADE_FRAC = 0.2; // last 20% of idle = amplitude ramps to 0 tl.to( phase, { p: Math.PI * 2 * CYCLES, duration: IDLE_DUR, - ease: "none", + ease: "none", // sine provides the easing; a non-linear phase tween distorts the wave onUpdate: () => { - const t = phase.p / (Math.PI * 2 * CYCLES); // 0 → 1 across idle - const env = t < 1 - FADE_FRAC ? 1 : (1 - t) / FADE_FRAC; // 1 → 0 in tail - const scale = 1 + Math.sin(phase.p) * SCALE_AMP * env; - const y = Math.sin(phase.p) * Y_AMP_PX * env; - hero.style.transform = `translateY(${y}px) scale(${scale})`; + const s = Math.sin(phase.p); + hero.style.transform = `translateY(${s * Y_AMP_PX}px) scale(${1 + s * SCALE_AMP})`; + // secondary elements: offset by Math.PI / 2 — synced motion looks mechanical + dot.style.transform = `scale(${1 + Math.sin(phase.p + Math.PI / 2) * DOT_SCALE_AMP})`; }, }, IDLE_START_TIME, ); -``` -The element is in motion for the first 80% of idle, then comes to rest in the last 20%. Pairs naturally with break-boundary Tier-B transitions (the outgoing visual is static when the crossfade/push begins). +// Yoyo form — standalone property, finite repeats. +tl.to( + "#badge", + { y: -Y_AMP_PX, duration: PERIOD / 2, ease: "sine.inOut", yoyo: true, repeat: REPEATS }, + IDLE_START_TIME, +); +``` -### Period vs cycle math +## Variations -For an exact cycle of N seconds: +- **Multi-octave** (organic): stack a higher-frequency overlay — `1 + Math.sin(p) * AMP_PRIMARY + Math.sin(p * OCTAVE_RATIO) * AMP_SECONDARY`, with `AMP_SECONDARY < AMP_PRIMARY` and the combined max inside the normal SCALE_AMP range. +- **Settle and fade** (strongly recommended when `IDLE_DUR > 6s`): ramp amplitude to zero over the last ~20% of idle so the scene visibly settles before the inter-scene transition, instead of handing off mid-drift: ```js -const divisor = (idleDurationSec * fps) / (Math.PI * 2); -const value = Math.sin(frame / divisor) * amplitude; +const t = phase.p / (Math.PI * 2 * CYCLES); // 0 → 1 across idle +const env = t < 1 - FADE_FRAC ? 1 : (1 - t) / FADE_FRAC; // FADE_FRAC ≈ 0.2 +const scale = 1 + Math.sin(phase.p) * SCALE_AMP * env; ``` -For HF (`onUpdate` doesn't expose frame directly), use the tween's `phase` value: drive `p: Math.PI * 2 * cyclesWanted` over `duration: idleDurationSec`. - -## How to Choose Values +This is the single biggest fix when finalize snapshots show "everything's still moving at the end"; it pairs naturally with break-boundary transitions (the outgoing visual is static when the crossfade/push begins). -### Layout / typography +## Values -- **STACK_GAP** — vertical gap between hero and dot. - - Range: 0.2-0.4× `HERO_FONT_SIZE` -- **HERO_FONT_SIZE / HERO_LETTER_SPACING** — typographic emphasis. - - Range: 100-240 px for full-bleed compositions; spacing 0.04-0.06em -- **DOT_SIZE** — accent indicator size. - - Range: ~0.15-0.25× `HERO_FONT_SIZE` so the dot reads as accent, not a peer -- **{accentGlow}** — `box-shadow` halo on the dot; typically `0 0 (DOT_SIZE) rgba(accentColor, 0.5-0.7)` - -### Entry phase - -- **ENTRY_Y / ENTRY_SCALE** — initial state before fade-up. - - Range: `ENTRY_Y` 16-32 px (subtle rise), `ENTRY_SCALE` 0.94-0.98 (subtle inflation) -- **ENTRY_DUR** — hero fade-up duration. - - Range: 0.6-1.2s; bigger heroes want a longer settle -- **DOT_ENTRY_START** — when the dot pops in relative to hero. - - Constraints: typically `≈ 0.4-0.6× ENTRY_DUR` so the dot lands while the hero is still settling, not after -- **DOT_ENTRY_DUR** — dot back-out pop duration. - - Range: 0.4-0.7s -- **BOUNCE_FACTOR** — `back.out(BOUNCE_FACTOR)` overshoot strength on the dot pop. - - Range: 1.4 (soft) → 2.0 (firm) → 2.8 (cartoony) - -### Idle phase - -- **IDLE_START_TIME** — when breathing begins. - - Constraints: `≥ ENTRY_DUR + small buffer (~0.1s)` so the breath doesn't fight the entry tail. `sin(0) = 0` at this moment, so the offset is exactly the entry's resting state — no jump -- **IDLE_DUR** — breath tween length. - - Constraints: must equal `TOTAL_DURATION − IDLE_START_TIME` to fill the composition with motion -- **CYCLES** — number of full breath cycles across `IDLE_DUR`. - - Range: `IDLE_DUR / 3s ≤ CYCLES ≤ IDLE_DUR / 1.5s` (cycle period 1.5-3s reads as natural breathing) -- **SCALE_AMP** — sine amplitude on scale (hero). - - **Default: 0.008-0.015** (barely-perceptible breath — the right answer for most scenes) - - Push to 0.02-0.04 only when the element is **alone on canvas**, the scene is **short (< 6s)**, or the brief explicitly calls for **kinetic / playful** register - - See Key Principles for the long-idle / concurrent-element scaling rules -- **Y_AMP_PX** — sine amplitude on y translation (hero). - - **Default: 2-3 px** (barely-perceptible — the right answer for most scenes) - - Push to 4-6 px only when isolated / short / kinetic — same gating as `SCALE_AMP` -- **DOT_SCALE_AMP** — sine amplitude on dot scale (offset by π/2 for out-of-phase motion). - - Range: 0.04-0.12 — larger than hero amplitude is fine because the dot is a small accent -- **PERIOD** (conditional-activation variation) — seconds per cycle when using the `(time - IDLE_START_TIME) / PERIOD` form. - - Range: 1.5-3s -- **GATE_THRESHOLD** (conditional-activation variation) — entryProgress required to start idle. - - Range: 0.85-1.0; lower gates start idle slightly before entry completes for an overlap - -### Multi-octave variation - -- **SCALE_AMP_PRIMARY / SCALE_AMP_SECONDARY** — amplitudes of the two stacked sines. - - Constraints: `SCALE_AMP_PRIMARY > SCALE_AMP_SECONDARY` (secondary is a higher-frequency overlay, not a peer); combined max amplitude should stay within the SCALE_AMP range above -- **OCTAVE_RATIO** — frequency multiplier of secondary relative to primary. - - Range: 2.0-4.0 (whole-number-ish ratios feel musical/coherent; non-integer ratios feel organic/unpredictable) - -### Color tokens - -- **{bgGradient}** — typically a dark radial gradient so the lit hero pops -- **{textColor}** — high-contrast against `{bgGradient}` -- **{accentColor}** — single accent reserved for the dot; the glow color in `{accentGlow}` is the same hue - -## Key Principles - -- **Prefer reveal, then jitter, then breath** — circular breathing as "aliveness" is cheap and reads weak; "no motion over bad motion." First fill the back of a shot with **sequential reveal timed to the VO**; if it's genuinely settled and still feels dead, use this rule at the **LOW end of its amplitude range as subtle jitter**; a full breathing loop is the rare last resort on a single held hero, never stamped on every element. -- **`sin(0) = 0`** — at the moment idle begins, the offset must be zero so there's no visible jump from the entry's settled state to idle. Start the phase tween at `phase = 0`. -- **Amplitude subtlety — default to the LOW end of the range.** Scale `0.008-0.015` (push to 0.02-0.04 only when isolated / short scene / kinetic brief), rotation `±0.3-0.8°` (rarely needed at all), translation `±2-3px` (push to 4-6px only when isolated). Bigger and idle reads as "still animating" instead of "alive but resting" — and a viewer watching 5+ consecutive scenes at the upper end will read the whole film as "shimmering." -- **Cycle duration: 2.5-4s per breath when idle is long, 1.5-3s otherwise** — 2.5-3s is a comfortable breathing cadence; under 1.5s feels frantic in a long-idle window; over 4s feels lifeless in a short one. -- **Long idle window (`IDLE_DUR > 6s` OR idle proportion > 30% of composition):** halve `SCALE_AMP` and `Y_AMP_PX`, slow `CYCLES` so each breath is 3-4s. Consider gating amplitude to fade to zero over the last ~20% of idle so the scene actually **settles before the transition**, instead of handing off mid-drift. This is the single biggest fix when finalize snapshots show "everything's still moving at the end." -- **Concurrent idle on N elements** (triptych columns, card grid, multi-stat row, side-by-side panels): per-element amplitude ≤ default `/ √N`. Three columns each at `±6px` visually adds to `±18px+` of competing motion; three at `±2-3px` reads as one collective breath. Stagger the **period** between elements (2.1s / 1.9s / 2.4s) for organic feel — but the **amplitude** must also be smaller, not just the period. -- **Different elements at different phases** — offset secondary elements by `Math.PI / 2` (90° offset) so they're not all moving in sync. Synced motion looks mechanical; out-of-phase looks alive. -- **Compose, don't replace** — idle motion ADDS to the element's resting transform, not replace it. If the entry settled at `translateY(0)`, idle should produce `translateY(0 + sin*4)`. Don't overwrite the entry's final translation. -- **❗ Don't use CSS `@keyframes` for the idle loop** — CSS animation runs on the browser's render clock, which is independent of the HF seek clock. HF seeks frame-by-frame and a CSS-driven idle will flicker/desync. Drive idle inside the GSAP timeline. +| token | range / default | notes | +| --------------- | ------------------------------------ | -------------------------------------------------------------------------- | +| SCALE_AMP | **0.008–0.015 default** | push to 0.02–0.04 only when isolated on canvas / scene <6s / kinetic brief | +| Y_AMP_PX | **2–3px default** | 4–6px only under the same gating; rotation ±0.3–0.8° rarely needed at all | +| period | 1.5–3s (2.5–4s when idle is long) | <1.5s frantic; >4s lifeless in a short window | +| CYCLES | `IDLE_DUR/3 ≤ CYCLES ≤ IDLE_DUR/1.5` | derive from the period, not the other way round | +| IDLE_START_TIME | ≥ entry settle + ~0.1s | `sin(0)=0` at this moment → no jump off the entry tail | +| IDLE_DUR | `TOTAL_DURATION − IDLE_START_TIME` | one long tween fills the hold — never restarted | +| DOT_SCALE_AMP | 0.04–0.12 | small accents tolerate more than the hero | +| OCTAVE_RATIO | 2.0–4.0 | integer-ish reads musical; non-integer reads organic | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `animation`** for idle — must be timeline-driven -- **`will-change: transform`** if the idle compounds with other tweens on the same element -- **Phase tween `ease: 'none'`** — sine itself provides the easing; tweening the phase non-linearly produces non-sinusoidal motion -- **Don't restart the idle tween** — it's a single long tween from start to end of composition idle window - -## Combinations - -- After [press-release-spring.md](press-release-spring.md) — button idle-breathes after release settles -- After [counting-dynamic-scale.md](counting-dynamic-scale.md) — final number breathes -- After [card-morph-anchor.md](card-morph-anchor.md) — settled card idle-bobs -- After [orbit-3d-entry.md](orbit-3d-entry.md) — center label idle-breathes while items orbit +- **Prefer reveal, then jitter, then breath** — the doctrine order above; default to the LOW end of every amplitude range. At the upper end across 5+ consecutive scenes the whole film reads as "shimmering". +- **Long idle window** (`IDLE_DUR > 6s` OR idle > 30% of composition): halve `SCALE_AMP` / `Y_AMP_PX`, slow the period to 3–4s, and add the settle-and-fade tail. +- **Concurrent idle on N elements** (columns, card grid, stat row): per-element amplitude ≤ default `/ √N`, AND stagger the periods (2.1s / 1.9s / 2.4s). Three columns at ±6px compound to ±18px of competing motion; three at ±2–3px read as one collective breath. +- **Compose, don't replace** — idle ADDS to the element's resting transform; never overwrite the entry's final translation. +- **Phase tween `ease: "none"`** — sine itself is the curve. +- **No CSS `@keyframes` for idle** — CSS animation runs on the browser's render clock, independent of the HF seek clock; a CSS-driven idle flickers/desyncs. Drive idle inside the timeline. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — `onUpdate` writing transform -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`ambient-glow-bloom` (the glow-layer counterpart, same bounded-breathe discipline) · `press-release-spring` / `counting-dynamic-scale` / `card-morph-anchor` / `orbit-3d-entry` (settled elements this can follow) · `spring-pop-entrance` (the arrival that precedes any idle). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/split-tilt-cards.md b/plugins/visual-content/skills/hyperframes-animation/rules/split-tilt-cards.md index 2908901..def415a 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/split-tilt-cards.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/split-tilt-cards.md @@ -7,53 +7,31 @@ metadata: # Split Tilt Cards -Two cards positioned side-by-side, each rotated in opposite Y directions. Creates a symmetric "book-open" 3D effect — natural fit for comparisons, before/after, or feature pairs. +Two cards side-by-side with opposing `rotateY` (left `+TILT`, right `−TILT`) — a symmetric "book-open" 3D split for comparisons, before/after, feature pairs. Each card slides in from its own side (reinforcing "they came from their own worlds and met here"), then the pair idles in counter-phase. ## How It Works -- Left card rotates `+Y` (faces toward the right viewer angle) -- Right card rotates `-Y` (faces toward the left viewer angle) -- Both share the same `perspective` parent → opposing rotations balance visually -- Each card enters from outside (left card slides in from the left, right card from the right) to reinforce its identity -- Idle phase: gentle counter-phase float (`Math.PI` offset on sine) — cards bob in opposition +`perspective` on the scene root (REQUIRED — without it `rotateY` flattens to a 2D layout) and `transform-style: preserve-3d` on the stage and both cards. Entry starts each card off-axis with `TILT + TILT_OVERSHOOT`, settling to `TILT` — a pivot-into-place. Idle is a gentle counter-phase y-bob (the two yoyo tweens run in opposite directions); copy fades up during the cards' settle, not after. -## HTML +## Recipe ```html -
-
-
-
{leftEyebrow}
-
{leftHeadline}
-
{leftBody}
-
-
-
{rightEyebrow}
-
{rightHeadline}
-
{rightBody}
-
+ +
+
+
{leftEyebrow}
+
{leftHeadline}
+
{leftBody}
+
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; +.scene-root { display: grid; place-items: center; - background: {bgGradient}; - perspective: SCENE_PERSPECTIVE; /* REQUIRED — without perspective rotateY flattens */ + perspective: SCENE_PERSPECTIVE; /* REQUIRED */ } .split-stage { display: flex; @@ -62,216 +40,84 @@ Two cards positioned side-by-side, each rotated in opposite Y directions. Create } .card { width: CARD_WIDTH; - min-height: CARD_MIN_HEIGHT; - padding: CARD_PADDING; - display: flex; - flex-direction: column; - gap: CARD_INNER_GAP; - border-radius: CARD_RADIUS; - background: {cardSurface}; - border: 1px solid {cardBorder}; - color: {textColor}; - font-family: {font}; transform-style: preserve-3d; will-change: transform; } +/* Shadow falls WITH the facing direction: left card faces right → shadow right. */ .card-left { - /* Faces right → shadow falls right */ - box-shadow: - -CARD_SHADOW_OFFSET CARD_SHADOW_DROP CARD_SHADOW_BLUR {shadowColor}, - 0 0 CARD_GLOW_BLUR {accentGlowColor}; + box-shadow: -CARD_SHADOW_OFFSET CARD_SHADOW_DROP CARD_SHADOW_BLUR {shadowColor}; } .card-right { - /* Faces left → shadow falls left */ - box-shadow: - CARD_SHADOW_OFFSET CARD_SHADOW_DROP CARD_SHADOW_BLUR {shadowColor}, - 0 0 CARD_GLOW_BLUR {accentGlowColor}; -} -.card-eyebrow { - font-size: EYEBROW_FONT_SIZE; - font-weight: 800; - letter-spacing: EYEBROW_LETTER_SPACING; - text-transform: uppercase; - color: {accentColor}; -} -.card-headline { - font-size: HEADLINE_FONT_SIZE; - font-weight: 900; - line-height: 1; - letter-spacing: HEADLINE_LETTER_SPACING; -} -.card-body { - font-size: BODY_FONT_SIZE; - font-weight: 500; - line-height: 1.3; - color: {bodyColor}; - opacity: BODY_OPACITY; + box-shadow: CARD_SHADOW_OFFSET CARD_SHADOW_DROP CARD_SHADOW_BLUR {shadowColor}; } ``` -## GSAP Timeline - -```html - - +```js +// Entry — from outside, opposing tilts settle with a small pivot +tl.fromTo( + ".card-left", + { x: -ENTRY_SLIDE_DIST, rotateY: TILT + TILT_OVERSHOOT, opacity: 0 }, + { x: 0, rotateY: TILT, opacity: 1, duration: ENTRY_DUR, ease: "power3.out" }, + LEFT_AT, +); +tl.fromTo( + ".card-right", + { x: ENTRY_SLIDE_DIST, rotateY: -TILT - TILT_OVERSHOOT, opacity: 0 }, + { x: 0, rotateY: -TILT, opacity: 1, duration: ENTRY_DUR, ease: "power3.out" }, + RIGHT_AT, +); + +// Counter-phase idle bob — opposite signs = alive; synchronized = conveyor belt +tl.to( + ".card-left", + { y: -FLOAT_AMP, duration: FLOAT_DURATION / 2, ease: "sine.inOut", yoyo: true, repeat: 1 }, + IDLE_START, +); +tl.to( + ".card-right", + { y: FLOAT_AMP, duration: FLOAT_DURATION / 2, ease: "sine.inOut", yoyo: true, repeat: 1 }, + IDLE_START, +); + +// Copy fades up during the settle +tl.from( + ".card-eyebrow, .card-headline, .card-body", + { opacity: 0, y: COPY_RISE, stagger: COPY_STAGGER, duration: COPY_DUR, ease: "power2.out" }, + COPY_REVEAL_AT, +); ``` -## How to Choose Values - -### Layout / typography - -- **SCENE_PERSPECTIVE** — perspective on the scene root. - - Range: 1000-2400 px - - Effects: lower exaggerates the tilt (more cone-like); higher reads as a near-isometric, flatter rotation -- **STAGE_GAP** — horizontal gap between the two cards. - - Range: 40-120 px (≈0.06-0.15× `CARD_WIDTH`) - - Effects: small gap reads "fused pair"; large gap reads "compared but separate" -- **CARD_WIDTH** — width of each card. - - Range: 480-820 px at 1920×1080 - - Constraints: `2 * CARD_WIDTH + STAGE_GAP ≤ 0.95 * stageWidth` so both cards stay on-screen at full tilt -- **CARD_MIN_HEIGHT** — minimum card height. - - Range: ≈0.75-1.05× `CARD_WIDTH` (square-ish reads as balanced; very tall reads as poster) -- **CARD_PADDING / CARD_INNER_GAP / CARD_RADIUS** — interior chrome. - - Range: padding 40-72 px; inner gap 24-48 px; radius 24-40 px -- **CARD_SHADOW_OFFSET / CARD_SHADOW_DROP / CARD_SHADOW_BLUR** — drop-shadow geometry. - - Constraints: offset's sign matches tilt direction (see Key Principles); drop > 0 grounds the card on the scene - - Range: offset 16-28 px; drop 20-32 px; blur 40-80 px -- **CARD_GLOW_BLUR** — secondary inner glow blur radius (`box-shadow` 0 0 blur). - - Range: 16-32 px (subtle accent rim; larger competes with the card content) -- **EYEBROW_FONT_SIZE / EYEBROW_LETTER_SPACING** — small uppercase label. - - Range: 22-32 px; spacing 6-12 px (uppercase reads cleaner with positive letter-spacing) -- **HEADLINE_FONT_SIZE / HEADLINE_LETTER_SPACING** — the main one-line punch. - - Range: 72-104 px at 1920×1080; spacing -1 to -3 px (tight tracking for display weight) -- **BODY_FONT_SIZE / BODY_OPACITY** — supporting copy. - - Range: 28-40 px; opacity 0.8-0.92 - - Constraints: body limited to ≤2 lines (see Critical Constraints — tilted long paragraphs blur) - -### Entry / tilt - -- **TILT** — static `rotateY` magnitude in degrees (left `+`, right `−`). - - Range: 10-18° (under 10 reads almost flat; over 18 the cards fold shut and body copy becomes hard to read) -- **TILT_OVERSHOOT** — extra degrees added to the starting `rotateY` before settling to `TILT`. - - Range: 4-12° - - Effects: gives the entry a slight pivot-into-place feel -- **ENTRY_SLIDE_DIST** — pixels each card slides from off-axis. - - Range: 200-500 px (≈0.3-0.6× `CARD_WIDTH`) -- **ENTRY_DUR** — per-card slide-in duration. - - Range: 0.6-1.2 s -- **LEFT_AT** — left card entry start. - - Range: 0.0-0.4 s -- **RIGHT_AT** — right card entry start. - - Range: `LEFT_AT + 0.0` to `LEFT_AT + 0.3` s (zero stagger feels mechanical; large stagger fragments the pair) - -### Idle bob - -- **FLOAT_AMP** — sine amplitude on idle y bob (px). - - Range: 3-8 px (see sine-wave-loop rule — subtle is the point) -- **FLOAT_DURATION** — full yoyo round-trip duration (one breath). - - Range: 1.6-3.2 s (≈breathing cadence) -- **IDLE_START** — when idle bob begins. - - Constraints: `≥ max(LEFT_AT, RIGHT_AT) + ENTRY_DUR` so idle doesn't fight the entry tail - -### Copy reveal - -- **COPY_REVEAL_AT** — when eyebrow/headline/body fade in. - - Constraints: usually starts during the cards' settle (overlaps the entry tail) — content shouldn't pop in after the cards are already idle -- **COPY_DUR / COPY_STAGGER / COPY_RISE** — fade-up shape. - - Range: duration 0.4-0.7 s; stagger 0.04-0.10 s; rise 12-24 px - -### Color / typography tokens - -- **{bgGradient}** — radial or linear gradient behind the cards; darker than `{cardSurface}` so cards lift -- **{cardSurface}** — card background (typically a low-saturation gradient layered over the scene) -- **{cardBorder}** — 1 px border color, usually `{accentColor}` at low alpha -- **{shadowColor}** — drop-shadow color, typically near-black at 0.5-0.7 alpha -- **{accentGlowColor}** — inner glow color, typically `{accentColor}` at low alpha -- **{accentColor}** — eyebrow + accent rim color (single hue per scene) -- **{textColor}** — primary headline color, high contrast against `{cardSurface}` -- **{bodyColor}** — body copy color, slightly desaturated vs `{textColor}` -- **{font}** — display font stack for all card copy - ## Variations -### Mid-tilt zoom-through (combined with camera move) - -If a separate camera tween scales `.split-stage`, the cards' tilt reads as the viewer crossing through the gap between them. - -### Asymmetric content density (badge / label / icon) - -Add a floating badge near each card for additional context. Position absolutely on the parent — not inside the card, so the badge doesn't inherit the 3D rotation: - -```html -
{leftBadge}
-
{rightBadge}
-``` - -### Stacked variants (3+ cards) - -For 3 cards, the center card stays flat (`rotateY 0`) and the outer two tilt inward — useful for "your old way / nothing in between / our way" comparisons. - -## Key Principles - -- **`perspective` on scene root REQUIRED** — without it rotateY flattens and the split-tilt collapses to a flat side-by-side layout -- **`transform-style: preserve-3d`** on both the stage and each card — preserves the 3D plane as cards have their own transforms -- **Shadow direction must match tilt** — left card faces right, shadow falls right (positive X), and vice versa. Wrong shadow direction reads as "broken 3D" -- **Symmetric content weight** — both cards same width, same vertical center, similar line counts. Asymmetric content breaks the comparison metaphor -- **Counter-phase float (`Math.PI` offset)** — left bobs up while right bobs down. Synchronized bob looks like both cards are on the same conveyor belt; counter-phase looks alive -- **Slide-in from the outside** — left card from left, right card from right — reinforces "they came from their own worlds and met here" -- **❗ Tilt magnitude 10-15°** — under 10° looks like a slight perspective offset (almost flat), over 18° looks like the cards are folding shut and copy becomes hard to read +- **Badges / floating labels**: position them on the PARENT, never inside a card — inside they inherit the `rotateY` and tilt off-axis. +- **3+ cards**: center card stays flat (`rotateY: 0`), outer two tilt inward — "old way / nothing / our way." +- **Zoom-through**: a separate camera tween scaling `.split-stage` reads as the viewer crossing the gap between the tilted pair. + +## Values + +| token | range | notes | +| ----------------- | -------------------------------- | ------------------------------------------------------- | +| SCENE_PERSPECTIVE | 1000–2400px | lower exaggerates the tilt; higher reads near-isometric | +| TILT | 10–18° | < 10 reads almost flat; > 18 folds shut and copy blurs | +| TILT_OVERSHOOT | 4–12° | the pivot-into-place feel | +| STAGE_GAP | 40–120px (~0.06–0.15×CARD_WIDTH) | small = fused pair; large = compared-but-separate | +| CARD_WIDTH | 480–820px @1920 | `2×CARD_WIDTH + STAGE_GAP ≤ 0.95×stage` at full tilt | +| ENTRY_SLIDE_DIST | 200–500px (~0.3–0.6×CARD_WIDTH) | | +| ENTRY_DUR | 0.6–1.2s | | +| RIGHT_AT | LEFT_AT + 0–0.3s | zero feels mechanical; large fragments the pair | +| FLOAT_AMP | 3–8px | subtle is the point | +| FLOAT_DURATION | 1.6–3.2s round trip | breathing cadence; IDLE_START ≥ entry end | +| COPY_REVEAL_AT | during the entry tail | copy popping in after cards are idle reads disconnected | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No `requestAnimationFrame`** for the idle float — drive it inside the timeline so seek is deterministic -- **Don't put badges inside the card divs** — they'd inherit the rotateY and tilt off-axis with the card. Float them on the parent -- **Body copy ≤ 2 lines per card** — tilted text becomes hard to read; long paragraphs collapse into a perspective blur - -## Combinations - -- [card-morph-anchor.md](card-morph-anchor.md) — both cards could morph into a single unified shape afterward -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — numbers as the headline content for each side +- **`perspective` on the scene root is REQUIRED**; `preserve-3d` on the stage AND each card. +- **Shadow direction matches tilt** — left card faces right → shadow falls right (and mirrored). Wrong sign reads as broken 3D. +- **Counter-phase idle** — the two bobs run with opposite signs at the same position. +- **Badges outside the card divs** (they'd inherit the rotation). +- **Body copy ≤ 2 lines per card** — tilted long paragraphs collapse into perspective blur. +- **Symmetric weight** — same width, same vertical center, similar line counts; asymmetry breaks the comparison metaphor. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + `yoyo` for the idle bob -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`card-morph-anchor` (the pair can morph into one unified shape afterward) · `counting-dynamic-scale` (numbers as each side's headline) · `sine-wave-loop` (the idle form). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/spring-pop-entrance.md b/plugins/visual-content/skills/hyperframes-animation/rules/spring-pop-entrance.md index ea8fc47..2debc1e 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/spring-pop-entrance.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/spring-pop-entrance.md @@ -7,80 +7,31 @@ metadata: # Spring-Pop Entrance -> **Smooth beats bouncy.** Per the motion doctrine (`references/motion-language.md`), this entrance **defaults to a smooth long-tail settle — `power3.out` (or `expo.out` for a faster arrival)** that decelerates cleanly into the resting size with **no overshoot**. Bouncy `back.out` overshoot is the **#1 instant turn-off** in agent-made videos and is almost never executed well; it is demoted here to a **rare, explicitly-playful exception** (a consumer / fun brand), never the default. When unsure, settle smoothly. +> **Smooth beats bouncy.** This entrance defaults to a smooth long-tail settle — `power3.out` (or `expo.out` for a faster front) — that decelerates cleanly into the resting size with **no overshoot**. Bouncy `back.out` is the **#1 instant turn-off** in agent-made videos and is almost never executed well; it is a rare, explicitly-playful exception (consumer / fun brand), never the default. When unsure, settle smoothly. -THE entrance primitive: an element (or a staggered group of them) arrives on screen by springing from nothing — `scale: 0 → 1`, optionally with a small `y` rise — riding a **smooth long-tail ease (`power3.out` default)** so it grows confidently into its resting size and settles without bouncing. This is **arrival**, not reaction. - -Explicitly distinct from [press-release-spring.md](press-release-spring.md): that rule is a click/press → release feedback chain (a press phase, then a spring recovery to `1.0`). This one has **no press phase** — there is no prior resting state, the element did not exist on screen, it springs into being. Many blueprints used to borrow `press-release-spring` to fake an entrance; reach for this instead. +THE entrance primitive: an element (or staggered group) arrives by springing from nothing — `scale: 0 → 1`, optional small `y` rise — and settles without bouncing. This is **arrival**, not reaction: distinct from [press-release-spring.md](press-release-spring.md) (a click/press → release feedback chain on an element that already rests on screen). Many blueprints used to borrow that rule to fake an entrance; reach for this instead. ## How It Works -A single `fromTo` carries the whole arrival: - -1. **From-state**: `{ scale: 0, opacity: 0 }` — the element is collapsed to a point and invisible. Stated explicitly in the `from` object so a seek to `t=0` lands the element in this exact state (never rely on a CSS-hidden start — see Critical Constraints). -2. **To-state (default)**: `{ scale: 1, opacity: 1, ease: "power3.out" }` — a long-tail decel that grows the element into its resting size and **settles smoothly, no overshoot**. Use `expo.out` instead for a punchier, faster-front arrival (still no bounce). This smooth settle is the house style; the bouncy `back.out` variant is the rare playful exception (see Variations). - -For a **group**, the same `fromTo` runs per element with a **deterministic, index-derived stagger** (`i * STAGGER`), and the total entry window is **capped** (`ITEM_COUNT × STAGGER ≤ ~0.5s`) so the group reads as one arriving beat, not a slow arpeggio. - -A small `y` rise (`y: 24 → 0`) layers a subtle "lifts into place" on top of the pop — optional garnish; the `scale` grow on a smooth ease is the load-bearing motion. (A `rotation` settle belongs only to the playful overshoot variant below.) +One `fromTo` carries the whole arrival: from `{ scale: 0, opacity: 0 }` (explicit, so t=0 is correct under seek) to `{ scale: 1, opacity: 1, ease: "power3.out" }`. For a **group**, the same `fromTo` runs per element at `i * STAGGER`, capped so the group reads as one arriving beat. The `scale` grow is load-bearing; the `y` rise is garnish — drop everything else and it must still read as a clean entrance. Let the ease produce the settle: never hand-key a `scale: 1.1` mid-state (it double-bounces against the curve). -## HTML +## Recipe ```html - -
-
{heroLabel}
-
+ +
{heroLabel}
- -
-
-
{itemA}
-
{itemB}
-
{itemC}
-
{itemD}
-
{itemE}
-
{itemF}
-
+
+
{itemA}
+
{itemB}
+
{itemC}
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; -} -.pop-hero { - display: grid; - place-items: center; - width: {heroSize}; - height: {heroSize}; - background: {heroBg}; - border-radius: HERO_RADIUS; - font-family: {font}; - font-weight: 900; - font-size: HERO_FONT_SIZE; - color: {heroTextColor}; - /* Pop scales around the center — see Critical Constraints */ - transform-origin: 50% 50%; +.pop-hero, +.pop-item { + transform-origin: 50% 50%; /* in-place pop; move to the source point for the anchored variation */ will-change: transform; } .pop-grid { @@ -89,84 +40,38 @@ A small `y` rise (`y: 24 → 0`) layers a subtle "lifts into place" on top of th gap: GRID_GAP; place-items: center; } -.pop-item { - display: grid; - place-items: center; - width: {itemSize}; - height: {itemSize}; - background: {itemBg}; - border-radius: ITEM_RADIUS; - font-family: {font}; - font-weight: 800; - font-size: ITEM_FONT_SIZE; - color: {itemTextColor}; - transform-origin: 50% 50%; - will-change: transform; -} ``` -## GSAP Timeline - -```html - - +}); ``` ## Variations -### Calm settle (refined / enterprise / "premium calm") — default - -`power3.out`, no rotation, drop the `y` rise or keep it tiny (~12px). Reads as a confident, weighted settle — right for a hero wordmark or a single product shot landing. The safe default for premium / enterprise brands. - -### Firm settle (default product reveal) — default - -The everyday entrance. `power3.out` (or `expo.out` for a punchier front), optional `Y_RISE` ~24px. Clear, deliberate arrival that decelerates clean — the safe default for cards, icons, and callouts. **No overshoot.** - -### Bouncy pop (RARE — explicitly-playful only) - -The exception, not the default. **Only** for a deliberately playful register (a consumer / fun brand, a toy-like icon set) where a bounce is clearly the intent — never for product / enterprise / serious launch tone. Bouncy is the #1 turn-off and the agent rarely lands it, so reach for this knowingly and sparingly. Swap `power3.out` for `back.out(OVERSHOOT)` and (optionally) add a `rotation` settle so each element looks hand-placed: +- **Calm settle** (premium / enterprise): `power3.out`, no rotation, `Y_RISE` 0–12px — a weighted, confident landing for a hero wordmark or product shot. +- **Firm settle** (everyday default): `power3.out` or `expo.out` for a punchier front, `Y_RISE` ~24px — cards, icons, callouts. +- **Exact-physics settle**: when the settle IS the shot, swap the ease for `springEase({ response: 0.4 })` (critically damped) from `../adapters/gsap-easing-and-stagger.md` → Spring Eases; take `duration` from the helper. +- **Origin-anchored pop**: a callout growing out of a specific point (marker, pointer tip) sets `transform-origin` to that point (e.g. `0% 100%`) so `scale: 0 → 1` reads as "emerging from the source", not "inflating in place". +- **Pop into a held slot**: land the pop and hold still — no idle loop baked into the entrance. If the held frame genuinely needs life, hand off to [sine-wave-loop.md](sine-wave-loop.md) for subtle jitter on a separate later tween; prefer revealing the next element on its VO cue. +- **Bouncy pop (RARE — explicitly-playful only)**: swap the ease for `back.out(OVERSHOOT)` and optionally settle a small `rotation: ROT_FROM → 0` so elements look hand-placed. Only for a deliberately playful register — never product / enterprise / serious tone: ```js -// Playful exception only — default to power3.out (see above). tl.fromTo( el, { scale: 0, opacity: 0, rotation: ROT_FROM }, @@ -175,99 +80,28 @@ tl.fromTo( ); ``` -Keep `OVERSHOOT` modest even here (≤ ~2) — past that it reads as a cartoon wobble, not an arrival. - -### Origin-anchored pop (callout springs from a pointer / source) - -When a callout should appear to grow out of a specific point (e.g. a station marker or pointer tip), set `transform-origin` to that point instead of center, so the `scale: 0 → 1` reads as "emerging from the source" rather than "inflating in place." - -```css -.callout { - transform-origin: 0% 100%; /* bottom-left = pointer tip; match to the anchor */ -} -``` - -### Pop into a held slot — then hold (jitter at most) - -When a popped element then **holds** an ongoing slot (a constellation node, a persistent badge), do **not** bake an idle loop into this entrance — it must stay finite. Land the pop and let it hold still; if the held frame genuinely needs life, hand off to [sine-wave-loop.md](sine-wave-loop.md) for **subtle jitter** (low amplitude) on a separate, later tween — not a breathing loop. Prefer revealing the next element on its VO cue over keeping this one animating. - -## How to Choose Values +Even here keep `OVERSHOOT ≤ ~2` — past that it reads as cartoon wobble. Better still: the baked spring at `dampingFraction: 0.6–0.7` (same adapters doc) gives ~5–10% overshoot that reads physical where `back.out` reads cartoon. -- **EASE** — the settle curve (the load-bearing decision) - - Default: **`power3.out`** — a smooth long-tail settle, no overshoot; the house style for product / enterprise / serious tone. Use `expo.out` for a punchier, faster-front arrival (still smooth). - - Playful exception only: `back.out(OVERSHOOT)` — see the Bouncy pop variation; reach for it only when a bounce is clearly the brand intent. +## Values -- **OVERSHOOT** — `back.out(OVERSHOOT)` overshoot strength — **only used in the rare bouncy variant**; the smooth default has no overshoot dial - - Range (playful only): ~1.3 (barely) → ~2.0 (clearly bouncy) - - Constraints: keep ≤ ~2 — past that the overshoot exceeds the element's bounds and reads as a cartoon wobble, not an arrival. If you're not in the explicitly-playful case, don't use this — use `power3.out`. - -- **POP_DUR** — duration of each element's `scale: 0 → 1` tween - - Range: 0.4 – 0.7 s - - Effects: shorter = tight snap; longer = a looser, more floating pop - - Constraints: the main subject must be visible by **`t ≤ 0.5s`** — keep `ENTRY_AT + POP_DUR`'s readable midpoint early; don't let the hero finish arriving after the half-second mark - -- **STAGGER** — gap between successive items' start times (group only) - - Range: 0.04 – 0.08 s - - Effects: < 0.04 reads as a simultaneous chord; > 0.08 feels lazy / arpeggiated - - Constraints: **`ITEM_COUNT × STAGGER ≤ ~0.5s`** (the cap) — beyond that the group stops reading as one beat. Cap the per-item stagger for large groups: `STAGGER = min(0.06, 0.5 / ITEM_COUNT)` - -- **ITEM_COUNT** — number of elements in a group pop - - Range: 3 – 9 - - Effects: 3 = sparse; 9 = full grid. More than ~9 forces `STAGGER` so small the stagger vanishes — switch to a wipe/sweep reveal instead - -- **Y_RISE** — optional upward offset the element lifts from (`y: Y_RISE → 0`) - - Range: 0 (pure pop) – 32 px - - Effects: adds a subtle "lifts into place"; keep small so the `scale` pop stays dominant - - Constraints: 0 for the calm-settle variant; never large enough to read as a slide-up (that's a different primitive) - -- **ROT_FROM** — optional starting rotation, **playful (bouncy) variant only** (`rotation: ROT_FROM → 0`) - - Range: −10° – +10° - - Effects: a small tilt that resolves makes the element look hand-placed - - Constraints: derive sign/size deterministically from index if you want alternating tilt (e.g. `i % 2 ? 6 : -6`) — never `Math.random` - -- **ENTRY_AT / GROUP_ENTRY_AT** — timeline offset before the (group's) pop begins - - Range: 0 – 0.4 s - - Effects: > 0 gives a beat of quiet before the arrival; keep small so the subject still lands by `t ≤ 0.5s` - -### Geometry & tokens - -- **{heroSize} / {itemSize}** — footprints. A hero entrance should occupy a clearly readable share of the frame; group items size down so the grid fits with `GRID_GAP` breathing room. -- **HERO_RADIUS / ITEM_RADIUS** — `height × 0.15` (sharp) → `height / 2` (pill). -- **{heroBg} / {itemBg} / {\*TextColor}** — surface + label tokens; inherit from the composition palette. - -## Key Principles - -- **Smooth beats bouncy** — default to `power3.out` (or `expo.out`): a long-tail settle into `scale: 1`, no overshoot. Bouncy `back.out` is the rare, explicitly-playful exception (the #1 turn-off, and the agent rarely lands it). When unsure, settle smoothly. -- **fromTo, always** — the collapsed `{ scale: 0, opacity: 0 }` start is stated in the `from` object so a seek to `t=0` lands it exactly there. An entrance built on a CSS-hidden start (e.g. `opacity:0` in CSS + a `.to()`) flickers under HF seek — the element renders visible before the tween claims it. -- **Easing carries the motion, not keyframes** — let the ease produce the settle for free. Don't hand-key a `scale: 1.1` mid-state; that double-bounces and fights the curve. (And in the playful variant, the overshoot is a byproduct of `back.out`, not a hand-keyed bounce.) -- **The grow is the motion** — `scale` is load-bearing; the `y` rise (and, in the playful variant, the `rotation` settle) is garnish layered on top. If you drop everything but the `scale` grow, it should still read as a clean entrance. -- **Cap the stagger window** — a group must arrive inside ~0.5s total or it stops reading as one beat and starts reading as a slow list reveal. Derive the stagger from `ITEM_COUNT` so it self-caps. -- **Deterministic per index** — all stagger and any rotation/tilt variation comes from the loop index, never `Math.random` — the renderer must produce the identical frame on every seek. -- **Visible early** — the main subject must be on screen by `t ≤ 0.5s`. A hero that finishes arriving at `t=1s` wastes the opening beat. -- **Don't bake an idle loop here** — this entrance is finite. If the element then holds a slot, hand off to `sine-wave-loop` on a later tween; an infinite `repeat`/`yoyo` here breaks seek. +| token | range | notes | +| ---------- | ----------------------------------------- | ---------------------------------------------------------------- | +| EASE | `power3.out` default; `expo.out` punchier | `back.out(OVERSHOOT)` only in the playful variant | +| POP_DUR | 0.4–0.7s | shorter = tight snap; hero must be visible by **t ≤ 0.5s** | +| STAGGER | 0.04–0.08s | `min(0.06, 0.5 / ITEM_COUNT)` — self-caps the window | +| ITEM_COUNT | 3–9 | >9 makes the stagger vanish — switch to a wipe/sweep reveal | +| Y_RISE | 0–32px | small; never large enough to read as a slide-up | +| ROT_FROM | −10°–+10° | playful variant only; alternate sign by index (`i % 2 ? 6 : -6`) | +| ENTRY_AT | 0–0.4s | a beat of quiet, but keep the subject landing by t ≤ 0.5s | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **Entrances use `fromTo`** — explicit `{ scale: 0, opacity: 0 }` from-state; never rely on a CSS-hidden starting state -- **No CSS `transition`** on popped elements — those interpolate independently of HF seek and cause flicker -- **No `repeat` / `yoyo` / infinite tweens** — this is a finite arrival; idle motion is a separate `sine-wave-loop` tween -- **No `Math.random` / `Date.now`** — stagger and tilt are index-derived and deterministic -- **GSAP transform aliases only**: `x`, `y`, `scale`, `rotation`. Never tween `width` / `height` / `left` / `top` -- **`transform-origin: 50% 50%`** for an in-place pop (default); set it to the source point only for the origin-anchored variation -- **Default ease `power3.out`** (smooth, no overshoot); `back.out(OVERSHOOT)` only in the explicitly-playful variant, and there keep **`OVERSHOOT ≤ ~2`** — beyond that it reads as a cartoon wobble, not an arrival -- **`ITEM_COUNT × STAGGER ≤ ~0.5s`** — the group must land inside one beat -- **`will-change: transform`** on popped elements, especially groups — many simultaneous spring tweens benefit from compositor hints - -## Combinations - -- [sine-wave-loop.md](sine-wave-loop.md) — at most **subtle jitter** on a held node/badge AFTER its pop lands (don't bake any loop into the entrance; and prefer a VO-timed reveal over ambient motion — see that rule's caution) -- [center-outward-expansion.md](center-outward-expansion.md) — elements pop in as they radiate from center to their slots -- [press-release-spring.md](press-release-spring.md) — the reaction counterpart: once popped in, a button can take a press→release; this rule supplies the arrival, that one the click feedback +- Default ease `power3.out` (no overshoot); `back.out` only in the explicitly-playful variant, and there `OVERSHOOT ≤ ~2`. +- `ITEM_COUNT × STAGGER ≤ ~0.5s` — the group must land inside one beat. +- Entrances state the collapsed from-state in `fromTo` — never rely on a CSS-hidden start (it renders visible before the tween claims it under seek). +- `transform-origin: 50% 50%` for an in-place pop; the source point only for the anchored variation. +- This is a finite arrival — idle motion on a held element is a separate, later `sine-wave-loop` tween. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — `power3.out` settle (smooth default), `fromTo` entrances, deterministic stagger -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`center-outward-expansion` (pop while radiating to slots) · `press-release-spring` (the click-feedback counterpart) · `sine-wave-loop` (post-arrival jitter, sparingly). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/stat-bars-and-fills.md b/plugins/visual-content/skills/hyperframes-animation/rules/stat-bars-and-fills.md index a2e654c..58679e7 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/stat-bars-and-fills.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/stat-bars-and-fills.md @@ -16,9 +16,11 @@ The graphics that give a stat **visual weight** beside its number: a small bar c Don't mix blueprints between stats in one piece — that reads as inconsistent. -## 1 — Growth Bars (CSS `scaleY` stagger) +## Recipe -Bars grow from the baseline with a stagger; the last bar is the accent. +### 1 — Growth Bars (CSS `scaleY` stagger) + +Bars grow from the baseline with a stagger; the last bar is the accent. Heights are authored in CSS (inline height per bar); GSAP only reveals `scaleY: 0 → 1` — never animate `height`. ```css .bars { @@ -34,18 +36,15 @@ Bars grow from the baseline with a stagger; the last bar is the accent. transform-origin: bottom center; /* grow UP from the baseline, not from center */ } .bar:last-child { - background: #ffc300; -} /* accent the final/current bar */ + background: #ffc300; /* accent the final/current bar */ +} ``` ```js -// Heights are authored in CSS (e.g. inline height per bar); GSAP only reveals scaleY 0→1. tl.to(".bar", { scaleY: 1, duration: 0.7, ease: "power3.out", stagger: 0.08 }, 0.3); ``` -> Use `scaleY` (a transform), never animate `height` — height tweens are forbidden by the runtime. Set each bar's final height in CSS, scale from 0. - -## 2 — Progress Fill +### 2 — Progress Fill **Bar form** — `scaleX` from a left origin: @@ -58,7 +57,7 @@ tl.to(".bar", { scaleY: 1, duration: 0.7, ease: "power3.out", stagger: 0.08 }, 0 overflow: hidden; } /* width:100% is REQUIRED — an absolutely-positioned fill with no width is 0px, and scaleX of 0 is - still 0 → the bar renders invisible (and no lint/inspect check catches a zero-width scaled element). */ + still 0 → the bar renders invisible (automated gates may miss a zero-width scaled element). */ .fill { width: 100%; height: 100%; @@ -73,7 +72,7 @@ const PCT = 0.92; // 92% tl.to(".fill", { scaleX: PCT, duration: 1.0, ease: "power2.out" }, 0.3); ``` -**Ring form** — measured stroke draw (delegates to [svg-path-draw.md](svg-path-draw.md)): +**Ring form** — measured stroke draw (mechanics in [svg-path-draw.md](svg-path-draw.md)): ```js const ring = document.querySelector("#ring"); @@ -84,7 +83,7 @@ ring.style.strokeDashoffset = LEN; // empty tl.to(ring, { strokeDashoffset: LEN * (1 - 0.92), duration: 1.1, ease: "power2.out" }, 0.3); ``` -## 3 — Star-Rating Fill (fractional) +### 3 — Star-Rating Fill (fractional) A gold star row revealed left-to-right to a fractional value (e.g. 4.6 / 5) via a clip wipe over a gold layer sitting on a gray layer. @@ -123,34 +122,24 @@ tl.to( ); ``` -## How to Choose Values +## Values -- **Bar count** — 4–6 reads as "a trend" without clutter; the last bar is the current/accent value. -- **Fill duration** — 0.8–1.2s, matched to the paired count-up so number and graphic land together (share the ease). -- **Accent hue** — exactly one; bars/fill/stars all use the same accent, the rest is muted. -- **Stagger** — 0.06–0.1s on bars; larger feels sluggish, 0 loses the build. +| token | range | notes | +| ------------- | ----------- | ----------------------------------------------------------------------------------- | +| bar count | 4–6 | reads as "a trend" without clutter; the last bar is the current/accent value | +| fill duration | 0.8–1.2s | matched to the paired count-up so number and graphic land together (share the ease) | +| stagger | 0.06–0.1s | larger feels sluggish, 0 loses the build | +| accent hue | exactly one | bars/fill/stars all use the same accent, the rest is muted | -## Key Principles +## Critical Constraints -- **Transforms only** — `scaleY` / `scaleX` / `clipPath`, never `width`/`height` tweens (runtime-forbidden). -- **Match the number's timing** — the fill and the count-up should peak together (same start + ease), so the stat resolves as one beat, not two. +- **`scaleY` / `scaleX` / `clipPath`, never `height`/`width` tweens** — author each bar's final height in CSS and scale from 0. +- **`transform-origin`** must be `bottom` (bars grow up) / `left` (fills grow right) — the default center origin scales from the middle and looks wrong. +- **`.fill` needs `width: 100%`** — a zero-width fill scaled by any factor is still invisible, and automated gates may miss it. - **Measure, don't hard-code** — ring length via `getTotalLength()`; a hard-coded circumference breaks if the radius changes. +- **Match the number's timing** — the fill and the count-up peak together (same start + ease) so the stat resolves as one beat, not two; a paired counter's `onUpdate` must be O(1) (see [counting-dynamic-scale.md](counting-dynamic-scale.md)). - **One accent hue, consistent blueprint** — see `hyperframes-creative/references/data-in-motion.md`. -## Critical Constraints - -- **Timeline paused**; build synchronously; registry key = `data-composition-id`. -- **`onUpdate` (if pairing a counter) must be O(1)** — the runtime seeks frame-by-frame (see [counting-dynamic-scale.md](counting-dynamic-scale.md)). -- **No `height`/`width` tweens, no `repeat: -1`** — transforms + finite repeats only. -- **`transform-origin`** must be `bottom` (bars grow up) / `left` (bars/fills grow right) — default center origin scales from the middle and looks wrong. - -## Combinations - -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — the number beside the graphic (pair them; same ease/duration) -- [svg-path-draw.md](svg-path-draw.md) — the progress-ring draw mechanics - -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + transform tweens -- `/hyperframes-creative` — `references/data-in-motion.md` (stat layout + visual weight) -- `/hyperframes-core` — composition wiring; the no-`width`/`height`-tween rule +`counting-dynamic-scale` (the number beside the graphic — same ease/duration) · `svg-path-draw` (progress-ring draw mechanics) · `hyperframes-creative/references/data-in-motion.md` (stat layout + visual weight). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/svg-icon-enrichment.md b/plugins/visual-content/skills/hyperframes-animation/rules/svg-icon-enrichment.md index 0be00b6..073d0fd 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/svg-icon-enrichment.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/svg-icon-enrichment.md @@ -7,323 +7,135 @@ metadata: # SVG Icon Enrichment -Treats an SVG icon as a composition of animated PARTS, not an opaque image. Each meaningful internal element (a clock hand, scissor blade, recording dot, data line) gets its own GSAP-driven micro-animation. Distinct from [svg-path-draw](svg-path-draw.md) (which animates the OUTLINE drawing) — enrichment animates INTERNAL PARTS, ideally after the outline has drawn. +Treats an SVG icon as a composition of animated PARTS, not an opaque image. Each meaningful internal element (a clock hand, scissor blade, recording dot, data line) gets its own micro-animation, targeted by id. Distinct from [svg-path-draw](svg-path-draw.md) (which animates the OUTLINE drawing) — enrichment animates INTERNAL PARTS, ideally after the outline has drawn. -## How It Works +Four signature patterns: -The SVG is authored with named ``, ``, ``, or `` children. The GSAP timeline targets these by selector and applies one of 4 signature motion patterns: +| Pattern | Use For | Math | Tip | +| ----------- | ---------------------------------- | ------------------------------------- | ---------------------------------- | +| Rotation | Clock, gear, loader, dial | `rotate(deg cx cy)` attribute, linear | see the transform-center gotcha | +| Oscillation | Scissors, wings, toggle | `rotate(±sin·amp)` on opposing groups | opposite signs on the two parts | +| Pulse | Recording dot, heart, notification | `scale(1 + sin·amp)` + opacity | ring lags dot by π/2 for ripple | +| Dash flow | Cutting line, data stream | `strokeDashoffset` linear via time | negative for L→R, positive for R→L | -1. **Rotation** — clock hand, gear, loading spinner (`transform: rotate(deg)`) -2. **Oscillation** — scissor blades, wing flap, toggle (`transform: rotate(±sin*amp)` on opposing groups) -3. **Pulse** — recording dot, heart, notification (`scale + opacity` via sin) -4. **Dash flow** — moving dashes along a stroke, like a data stream (`strokeDashoffset` linear) +## ❗ The transform-center gotcha -All run inside the paused GSAP timeline so HF seeks deterministically. +**For rotation around an explicit point inside an SVG, use the SVG `transform` ATTRIBUTE, not CSS transform**: `el.setAttribute("transform", `rotate(${deg} ${cx} ${cy})`)`. The CSS combination `transform: rotate(...)` + `transform-origin: 60px 60px` + `transform-box: fill-box` interprets the origin in the element's OWN **bbox-local** coordinates, NOT viewBox coordinates. For a thin `` (whose bbox is the line's narrow envelope), `60 60` bbox-local is a point OUTSIDE the line — the hand flies along an off-center arc instead of rotating in place. Same trap for small inner shapes (a dot circle whose bbox is the small circle, not the full viewBox). -## HTML +**Scaling around a center point**: same attribute route — `el.setAttribute("transform", `translate(${cx} ${cy}) scale(${s}) translate(-${cx} -${cy})`)`. -```html -
-
-
- - - - - - - - - - - - - - - - - - - -
-
{brandPhrase}
-
-
-``` - -## CSS - -```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; - font-family: {font}; -} -.stack { - display: flex; - flex-direction: column; - align-items: center; - gap: 80px; -} -.row { - display: flex; - gap: 120px; -} -.icon-svg { - width: 320px; - height: 320px; - filter: drop-shadow(0 12px 32px {shadowColor}); -} -.clock-hand { - /* transform-origin in SVG must be in viewBox units, not pixels */ - transform-origin: 60px 60px; - transform-box: fill-box; -} -.brand { - font-size: 64px; - font-weight: 900; - letter-spacing: 14px; - text-transform: uppercase; - color: {textColor}; -} -``` - -## GSAP Timeline +## Recipe ```html - - -``` - -## How to Choose Values - -- **MIN_REVOLUTIONS** — minute-hand revolutions across TOTAL_DURATION - - Range: 0.5–2.0 (continuous; faster reads as time-lapse) - - Constraints: avoid integer revolutions if visible end frame matters (lands back at start) - -- **SEC_REVOLUTIONS** — second-hand revolutions across TOTAL_DURATION - - Range: 4–10 (should be visibly faster than the minute hand) - - Constraints: SEC_REVOLUTIONS > MIN_REVOLUTIONS × 3 for the speed difference to read - -- **PULSE_CYCLES** — number of pulse cycles across TOTAL_DURATION - - Range: 2–4 over a 3–5 s comp - - Effects: ≥ 5 reads as anxious flicker; ≤ 1 reads as forgotten - -- **PULSE_DOT_AMP** — dot scale amplitude - - Range: 0.05–0.20 - - Effects: 0.05 = breathing; 0.20 = throbbing - -- **PULSE_RING_AMP** — ring scale amplitude (typically lower than DOT_AMP) - - Range: 0.04–0.12 - - Constraints: must be < PULSE_DOT_AMP or ring overshadows dot - -- **PULSE_RING_OPACITY_BASE / PULSE_RING_OPACITY_AMP** — ring opacity baseline + sine amplitude - - Range: BASE 0.4–0.6; AMP 0.3–0.5 - - Constraints: BASE − AMP ≥ 0 and BASE + AMP ≤ 1 - -- **DASH_FLOW_TOTAL_OFFSET** — total stroke-dashoffset change across TOTAL_DURATION - - Range: −400 to −100 (negative for L→R) or +100 to +400 (R→L) - - Effects: |large| = fast flow; |small| = slow drift - - Constraints: must be an integer multiple of the dash period (dash + gap) or the loop end frame shows a phase jump - -- **BRAND_AT** — when the brand phrase fades in - - Range: 0.3–1.0 s - - Effects: too early competes with icon entries; too late feels appended - -- **Ease family choices**: rotation = `none` (linear motion is the point); pulse driver = `none` (sine handles the curve); reveal of brand = `power3.out` - -## Signature Motion Patterns - -| Pattern | Use For | Math | Tip | -| ----------- | ---------------------------------- | ------------------------------------------------ | ----------------------------------- | -| Rotation | Clock, gear, loader, dial | `transform: rotate(deg)`, linear via sec-counter | `transform-origin` in viewBox units | -| Oscillation | Scissors, wings, toggle | `rotate(±sin*amp)` on opposing groups | Opposite signs on the two parts | -| Pulse | Recording dot, heart, notification | `scale(1 + sin*amp)` + opacity | Ring lags dot by π/2 for ripple | -| Dash flow | Cutting line, data stream | `strokeDashoffset` linear via time | Negative for L→R, positive for R→L | - -## Variations - -### Stroke draw → enrichment chain - -Draw the icon outline first (via [svg-path-draw](svg-path-draw.md)), THEN activate enrichment. The internal animation feels like "the icon woke up" after assembly. - -```js -// Phase 1: outline draws (0 → OUTLINE_DUR) -tl.fromTo( - "#icon-outline", - { strokeDashoffset: 360 }, - { strokeDashoffset: 0, duration: OUTLINE_DUR, ease: "power2.inOut" }, + }, 0, ); -// Phase 2: enrichment starts at OUTLINE_DUR ``` -### Per-icon entry stagger +## Variations -For a row of icons all animating, stagger their entries. Each icon's enrichment starts as it fades in, not synchronized — feels organic. +- **Stroke draw → enrichment chain** — draw the outline first via [svg-path-draw](svg-path-draw.md) (phase 1, `0 → OUTLINE_DUR`), then start enrichment at `OUTLINE_DUR`: the icon "wakes up" after assembly. +- **Per-icon entry stagger** — for a row of icons, each icon's enrichment starts as it fades in, not synchronized. -## Key Principles +## Values -- **❗ For rotation around an explicit point inside SVG, use the SVG `transform` attribute, NOT CSS transform** — `el.setAttribute('transform', `rotate(${deg} ${cx} ${cy})`)`. The CSS combination `transform: rotate(...)` + `transform-origin: 60px 60px` + `transform-box: fill-box` interprets the origin in the element's OWN bbox-local coordinates, NOT in viewBox coordinates. For a thin `` (whose bbox is the line's narrow envelope), `60 60` in bbox-local refers to a point OUTSIDE the line, so the hand flies along an off-center arc instead of rotating in place. Same trap for small inner shapes (rec-dot circle whose bbox is the small circle, not the full viewBox). -- **For scaling around a center point inside SVG**, use `el.setAttribute('transform', `translate(${cx} ${cy}) scale(${s}) translate(-${cx} -${cy})`)`. Same reason — avoids the CSS bbox-local origin trap. -- **Run continuous animations inside the timeline** — never CSS `@keyframes` or `requestAnimationFrame`. Both desync from HF's frame-by-frame seek. -- **Amplitudes subtle** — icons are decorative, not headlines. Pulse scale within the ranges above; rotation speeds calibrated against composition length, not absolute time. -- **Multiple parts of the same icon at different phases** — clock minute vs second hand at different speeds, ring vs dot pulse offset by π/2. Pure-sync looks mechanical; phase-offset looks alive. -- **❗ Climax dwell ≥ 1 s** — if the enrichment is the headline beat, the composition must continue ≥ 1 s after the most dramatic moment. +| token | range | notes | +| ------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------- | +| MIN_REVOLUTIONS | 0.5–2.0 | avoid integer revolutions if the end frame is visible (lands back at start) | +| SEC_REVOLUTIONS | 4–10 | > MIN × 3 or the speed difference doesn't read | +| PULSE_CYCLES | 2–4 over a 3–5s comp | ≥5 reads as anxious flicker; ≤1 reads as forgotten | +| PULSE_DOT_AMP | 0.05–0.20 | 0.05 = breathing; 0.20 = throbbing | +| PULSE_RING_AMP | 0.04–0.12 | must be < PULSE_DOT_AMP or the ring overshadows the dot | +| PULSE_RING_OPACITY_BASE / \_AMP | 0.4–0.6 / 0.3–0.5 | BASE − AMP ≥ 0 and BASE + AMP ≤ 1 | +| DASH_FLOW_TOTAL_OFFSET | ±100–400 | must be an integer multiple of the dash period (dash + gap) or the end frame shows a phase jump | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `animation`** on SVG children — must be timeline-driven -- **`transform-origin` matters per child** — set explicitly per animated element -- **`stroke-linecap: round`** on flowing/dashed lines for clean dash edges -- **Target SVG children by id** — `document.getElementById` is fine; selector chains into `` work the same as HTML - -## Combinations - -- [svg-path-draw.md](svg-path-draw.md) — outline draws first, enrichment activates second -- [orbit-3d-entry.md](orbit-3d-entry.md) — orbiting items are enriched icons (clock orbits a brand label) -- [sine-wave-loop.md](sine-wave-loop.md) — entire icon floats while internal parts animate +- **The transform-center gotcha above** — SVG `transform` attribute for any rotation/scale around an explicit interior point; never CSS `transform-origin` + `transform-box: fill-box` on thin lines or small inner shapes. +- **No `requestAnimationFrame`** — like CSS animation, it desyncs from HF's frame-by-frame seek; continuous motion lives inside the timeline as linear proxy tweens. +- **Amplitudes subtle** — icons are decorative, not headlines; calibrate rotation speed against composition length, not absolute time. +- **Phase-offset the parts** — minute vs second hand at different speeds, ring lagging dot by π/2. Pure sync looks mechanical. +- **`stroke-linecap: round`** on flowing/dashed lines for clean dash edges. +- **Climax dwell ≥1s** — if the enrichment is the headline beat, the composition continues ≥1s after the most dramatic moment. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — onUpdate writes transform/opacity per SVG child -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`svg-path-draw` (outline draws first, enrichment second) · `orbit-3d-entry` (orbiting items are enriched icons) · `sine-wave-loop` (the whole icon floats while internal parts animate). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/svg-path-draw.md b/plugins/visual-content/skills/hyperframes-animation/rules/svg-path-draw.md index 8dca375..6cf1199 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/svg-path-draw.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/svg-path-draw.md @@ -7,205 +7,68 @@ metadata: # SVG Path Draw -Reveals an SVG shape by animating its stroke as if a pen were tracing it. The line appears to be drawn in real-time. +Reveals an SVG shape by animating its stroke as if a pen were tracing it. Two stroke properties together: **`stroke-dasharray = `** makes the entire path one dash; **`stroke-dashoffset`** starts at the path length (dash shifted fully out of view → invisible) and tweens to `0` (fully drawn). The length comes from the DOM API `path.getTotalLength()` — measured, never guessed. -## How It Works +Works on anything with a stroke: ``, ``, ``, ``, ``, ``, ``. -The trick uses two SVG stroke properties together: - -1. **`stroke-dasharray = `** — sets the dash pattern to a single dash equal to the path's total length, so the entire path is "one dash" -2. **`stroke-dashoffset`** — controls how much of the dash is shifted out of view. Start at `pathLength` (entire path is offset out → invisible), animate to `0` (no offset → fully drawn) - -The path length is computed via the DOM API `path.getTotalLength()`. - -## HTML +## Recipe ```html -
- - - - - - -
{Brand}
-
+ + + + + + ``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; - gap: 32px; -} - -.logo-mark { - width: 320px; - height: 320px; -} - .logo-mark path { - fill: none; + fill: none; /* outline-only draw — a fill would appear immediately and ruin the reveal */ stroke: {accentColor}; stroke-width: 12; - stroke-linecap: round; /* soften endpoints */ + stroke-linecap: round; /* softer endpoints */ stroke-linejoin: round; - /* Initial state: invisible. GSAP fills strokeDasharray + strokeDashoffset - based on each path's measured length. */ -} - -.brand-line { - font-family: {font}; - font-weight: 700; - font-size: 48px; - color: {textColor}; - opacity: 0; /* fades in after stroke completes */ - letter-spacing: 0.04em; } ``` -## GSAP Timeline - -```html - - +// Companion wordmark fades in only after the last stroke settles. +tl.to( + ".brand-line", + { opacity: 1, duration: BRAND_FADE_DUR, ease: "power1.out" }, + BRAND_FADE_START, +); ``` -## How to Choose Values - -- **SEGMENT_DRAW_DUR** — per-segment stroke duration - - Range: 0.3-0.8s - - Effects: low end reads as a fast snap (good for short segments); high end reads as a deliberate pen trace (good for long curves) - - Constraints: must be short enough that the total chain (last segment finish) ends before BRAND_FADE_START; longer than ~1s feels sluggish for a logo reveal - - Reference: short outline segments use ~0.5s - -- **FINAL_SEGMENT_DUR** — duration of the shortest / final segment - - Range: 0.25-0.6s - - Effects: should be proportional to segment length — a short connector drawn at SEGMENT_DRAW_DUR appears slower than its longer siblings - - Constraints: typically 60-80% of SEGMENT_DRAW_DUR when the segment is visibly shorter than the others - - Reference: a mid-bar that is roughly 2/3 the length of the verticals uses ~0.35s - -- **SEG_1_START** — first segment start time - - Range: 0-0.4s - - Effects: 0 starts immediately on play; >0 gives a brief beat of empty stage before motion - - Constraints: should be ≥ 0 - - Reference: a small lead-in of ~0.2s lets the viewer settle before motion - -- **SEG_2_START** — second segment start time - - Range: SEG_1_START + (0.5 \* SEGMENT_DRAW_DUR) to SEG_1_START + SEGMENT_DRAW_DUR - - Effects: closer to SEG_1_START + 0.5\*SEGMENT_DRAW_DUR feels rapid/overlapping; closer to SEG_1_START + SEGMENT_DRAW_DUR feels sequential - - Constraints: stagger ~70-80% of SEGMENT_DRAW_DUR reads as continuous motion (not 3 isolated animations) - - Reference: SEG_1_START + ~0.25s (about half of SEGMENT_DRAW_DUR) - -- **SEG_3_START** — third segment start time - - Range: SEG_2_START + (0.5 \* SEGMENT_DRAW_DUR) to SEG_2_START + SEGMENT_DRAW_DUR - - Effects: same as SEG_2_START — controls perceived rhythm - - Constraints: should preserve the same stagger ratio used between SEG_1 and SEG_2 - - Reference: SEG_2_START + ~0.4s - -- **BRAND_FADE_DUR** — wordmark fade-in duration - - Range: 0.3-0.8s - - Effects: low end snaps in (urgent); high end glides in (premium / branded) - - Constraints: must finish before the composition's `data-duration` ends - - Reference: a calm logo lockup uses ~0.5s - -- **BRAND_FADE_START** — wordmark fade-in start time - - Range: max(SEG_3_START + FINAL_SEGMENT_DUR, …) to that value + 0.4s - - Effects: starting exactly at last stroke end feels tightly chained; adding a small beat gives the strokes a moment to "settle" before the wordmark joins - - Constraints: MUST be ≥ SEG_3_START + FINAL_SEGMENT_DUR (otherwise wordmark appears during the draw and competes with it) - - Reference: SEG_3_START + FINAL_SEGMENT_DUR + ~0.2s - -Ease families used here are discrete choices, not tunable scalars: - -- **stroke draws** use `power2.out` — gentle deceleration mimics a hand lifting at end of stroke. Do NOT use `back.out` or `elastic.out` (pens don't bounce). -- **brand fade** uses `power1.out` — soft tail on an opacity tween. -- For a constant-speed "real pen" tracing feel, swap to `none` (see Variations). - ## Variations -### Rotation start point (start from top instead of 3 o'clock) - -By default, `` and `` start their stroke at 3 o'clock. Rotate the element to start from top: +- **Ring starting at 12 o'clock** — `` / `` strokes start at 3 o'clock by default; rotate the element `-90deg` so a progress ring draws from the top: ```html ` and `` start their stroke at 3 o'clock. Rotate the cy="100" r="60" id="ring" - style="transform-origin: 100px 100px; transform: rotate(-90deg);" + style="transform-origin: 100px 100px; transform: rotate(-90deg)" /> ``` -### Linear (constant-speed) draw - -Use `ease: 'none'` for steady-rate drawing (like an actual pen tracing): - -```js -tl.to("#path", { strokeDashoffset: 0, duration: SEGMENT_DRAW_DUR, ease: "none" }, SEG_1_START); -``` - -### Draw then fill - -For SVG shapes that have a fill color, animate fill opacity to come in AFTER the stroke completes: +- **Linear (constant-speed) draw** — `ease: "none"` for a steady-rate "real pen" trace. +- **Draw then fill** — for filled shapes, tween `fillOpacity: 0 → 1` AFTER the stroke completes (requires `fill-opacity: 0` initially and a real `fill` in CSS): ```js tl.to( @@ -242,33 +96,27 @@ tl.to( ); ``` -Requires `fill-opacity: 0` initially and a real `fill` color in CSS. +## Values -## Key Principles +| token | range | notes | +| ----------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------- | +| SEGMENT_DRAW_DUR | 0.3–0.8s | fast snap vs deliberate pen trace; >~1s feels sluggish for a logo reveal | +| FINAL_SEGMENT_DUR | 60–80% of SEGMENT_DRAW_DUR | proportional to segment length — a short connector at full duration reads slower than its siblings | +| SEG_N_START | previous start + 70–80% of its duration | reads as continuous motion, not N isolated animations | +| SEG_1_START | 0–0.4s | a small ~0.2s lead-in lets the viewer settle before motion | +| BRAND_FADE_START | ≥ last stroke end (+ ~0.2s beat) | earlier and the wordmark competes with the draw | +| BRAND_FADE_DUR | 0.3–0.8s | snap (urgent) vs glide (premium) | -- **Set `strokeDasharray` to the path's `getTotalLength()` value**, not an arbitrary number — guessing means stroke will animate but not match the geometry -- **Start `strokeDashoffset` at the same length**, animate down to `0` -- **Measure inside the timeline setup, not at module top** — SVG may not be rendered when module code runs in some environments. In HF runtime this works at top because SVG is inline, but be safe -- **`stroke-linecap: round`** for softer endpoints (less abrupt finish) -- **For sequential multi-path draws, stagger by ~70-80% of the previous segment's duration** — eye reads it as continuous motion, not N separate animations -- **Don't pair with `back.out` or `elastic.out`** — bouncing strokes feel wrong (the pen wouldn't bounce) +Ease families are discrete choices: **stroke draws** use `power2.out` (a hand lifting at end of stroke) or `none` for constant speed — never `back.out` / `elastic.out` (pens don't bounce). **Fades** use `power1.out`. ## Critical Constraints -- **`fill: none` in CSS for outline-only draws** — otherwise the fill area appears immediately and ruins the reveal -- **Path length is measured in the browser**: requires SVG to be in the DOM. HF inline SVG is fine; loaded `` SVGs may not be -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **Works on**: ``, ``, ``, ``, ``, ``, `` (anything with a stroke) -- **For complex paths**, if `getTotalLength()` looks wrong, overestimate `strokeDasharray` slightly (e.g. `len * 1.05`) — too large is invisible during animation start (no visible gap), too small clips the end - -## Combinations - -- [counting-dynamic-scale.md](counting-dynamic-scale.md) — pair: stroke draws an icon while a number counts up beside it -- [hacker-flip-3d.md](hacker-flip-3d.md) — pair: SVG logo draws, then a hacker-flipped wordmark reveals under it +- **`fill: none`** for outline-only draws — otherwise the fill appears immediately. +- **Dasharray/dashoffset = the measured `getTotalLength()`**, set at setup; requires the SVG in the DOM (inline SVG is fine; a loaded `` SVG is not). +- **Complex paths**: if `getTotalLength()` looks wrong, overestimate slightly (`len * 1.05`) — too large is invisible at animation start; too small clips the end. +- **Stagger multi-path draws at ~70–80%** of the previous segment's duration. +- **A drawn line must land on something.** When the path is a connector (rail, beam, underline, callout) rather than a shape, both endpoints must sit on real elements and the draw must do a job — reveal, route, validate, or emphasize. A stroke that only decorates empty space reads as filler; attach it or cut it. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — timeline + stroke property tween -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`svg-icon-enrichment` (internal parts animate after the outline draws) · `counting-dynamic-scale` (stroke draws an icon while a number counts up) · `hacker-flip-3d` (logo draws, wordmark decodes beneath). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/theme-crossfade-morph.md b/plugins/visual-content/skills/hyperframes-animation/rules/theme-crossfade-morph.md new file mode 100644 index 0000000..0621633 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/theme-crossfade-morph.md @@ -0,0 +1,126 @@ +--- +name: theme-crossfade-morph +description: Whole-theme in-place morph under a fixed anchor — background, typography, corner radii, icons, chrome and logos all blend simultaneously (~0.3s) through N pre-styled skins while one anchor element never moves. Recipe = stacked full layers + opacity crossfade, anchor rendered once on top. Seek-safe by construction. +metadata: + tags: theme, skin, crossfade, morph, anchor, reskin, cycle, ui, stacked-layers +--- + +# Theme Crossfade Morph + +The whole world re-skins while one thing holds still. A composer box cycles through four IDE themes; a checkout widget flips through brand skins — background, typography, corner radii, toolbar icons, footer logos all change **at once**, in place, in ~0.3s, N times — and through every flip one anchor element (the prompt string, the widget layout, the wordmark) **never moves**. The anchor's stillness is the rhetorical claim: _everything changes, this doesn't._ + +Boundary: [card-morph-anchor.md](card-morph-anchor.md) morphs **one container** between two shots — its dimensions, radius, and surface tween continuously. This rule re-skins an **entire scene** through **N discrete states**: nothing tweens property-by-property (fonts, icons, and logos can't interpolate); the "morph" is a fast simultaneous crossfade of complete pre-styled layers. ([scale-swap-transition.md](scale-swap-transition.md) swaps an element at center; here the surroundings swap and the element holds.) + +## How It Works + +1. **One skin = one complete layer.** Each theme state is a fully pre-styled, full-bleed layer (`position: absolute; inset: 0`) containing everything that changes: background, shell/chrome, toolbar icons, footer logos, typography. All `N_SKINS` layers exist in the DOM from `t=0`, stacked; skin 0 starts visible, the rest at `opacity: 0`. +2. **The morph is a crossfade.** At each boundary, two opposing opacity tweens run at the same timeline position over `MORPH_DUR` (~0.3s): outgoing `1 → 0`, incoming `0 → 1`. Because both layers are complete, every property "blends" simultaneously for free — including the un-tweenable ones (font families, icon glyphs, logos), which read as morphing precisely because everything else is mid-blend around them. +3. **The anchor renders once, on top.** The element that must not move lives in its own layer above all skins and is **excluded from every skin layer**. No transforms, no re-parenting, no per-skin restyle. +4. **Windows are precomputed.** `T_k = CYCLE_START + k × (SKIN_HOLD + MORPH_DUR)`. Steady cadence by default; hold the final skin longest when it's the resolve. + +The only animated property is `opacity` — which is why this rule is seek-safe with zero special machinery. + +## Recipe + +```html + +
+ +
…terminal chrome, mono type, footer badge…
+
+
…rounded composer, sans type, toolbar pills, logo…
+
+
…dark shell, its own chrome and footer…
+ + +
{anchorText}
+
+``` + +```css +.theme-stage { + position: absolute; + inset: 0; +} +.skin { + position: absolute; + inset: 0; + opacity: 0; + /* Each skin fully self-styled: its own background, fonts, radii, + icons, chrome, logos. Nothing inherited across skins. */ +} +.skin-0 { + opacity: 1; /* the opening state — matches the timeline's fromTo */ +} +.shell { + /* CRITICAL: shared geometry. The shell box (and any element that + "persists" across skins — toolbar row, footer row) sits at the SAME + coordinates in every skin, so mid-blend frames read as one UI + changing clothes, not two UIs ghosting. */ + position: absolute; + left: SHELL_LEFT; + top: SHELL_TOP; + width: SHELL_WIDTH; + height: SHELL_HEIGHT; +} +.anchor { + position: absolute; + z-index: 10; /* above every skin */ + left: ANCHOR_LEFT; + top: ANCHOR_TOP; + /* No transforms, no transitions — the stillness is load-bearing. */ +} +``` + +```js +const skins = gsap.utils.toArray(".skin"); + +// Boundary k→k+1 at T_k: outgoing fades down as incoming fades up — +// ONE simultaneous crossfade, everything blends at once. +skins.forEach((skin, k) => { + if (k === 0) return; // skin-0 is the opening state + const at = CYCLE_START + k * (SKIN_HOLD + MORPH_DUR); + tl.fromTo(skin, { opacity: 0 }, { opacity: 1, duration: MORPH_DUR, ease: "power2.inOut" }, at); + tl.to( + skins[k - 1], + { opacity: 0, duration: MORPH_DUR, ease: "power2.inOut" }, + at, // same position — the blend is simultaneous, never sequential + ); +}); + +// The anchor gets NO tweens. Its absence from the timeline is the point. +``` + +## Variations + +- **Anchor-typography reskin (per-layer copies)** — when the anchor's own type treatment must change with the theme (mono in the terminal skin, sans in the editor skin), each skin carries its own copy of the anchor at **pixel-identical geometry** and there is no separate top layer; the invariant shifts from "one element" to "one geometry." Verify the copies overlay exactly (screenshot two skins at 50% opacity) — a 2px baseline drift reads as the anchor flinching, which breaks the whole claim. +- **Skin-cycle tour with logo relay** — a large brand logo outside the anchored shell crossfades **in the same windows** as the skins (logo k with skin k, same `MORPH_DUR`). The paired swap sells "same product, every brand." +- **Washout finale** — after the last skin, a final low-key layer (faint dot-grid, blueprint wash) fades in while the last shell drops to ~0.25 opacity — the cycle resolves into a held diagram of itself. One extra window; the anchor may fade with the shell or hold full-strength. +- **Emphasis brake** — steady cadence for `N−1` skins, then hold the final skin 2–3× `SKIN_HOLD`; the cycle demonstrates breadth, the brake lands the resolve. Precompute the hold array; don't drift the cadence without cause. + +## Values + +| token | range | notes | +| --------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| N_SKINS | 3–5 | two is a before/after (consider `card-morph-anchor`); past five the cycle pads | +| SKIN_HOLD | 0.8–1.5s | long enough to register the logo/footer identity, short enough to keep the churn rhetorical | +| MORPH_DUR | 0.25–0.4s, ~0.3s canonical | faster reads as a hard cut; slower reads as a mushy dissolve with lingering double-exposure | +| CYCLE_START | ≥ anchor settle + a beat | after the anchor and skin-0 have fully registered | +| SHELL geometry | — | shell / toolbar / footer coordinates identical across skins; contents inside the slots differ freely | +| ANCHOR position | — | identical to the pixel across the scene (per-layer form: identical in every skin) | +| washout / brake | shell ~0.2–0.3 opacity; hold 2–3× SKIN_HOLD | — | + +## Critical Constraints + +- **The anchor never moves.** No transforms, no opacity dips, no re-parenting, no restyle — the contrast between total churn and total stillness is the entire device; one flinch and the shot becomes a slideshow. +- **Nothing tweens but `opacity`** — no `borderRadius` / `background` tweens; radii and colors change by being different in the next layer. Visibility via `opacity` only, never `display` / `visibility` toggles (they can't blend mid-fade). +- **Pixel-align the shared geometry** — mid-blend both skins are partially visible; aligned shells read as one UI changing clothes, misaligned shells ghost into two UIs. +- **Pre-style everything** — each skin is complete and static; no class toggling, no runtime restyle mid-tween. +- **Outgoing and incoming tweens share one timeline position** — a staggered blend flashes the stage background between skins. +- **Adjacent windows only** — skin k crossfades with k+1, never k+2; at no frame are three skins partially visible. +- **Camera static — always.** A push-in on top of a theme cycle destroys the stillness that makes the anchor read. +- **Hard cuts are the cheaper sibling** — if the states should _snap_, that's `discrete-text-sequence` territory; the ~0.3s blend is specifically the "morph" read. + +## See also + +`context-sensitive-cursor` (caret color switches at each `T_k`) · `discrete-text-sequence` (type the anchor first; or the hard-cut alternative) · `card-morph-anchor` (the single-container sibling) · `spring-pop-entrance` (the lockup that joins the anchor at the resolve) · `sine-wave-loop` (drifting field under the cycle — never on the anchor). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/vertical-spring-ticker.md b/plugins/visual-content/skills/hyperframes-animation/rules/vertical-spring-ticker.md index 88bbd17..9c2c8fe 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/vertical-spring-ticker.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/vertical-spring-ticker.md @@ -7,233 +7,98 @@ metadata: # Vertical Spring Ticker (Slot Machine) -Multiple spring tweens are ADDED TOGETHER to produce total Y translation. Each spring contributes one discrete "step." The combined motion has snappy distinct moves with natural settling — instead of a single linear scroll, you get the slot-machine "click click click" rhythm. +Multiple spring tweens are ADDED TOGETHER to produce total Y translation — each spring contributes one discrete "step", so instead of a single linear scroll you get the slot-machine "click click click" rhythm with natural settling. Distinct from a continuous marquee: this rule's semantics are discrete steps that land; for endless linear motion see [sine-wave-loop.md](sine-wave-loop.md). ## How It Works -Container has fixed height `ITEM_HEIGHT`, `overflow: hidden`. Inside is a vertical stack of items, each also `ITEM_HEIGHT` tall. The translate of the inner stack is computed as: +A masked window of fixed height `ITEM_HEIGHT` (`overflow: hidden`) holds a vertical stack of items, each exactly `ITEM_HEIGHT` tall. Each spring holds a 0→1 progress; a shared `onUpdate` sums them and applies `translateY(-sum × ITEM_HEIGHT)`. Springs fire sequentially with overlap (`STEP_SPACING ≤ STEP_DUR`), so each step snaps in while the previous is still settling — that overlap is what makes them additive, and the `back.out` overshoot is what makes each step read as a "click". -``` -translateY = -ITEM_HEIGHT * sum(spring_i.progress for each spring) -``` - -Each spring fires at a different time, settles, then the next fires. When summed, the stack snaps forward step-by-step. The "spring" easing gives each step a tiny overshoot/settle that distinguishes it from a linear marquee. - -## HTML +## Recipe ```html -
-
-
{eyebrow}
-
-
- -
{item0}
-
{item1}
-
{item2}
-
{item3}
-
{itemN}
-
-
-
{footerLine}
+ +
+
+
{item0}
+
{item1}
+
{itemN}
``` -## CSS - ```css -.scene { - position: relative; - width: 100%; - height: 100%; - display: grid; - place-items: center; - background: {bgColor}; - font-family: {font}; -} -.stack { - display: flex; - flex-direction: column; - align-items: center; - gap: STACK_GAP; -} -.eyebrow { - font-size: EYEBROW_FONT_SIZE; - font-weight: 800; - letter-spacing: 14px; - text-transform: uppercase; - color: {accentColor}; -} -/* MANDATORY: container height matches the per-item height exactly */ .ticker { width: TICKER_WIDTH; - height: ITEM_HEIGHT; /* MUST match .item height */ - overflow: hidden; - border-top: 2px solid {dividerColor}; - border-bottom: 2px solid {dividerColor}; - position: relative; + height: ITEM_HEIGHT; /* MUST match .item height exactly */ + overflow: hidden; /* the mask is the window */ } .stack-inner { display: flex; - flex-direction: column; /* MANDATORY for vertical ticker */ - will-change: transform; + flex-direction: column; /* mandatory — vertical stacking */ } .item { - height: ITEM_HEIGHT; /* MUST equal .ticker height */ + height: ITEM_HEIGHT; /* MUST equal .ticker height */ display: flex; align-items: center; justify-content: center; - font-size: ITEM_FONT_SIZE; - font-weight: 900; - letter-spacing: 8px; - text-transform: uppercase; - color: {textColor}; /* font-variant-numeric: tabular-nums; — for numeric tickers */ } -.brand { - font-size: BRAND_FONT_SIZE; - font-weight: 800; - letter-spacing: 10px; - color: {accentColor}; - text-transform: uppercase; -} ``` -## GSAP Timeline +```js +const innerEl = document.getElementById("stack-inner"); +const springs = Array.from({ length: STEPS }, () => ({ p: 0 })); -```html - - +}); ``` -## How to Choose Values - -- **ITEM_HEIGHT** — px height of each ticker slot AND the masked window. - - Range: ~`ITEM_FONT_SIZE × 1.25`; the line must hold capital descenders without clipping - - Constraints: **`.ticker` height MUST equal `.item` height** exactly — mismatched values cause partial items to peek above/below the mask - - Reference: ../../examples/proof-logo-chain.html uses `204px` -- **TICKER_WIDTH** — px width of the masked window. - - Range: wide enough to hold the longest item without ellipsis; typically 30-60% of viewport width -- **STEPS** — number of additive springs (number of state transitions, not number of items). - - Range: typically 1-4; each step = one "click" in the slot-machine cadence - - Constraints: `STEPS ≤ itemCount − 1` (you can only roll as far as there are items below the visible one) - - Reference: ../../examples/proof-logo-chain.html uses `1` (single roll between two states) -- **STEP_DUR** — duration of each spring tween. - - Range: 0.3-0.7s; under 0.3 the overshoot is invisible, over 0.7 the click reads as a slide - - Reference: ../../examples/proof-logo-chain.html uses `0.45s` -- **STEP_SPACING** — seconds between consecutive springs' start times. - - Range: 0.3-0.5s; closer and the steps blur together (looks like linear scroll), further and the ticker feels lazy - - Constraints: `STEP_SPACING ≤ STEP_DUR` so the previous step is still settling when the next fires (this is what makes them "additive") -- **STEP_START** — when the first spring fires. - - Range: 0+; gate behind any preceding beat -- **BOUNCE_FACTOR** — `back.out(BOUNCE_FACTOR)` overshoot strength per step. - - Range: 1.4 (gentle click) → 2.0 (firm click) → 2.5+ (cartoony spin-and-land for a climax step) - - Effects: low end reads as polished UI, high end reads as casino / game show -- **BRAND_DELAY** — gap after the final step before the footer line reveals, in seconds. - - Range: 0.2-0.5s; lets the final overshoot settle before the next element competes for attention -- **BRAND_FADE_DUR** — footer fade-in duration. - - Range: 0.4-0.7s -- **BRAND_Y** — initial vertical offset of the footer before fade-up (in px). - - Range: 8-24 px; bigger feels "punched in," smaller feels gentle -- **EYEBROW_FONT_SIZE / ITEM_FONT_SIZE / BRAND_FONT_SIZE / STACK_GAP** — typographic + layout scaling. - - Constraints: items are the focal beat, sized 4-8× larger than eyebrow/footer -- **{bgColor} / {accentColor} / {textColor} / {dividerColor}** — semantic color tokens; accent reserved for the eyebrow and footer so the ticker items stay neutral. -- **{font}** — base typography stack. For numeric tickers add `font-variant-numeric: tabular-nums` so digit widths stay constant. - ## Variations -### Numeric ticker (price / counter rolling) - -Replace text items with the digit sequence and use the same spring-step pattern per decimal position (units, tens, hundreds...). Add `font-variant-numeric: tabular-nums` for digit-width stability. - -### Reverse direction (counting down) - -Swap the sign on the translate: `transform: translateY(${sumP * ITEM_HEIGHT}px)` and arrange items in reverse order. Reads as a countdown. +- **Numeric ticker (price / counter rolling)** — items are the digit sequence; run the same spring-step pattern per decimal position. `font-variant-numeric: tabular-nums` required. +- **Reverse direction (countdown)** — flip the sign (`translateY(${sumP * ITEM_HEIGHT}px)`) and arrange items in reverse order. +- **Pause between groups** — several fast steps (small `STEP_SPACING`), a long pause, then one dramatic final step with a bigger `BOUNCE_FACTOR`. The pause is where the eye locks in. +- **Continuous infinite ticker** — NOT this rule (this rule is discrete steps); a looping news ticker is a single linear tween with duplicated items — see [sine-wave-loop.md](sine-wave-loop.md) for continuous-motion semantics. -### Continuous infinite ticker (no settling) +## Values -Loop forever (e.g. news ticker) — use linear ease on a single long tween, duplicate the items list, reset when translation exceeds total height. NOT this rule — see [sine-wave-loop](sine-wave-loop.md) pattern for continuous motion vs this rule's discrete-step semantics. +| token | range | notes | +| ------------- | --------------------- | ------------------------------------------------------------------------------------- | +| ITEM_HEIGHT | ~`fontSize × 1.25` | must hold capital descenders; `.ticker` height MUST equal it exactly | +| TICKER_WIDTH | 30–60% viewport width | wide enough for the longest item without ellipsis | +| STEPS | 1–4 | number of transitions, not items; `STEPS ≤ itemCount − 1` | +| STEP_DUR | 0.3–0.7s | under 0.3 the overshoot is invisible; over 0.7 the click reads as a slide | +| STEP_SPACING | 0.3–0.5s | **≤ STEP_DUR** so springs overlap (additive); wider gaps read as a lazy linear scroll | +| BOUNCE_FACTOR | 1.4–2.5 | 1.4 gentle click / 2.0 firm / 2.5+ casino spin-and-land for a climax step | -### Pause between groups - -For dramatic "spin then land" feel, group several fast spring steps (`STEP_SPACING` small) + a long `BRAND_DELAY`-style pause + a final dramatic step with bigger `BOUNCE_FACTOR`. The pause is where the eye locks in. - -## Key Principles - -- **Container height MUST equal item height** — otherwise items don't snap cleanly into the visible window. If container is 200px and items are 220px, every step shows a partial item edge above/below. -- **`overflow: hidden` on container, NOT on inner stack** — the mask is the window; the stack inside is free to extend below. -- **`flex-direction: column` on inner stack** — required for vertical stacking; row would make items horizontal. -- **Step spacing tighter than step duration** — overlap is what makes the springs additive and gives the "click click" cadence; non-overlapping steps read as a linear scroll. -- **`back.out` per step** — the overshoot is what makes each step feel like a "click." Linear ease or out-only ease loses the slot-machine feel. -- **Sum the springs in onUpdate, don't tween the final position directly** — this is the "additive" trick; each spring contributes its OWN snap, which is the slot-machine pacing. -- **❗ Don't update items via `innerHTML` between steps** — the ticker moves the SAME items via translate; replacing content makes the previous item visible AS the new one (broken illusion). -- **❗ Climax dwell ≥1s after final step** — see SKILL universal constraints. +Reference: `../examples/proof-logo-chain.html` (204px, 1 step, 0.45s). ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition`** on stack-inner — competes with the additive transform -- **`will-change: transform`** on stack-inner — many small transform updates per second -- **All items same height (pixel-exact)** — mismatched heights cause cumulative drift -- **For numeric: `font-variant-numeric: tabular-nums`** — variable digit widths break alignment - -## Combinations - -- [reactive-displacement.md](reactive-displacement.md) — ticker is "pushed" by an incoming element -- [scale-swap-transition.md](scale-swap-transition.md) — ticker scales out after settling on final state, scaled-in subtitle replaces it -- [press-release-spring.md](press-release-spring.md) — button press TRIGGERS the ticker spin +- **Container height = item height, pixel-exact, all items equal** — mismatches show partial item edges above/below the mask and accumulate drift across steps. +- **`overflow: hidden` on the container, not the inner stack**; `flex-direction: column` on the stack. +- **Sum the springs in `onUpdate` — never tween the final position directly.** Each spring contributing its OWN snap is the slot-machine pacing. +- **Overlap steps and keep `back.out` per step** — non-overlapping steps or an out-only ease collapse into a linear scroll. +- **Never update items via `innerHTML` between steps** — the ticker moves the SAME items via translate; swapping content shows the previous item AS the new one (broken illusion). +- **Climax dwell ≥1s after the final step** (SKILL universal constraint). +- **`tabular-nums` for numeric tickers** — variable digit widths break alignment. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — additive spring tweens via shared onUpdate -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +`reactive-displacement` (ticker pushed by an incoming element) · `scale-swap-transition` (ticker scales out after settling) · `press-release-spring` (button press triggers the spin). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/viewport-change.md b/plugins/visual-content/skills/hyperframes-animation/rules/viewport-change.md index 19df966..fe6faac 100644 --- a/plugins/visual-content/skills/hyperframes-animation/rules/viewport-change.md +++ b/plugins/visual-content/skills/hyperframes-animation/rules/viewport-change.md @@ -7,161 +7,74 @@ metadata: # Viewport Change (Virtual Camera) -Simulates camera effects (zoom / pan / focus-lock on a moving element) by transforming a wrapper around ALL scene content. The "world" moves opposite to the perceived camera. Distinct from [multi-phase-camera](multi-phase-camera.md) (which is 2-3 discrete phases + drift) — viewport-change is a single continuous zoom/pan, often used for focus-lock following a moving element. +Simulates camera effects (zoom / pan / focus-lock on a moving element) by transforming a wrapper around ALL scene content. The "world" moves opposite to the perceived camera. Distinct from [multi-phase-camera](multi-phase-camera.md) (2-3 discrete phases + drift) — viewport-change is a single continuous zoom/pan, often used for focus-lock following a moving element. ## How It Works -Camera intent → world transform: +Camera intent → world transform. Camera **pans right** → world `translateX(-distance)`; camera **zooms in** → world `scale(>1)`; camera **follows element X** → world `translateX(viewportCenter - elementWorldX)` per-frame. Get the sign right or everything moves the wrong way. The single `.world` wrapper holds the camera transform; elements inside are positioned in world space, unchanged. -- Camera **pans right** → world `translateX(-distance)` -- Camera **zooms in** → world `scale(>1)` -- Camera **follows element X** → world `translateX(viewportCenter - elementWorldX)` updated per-frame - -The wrapper holds the camera transform; the elements inside are positioned in "world space" unchanged. - -**Single-element composite transform (this rule's form).** Both scale and translate live on ONE wrapper as `translate(x, y) scale(S)`. CSS applies scale FIRST, then translate (right-to-left matrix composition), so a point at world offset `(ox, oy)` lands on screen at `(S × ox + x, S × oy + y)`. To map the target to viewport center: +**Single-element composite transform (this rule's form).** Both scale and translate live on ONE wrapper as `translate(x, y) scale(S)`. CSS applies scale FIRST, then translate (right-to-left matrix composition), so a point at world offset `(ox, oy)` lands on screen at `(S × ox + x, S × oy + y)`. To map the target to viewport center, solve `S × offset + T = 0`: ``` T = -offset × S ``` -This is **different from [coordinate-target-zoom](coordinate-target-zoom.md)**, which uses two nested wrappers (outer scales, inner translates) and derives `T = -offset` (independent of S). Use this rule's single-wrapper form when you want one source of truth for camera state (`cam.scale`, `cam.x`, `cam.y`) updated via `onUpdate`; use nested wrappers when scale and translate can tween independently with shared ease. +This is **different from [coordinate-target-zoom](coordinate-target-zoom.md)**, which uses two nested wrappers (outer scales, inner translates) and derives `T = -offset` (independent of S). Mixing up the two forms drifts the target off-center as scale changes. Use this single-wrapper form when you want one source of truth for camera state (`cam.scale`, `cam.x`, `cam.y`) written via `onUpdate`; use nested wrappers when scale and translate can tween independently with shared ease. -## HTML +## Recipe ```html -
-
-
-
{Brand}
-
{tagline}
-
-
{ctaUrl}
-
-
+
+
+
{Brand}
+
{tagline}
+
{ctaUrl}
``` -## CSS - ```css .scene { - position: relative; - width: 100%; - height: 100%; - overflow: hidden; - background: {bgGradient}; - font-family: {font}; + overflow: hidden; /* REQUIRED — any non-1.0 scale reveals edges or pushes content off-frame */ + background: {bgGradient}; /* on .scene, NOT .world — a world-borne background warps with the camera */ } .world { position: absolute; inset: 0; display: grid; place-items: center; - transform-origin: 50% 50%; + transform-origin: 50% 50%; /* centered scaling is what the math assumes */ will-change: transform; } -.content { - display: flex; - flex-direction: column; - align-items: center; - gap: CONTENT_GAP; - text-align: center; -} -.hero { - font-size: HERO_FONT_SIZE; - font-weight: 900; - letter-spacing: HERO_LETTER_SPACING; - text-transform: uppercase; - color: {textColor}; -} -.tagline { - font-size: TAGLINE_FONT_SIZE; - font-weight: 600; - color: {labelColor}; -} -.cta { - display: inline-block; - padding: CTA_PADDING_Y CTA_PADDING_X; - font-family: {monoFont}; - font-size: CTA_FONT_SIZE; - font-weight: 700; - letter-spacing: CTA_LETTER_SPACING; - color: {accentColor}; - text-transform: uppercase; - background: {ctaBg}; - border: 1px solid {ctaBorder}; - border-radius: CTA_BORDER_RADIUS; -} ``` -## GSAP Timeline - -```html - - +tl.to( + cam, + { + scale: TARGET_SCALE, + y: counterY, + duration: ZOOM_DUR, + ease: "power3.inOut", + onUpdate: applyCamera, + }, + ZOOM_START, +); ``` ## Scale Value Guide @@ -174,32 +87,34 @@ This is **different from [coordinate-target-zoom](coordinate-target-zoom.md)**, | Dramatic | 1.5 - 2.5 | Element fills screen | | Full-screen | 3.0+ | Element covers viewport | -| Perception threshold | Result | -| -------------------- | -------------------- | -| < 5% | Imperceptible | -| 10-15% | Comfortable emphasis | -| > 30% | Cinematic / dramatic | +Perception: < 5% scale change is imperceptible; 10-15% is comfortable emphasis; > 30% is cinematic/dramatic. For a natural product feel, prefer 1.05-1.15× over 2-3s; save big > 1.3× zooms for dramatic narrative moments. -## Variations +### Extreme range — 4–12× outward (workspace reveal) -### Focus-lock (camera follows moving cursor/character) +The same single-cam math runs far past the table: a zoom-out workspace reveal opens punched-in at **4–12×** on one detail (a single cell, message, or button) and pulls out to the full workspace in one continuous move. The mechanics don't change — one `cam` object, `T = -offset × S`, one `applyCamera()` writer — only the authoring direction does: -For an element moving across the world, keep it at fixed screen X. Compute world offset per-frame: +- **Build the workspace at its final (1×) layout and OPEN scaled-in** (`cam.scale = 8`, counter-translate aiming the opening detail; state it in a `fromTo` / seed via `applyCamera()` so a seek to t=0 lands punched-in). The wide landing frame is then everything at native design size — text crisp, raster assets at source resolution. +- **Never the inverse** — authoring the close-up at 1× and scaling the world down to 0.08–0.25 for the wide frame drops every label below legible pixel size and softens raster media; the reveal lands on mush. +- **Measure the opening target** — at S = 8, a 1 px error in the baked offset is 8 px on screen at the opening pose. Take the offset from the target's real laid-out center (`getBoundingClientRect` after `fonts.ready`, once at setup — the measuring doctrine in [coordinate-target-zoom.md](coordinate-target-zoom.md)), never from a layout formula. +- **The opening detail must survive ×S** — it renders at `S ×` its design size on the first frames (vector/DOM text is safe; raster needs `sourceResolution ≥ rendered × S`). + +## Variations + +- **Focus-lock (camera follows a moving cursor/character)** — keep the element at a fixed screen X by computing the world offset per-frame inside the driver's `onUpdate`: ```js const focusEl = document.querySelector(".moving-cursor"); -const targetScreenX = VIEWPORT_WIDTH * FOCUS_SCREEN_X_FRAC; +const targetScreenX = VIEWPORT_WIDTH * FOCUS_SCREEN_X_FRAC; // 0.4–0.7; 0.5 = dead center const focusUpdate = { p: 0 }; tl.to( focusUpdate, { p: 1, - duration: FOLLOW_DUR, + duration: FOLLOW_DUR, // matches how long the focused element is in motion ease: "power2.inOut", onUpdate: () => { const rect = focusEl.getBoundingClientRect(); - const focusWorldX = rect.left + rect.width / 2; - cam.x = targetScreenX - focusWorldX; + cam.x = targetScreenX - (rect.left + rect.width / 2); applyCamera(); }, }, @@ -207,143 +122,27 @@ tl.to( ); ``` -### Composite scale (multi-phase) - -Multiply two scale tweens for compound effects: - -```js -const scaleUp = { v: 1 }; -const scaleDown = { v: 1 }; -function applyCompositeCamera() { - cam.scale = scaleUp.v * scaleDown.v; - applyCamera(); -} -tl.to( - scaleUp, - { v: SCALE_UP_TARGET, duration: SCALE_UP_DUR, onUpdate: applyCompositeCamera }, - SCALE_UP_START, -); -tl.to( - scaleDown, - { v: SCALE_DOWN_TARGET, duration: SCALE_DOWN_DUR, onUpdate: applyCompositeCamera }, - SCALE_DOWN_START, -); -``` - -### Camera mode transition (centered → follow) - -Crossfade between two camera modes via a 0→1 weight tween. At weight 0, mode A; at weight 1, mode B; intermediate is interpolated. - -## How to Choose Values - -### Layout (CSS) - -- **CONTENT_GAP** — vertical gap between hero, tagline, and CTA. - - Range: 16-48 px - - Effects: small → tightly stacked (logo-lockup feel); large → airy, editorial -- **HERO_FONT_SIZE / TAGLINE_FONT_SIZE / CTA_FONT_SIZE** — typographic hierarchy. - - Range: hero >> tagline > CTA (hero is the brand mark, CTA is the actionable footer) - - Constraints: hero must remain readable when scaled DOWN at neutral camera AND when scaled UP during the zoom — pick the size at neutral camera, the zoom only enlarges it -- **HERO_LETTER_SPACING / CTA_LETTER_SPACING** — uppercase tracking. - - Range: 4-10 px for uppercase display type; 0 for sentence case -- **CTA_PADDING_X / CTA_PADDING_Y / CTA_BORDER_RADIUS** — pill geometry around the CTA text. - - Constraints: `CTA_BORDER_RADIUS ≥ CTA_FONT_SIZE` to keep the pill ends fully rounded - -### Phase 1 — Content reveal - -- **HERO_START** — when the hero begins fading in. - - Range: 0.2-0.5s (small offset for a beat of black before content appears) -- **HERO_DUR** — hero fade-up duration. - - Range: 0.6-1.2s -- **HERO_Y** — initial Y offset of hero before fade-up (in px). - - Range: 16-48 px -- **TAGLINE_START** — when the tagline begins fading in. - - Constraints: `≥ HERO_START + 0.3` (let the hero land first so the eye reads top-down) -- **TAGLINE_DUR / TAGLINE_Y** — same shape as hero, typically smaller (`TAGLINE_Y` half of `HERO_Y`). +- **Composite scale (multi-phase)** — two proxy tweens multiplied through one writer: `cam.scale = scaleUp.v * scaleDown.v; applyCamera()`. Combine a slow push-in (~1.15) with a brief release (~0.9) for a breath/punch shape. +- **Camera mode transition (centered → follow)** — crossfade two camera modes via a 0→1 weight tween; intermediate frames interpolate between the modes' offsets. -### Phase 2 — Zoom +## Values -- **TARGET_OFFSET_Y** — measured Y offset (in px) of the CTA from viewport center at neutral camera. - - Constraints: derived from layout, NOT a free parameter. Measure via `getBoundingClientRect()` OR compute from `CONTENT_GAP + (HERO_HEIGHT + TAGLINE_HEIGHT) / 2`. Sign matters — positive = below center. -- **TARGET_SCALE** — final magnification of the world. - - Range: 1.3× (modest) → 1.6-2.0× (typical CTA zoom) → 3×+ (cinematic) - - Constraints: raster source media needs `sourceResolution ≥ rendered × TARGET_SCALE`; text remains crisp at any scale -- **ZOOM_START** — when the zoom begins. - - Constraints: `≥ TAGLINE_START + TAGLINE_DUR + viewer-scan-time` (give viewer ~0.5s after content lands before camera moves) -- **ZOOM_DUR** — duration of the zoom tween. - - Range: 1.0-2.0s; under 0.8s feels like a teleport, over 2.5s drags - -### Phase 3 — CTA reveal + dwell - -- **CTA_REVEAL_START** — when the CTA pops in. - - Constraints: `≥ ZOOM_START + ZOOM_DUR × 0.9` (start near the end of the zoom so the CTA "lands" with the camera) -- **CTA_REVEAL_DUR** — CTA fade-in / pop duration. - - Range: 0.4-0.8s -- **CTA_REVEAL_SCALE** — initial scale of the CTA before pop. - - Range: 0.85-0.95 (sub-1 → grows into place); >1.0 inverts to a shrink-into-place feel -- **BOUNCE_FACTOR** — overshoot coefficient for `back.out(${BOUNCE_FACTOR})`. - - Range: 1.2-2.5; lower = subtle settle, higher = pronounced overshoot. The ease family (`back.out`) is the choice; this number tunes its intensity. - - Reference: ease family options: `back.out` (overshoot then settle), `elastic.out` (oscillation), `power3.out` (clean decel, no overshoot) -- **DWELL_DUR** — implicit hold after `CTA_REVEAL_START + CTA_REVEAL_DUR` until `data-duration` ends. - - Range: ≥ 1.0s (see "Climax dwell" in Key Principles) - -### Focus-lock variation - -- **VIEWPORT_WIDTH** — composition width in px. Real value (`data-width` on the root); not abstract. -- **FOCUS_SCREEN_X_FRAC** — where on screen to lock the focused element. - - Range: 0.4-0.7 (rule of thirds positions); 0.5 is dead center -- **FOLLOW_START / FOLLOW_DUR** — when the follow-cam engages and for how long. - - Constraints: `FOLLOW_DUR` matches the duration the focused element is in motion - -### Composite-scale variation - -- **SCALE_UP_TARGET / SCALE_DOWN_TARGET** — multiplicative factors composed via `cam.scale = scaleUp.v * scaleDown.v`. - - Effects: combine a slow push-in (`SCALE_UP_TARGET` ~1.15) with a brief release (`SCALE_DOWN_TARGET` ~0.9) for a breath/punch shape -- **SCALE_UP_START / SCALE_UP_DUR / SCALE_DOWN_START / SCALE_DOWN_DUR** — phase timing for each multiplicand. - -### Color tokens - -- **{bgGradient}** — scene background (typically a dark radial vignette so edges fall off as zoom reveals them) -- **{textColor}** — hero text; highest contrast against `{bgGradient}` -- **{labelColor}** — tagline / secondary copy; one step softer than `{textColor}` -- **{accentColor}** — CTA text + border; reserved hue that pops on reveal -- **{ctaBg} / {ctaBorder}** — semi-transparent fills derived from `{accentColor}` (typical `rgba` at 10-15% / 35-45% alpha) - -### Font tokens - -- **{font}** — sans-serif body / hero stack (e.g. `"Inter", sans-serif`) -- **{monoFont}** — monospace CTA stack (e.g. `"JetBrains Mono", monospace`); reserved for the URL/code-like CTA so it reads as actionable - -## Key Principles - -- **World moves opposite to perceived camera** — pan camera right = `translateX(-x)` on the world wrapper. Get this sign right, otherwise everything moves the wrong way. -- **Single-wrapper transform order matters** — `translate(x, y) scale(S)` applies scale first; counter-translate is `T = -offset × S`. Mixing this up with the nested-wrapper form (`T = -offset`) drifts the target off-center as scale changes. -- **`overflow: hidden` on `.scene` REQUIRED** — at any non-1.0 scale the world transform reveals edges or pushes content off-frame. -- **`transform-origin: 50% 50%`** on the world wrapper — centered scaling is what the math assumes. -- **Background on `.scene`, NOT on `.world`** — if background is on the world, transforming the world warps/translates the background. -- **Single source of truth via `cam` object + `applyCamera()`** — when scale and translate both change, write them in ONE place. Otherwise the transform string composition order is unpredictable. -- **Subtle continuous motion > big sudden zoom** — for a feel-natural product video, use 1.05-1.15× zoom over 2-3s. Big > 1.3× zooms read as dramatic narrative moments, save them. -- **Climax dwell >=1s** — after the zoom settles, the comp must continue for >=1s so the viewer can read the focal point. +| token | range | notes | +| --------------- | ------------------------------------ | ------------------------------------------------------------------------------------------- | +| TARGET_OFFSET_Y | measured, not a free parameter | target's offset from viewport center at neutral camera; measure via `getBoundingClientRect` | +| TARGET_SCALE | 1.3× modest → 1.6–2.0× typical → 3×+ | raster media needs `sourceResolution ≥ rendered × TARGET_SCALE` | +| ZOOM_START | content landed + ~0.5s scan time | let the viewer read before the camera moves | +| ZOOM_DUR | 1.0–2.0s | under 0.8s teleports, over 2.5s drags | +| DWELL | ≥ 1.0s after the zoom settles | the viewer must be able to read the focal point (climax dwell) | +| VIEWPORT_WIDTH | = the root's `data-width` | real value, not abstract | ## Critical Constraints -- **Timeline must be paused**: `gsap.timeline({ paused: true })` -- **Registry key = `data-composition-id`** -- **No CSS `transition` on `.world`** — competes with GSAP -- **`will-change: transform`** on `.world` -- **`overflow: hidden` on `.scene`** -- **`transform-origin: 50% 50%` on `.world`** -- **Background on `.scene`** — never on `.world` -- **Scale and translate share one `onUpdate`** — both read from `cam` and write the composite transform string together; never split them across tweens that touch `world.style.transform` directly - -## Combinations - -- [multi-phase-camera.md](multi-phase-camera.md) — viewport-change inside one phase of a multi-phase camera -- [coordinate-target-zoom.md](coordinate-target-zoom.md) — alternative for off-center zoom (nested wrappers, `T = -offset` form) -- [sine-wave-loop.md](sine-wave-loop.md) — idle micro-drift after viewport settles +- **One `.world` wrapper carries the whole camera** — every scene element lives inside it; a second transformed wrapper is a second camera. +- **Single source of truth via the `cam` object + `applyCamera()`** — when scale and translate both change, write them in ONE place; never split them across tweens that touch `world.style.transform` directly (the transform string composition order becomes unpredictable). +- **Single-wrapper counter-translate is `T = -offset × S`** — don't import the nested-wrapper `T = -offset` formula. +- **`overflow: hidden` on `.scene`**; **`transform-origin: 50% 50%` on `.world`**; **background on `.scene`, never on `.world`**. -## Pairs with HF skills +## See also -- `/hyperframes-animation` — single tween writing composite transform -- `/hyperframes-core` — composition wiring -- `/hyperframes-cli` — `hyperframes lint` +[coordinate-target-zoom.md](coordinate-target-zoom.md) (nested-wrapper alternative, `T = -offset`) · [multi-phase-camera.md](multi-phase-camera.md) (viewport-change inside one phase) · [sine-wave-loop.md](sine-wave-loop.md) (idle micro-drift after the viewport settles). diff --git a/plugins/visual-content/skills/hyperframes-animation/rules/waterfall-entry.md b/plugins/visual-content/skills/hyperframes-animation/rules/waterfall-entry.md new file mode 100644 index 0000000..e19322b --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/rules/waterfall-entry.md @@ -0,0 +1,81 @@ +--- +name: waterfall-entry +description: Staggered ARRIVAL cascade — words/elements whip in from below (one consistent direction), each starting before the previous settles, an accelerating wave that resolves into a composed layout. Title cards, segment openers, list/feature intros. Opacity is BINARY 0→1 via tl.set — never fade an arrival. +metadata: + tags: entrance, cascade, stagger, kinetic-text, title-card, segment-opener, arrival, waterfall, whip +--- + +# Waterfall Entry + +Staggered ARRIVAL cascade: words/elements whip in from below (one consistent direction), +each starting before the previous settles — an accelerating wave that resolves into a +composed layout. Title cards, segment openers, list/feature intros. + +**This is an in-scene arrival, not a seam.** Its seam sibling is the waterfall CUT +(`cut-the-curve` doctrine skill, `seams/waterfall-cut.md`); do not mix their rules: + +| | Entry (this rule — arrival) | Waterfall Cut (seam) | +| ------------- | --------------------------------------------- | --------------------------------------------------------- | +| Opacity | BINARY 0→1 via `tl.set` at entry — never fade | ignites at 0.35 mid-path — the fade IS the velocity trick | +| Axis default | Y, from below | X, riding the current | +| Outgoing side | none | words ramp out on mirrored power4.in | + +## Choreography + +- **Overlap, don't queue** — next element starts within ±2 frames of the previous + settling; gaps SHRINK across the cascade; the last element snaps. +- **Velocity varies by weight** — heavy/anchor elements travel further and longer; + light words/punctuation snap in tight: + +| Parameter | Anchor/heavy | Normal word | Light/punctuation | +| --------- | ------------ | ----------- | ----------------- | +| Y offset | 60–80px | 40–50px | 30–48px | +| Duration | 0.16–0.20s | 0.13–0.16s | 0.10–0.13s | +| Overlap | 0–2f gap | 1f overlap | 1–2f overlap | + +- Ease `power4.out` (`expo.out` for extra snap); never `.inOut` on an entry. +- One direction per cascade. +- Split the FINAL word into fragments to extend the climax; fragments travel further. +- Post-settle, the group usually slides to make room for the next beat — that's + [nudge-curve.md](nudge-curve.md). + +## JS + +Each element: `tl.set` (instant reveal + offset) then `tl.to` (whip to rest). +`nextStart = prevStart + prevDuration − (overlapFrames × F)`; +overlap = cascade, +−overlap = deliberate gap. CSS: elements start `opacity: 0; display: inline-block`. + +```js +var F = 1 / 60; +var t0 = 0.1; +// anchor (heaviest): biggest travel, longest settle +tl.set("#el-1", { opacity: 1, y: 80 }, t0); +tl.to("#el-1", { y: 0, duration: 0.18, ease: "power4.out" }, t0); +// normal word: 2 frames after the anchor finishes +var t1 = t0 + 0.18 + 2 * F; +tl.set("#el-2", { opacity: 1, y: 45 }, t1); +tl.to("#el-2", { y: 0, duration: 0.15, ease: "power4.out" }, t1); +// light word: 1 frame BEFORE the previous finishes (overlap) +var t2 = t1 + 0.15 - F; +tl.set("#el-3", { opacity: 1, y: 40 }, t2); +tl.to("#el-3", { y: 0, duration: 0.14, ease: "power4.out" }, t2); +// split final-word fragments: tightest overlap, extra travel (lighter) +var t3 = t2 + 0.14 - F; +tl.set("#frag-a", { opacity: 1, y: 70 }, t3); +tl.to("#frag-a", { y: 0, duration: 0.16, ease: "power4.out" }, t3); +var t4 = t3 + 0.14 - F; +tl.set("#frag-b", { opacity: 1, y: 70 }, t4); +tl.to("#frag-b", { y: 0, duration: 0.15, ease: "power4.out" }, t4); +// punctuation: lightest, fastest +var t5 = t4 + 0.13 - 2 * F; +tl.set("#dot", { opacity: 1, y: 48 }, t5); +tl.to("#dot", { y: 0, duration: 0.12, ease: "power4.out" }, t5); +``` + +## Anti-patterns + +| Don't | Instead | +| ------------------------------------------------------ | --------------------------------------------------------------------------------- | +| Queued entries (each waits for the previous to settle) | Overlap ±1–2 frames — the cascade is a wave, not a queue | +| Same offset/duration for every cascade element | Vary by weight: anchors travel further, punctuation snaps | +| Gradual opacity fade on an arrival | Binary 0→1 via `tl.set` — fading fights the snap (seam cuts fade; arrivals don't) | diff --git a/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map-sampling.mjs b/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map-sampling.mjs new file mode 100644 index 0000000..7a118c6 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map-sampling.mjs @@ -0,0 +1,40 @@ +/** + * Seek and measure every sample for one tween inside a single browser + * evaluation. GSAP/HyperFrames seeks update DOM state synchronously, so a + * separate CDP round trip and wall-clock sleep per sample only adds latency. + */ +export async function sampleTweenBboxes(page, selector, times) { + return page.evaluate( + ({ selector: sel, times: sampleTimes }) => { + const seek = (time) => { + if (window.__hf && typeof window.__hf.seek === "function") { + window.__hf.seek(time); + return; + } + const timelines = window.__timelines; + if (!timelines) return; + for (const timeline of Object.values(timelines)) { + if (typeof timeline.seek === "function") timeline.seek(time); + } + }; + + return sampleTimes.map((time) => { + seek(time); + const el = document.querySelector(sel); + if (!el) return { t: time, x: 0, y: 0, w: 0, h: 0, missing: true }; + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + return { + t: time, + x: Math.round(rect.x), + y: Math.round(rect.y), + w: Math.round(rect.width), + h: Math.round(rect.height), + opacity: parseFloat(style.opacity), + visible: style.visibility !== "hidden" && style.display !== "none", + }; + }); + }, + { selector, times }, + ); +} diff --git a/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map-sampling.test.mjs b/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map-sampling.test.mjs new file mode 100644 index 0000000..ef6ddb8 --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map-sampling.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { sampleTweenBboxes } from "./animation-map-sampling.mjs"; + +test("samples every tween time in one browser evaluation", async () => { + const calls = []; + const seekTimes = []; + const originalGlobals = { + window: globalThis.window, + document: globalThis.document, + getComputedStyle: globalThis.getComputedStyle, + }; + let currentTime = 0; + globalThis.window = { __hf: { seek: (time) => (currentTime = time) } }; + globalThis.document = { + querySelector: () => ({ + getBoundingClientRect: () => ({ x: currentTime, y: 20, width: 30, height: 40 }), + }), + }; + globalThis.getComputedStyle = () => ({ opacity: "1", visibility: "visible", display: "block" }); + const page = { + async evaluate(callback, payload) { + calls.push(payload); + const originalSeek = globalThis.window.__hf.seek; + globalThis.window.__hf.seek = (time) => { + seekTimes.push(time); + originalSeek(time); + }; + return callback(payload); + }, + }; + + try { + const result = await sampleTweenBboxes(page, "#card", [1, 2, 3]); + + assert.deepEqual(result, [ + { t: 1, x: 1, y: 20, w: 30, h: 40, opacity: 1, visible: true }, + { t: 2, x: 2, y: 20, w: 30, h: 40, opacity: 1, visible: true }, + { t: 3, x: 3, y: 20, w: 30, h: 40, opacity: 1, visible: true }, + ]); + assert.deepEqual(seekTimes, [1, 2, 3]); + assert.deepEqual(calls, [{ selector: "#card", times: [1, 2, 3] }]); + } finally { + globalThis.window = originalGlobals.window; + globalThis.document = originalGlobals.document; + globalThis.getComputedStyle = originalGlobals.getComputedStyle; + } +}); diff --git a/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map.mjs b/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map.mjs index 95771ab..0bb3c81 100644 --- a/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map.mjs +++ b/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map.mjs @@ -8,22 +8,34 @@ // Usage: // node skills/hyperframes-animation/scripts/animation-map.mjs \ // [--frames N] [--out ] [--min-duration S] [--width W] [--height H] [--fps N] +// +// Env: +// HYPERFRAMES_SKILL_PKG_VERSION — pin the @hyperframes/producer version used +// when bootstrapping (global skill installs cannot infer it; falls back to +// @latest with a warning otherwise). import { mkdir, writeFile } from "node:fs/promises"; import { resolve, join } from "node:path"; -import { hyperframesPackageSpec, importPackagesOrBootstrap } from "./package-loader.mjs"; - -const { - createFileServer, - createCaptureSession, - initializeSession, - closeCaptureSession, - getCompositionDuration, -} = ( - await importPackagesOrBootstrap(["@hyperframes/producer"], { - npmPackages: [hyperframesPackageSpec("@hyperframes/producer")], - }) -)["@hyperframes/producer"]; +import { sampleTweenBboxes } from "./animation-map-sampling.mjs"; +import { + bundleCompositionForCapture, + hyperframesPackageSpec, + importPackagesOrBootstrap, + initializeSessionWithRetry, +} from "./package-loader.mjs"; + +const packages = await importPackagesOrBootstrap( + ["@hyperframes/producer", "@hyperframes/core", "@hyperframes/core/compiler"], + { + npmPackages: [ + hyperframesPackageSpec("@hyperframes/producer"), + hyperframesPackageSpec("@hyperframes/core"), + ], + }, +); +const { createFileServer, createCaptureSession, closeCaptureSession, getCompositionDuration } = + packages["@hyperframes/producer"]; +const { parseFps } = packages["@hyperframes/core"]; // ─── CLI ───────────────────────────────────────────────────────────────────── @@ -35,23 +47,43 @@ const OUT_DIR = resolve(args.out ?? ".hyperframes/anim-map"); const MIN_DUR = Number(args["min-duration"] ?? 0.15); const WIDTH = Number(args.width ?? 1920); const HEIGHT = Number(args.height ?? 1080); -const FPS = Number(args.fps ?? 30); +const parsedFps = parseFps(args.fps ?? 30); +if (!parsedFps.ok) die(`Invalid --fps "${args.fps ?? ""}": ${parsedFps.reason}`); +const FPS = parsedFps.value; const COMP_DIR = resolve(args.composition); await mkdir(OUT_DIR, { recursive: true }); // ─── Main ──────────────────────────────────────────────────────────────────── -const server = await createFileServer({ projectDir: COMP_DIR, port: 0 }); -const session = await createCaptureSession( - server.url, - OUT_DIR, - { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, - null, -); -await initializeSession(session); - +// Raw modular hosts do not mount child compositions in the capture helper. +// Bundle first so duration/timeline discovery sees the same DOM as render/check. +const bundle = await bundleCompositionForCapture(packages["@hyperframes/core/compiler"], COMP_DIR); +let server; +let session; try { + server = await createFileServer({ + projectDir: COMP_DIR, + compiledDir: bundle.compiledDir, + port: 0, + }); + // Canonical transient-init retry/cleanup (mirrors the render pipeline's + // probeStage): a valid modular project's sub-composition timelines register + // asynchronously, so the first attempt can time out as transient + // "zero duration / Runtime ready: false" — retry once with a fresh browser + // instead of false-failing the project. + session = await initializeSessionWithRetry( + packages["@hyperframes/producer"], + () => + createCaptureSession( + server.url, + OUT_DIR, + { width: WIDTH, height: HEIGHT, fps: FPS, format: "png" }, + null, + ), + { log: (message) => console.error(`animation-map: ${message}`) }, + ); + const duration = await getCompositionDuration(session); const tweens = await enumerateTweens(session); const kept = tweens.filter((tw) => tw.end - tw.start >= MIN_DUR); @@ -72,12 +104,11 @@ try { (_, k) => +(tw.start + ((k + 0.5) / FRAMES) * (tw.end - tw.start)).toFixed(3), ); - const bboxes = []; - for (const t of times) { - await seekTo(session, t); - const bbox = await measureTarget(session, tw.selectorHint); - bboxes.push({ t, ...bbox }); - } + // No selector means no element to measure (an onUpdate driver). Sampling anyway + // would hand querySelector an unmatchable string. + const bboxes = tw.selectorHint + ? await sampleTweenBboxes(session.page, tw.selectorHint, times) + : []; const animProps = tw.props.filter( (p) => !["parent", "overwrite", "immediateRender", "startAt", "runBackwards"].includes(p), @@ -87,7 +118,8 @@ try { report.tweens.push({ index: i + 1, - selector: tw.selectorHint, + selector: tw.selectorHint ?? "(onUpdate driver)", + driver: tw.driver, targets: tw.targetCount, props: animProps, start: +tw.start.toFixed(3), @@ -111,8 +143,14 @@ try { // ── Composition-level analysis ── report.choreography = buildTimeline(report.tweens, duration); report.density = computeDensity(report.tweens, duration); - report.staggers = detectStaggers(report.tweens); - report.elements = buildElementLifecycles(report.tweens); + // Staggers and lifecycles are per-ELEMENT, and a driver tween has none. Keyed on + // tw.selector they would collapse every driver in the composition into one + // "(onUpdate driver)" pseudo-element with null geometry, and let three same-duration + // drivers read as a stagger no element performs. Density, dead zones and the timeline + // still count them — those are per-SPAN, which is what a driver does have. + const elementTweens = report.tweens.filter((tw) => tw.driver !== "onUpdate"); + report.staggers = detectStaggers(elementTweens); + report.elements = buildElementLifecycles(elementTweens); report.deadZones = findDeadZones(report.density, duration); report.snapshots = await captureSnapshots(session, report.tweens, duration); @@ -120,8 +158,9 @@ try { printSummary(report); } finally { - await closeCaptureSession(session).catch(() => {}); - server.close(); + if (session) await closeCaptureSession(session).catch(() => {}); + server?.close(); + bundle.cleanup(); } // ─── Seek helper ──────────────────────────────────────────────────────────── @@ -156,17 +195,21 @@ async function enumerateTweens(session) { return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase(); }; - const walk = (node, parentOffset = 0) => { + const walk = (node, parentOffset = 0, parentDriven = false) => { if (!node) return; if (typeof node.getChildren === "function") { const offset = parentOffset + (node.startTime?.() ?? 0); + // A TIMELINE can own the driver instead of the tween. The WebGL/uniform idiom is + // gsap.timeline({ onUpdate: renderFrame }) over children that tween plain uniform + // objects; those children carry no onUpdate of their own, so the driver has to + // reach them from above or their motion reads as a dead zone all the same. + const driven = parentDriven || typeof node.vars?.onUpdate === "function"; for (const child of node.getChildren(true, true, true)) { - walk(child, offset); + walk(child, offset, driven); } return; } const targets = (node.targets?.() ?? []).filter((t) => t instanceof Element); - if (!targets.length) return; const vars = node.vars ?? {}; const props = Object.keys(vars).filter( (k) => @@ -182,10 +225,28 @@ async function enumerateTweens(session) { "stagger", ].includes(k), ); + // The proxy-driver idiom tweens a plain object and applies the motion in onUpdate, + // so targets() holds no Element. Dropping those tweens hid real motion from the + // map: computeDensity saw zero active tweens over their span and findDeadZones + // reported it as dead. There is no element to select or measure here, but the span + // is real, so keep the tween and mark why it carries no geometry. + // + // Under an inherited driver the tween must also CHANGE something. Its own onUpdate is + // proof of work by itself (a repaint loop need not animate a property), but a parent's + // is not: a bare `tl.to({}, { duration: D })` spacer inside a driven timeline advances + // the playhead without altering any value, so counting it would mask a genuine dead + // zone — the exact false positive the tween-local rule was careful to avoid. + const isProxyDriver = + targets.length === 0 && + (typeof vars.onUpdate === "function" || (parentDriven && props.length > 0)); + if (!targets.length && !isProxyDriver) return; const start = parentOffset + (node.startTime?.() ?? 0); const end = start + (node.duration?.() ?? 0); results.push({ - selectorHint: selectorOf(targets[0]) ?? "(unknown)", + // null, not a placeholder string: this feeds document.querySelector downstream, + // so it must be absent rather than unmatchable. + selectorHint: isProxyDriver ? null : (selectorOf(targets[0]) ?? "(unknown)"), + driver: isProxyDriver ? "onUpdate" : "target", targetCount: targets.length, props, start, @@ -200,30 +261,21 @@ async function enumerateTweens(session) { }); } -async function measureTarget(session, selector) { - return await session.page.evaluate((sel) => { - const el = document.querySelector(sel); - if (!el) return { x: 0, y: 0, w: 0, h: 0, missing: true }; - const r = el.getBoundingClientRect(); - const cs = getComputedStyle(el); - return { - x: Math.round(r.x), - y: Math.round(r.y), - w: Math.round(r.width), - h: Math.round(r.height), - opacity: parseFloat(cs.opacity), - visible: cs.visibility !== "hidden" && cs.display !== "none", - }; - }, selector); -} - // ─── Tween description (the key output for agents) ────────────────────────── function describeTween(tw, props, bboxes, flags) { const dur = (tw.end - tw.start).toFixed(2); const parts = []; - parts.push(`${tw.selectorHint} animates ${props.join("+")} over ${dur}s (${tw.ease})`); + if (tw.selectorHint) { + parts.push(`${tw.selectorHint} animates ${props.join("+")} over ${dur}s (${tw.ease})`); + } else { + // An onUpdate driver: the span and props are known, the affected element is not. + parts.push( + `an onUpdate driver animates ${props.join("+")} over ${dur}s (${tw.ease}) — ` + + `motion is applied in JS, so no element geometry was measured`, + ); + } // Movement const first = bboxes[0]; @@ -288,7 +340,10 @@ function computeFlags(tw, bboxes, { width, height }) { const flags = []; const dur = tw.end - tw.start; - if (bboxes.every((b) => b.w === 0 || b.h === 0)) flags.push("degenerate"); + // No samples at all (an onUpdate driver has no element to measure) is not evidence of + // a degenerate or invisible box — `[].every()` is vacuously true, so guard the + // geometry-derived flags. The pacing flags below read only start/end and still apply. + if (bboxes.length && bboxes.every((b) => b.w === 0 || b.h === 0)) flags.push("degenerate"); const anyOffscreen = bboxes.some( (b) => @@ -303,7 +358,10 @@ function computeFlags(tw, bboxes, { width, height }) { ); if (anyOffscreen) flags.push("offscreen"); - if (bboxes.every((b) => b.opacity !== undefined && b.opacity < 0.01 && b.visible)) { + if ( + bboxes.length && + bboxes.every((b) => b.opacity !== undefined && b.opacity < 0.01 && b.visible) + ) { flags.push("invisible"); } diff --git a/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map.test.mjs b/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map.test.mjs new file mode 100644 index 0000000..d93c33f --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/scripts/animation-map.test.mjs @@ -0,0 +1,444 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const HELPERS = [ + join(REPO_ROOT, "skills", "hyperframes-animation", "scripts", "animation-map.mjs"), + join(REPO_ROOT, "skills", "hyperframes-creative", "scripts", "contrast-report.mjs"), +]; + +describe("HyperFrames skill helpers", () => { + for (const helper of HELPERS) + it(`${helper.split("/").at(-1)} bundles modular input and uses rational fps`, () => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-helper-test-")); + const packageDir = join(root, "node_modules", "@hyperframes", "producer"); + const corePackageDir = join(root, "node_modules", "@hyperframes", "core"); + const sharpPackageDir = join(root, "node_modules", "sharp"); + const compositionDir = join(root, "composition"); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(corePackageDir, { recursive: true }); + mkdirSync(sharpPackageDir, { recursive: true }); + mkdirSync(compositionDir, { recursive: true }); + writeFileSync( + join(packageDir, "package.json"), + JSON.stringify({ name: "@hyperframes/producer", type: "module", exports: "./index.mjs" }), + ); + writeFileSync( + join(packageDir, "index.mjs"), + [ + 'import { readFileSync } from "node:fs";', + 'import { join } from "node:path";', + "export async function createFileServer(options) {", + ' const bundled = readFileSync(join(options.compiledDir, "index.html"), "utf8");', + ' if (bundled !== "
bundled modular composition
") {', + " throw new Error(`UNEXPECTED_BUNDLE=${bundled}`);", + " }", + ' return { url: "http://test", close() {} };', + "}", + "export async function createCaptureSession(_url, _out, options) {", + " throw new Error(`CAPTURE_OPTIONS=${JSON.stringify(options)}`);", + "}", + "export async function initializeSession() {}", + "export async function closeCaptureSession() {}", + "export async function getCompositionDuration() { return 0; }", + ].join("\n"), + ); + writeFileSync( + join(corePackageDir, "package.json"), + JSON.stringify({ + name: "@hyperframes/core", + type: "module", + exports: { ".": "./index.mjs", "./compiler": "./compiler.mjs" }, + }), + ); + writeFileSync( + join(corePackageDir, "index.mjs"), + [ + "export function parseFps(input) {", + " if (input === '30000/1001') return { ok: true, value: { num: 30000, den: 1001 } };", + " if (input === '29.97') return { ok: false, reason: 'ambiguous-decimal' };", + " return { ok: true, value: { num: Number(input), den: 1 } };", + "}", + ].join("\n"), + ); + writeFileSync( + join(corePackageDir, "compiler.mjs"), + [ + "export async function bundleToSingleHtml() {", + ' return "
bundled modular composition
";', + "}", + ].join("\n"), + ); + writeFileSync( + join(sharpPackageDir, "package.json"), + JSON.stringify({ name: "sharp", type: "module", exports: "./index.mjs" }), + ); + writeFileSync(join(sharpPackageDir, "index.mjs"), "export default function sharp() {}\n"); + + try { + const result = spawnSync( + process.execPath, + [helper, compositionDir, "--fps", "30000/1001", "--out", join(root, "output")], + { + encoding: "utf8", + env: { + ...process.env, + HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules"), + }, + }, + ); + const output = `${result.stdout}\n${result.stderr}`; + assert.notEqual(result.status, 0); + assert.match(output, /CAPTURE_OPTIONS=.*"fps":\{"num":30000,"den":1001\}/); + + const invalid = spawnSync( + process.execPath, + [helper, compositionDir, "--fps", "29.97", "--out", join(root, "invalid-output")], + { + encoding: "utf8", + env: { + ...process.env, + HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules"), + }, + }, + ); + const invalidOutput = `${invalid.stdout}\n${invalid.stderr}`; + assert.notEqual(invalid.status, 0); + assert.match(invalidOutput, /Invalid --fps "29\.97": ambiguous-decimal/); + assert.doesNotMatch(invalidOutput, /CAPTURE_OPTIONS=/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +// The two package-loader.mjs copies are intentionally byte-identical (each +// skill ships standalone, so neither can import the other's) and now carry +// shared logic (initializeSessionWithRetry + FALLBACK_TRANSIENT_PATTERNS) +// that a future fix could land in one copy and silently miss in the other — +// the exact drift class the audio.mjs identity pin was born to catch. +describe("package-loader parity", () => { + it("package-loader.mjs is byte-identical to hyperframes-creative's copy (the stated contract)", () => { + const here = readFileSync( + join(REPO_ROOT, "skills", "hyperframes-animation", "scripts", "package-loader.mjs"), + "utf8", + ); + const sibling = readFileSync( + join(REPO_ROOT, "skills", "hyperframes-creative", "scripts", "package-loader.mjs"), + "utf8", + ); + assert.equal(here, sibling); + }); +}); + +// ── Transient-init retry (the zero-duration false-fail fix) ───────────────── +// A valid modular project's sub-composition timelines register asynchronously; +// the first initializeSession can time out with the transient "zero duration / +// Runtime ready: false" diagnostic. The render pipeline closes the crashed +// session and retries once with a fresh browser (probeStage) — the standalone +// helpers must do the same instead of reporting the project as zero-duration. + +/** Write a fake node_modules with the given producer index.mjs source. */ +function writeFakeEnv(root, producerIndexSource) { + const packageDir = join(root, "node_modules", "@hyperframes", "producer"); + const corePackageDir = join(root, "node_modules", "@hyperframes", "core"); + const sharpPackageDir = join(root, "node_modules", "sharp"); + const compositionDir = join(root, "composition"); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(corePackageDir, { recursive: true }); + mkdirSync(sharpPackageDir, { recursive: true }); + mkdirSync(compositionDir, { recursive: true }); + writeFileSync( + join(packageDir, "package.json"), + JSON.stringify({ name: "@hyperframes/producer", type: "module", exports: "./index.mjs" }), + ); + writeFileSync(join(packageDir, "index.mjs"), producerIndexSource); + writeFileSync( + join(corePackageDir, "package.json"), + JSON.stringify({ + name: "@hyperframes/core", + type: "module", + exports: { ".": "./index.mjs", "./compiler": "./compiler.mjs" }, + }), + ); + writeFileSync( + join(corePackageDir, "index.mjs"), + "export function parseFps(input) { return { ok: true, value: { num: Number(input), den: 1 } }; }", + ); + writeFileSync( + join(corePackageDir, "compiler.mjs"), + 'export async function bundleToSingleHtml() { return "
x
"; }', + ); + writeFileSync( + join(sharpPackageDir, "package.json"), + JSON.stringify({ name: "sharp", type: "module", exports: "./index.mjs" }), + ); + writeFileSync(join(sharpPackageDir, "index.mjs"), "export default function sharp() {}\n"); + return compositionDir; +} + +function runHelper(helper, root, compositionDir) { + const result = spawnSync(process.execPath, [helper, compositionDir, "--out", join(root, "out")], { + encoding: "utf8", + env: { ...process.env, HYPERFRAMES_SKILL_NODE_MODULES: join(root, "node_modules") }, + }); + return `${result.stdout}\n${result.stderr}`; +} + +const FAKE_PRODUCER_COMMON = [ + 'export async function createFileServer() { return { url: "http://test", close() {} }; }', + 'export async function createCaptureSession() { console.error("SESSION_CREATED"); return {}; }', + 'export async function closeCaptureSession() { console.error("SESSION_CLOSED"); }', + "export async function getCompositionDuration() { return 0; }", +].join("\n"); + +describe("transient-init retry", () => { + for (const helper of HELPERS) { + it(`${helper.split("/").at(-1)} retries a transient zero-duration init once with a fresh session`, () => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-retry-test-")); + try { + const compositionDir = writeFakeEnv( + root, + [ + FAKE_PRODUCER_COMMON, + "let initCalls = 0;", + "export async function initializeSession() {", + " initCalls++;", + " if (initCalls === 1) {", + // The transient shape: readiness deadline hit before async + // sub-composition timelines landed (Runtime ready: false). + ' throw new Error("Composition has zero duration after initialization.\\nRuntime ready: false");', + " }", + ' throw new Error("INIT_ATTEMPT_2_REACHED");', + "}", + ].join("\n"), + ); + + const output = runHelper(helper, root, compositionDir); + + // Retried: fresh session created for attempt 2, crashed one closed. + assert.match(output, /retrying with a fresh browser session/); + assert.equal((output.match(/SESSION_CREATED/g) ?? []).length, 2); + assert.equal((output.match(/SESSION_CLOSED/g) ?? []).length, 2); + // ...and the retry genuinely re-ran init (bounded: no third attempt). + assert.match(output, /INIT_ATTEMPT_2_REACHED/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it(`${helper.split("/").at(-1)} does NOT retry a genuine authoring failure (Runtime ready: true)`, () => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-retry-test-")); + try { + const compositionDir = writeFakeEnv( + root, + [ + FAKE_PRODUCER_COMMON, + "export async function initializeSession() {", + // The fast-fail shape: runtime IS ready, there is genuinely no + // timeline/duration — an authoring bug retries can't fix. + ' throw new Error("Composition has zero duration after initialization.\\nRuntime ready: true");', + "}", + ].join("\n"), + ); + + const output = runHelper(helper, root, compositionDir); + + assert.doesNotMatch(output, /retrying with a fresh browser session/); + assert.equal((output.match(/SESSION_CREATED/g) ?? []).length, 1); + assert.match(output, /Composition has zero duration/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + } + + it("prefers the producer's own isTransientBrowserError classifier when exported", () => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-retry-test-")); + try { + const compositionDir = writeFakeEnv( + root, + [ + FAKE_PRODUCER_COMMON, + // A message the frozen fallback patterns would NOT match — only the + // producer-provided classifier can mark it transient. + "export function isTransientBrowserError(err) { return String(err && err.message).includes('CUSTOM_TRANSIENT'); }", + "let initCalls = 0;", + "export async function initializeSession() {", + " initCalls++;", + ' if (initCalls === 1) throw new Error("CUSTOM_TRANSIENT flake");', + ' throw new Error("INIT_ATTEMPT_2_REACHED");', + "}", + ].join("\n"), + ); + + const output = runHelper(HELPERS[0], root, compositionDir); + + assert.match(output, /retrying with a fresh browser session/); + assert.match(output, /INIT_ATTEMPT_2_REACHED/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +// ── Proxy-driver tweens (the false dead-zone fix) ─────────────────────────── +// The proxy-driver idiom tweens a plain object and applies the motion inside +// onUpdate, so the tween's targets() holds no Element. The map used to drop those +// tweens outright, which meant computeDensity counted zero active tweens over their +// span and findDeadZones reported real motion as a dead zone. +// +// The fake producer hands animation-map a session whose page.evaluate runs the +// callback in this process, against a stubbed window/document. That exercises the real +// enumerateTweens/computeDensity/findDeadZones code without a browser. +const FAKE_PROXY_DRIVER_ENV = [ + "globalThis.Element = class Element {};", + "const mover = new globalThis.Element();", + 'mover.id = "mover";', + "mover.classList = [];", + // 0-1s: an ordinary element tween. + "const elementTween = {", + " targets: () => [mover],", + ' vars: { x: 900, duration: 1, ease: "power2.out" },', + " startTime: () => 0,", + " duration: () => 1,", + "};", + // 2-4s: a proxy driver. Real motion, no Element target. + "const proxyTween = {", + " targets: () => [{ v: 0 }],", + ' vars: { v: 100, duration: 2, ease: "none", onUpdate() {} },', + " startTime: () => 2,", + " duration: () => 2,", + "};", + // 2-4s as well: a bare spacer with no onUpdate. Produces nothing, must stay dropped, + // otherwise every full-span anchor tween would mask genuine dead zones. + "const spacerTween = {", + " targets: () => [{}],", + " vars: { duration: 2 },", + " startTime: () => 2,", + " duration: () => 2,", + "};", + "const timeline = {", + " getChildren: () => [elementTween, proxyTween, spacerTween],", + " startTime: () => 0,", + " duration: () => 4,", + " seek() {},", + "};", + "globalThis.window = { __timelines: { main: timeline } };", + "globalThis.document = { querySelector: () => null, querySelectorAll: () => [] };", + "globalThis.getComputedStyle = () => ({", + ' opacity: "1",', + ' visibility: "visible",', + ' display: "block",', + "});", + 'export async function createFileServer() { return { url: "http://test", close() {} }; }', + "export async function createCaptureSession() {", + " return { page: { evaluate: async (fn, arg) => fn(arg) } };", + "}", + "export async function closeCaptureSession() {}", + "export async function initializeSession() {}", + "export async function getCompositionDuration() { return 4; }", +].join("\n"); + +// The WebGL/uniform shape, e.g. skills/music-to-video/references/templates/ +// held-message-living-field: the TIMELINE carries onUpdate: renderFrame and its children +// tween plain uniform objects. No child has an onUpdate of its own, so a tween-local +// discriminator misses all of them and the whole composition reads as one dead zone. +const FAKE_PARENT_DRIVER_ENV = [ + "globalThis.Element = class Element {};", + "const uniformTween = {", + " targets: () => [{ value: 0 }],", + ' vars: { value: 12, duration: 12, ease: "none" },', + " startTime: () => 0,", + " duration: () => 12,", + "};", + // Same driven timeline, but this one alters nothing — the repaint it triggers is + // identical frame to frame, so it must NOT count as motion. + "const spacerTween = {", + " targets: () => [{}],", + " vars: { duration: 12 },", + " startTime: () => 0,", + " duration: () => 12,", + "};", + "const timeline = {", + " vars: { onUpdate() {} },", + " getChildren: () => [uniformTween, spacerTween],", + " startTime: () => 0,", + " duration: () => 12,", + " seek() {},", + "};", + "globalThis.window = { __timelines: { main: timeline } };", + "globalThis.document = { querySelector: () => null, querySelectorAll: () => [] };", + "globalThis.getComputedStyle = () => ({", + ' opacity: "1",', + ' visibility: "visible",', + ' display: "block",', + "});", + 'export async function createFileServer() { return { url: "http://test", close() {} }; }', + "export async function createCaptureSession() {", + " return { page: { evaluate: async (fn, arg) => fn(arg) } };", + "}", + "export async function closeCaptureSession() {}", + "export async function initializeSession() {}", + "export async function getCompositionDuration() { return 12; }", +].join("\n"); + +describe("proxy-driver tweens", () => { + it("counts an onUpdate driver's span instead of reporting it as a dead zone", () => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-proxy-test-")); + try { + const compositionDir = writeFakeEnv(root, FAKE_PROXY_DRIVER_ENV); + const output = runHelper(HELPERS[0], root, compositionDir); + const report = JSON.parse(readFileSync(join(root, "out", "animation-map.json"), "utf8")); + + const drivers = report.tweens.filter((tw) => tw.driver === "onUpdate"); + assert.equal(drivers.length, 1, `expected one onUpdate driver in:\n${output}`); + assert.equal(drivers[0].start, 2); + assert.equal(drivers[0].end, 4); + assert.equal(drivers[0].targets, 0); + assert.deepEqual(drivers[0].bboxes, [], "there is no element to measure"); + // `[].every()` is vacuously true, so unmeasured must not read as degenerate/invisible. + assert.deepEqual(drivers[0].flags, []); + + assert.deepEqual(report.deadZones, [], "2-4s is animating, not dead"); + // The bare spacer stays out — only the element tween and the driver are mapped. + assert.equal(report.tweens.length, 2); + + // Per-ELEMENT analyses must not adopt the driver as a pseudo-element. + assert.deepEqual(Object.keys(report.elements), ["#mover"]); + assert.deepEqual(report.staggers, []); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("inherits a driver the TIMELINE owns, without counting a spacer under it", () => { + const root = mkdtempSync(join(tmpdir(), "hyperframes-skill-parent-driver-test-")); + try { + const compositionDir = writeFakeEnv(root, FAKE_PARENT_DRIVER_ENV); + const output = runHelper(HELPERS[0], root, compositionDir); + const report = JSON.parse(readFileSync(join(root, "out", "animation-map.json"), "utf8")); + + const drivers = report.tweens.filter((tw) => tw.driver === "onUpdate"); + assert.equal(drivers.length, 1, `expected one inherited driver in:\n${output}`); + assert.deepEqual(drivers[0].props, ["value"]); + assert.equal(drivers[0].start, 0); + assert.equal(drivers[0].end, 12); + + assert.deepEqual(report.deadZones, [], "the uniform tween animates the whole span"); + // Nothing element-backed here at all, so both per-element analyses stay empty. + assert.deepEqual(report.elements, {}); + assert.deepEqual(report.staggers, []); + // The spacer changes no value, so the parent's onUpdate repaints an identical frame. + // Counting it would mask a real dead zone. + assert.equal(report.tweens.length, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/plugins/visual-content/skills/hyperframes-animation/scripts/package-loader.mjs b/plugins/visual-content/skills/hyperframes-animation/scripts/package-loader.mjs index d3e3882..3b20459 100644 --- a/plugins/visual-content/skills/hyperframes-animation/scripts/package-loader.mjs +++ b/plugins/visual-content/skills/hyperframes-animation/scripts/package-loader.mjs @@ -1,12 +1,23 @@ +// package-loader — bootstrap optional helper packages only when missing, with +// defense-in-depth so a malicious or typo'd dependency can't run on install: +// • specs are version-pinned (assertPinnedPackageSpecs) — no floating "latest" +// • install runs `npm install --ignore-scripts` — package lifecycle scripts +// never execute +// • `--no-save` into a throwaway tmp dir — the host project is left untouched +// • requires an interactive y/N (or an explicit $HYPERFRAMES_SKILL_BOOTSTRAP_DEPS=1) +// • npm is spawned with an argv array (no shell) — never a built command string +// The `installLine` strings below are DISPLAY ONLY (shown in the prompt / error +// text); they are never handed to a shell or executed. import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { basename, delimiter, dirname, join, parse, resolve } from "node:path"; +import { basename, delimiter, dirname, join, parse, resolve, win32 as win32Path } from "node:path"; import { createInterface } from "node:readline/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); +const VERSION_OVERRIDE_ENV = "HYPERFRAMES_SKILL_PKG_VERSION"; const BOOTSTRAP_ENV = "HYPERFRAMES_SKILL_DEPS_BOOTSTRAPPED"; const BOOTSTRAP_CONFIRM_ENV = "HYPERFRAMES_SKILL_BOOTSTRAP_DEPS"; const NODE_MODULES_ENV = "HYPERFRAMES_SKILL_NODE_MODULES"; @@ -45,21 +56,112 @@ export async function importPackagesOrBootstrap(packageNames, options = {}) { return modules; } +export async function bundleCompositionForCapture(compiler, projectDir) { + const compiledDir = mkdtempSync(join(tmpdir(), "hyperframes-skill-bundle-")); + try { + const html = await compiler.bundleToSingleHtml(projectDir); + writeFileSync(join(compiledDir, "index.html"), html); + return { + compiledDir, + cleanup() { + rmSync(compiledDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + rmSync(compiledDir, { recursive: true, force: true }); + throw error; + } +} + +// ── Transient-init retry ───────────────────────────────────────────────────── +// Frozen snapshot of the engine's TRANSIENT_BROWSER_ERROR_PATTERNS (see +// packages/engine frameCapture.ts), used only when the imported +// @hyperframes/producer predates the isTransientBrowserError re-export. The +// last pattern is the load-bearing one for modular projects: sub-composition +// timelines register asynchronously, so a first init attempt can time out as +// "zero duration / Runtime ready: false" on a valid project. +const FALLBACK_TRANSIENT_PATTERNS = [ + /Navigating frame was detached/i, + /Target closed/i, + /Session closed/i, + /browser has disconnected/i, + /Page crashed/i, + /Execution context was destroyed/i, + /Cannot find context with specified id/i, + /Failed to launch the browser process/i, + /Navigation timeout of \d+ ms exceeded/i, + /ECONNREFUSED/i, + /net::ERR_NETWORK_CHANGED/i, + /Composition has zero duration[\s\S]*Runtime ready: false/, +]; + +/** + * Create + initialize a capture session with the canonical transient-init + * retry/cleanup the render pipeline uses (see probeStage in + * @hyperframes/producer): on a transient failure, close the crashed session + * and retry ONCE with a fresh browser. Without this, a standalone helper + * false-fails valid modular projects whose sub-composition timelines land a + * beat after the first readiness deadline ("zero duration" with + * "Runtime ready: false"). + * + * `producer` is the imported @hyperframes/producer namespace; + * `createSession` is a factory returning a fresh (uninitialized) session. + * Non-transient init failures (e.g. the "Runtime ready: true" zero-duration + * fast-fail — a genuine authoring bug) still throw on the first attempt. + */ +export async function initializeSessionWithRetry(producer, createSession, options = {}) { + const maxAttempts = options.maxAttempts ?? 2; + const log = options.log ?? ((message) => console.error(message)); + const isTransient = + typeof producer.isTransientBrowserError === "function" + ? producer.isTransientBrowserError + : (err) => { + const message = err instanceof Error ? err.message : String(err); + return FALLBACK_TRANSIENT_PATTERNS.some((pattern) => pattern.test(message)); + }; + + for (let attempt = 1; ; attempt++) { + const session = await createSession(); + try { + await producer.initializeSession(session); + return session; + } catch (error) { + await producer.closeCaptureSession(session).catch(() => {}); + if (attempt >= maxAttempts || !isTransient(error)) throw error; + log( + `transient browser-init failure (attempt ${attempt}/${maxAttempts}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + log("retrying with a fresh browser session..."); + } + } +} + export function hyperframesPackageSpec(packageName) { + const override = process.env[VERSION_OVERRIDE_ENV]?.trim(); + if (override) return `${packageName}@${override}`; + const version = readBundledHyperframesVersion(); - if (!version) { - throw new Error( - [ - `Could not determine the bundled HyperFrames version for ${packageName}.`, - "Install the package yourself or pass a pinned options.npmPackages entry.", - ].join("\n"), - ); - } - return `${packageName}@${version}`; + if (version) return `${packageName}@${version}`; + + // Global skill installs have no hyperframes package.json + // in their ancestor chain, so the bundled version is unknowable. Fall back to + // @latest instead of throwing: already-installed packages still import, and a + // bootstrap install can still proceed (@latest satisfies the pinned-spec guard). + process.stderr.write( + [ + `hyperframes: could not determine the bundled version for ${packageName}; using @latest.`, + `Set ${VERSION_OVERRIDE_ENV}= to pin it.`, + "", + ].join("\n"), + ); + return `${packageName}@latest`; } function resolvePackageEntry(packageName) { const bases = [process.cwd(), HERE, ...envNodeModulesDirs(), ...nodeModulesDirsFromPath()]; + const { rootName, subpath } = splitPackageSpecifier(packageName); const seen = new Set(); for (const base of bases) { @@ -72,8 +174,8 @@ function resolvePackageEntry(packageName) { packageName, ); } catch { - const packageDir = findPackageDir(normalized, packageName); - const packageEntry = packageDir ? readPackageEntry(packageDir) : null; + const packageDir = findPackageDir(normalized, rootName); + const packageEntry = packageDir ? readPackageEntry(packageDir, subpath) : null; if (packageEntry) return packageEntry; } } @@ -81,6 +183,15 @@ function resolvePackageEntry(packageName) { return null; } +function splitPackageSpecifier(packageName) { + const segments = packageName.split("/"); + const rootLength = packageName.startsWith("@") ? 2 : 1; + return { + rootName: segments.slice(0, rootLength).join("/"), + subpath: segments.slice(rootLength).join("/"), + }; +} + function readBundledHyperframesVersion() { for (const ancestor of ancestors(HERE)) { const directVersion = readPackageVersion(join(ancestor, "package.json")); @@ -133,10 +244,14 @@ function findPackageDir(base, packageName) { return null; } -function readPackageEntry(packageDir) { +function readPackageEntry(packageDir, subpath = "") { try { const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); - const entry = exportEntry(manifest.exports) ?? manifest.module ?? manifest.main ?? "index.js"; + const requestedExport = subpath ? manifest.exports?.[`./${subpath}`] : manifest.exports; + const entry = + exportEntry(requestedExport) ?? + (!subpath ? (manifest.module ?? manifest.main ?? "index.js") : null); + if (!entry) return null; const entryPath = join(packageDir, entry); return existsSync(entryPath) ? entryPath : null; } catch { @@ -224,23 +339,54 @@ function ancestors(start) { return dirs; } +export function resolveNpmSpawnCommand( + args, + platform = process.platform, + env = process.env, + nodeExecPath = process.execPath, + pathExists = existsSync, +) { + if (platform !== "win32") { + return { cmd: "npm", args, opts: { stdio: "inherit" } }; + } + + const bundledNpmCli = win32Path.join( + win32Path.dirname(nodeExecPath), + "node_modules", + "npm", + "bin", + "npm-cli.js", + ); + const npmCli = [env.npm_execpath, bundledNpmCli].find( + (candidate) => candidate && pathExists(candidate), + ); + if (!npmCli) return null; + return { + cmd: env.npm_node_execpath || nodeExecPath, + args: [npmCli, ...args], + opts: { stdio: "inherit", windowsHide: true }, + }; +} + function bootstrapWithNpmInstall(packageNames) { const installRoot = mkdtempSync(join(tmpdir(), "hyperframes-skill-deps-")); - const installResult = spawnSync( - process.platform === "win32" ? "npm.cmd" : "npm", - [ - "install", - "--silent", - "--no-audit", - "--no-fund", - "--ignore-scripts", - "--no-save", - "--prefix", - installRoot, - ...packageNames, - ], - { stdio: "inherit" }, - ); + const npmArgs = [ + "install", + "--silent", + "--no-audit", + "--no-fund", + "--ignore-scripts", + "--no-save", + "--prefix", + installRoot, + ...packageNames, + ]; + const npmCommand = resolveNpmSpawnCommand(npmArgs); + if (!npmCommand) { + rmSync(installRoot, { recursive: true, force: true }); + throw new Error("Could not locate npm-cli.js for dependency bootstrap on Windows."); + } + const installResult = spawnSync(npmCommand.cmd, npmCommand.args, npmCommand.opts); if (installResult.error) throw installResult.error; if (installResult.status !== 0) { diff --git a/plugins/visual-content/skills/hyperframes-animation/scripts/package-loader.test.mjs b/plugins/visual-content/skills/hyperframes-animation/scripts/package-loader.test.mjs new file mode 100644 index 0000000..54abaac --- /dev/null +++ b/plugins/visual-content/skills/hyperframes-animation/scripts/package-loader.test.mjs @@ -0,0 +1,114 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { resolveNpmSpawnCommand } from "./package-loader.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ENV = "HYPERFRAMES_SKILL_PKG_VERSION"; + +test("resolveNpmSpawnCommand routes Windows npm through node and npm-cli.js", () => { + const npmCli = "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js"; + const node = "C:\\Program Files\\nodejs\\node.exe"; + const resolved = resolveNpmSpawnCommand( + ["install", "@hyperframes/producer@0.7.55", "value & calc"], + "win32", + { npm_execpath: npmCli, npm_node_execpath: node }, + node, + (path) => path === npmCli, + ); + + assert.deepEqual(resolved, { + cmd: node, + args: [npmCli, "install", "@hyperframes/producer@0.7.55", "value & calc"], + opts: { stdio: "inherit", windowsHide: true }, + }); + assert.equal(resolved.opts.shell, undefined); +}); + +test("resolveNpmSpawnCommand finds npm-cli.js beside node for direct Windows runs", () => { + const node = "C:\\Program Files\\nodejs\\node.exe"; + const npmCli = "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js"; + const resolved = resolveNpmSpawnCommand( + ["install", "@hyperframes/producer@0.7.55"], + "win32", + {}, + node, + (path) => path === npmCli, + ); + + assert.equal(resolved?.cmd, node); + assert.deepEqual(resolved?.args, [npmCli, "install", "@hyperframes/producer@0.7.55"]); +}); + +test( + "resolveNpmSpawnCommand launches the installed npm CLI on Windows", + { skip: process.platform !== "win32" }, + () => { + const resolved = resolveNpmSpawnCommand(["--version"]); + assert.ok(resolved); + + const result = spawnSync(resolved.cmd, resolved.args, { + encoding: "utf8", + windowsHide: true, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout.trim(), /^\d+\./); + }, +); + +// (a) env override wins — no ancestor lookup, exact version echoed back. +test("hyperframesPackageSpec: env override wins", async () => { + const prev = process.env[ENV]; + process.env[ENV] = "9.9.9"; + try { + const { hyperframesPackageSpec } = await import("./package-loader.mjs"); + assert.equal(hyperframesPackageSpec("@hyperframes/producer"), "@hyperframes/producer@9.9.9"); + } finally { + if (prev === undefined) delete process.env[ENV]; + else process.env[ENV] = prev; + } +}); + +// (b) resolvable version (in-repo) pins the bundled hyperframes/@hyperframes/cli version. +test("hyperframesPackageSpec: resolvable in-repo version pins it", async () => { + const prev = process.env[ENV]; + delete process.env[ENV]; + try { + const { hyperframesPackageSpec } = await import("./package-loader.mjs"); + const spec = hyperframesPackageSpec("@hyperframes/producer"); + assert.match(spec, /^@hyperframes\/producer@\d+\.\d+\.\d+/); + } finally { + if (prev !== undefined) process.env[ENV] = prev; + } +}); + +// (c) unresolvable + no override -> @latest fallback, no throw (global-install case). +// Copy the loader into an isolated temp dir whose ancestor chain has no hyperframes +// package.json, and run node from there so cwd cannot resolve one either. +test("hyperframesPackageSpec: unresolvable falls back to @latest without throwing", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-pkgloader-")); + try { + copyFileSync(join(HERE, "package-loader.mjs"), join(dir, "package-loader.mjs")); + const probe = join(dir, "probe.mjs"); + writeFileSync( + probe, + [ + 'import { hyperframesPackageSpec } from "./package-loader.mjs";', + 'process.stdout.write(hyperframesPackageSpec("@hyperframes/producer"));', + "", + ].join("\n"), + ); + const res = spawnSync(process.execPath, [probe], { cwd: dir, encoding: "utf8" }); + assert.equal(res.status, 0, res.stderr); + assert.equal(res.stdout.trim(), "@hyperframes/producer@latest"); + assert.match(res.stderr, /using @latest/); + assert.match(res.stderr, new RegExp(ENV)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/plugins/visual-content/skills/hyperframes-animation/transitions/TRANSITION-REGISTRY.md b/plugins/visual-content/skills/hyperframes-animation/transitions/TRANSITION-REGISTRY.md index 3f1bd33..f554e33 100644 --- a/plugins/visual-content/skills/hyperframes-animation/transitions/TRANSITION-REGISTRY.md +++ b/plugins/visual-content/skills/hyperframes-animation/transitions/TRANSITION-REGISTRY.md @@ -23,7 +23,8 @@ injector: 2. Pulls `#el-` wrapper `data-start` earlier by `duration_s` (creates the overlap window). 3. Reassigns **all** clip `data-track-index` as a 0/1 ping-pong so the two - overlapping wrappers never share a track (same-track overlap is illegal — + overlapping wrappers never share a track (a readability convention, not a + render constraint, `core/src/lint/rules/composition.ts`). Higher track composites on top. 4. Stamps the `gsap_template` into `window.__timelines["main"]` at `T = overlap-start`. diff --git a/plugins/visual-content/skills/hyperframes-animation/transitions/css-destruction.md b/plugins/visual-content/skills/hyperframes-animation/transitions/css-destruction.md index 9229cff..5db9b97 100644 --- a/plugins/visual-content/skills/hyperframes-animation/transitions/css-destruction.md +++ b/plugins/visual-content/skills/hyperframes-animation/transitions/css-destruction.md @@ -10,7 +10,7 @@ This transition has three systems working together: 2. **Scene clipping** — the outgoing scene uses an SVG clip-path (with `fill-rule: evenodd`) that cuts a hole matching the fire front. As the fire expands, more of the scene is clipped away. All content (text, images, lines) burns with the page — no separate debris. 3. **Scorched edge** — a `` overlay draws a radial gradient fringe at the fire boundary to simulate charring -**When to use:** Dramatic reveals, edgy/destructive mood, gaming, cyberpunk. This is the most dramatic transition in the catalog — reserve it for hero moments. +**When to use:** Dramatic reveals, edgy/destructive mood, gaming, neon-noir. This is the most dramatic transition in the catalog — reserve it for hero moments. **Requirements:** diff --git a/plugins/visual-content/skills/remotion-best-practices/SKILL.md b/plugins/visual-content/skills/remotion-best-practices/SKILL.md index 28eb6f3..eb1bd57 100644 --- a/plugins/visual-content/skills/remotion-best-practices/SKILL.md +++ b/plugins/visual-content/skills/remotion-best-practices/SKILL.md @@ -1,364 +1,59 @@ --- name: remotion-best-practices -description: Best practices for Remotion - Video creation in React -metadata: - tags: remotion, video, react, animation, composition +description: Router for all Remotion skills +version: 4.0.526 --- -## When to use +## Preserve user changes -Use this skills whenever you are dealing with Remotion code to obtain the domain-specific knowledge. +Users may make edits in the code outside of the conversation. -## New project setup - -When in an empty folder or workspace with no existing Remotion project, scaffold one using: - -```bash -npx create-video@latest --yes --blank --no-tailwind my-video -``` - -Replace `my-video` with a suitable project name. - -## Designing a video - -Before designing visual scenes, layouts, promos, motion graphics, or text-heavy videos, load [rules/video-layout.md](rules/video-layout.md) for video-first layout and text sizing guidance. - -Animate properties using `useCurrentFrame()` and `interpolate()`. Prefer `interpolate()` over `spring()` unless physics-based motion is explicitly needed. Use `Easing.bezier()` to customize timing, including jumpy or overshooting motion. - -For animations that should be editable in Remotion Studio, keep the `interpolate()` call inline in the `style` prop and use individual CSS transform properties (`scale`, `translate`, `rotate`) instead of composing a `transform` string. - -```tsx -import { useCurrentFrame, Easing, interpolate, useVideoConfig } from "remotion"; - -export const FadeIn = () => { - const frame = useCurrentFrame(); - const { fps } = useVideoConfig(); - - const opacity = interpolate(frame, [0, 2 * fps], [0, 1], { - extrapolateRight: "clamp", - extrapolateLeft: "clamp", - easing: Easing.bezier(0.16, 1, 0.3, 1), - }); - - return
Hello World!
; -}; -``` - -Prefer: - -```tsx -style={{ - scale: interpolate(frame, [0, 100], [0, 1]), - translate: interpolate(frame, [0, 100], ["0px 0px", "100px 100px"]), - rotate: interpolate(frame, [0, 100], ["20deg", "90deg"]), -}} -``` - -Over: - -```tsx -const scale = interpolate(frame, [0, 100], [0, 1]); - -style={{ - transform: `scale(${scale})`, -}} -``` - -CSS transitions or animations are FORBIDDEN - they will not render correctly. -Tailwind animation class names are FORBIDDEN - they will not render correctly. - -Place assets in the `public/` folder at your project root. - -Use `staticFile()` to reference files from the `public/` folder. - -Add images using the `` component: - -```tsx -import { Img, staticFile } from "remotion"; - -export const MyComposition = () => { - return ; -}; -``` - -Add videos using the `