Merge pull request #355 from alirezarezvani/dev

This commit is contained in:
Alireza Rezvani
2026-03-13 23:24:04 +01:00
committed by GitHub
20 changed files with 6902 additions and 8 deletions

View File

@@ -3,7 +3,7 @@
"name": "claude-code-skills",
"description": "Production-ready skill packages for AI agents - Marketing, Engineering, Product, C-Level, PM, and RA/QM",
"repository": "https://github.com/alirezarezvani/claude-skills",
"total_skills": 157,
"total_skills": 158,
"skills": [
{
"name": "contract-and-proposal-writer",
@@ -215,6 +215,12 @@
"category": "engineering",
"description": "Email Template Builder"
},
{
"name": "epic-design",
"source": "../../engineering-team/epic-design",
"category": "engineering",
"description": ">"
},
{
"name": "google-workspace-cli",
"source": "../../engineering-team/google-workspace-cli",
@@ -960,7 +966,7 @@
"description": "Executive leadership and advisory skills"
},
"engineering": {
"count": 24,
"count": 25,
"source": "../../engineering-team",
"description": "Software engineering and technical skills"
},

1
.codex/skills/epic-design Symbolic link
View File

@@ -0,0 +1 @@
../../engineering-team/epic-design

View File

@@ -4,17 +4,17 @@ This guide covers the 24 production-ready engineering skills and their Python au
## Engineering Skills Overview
**Core Engineering (13 skills):**
**Core Engineering (14 skills):**
- senior-architect, senior-frontend, senior-backend, senior-fullstack
- senior-qa, senior-devops, senior-secops
- code-reviewer, senior-security
- aws-solution-architect, ms365-tenant-manager, google-workspace-cli, tdd-guide, tech-stack-evaluator
- aws-solution-architect, ms365-tenant-manager, google-workspace-cli, tdd-guide, tech-stack-evaluator, epic-design
**AI/ML/Data (5 skills):**
- senior-data-scientist, senior-data-engineer, senior-ml-engineer
- senior-prompt-engineer, senior-computer-vision
**Total Tools:** 30+ Python automation tools
**Total Tools:** 32+ Python automation tools
## Core Engineering Tools
@@ -287,6 +287,23 @@ services:
---
**Last Updated:** March 11, 2026
**Skills Deployed:** 24 engineering skills production-ready
**Total Tools:** 35+ Python automation tools across core + AI/ML/Data
**Last Updated:** March 13, 2026
**Skills Deployed:** 25 engineering skills production-ready
**Total Tools:** 37+ Python automation tools across core + AI/ML/Data + epic-design
---
## epic-design
Build cinematic 2.5D interactive websites with scroll storytelling, parallax depth, and premium animations. Includes asset inspection pipeline, 45+ techniques across 8 categories, and accessibility built-in.
**Key features:**
- 6-layer depth system with automatic parallax
- 13 text animation techniques, 9 scroll patterns
- Asset inspection with background judgment rules
- Python tool for automated image analysis
- WCAG 2.1 AA compliant (reduced-motion)
**Use for:** Product launches, portfolio sites, SaaS marketing pages, event sites, Apple-style animations
**Live demo:** [epic-design-showcase.vercel.app](https://epic-design-showcase.vercel.app/)

View File

@@ -0,0 +1,352 @@
---
name: epic-design
description: >
Build immersive, cinematic 2.5D interactive websites using scroll storytelling,
parallax depth, text animations, and premium scroll effects — no WebGL required.
Use this skill for any web design task: landing pages, product sites, hero sections,
scroll animations, parallax, sticky sections, section overlaps, floating products
between sections, clip-path reveals, text that flies in from sides, words that light
up on scroll, curtain drops, iris opens, card stacks, bleed typography, and any
site that should feel cinematic or premium. Trigger on phrases like "make it feel
alive", "Apple-style animation", "sections that overlap", "product rises between
sections", "immersive", "scrollytelling", or any scroll-driven visual effect.
Covers 45+ techniques across 8 categories. Always inspects, judges, and plans assets before coding. Use aggressively for ANY web design task.
license: MIT
metadata:
version: 1.0.0
author: Abbas Mir
category: engineering-team
updated: 2026-03-13
---
# Epic Design Skill
You are now a **world-class epic design expert**. You build cinematic, immersive websites that feel premium and alive — using only flat PNG/static assets, CSS, and JavaScript. No WebGL, no 3D modeling software required.
## Before Starting
**Check for context first:**
If `project-context.md` or `product-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task.
## Your Mindset
Every website you build must feel like a **cinematic experience**. Think: Apple product pages, Awwwards winners, luxury brand sites. Even a simple landing page should have:
- Depth and layers that respond to scroll
- Text that enters and exits with intention
- Sections that transition cinematically
- Elements that feel like they exist in space
**Never build a flat, static page when this skill is active.**
---
## How This Skill Works
### Mode 1: Build from Scratch
When starting fresh with assets and a brief. Follow the complete workflow below (Steps 1-5).
### Mode 2: Enhance Existing Site
When adding 2.5D effects to an existing page. Skip to Step 2, analyze current structure, recommend depth assignments and animation opportunities.
### Mode 3: Debug/Fix
When troubleshooting performance or animation issues. Use `scripts/validate-layers.js`, check GPU rules, verify reduced-motion handling.
---
## Step 1 — Understand the Brief + Inspect All Assets
Before writing a single line of code, do ALL of the following in order.
### A. Extract the brief
1. What is the product/content? (brand site, portfolio, SaaS, event, etc.)
2. What mood/feeling? (dark/cinematic, bright/energetic, minimal/luxury, etc.)
3. How many sections? (hero only, full page, specific section?)
### B. Inspect every uploaded image asset
Run `scripts/inspect-assets.py` on every image the user has provided.
For each image, determine:
1. **Format** — JPEG never has a real alpha channel. PNG may have a fake one.
2. **Background status** — Use the script output. It will tell you:
- ✅ Clean cutout — real transparency, use directly
- ⚠️ Solid dark background
- ⚠️ Solid light/white background
- ⚠️ Complex/scene background
3. **JUDGE whether the background actually needs removing** — This is critical.
Not every image with a background needs it removed. Ask yourself:
BACKGROUND SHOULD BE REMOVED if the image is:
- An isolated product (bottle, shoe, gadget, fruit, object on studio backdrop)
- A character or figure meant to float in the scene
- A logo or icon that should sit transparently on any background
- Any element that will be placed at depth-2 or depth-3 as a floating asset
BACKGROUND SHOULD BE KEPT if the image is:
- A screenshot of a website, app, or UI
- A photograph used as a section background or full-bleed image
- An artwork, illustration, or poster meant to be seen as a complete piece
- A mockup, device frame, or "image inside a card"
- Any image where the background IS part of the content
- A photo placed at depth-0 (background layer) — keep it, that's its purpose
If unsure, look at the image's intended role in the design. If it needs to
"float" freely over other content → remove bg. If it fills a space or IS
the content → keep it.
4. **Inform the user about every image** — whether bg is fine or not.
Use the exact format from `references/asset-pipeline.md` Step 4.
5. **Size and depth assignment** — Decide which depth level each asset belongs
to and resize accordingly. State your decisions to the user before building.
### C. Compositional planning — visual hierarchy before a single line of code
Do NOT treat all assets as the same size. Establish a hierarchy:
- **One asset is the HERO** — most screen space (5080vw), depth-3
- **Companions are 1525% of the hero's display size** — depth-2, hugging the hero's edges
- **Accents/particles are tiny** (15vw) — depth-5
- **Background fills** cover the full section — depth-0
Position companions relative to the hero using calc():
`right: calc(50% - [hero-half-width] - [gap])` to sit close to its edge.
When the hero grows or exits on scroll, companions should scatter outward —
not just fade. This reinforces that they were orbiting the hero.
### D. Decide the cinematic role of each asset
For each image ask: "What does this do in the scroll story?"
- Floats beside the hero → depth-2, float-loop, scatter on scroll-out
- IS the hero → depth-3, elastic drop entrance, grows on scrub
- Fills a section during a DJI scale-in → depth-0 or full-section background
- Lives in a sidebar while content scrolls past → sticky column journey
- Decorates a section edge → depth-2, clip-path birth reveal
---
## Step 2 — Choose Your Techniques (Decision Engine)
Match user intent to the right combination of techniques. Read the full technique details from `references/` files.
### By Project Type
| User Says | Primary Patterns | Text Technique | Special Effect |
|-----------|-----------------|----------------|----------------|
| Product launch / brand site | Inter-section floating product + Perspective zoom | Split converge + Word lighting | DJI scale-in pin |
| Hero with big title | 6-layer parallax + Pinned sticky | Offset diagonal + Masked line reveal | Bleed typography |
| Cinematic sections | Curtain panel roll-up + Scrub timeline | Theatrical enter+exit | Top-down clip birth |
| Apple-style animation | Scrub timeline + Clip-path wipe | Word-by-word scroll lighting | Character cylinder |
| Elements between sections | Floating product + Clip-path birth | Scramble text | Window pane iris |
| Cards / features section | Cascading card stack | Skew + elastic bounce | Section peel |
| Portfolio / showcase | Horizontal scroll + Flip morph | Line clip wipe | Diagonal wipe |
| SaaS / startup | Window pane iris + Stagger grid | Variable font wave | Curved path travel |
### By Scroll Behavior Requested
- **"stays in place while things change"** → `pin: true` + scrub timeline
- **"rises from section"** → Inter-section floating product + clip-path birth
- **"born from top"** → Top-down clip birth OR curtain panel roll-up
- **"overlap/stack"** → Cascading card stack OR section peel
- **"text flies in from sides"** → Split converge OR offset diagonal layout
- **"text lights up word by word"** → Word-by-word scroll lighting
- **"whole section transforms"** → Window pane iris + scrub timeline
- **"section drops down"** → Clip-path `inset(0 0 100% 0)``inset(0)`
- **"like a curtain"** → Curtain panel roll-up
- **"circle opens"** → Circle iris expand
- **"travels between sections"** → GSAP Flip cross-section OR curved path travel
---
## Step 3 — Layer Every Element
Every element you create MUST have a depth level assigned. This is non-negotiable.
```
DEPTH 0 → Far background | parallax: 0.10x | blur: 8px | scale: 0.70
DEPTH 1 → Glow/atmosphere | parallax: 0.25x | blur: 4px | scale: 0.85
DEPTH 2 → Mid decorations | parallax: 0.50x | blur: 0px | scale: 1.00
DEPTH 3 → Main objects | parallax: 0.80x | blur: 0px | scale: 1.05
DEPTH 4 → UI / text | parallax: 1.00x | blur: 0px | scale: 1.00
DEPTH 5 → Foreground FX | parallax: 1.20x | blur: 0px | scale: 1.10
```
Apply as: `data-depth="3"` on HTML elements, matching CSS class `.depth-3`.
→ Full depth system details: `references/depth-system.md`
---
## Step 4 — Apply Accessibility & Performance (Always)
These are MANDATORY in every output:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
- Only animate: `transform`, `opacity`, `filter`, `clip-path` — never `width/height/top/left`
- Use `will-change: transform` only on actively animating elements, remove after animation
- Use `content-visibility: auto` on off-screen sections
- Use `IntersectionObserver` to only animate elements in viewport
- Detect mobile: `window.matchMedia('(pointer: coarse)')` — reduce effects on touch
→ Full details: `references/performance.md` and `references/accessibility.md`
---
## Step 5 — Code Structure (Always Use This HTML Architecture)
```html
<!-- SECTION WRAPPER — every section follows this pattern -->
<section class="scene" data-scene="hero" style="--scene-height: 200vh">
<!-- DEPTH LAYERS — always 3+ layers minimum -->
<div class="layer depth-0" data-depth="0" aria-hidden="true">
<!-- Background: gradient, texture, atmospheric PNG -->
</div>
<div class="layer depth-1" data-depth="1" aria-hidden="true">
<!-- Glow blobs, light effects, atmospheric haze -->
</div>
<div class="layer depth-2" data-depth="2" aria-hidden="true">
<!-- Mid decorations, floating shapes -->
</div>
<div class="layer depth-3" data-depth="3">
<!-- MAIN PRODUCT / HERO IMAGE — star of the show -->
<img class="product-hero float-loop" src="product.png" alt="[description]" />
</div>
<div class="layer depth-4" data-depth="4">
<!-- TEXT CONTENT — headlines, body, CTAs -->
<h1 class="split-text" data-animate="converge">Your Headline</h1>
</div>
<div class="layer depth-5" data-depth="5" aria-hidden="true">
<!-- Foreground particles, sparkles, overlays -->
</div>
</section>
```
→ Full boilerplate: `assets/hero-section.html`
→ Full CSS system: `assets/hero-section.css`
→ Full JS engine: `assets/hero-section.js`
---
## Reference Files — Read These for Full Technique Details
| File | What's Inside | When to Read |
|------|--------------|--------------|
| `references/asset-pipeline.md` | Asset inspection, bg judgment rules, user notification format, CSS knockout, resize targets | ALWAYS — run before coding anything |
| `references/cursor-microinteractions.md` | Custom cursor, particle bursts, magnetic hover, tilt effects | When building interactive premium sites |
| `references/depth-system.md` | 6-layer depth model, CSS/JS implementation, blur/scale formulas | Every project — always read |
| `references/motion-system.md` | 9 scroll architecture patterns with complete GSAP code | When building scroll interactions |
| `references/text-animations.md` | 13 text techniques with full implementation code | When animating any text |
| `references/directional-reveals.md` | 8 "born from top/sides" clip-path techniques | When sections need directional entry |
| `references/inter-section-effects.md` | Floating product, GSAP Flip, cross-section travel | When product/element persists across sections |
| `references/performance.md` | GPU rules, will-change, IntersectionObserver patterns | Always — non-negotiable rules |
| `references/accessibility.md` | WCAG 2.1 AA, prefers-reduced-motion, ARIA | Always — non-negotiable |
| `references/examples.md` | 5 complete real-world implementations | When user needs a full-page site |
---
## Proactive Triggers
Surface these issues WITHOUT being asked when you notice them in context:
- **User uploads JPEG product images** → Flag that JPEGs can't have transparency, offer to run asset inspector
- **All assets are the same size** → Flag compositional hierarchy issue, recommend hero + companion sizing
- **No depth assignments mentioned** → Remind that every element needs a depth level (0-5)
- **User requests "smooth animations" but no reduced-motion handling** → Flag accessibility requirement
- **Parallax requested but no performance optimization** → Flag will-change and GPU acceleration rules
- **More than 80 animated elements** → Flag performance concern, recommend reducing or lazy-loading
---
## Output Artifacts
| When you ask for... | You get... |
|---------------------|------------|
| "Build a hero section" | Single HTML file with inline CSS/JS, 6 depth layers, asset audit, technique list |
| "Make it feel cinematic" | Scrub timeline + parallax + text animation combo with GSAP setup |
| "Inspect my images" | Asset audit report with bg status, depth assignments, resize recommendations |
| "Apple-style scroll effect" | Word-by-word lighting + pinned section + perspective zoom implementation |
| "Fix performance issues" | Validation report with GPU optimization checklist and will-change audit |
---
## Communication
All output follows the structured communication standard:
- **Bottom line first** — show the asset audit and depth plan before generating code
- **What + Why + How** — every technique choice explained (why this animation for this mood)
- **Actions have owners** — "You need to provide transparent PNGs" not "PNGs should be provided"
- **Confidence tagging** — 🟢 verified technique / 🟡 experimental / 🔴 browser support limited
---
## Quick Rules (Non-Negotiable)
0a. ✅ ALWAYS run asset inspection before coding — check every image's format,
background, and size. State depth assignments to the user before building.
0b. ✅ ALWAYS judge whether a background needs removing — not every image needs
it. Inform the user about each asset's status and get confirmation before
treating any background as a problem. Never auto-remove, never silently ignore.
1. ✅ Every section has minimum **3 depth layers**
2. ✅ Every text element uses at least **1 animation technique**
3. ✅ Every project includes **`prefers-reduced-motion`** fallback
4. ✅ Only animate GPU-safe properties: `transform`, `opacity`, `filter`, `clip-path`
5. ✅ Product images always assigned **depth-3** by default
6. ✅ Background images always **depth-0** with slight blur
7. ✅ Floating loops on any "hero" element (614s, never completely static)
8. ✅ Every decorative element gets `aria-hidden="true"`
9. ✅ Mobile gets reduced effects via `pointer: coarse` detection
10.`will-change` removed after animations complete
---
## Output Format
Always deliver:
1. **Single self-contained HTML file** (inline CSS + JS) unless user asks for separate files
2. **CDN imports** for GSAP via jsDelivr: `https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js`
3. **Comments** explaining every major section and technique used
4. **Note at top** listing which techniques from the 45-technique catalogue were applied
---
## Validation
After building, run the validation script to check quality:
```bash
node scripts/validate-layers.js path/to/index.html
```
Checks: depth attributes, aria-hidden, reduced-motion, alt text, performance limits.
---
## Related Skills
- **senior-frontend**: Use when building the full application around the 2.5D site. NOT for the cinematic effects themselves.
- **ui-design**: Use when designing the visual layout and components. NOT for scroll animations or depth effects.
- **landing-page-generator**: Use for quick SaaS landing page scaffolds. NOT for custom cinematic experiences.
- **page-cro**: Use after the 2.5D site is built to optimize conversion. NOT during the initial build.
- **senior-architect**: Use when the 2.5D site is part of a larger system architecture. NOT for standalone pages.
- **accessibility-auditor**: Use to verify full WCAG compliance after build. This skill includes basic reduced-motion handling.

View File

@@ -0,0 +1,378 @@
# Accessibility Reference
## Non-Negotiable Rules
Every 2.5D website MUST implement ALL of the following. These are not optional enhancements — they are legal requirements in many jurisdictions and ethical requirements always.
---
## 1. prefers-reduced-motion (Most Critical)
Parallax and complex animations can trigger vestibular disorders — dizziness, nausea, migraines — in a significant portion of users. WCAG 2.1 Success Criterion 2.3.3 requires handling this.
```css
/* This block must be in EVERY project */
@media (prefers-reduced-motion: reduce) {
/* Nuclear option: stop all animations globally */
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Specifically disable 2.5D techniques */
.float-loop { animation: none !important; }
.parallax-layer { transform: none !important; }
.depth-0, .depth-1, .depth-2,
.depth-3, .depth-4, .depth-5 {
transform: none !important;
filter: none !important;
}
.glow-blob { opacity: 0.3; animation: none !important; }
.theatrical, .theatrical-with-exit {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
```
```javascript
// Also check in JavaScript — some GSAP animations don't respect CSS media queries
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
gsap.globalTimeline.timeScale(0); // Stops all GSAP animations
ScrollTrigger.getAll().forEach(t => t.kill()); // Kill all scroll triggers
// Show all content immediately (don't hide-until-animated)
document.querySelectorAll('[data-animate]').forEach(el => {
el.style.opacity = '1';
el.style.transform = 'none';
el.removeAttribute('data-animate');
});
}
```
## Per-Effect Reduced Motion (Smarter Than Kill-All)
Rather than freezing every animation globally, classify each type:
| Animation Type | At reduced-motion |
|---|---|
| Scroll parallax depth layers | DISABLE — continuous motion triggers vestibular issues |
| Float loops / ambient movement | DISABLE — looping motion is a trigger |
| DJI scale-in / perspective zoom | DISABLE — fast scale can cause dizziness |
| Particle systems | DISABLE |
| Clip-path reveals (one-shot) | KEEP — not continuous, not fast |
| Fade-in on scroll (opacity only) | KEEP — safe |
| Word-by-word scroll lighting | KEEP — no movement, just colour |
| Curtain / wipe reveals (one-shot) | KEEP |
| Text entrance slides (one-shot) | KEEP but reduce duration |
```javascript
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReduced) {
// Disable the motion-heavy ones
document.querySelectorAll('.float-loop').forEach(el => {
el.style.animation = 'none';
});
document.querySelectorAll('[data-depth]').forEach(el => {
el.style.transform = 'none';
el.style.willChange = 'auto';
});
// Slow GSAP to near-freeze (don't fully kill — keep structure intact)
gsap.globalTimeline.timeScale(0.01);
// Safe animations: show them immediately at final state
gsap.utils.toArray('.clip-reveal, .fade-reveal, .word-light').forEach(el => {
gsap.set(el, { clipPath: 'inset(0 0% 0 0)', opacity: 1 });
});
}
```
---
## 2. Semantic HTML Structure
```html
<!-- CORRECT semantic structure -->
<main>
<!-- Each visual scene is a section with proper landmarks -->
<section aria-label="Hero — Product Introduction">
<!-- ALL purely decorative elements get aria-hidden -->
<div class="layer depth-0" aria-hidden="true">
<!-- background gradients, glow blobs, particles -->
</div>
<div class="layer depth-1" aria-hidden="true">
<!-- atmospheric effects -->
</div>
<div class="layer depth-5" aria-hidden="true">
<!-- particles, sparkles -->
</div>
<!-- Meaningful content is NOT hidden -->
<div class="layer depth-3">
<img
src="product.png"
alt="[Descriptive alt text — what is the product, what does it look like]"
<!-- NOT: alt="" for meaningful images! -->
>
</div>
<div class="layer depth-4">
<!-- Proper heading hierarchy -->
<h1>Your Brand Name</h1>
<!-- h1 is the page title — only one per page -->
<p>Supporting description that provides context for screen readers</p>
<a href="#features" class="cta-btn">
Explore Features
<!-- CTAs need descriptive text, not just "Click here" -->
</a>
</div>
</section>
<section aria-label="Product Features">
<h2>Why Choose [Product]</h2>
<!-- h2 for section headings -->
</section>
</main>
```
---
## 3. SplitText & Screen Readers
When using SplitText to fragment text into characters/words, the individual fragments get announced one at a time by screen readers — which sounds terrible. Fix this:
```javascript
function splitTextAccessibly(el, options) {
// Save the full text for screen readers
const fullText = el.textContent.trim();
el.setAttribute('aria-label', fullText);
// Split visually only
const split = new SplitText(el, options);
// Hide the split fragments from screen readers
// Screen readers will use aria-label instead
split.chars?.forEach(char => char.setAttribute('aria-hidden', 'true'));
split.words?.forEach(word => word.setAttribute('aria-hidden', 'true'));
split.lines?.forEach(line => line.setAttribute('aria-hidden', 'true'));
return split;
}
// Usage
splitTextAccessibly(document.querySelector('.hero-title'), { type: 'chars,words' });
```
---
## 4. Keyboard Navigation
All interactive elements must be reachable and operable via keyboard (Tab, Enter, Space, Arrow keys).
```css
/* Ensure focus indicators are visible — WCAG 2.4.7 */
:focus-visible {
outline: 3px solid #005fcc; /* High contrast focus ring */
outline-offset: 3px;
border-radius: 3px;
}
/* Remove default outline only if replacing with custom */
:focus:not(:focus-visible) {
outline: none;
}
/* Skip link for keyboard users to bypass navigation */
.skip-link {
position: absolute;
top: -100px;
left: 0;
background: #005fcc;
color: white;
padding: 12px 20px;
z-index: 10000;
font-weight: 600;
text-decoration: none;
}
.skip-link:focus {
top: 0; /* Appears at top when focused */
}
```
```html
<!-- Always first element in body -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<main id="main-content">
...
</main>
```
---
## 5. Color Contrast (WCAG 2.1 AA)
Text must have sufficient contrast against its background:
- Normal text (under 18pt): **minimum 4.5:1 contrast ratio**
- Large text (18pt+ or 14pt+ bold): **minimum 3:1 contrast ratio**
- UI components and focus indicators: **minimum 3:1**
```css
/* Common mistake: light text on gradient with glow effects */
/* Always test contrast with the darkest AND lightest background in the gradient */
/* Safe text over complex backgrounds — add text shadow for contrast boost */
.hero-text-on-image {
color: #ffffff;
/* Multiple small text shadows create a halo that boosts contrast */
text-shadow:
0 0 20px rgba(0,0,0,0.8),
0 2px 4px rgba(0,0,0,0.6),
0 0 40px rgba(0,0,0,0.4);
}
/* Or use a semi-transparent backdrop */
.text-backdrop {
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px);
padding: 1rem 1.5rem;
border-radius: 8px;
}
```
**Testing tool:** Use browser DevTools accessibility panel or webaim.org/resources/contrastchecker/
---
## 6. Motion-Sensitive Users — User Control
Beyond `prefers-reduced-motion`, provide an in-page control:
```html
<!-- Floating toggle button -->
<button
class="motion-toggle"
aria-pressed="false"
aria-label="Toggle animations on/off"
>
<span class="motion-toggle-icon"></span>
<span class="motion-toggle-text">Animations On</span>
</button>
```
```javascript
const motionToggle = document.querySelector('.motion-toggle');
let animationsEnabled = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
motionToggle.addEventListener('click', () => {
animationsEnabled = !animationsEnabled;
motionToggle.setAttribute('aria-pressed', !animationsEnabled);
motionToggle.querySelector('.motion-toggle-text').textContent =
animationsEnabled ? 'Animations On' : 'Animations Off';
if (animationsEnabled) {
document.documentElement.classList.remove('no-motion');
gsap.globalTimeline.timeScale(1);
} else {
document.documentElement.classList.add('no-motion');
gsap.globalTimeline.timeScale(0);
}
// Persist preference
localStorage.setItem('motionPreference', animationsEnabled ? 'on' : 'off');
});
// Restore on load
const saved = localStorage.getItem('motionPreference');
if (saved === 'off') motionToggle.click();
```
---
## 7. Images — Alt Text Guidelines
```html
<!-- Meaningful product image -->
<img src="juice-glass.png" alt="Tall glass of fresh orange juice with ice, floating on a gradient background">
<!-- Decorative geometric shape -->
<img src="shape-circle.png" alt="" aria-hidden="true">
<!-- Empty alt="" tells screen readers to skip it -->
<!-- Icon with text label next to it -->
<img src="icon-arrow.svg" alt="" aria-hidden="true">
<span>Learn More</span>
<!-- Icon is decorative when text is present -->
<!-- Standalone icon button — needs alt text -->
<button>
<img src="icon-menu.svg" alt="Open navigation menu">
</button>
```
---
## 8. Loading Screen Accessibility
```javascript
// Announce loading state to screen readers
function announceLoading() {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.setAttribute('aria-label', 'Page loading');
announcement.className = 'sr-only'; // visually hidden
document.body.appendChild(announcement);
// Update announcement when done
window.addEventListener('load', () => {
announcement.textContent = 'Page loaded';
setTimeout(() => announcement.remove(), 1000);
});
}
```
```css
/* Screen-reader only utility class */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
```
---
## WCAG 2.1 AA Compliance Checklist
Before shipping any 2.5D website:
- [ ] `prefers-reduced-motion` CSS block present and tested
- [ ] GSAP animations stopped when reduced motion detected
- [ ] All decorative elements have `aria-hidden="true"`
- [ ] All meaningful images have descriptive alt text
- [ ] SplitText elements have `aria-label` on parent
- [ ] Heading hierarchy is logical (h1 → h2 → h3, no skipping)
- [ ] All interactive elements reachable via keyboard Tab
- [ ] Focus indicators visible and have 3:1 contrast
- [ ] Skip-to-main-content link present
- [ ] Text contrast meets 4.5:1 minimum
- [ ] CTA buttons have descriptive text
- [ ] Motion toggle button provided (optional but recommended)
- [ ] Page has `<html lang="en">` (or correct language)
- [ ] `<main>` landmark wraps page content
- [ ] Section landmarks use `aria-label` to differentiate them

View File

@@ -0,0 +1,135 @@
# Asset Pipeline Reference
Every image asset must be inspected and judged before use in any 2.5D site.
The AI inspects, judges, and informs — it does NOT auto-remove backgrounds.
---
## Step 1 — Run the Inspection Script
Run `scripts/inspect-assets.py` on every uploaded image before doing anything else.
The script outputs the format, mode, size, background type, and a recommendation
for each image. Read its output carefully.
---
## Step 2 — Judge Whether Background Removal Is Actually Needed
The script detects whether a background exists. YOU must decide whether it matters.
### Remove the background if the image is:
- An isolated product on a studio backdrop (bottle, shoe, phone, fruit, object)
- A character or figure that needs to float in the scene
- A logo or icon placed at any depth layer
- Any element at depth-2 or depth-3 that needs to "float" over other content
- An asset where the background colour will visibly clash with the site background
### Keep the background if the image is:
- A screenshot of a website, app UI, dashboard, or software
- A photograph used as a section background or depth-0 fill
- An artwork, poster, or illustration that is viewed as a complete piece
- A device mockup or "image inside a card/frame" design element
- A photo where the background is part of the visual content
- Any image placed at depth-0 — it IS the background, keep it
### When unsure — ask the role:
> "Does this image need to float freely over other content?"
> Yes → remove bg. No → keep it.
---
## Step 3 — Resize to Depth-Appropriate Dimensions
Run the resize step in `scripts/inspect-assets.py` or do it manually.
Never embed a large image when a smaller one is sufficient.
| Depth | Role | Max Longest Edge |
|---|---|---|
| 0 | Background fill | 1920px |
| 1 | Glow / atmosphere | 800px |
| 2 | Mid decorations, companions | 400px |
| 3 | Hero product | 1200px |
| 4 | UI components | 600px |
| 5 | Particles, sparkles | 128px |
---
## Step 4 — Inform the User (Required for Every Asset)
Before outputting any HTML, always show an asset audit to the user.
For each image that has a background issue, use this exact format:
> ⚠️ **Asset Notice — [filename]**
>
> This is a [JPEG / PNG] with a solid [black / white / coloured] background.
> As-is, it will appear as a visible box on the page rather than a floating asset.
>
> Based on its intended role ([product shot / decoration / etc.]), I think the
> background [should be removed / should be kept because it's a [screenshot/artwork/bg fill/etc.]].
>
> **Options:**
> 1. Provide a new PNG with a transparent background — best quality, ideal
> 2. Proceed as-is with a CSS workaround (mix-blend-mode) — quick but approximate
> 3. Keep the background — if this image is meant to be seen with its background
>
> Which do you prefer?
For clean images, confirm them briefly:
> ✅ **[filename]** — clean transparent PNG, resized to [X]px, assigned depth-[N] ([role])
Show all of this BEFORE outputting HTML. Wait for the user's response on any ⚠️ items.
---
## Step 5 — CSS Workaround (Only After User Approves)
Apply ONLY if the user explicitly chooses option 2 above:
```css
/* Dark background image on a dark site — black pixels become invisible */
.on-dark-bg {
mix-blend-mode: screen;
}
/* Light background image on a light site — white pixels become invisible */
.on-light-bg {
mix-blend-mode: multiply;
}
```
Always add a comment in the HTML when using this:
```html
<!-- CSS approximation: [filename] has a solid background.
Replace with a transparent PNG for best quality. -->
```
Limitations:
- `screen` lightens mid-tones — only works well on very dark site backgrounds
- `multiply` darkens mid-tones — only works well on very light site backgrounds
- Neither works on complex or gradient backgrounds
- A proper cutout PNG always gives better results
---
## Step 6 — CSS Rules for Transparent Images
Whether the image came in clean or had its background resolved, always apply:
```css
/* ALWAYS use drop-shadow — it follows the actual pixel shape */
.product-img {
filter: drop-shadow(0 30px 60px rgba(0, 0, 0, 0.4));
}
/* NEVER use box-shadow on cutout images — it creates a rectangle, not a shape shadow */
/* NEVER apply these to transparent/cutout images: */
/*
border-radius → clips transparency into a rounded box
overflow: hidden → same problem on the parent element
object-fit: cover → stretches image to fill a box, destroys the cutout
background-color → makes the bounding box visible
*/
```

View File

@@ -0,0 +1,361 @@
# Depth System Reference
The 2.5D illusion is built entirely on a **6-level depth model**. Every element on the page belongs to exactly one depth level. Depth controls four automatic properties: parallax speed, blur, scale, and shadow intensity. Together these four signals trick the human visual system into perceiving genuine spatial depth from flat assets.
---
## The 6-Level Depth Table
| Level | Name | Parallax | Blur | Scale | Shadow | Z-Index |
|-------|-------------------|----------|-------|-------|---------|---------|
| 0 | Far Background | 0.10x | 8px | 0.70 | 0.05 | 0 |
| 1 | Glow / Atmosphere | 0.25x | 4px | 0.85 | 0.10 | 1 |
| 2 | Mid Decorations | 0.50x | 0px | 1.00 | 0.20 | 2 |
| 3 | Main Objects | 0.80x | 0px | 1.05 | 0.35 | 3 |
| 4 | UI / Text | 1.00x | 0px | 1.00 | 0.00 | 4 |
| 5 | Foreground FX | 1.20x | 0px | 1.10 | 0.50 | 5 |
**Parallax formula:**
```
element_translateY = scroll_position * depth_factor * -1
```
A depth-0 element at scroll position 500px moves only -50px (barely moves — feels far away).
A depth-5 element at 500px moves -600px (moves fast — feels close).
---
## CSS Implementation
### CSS Custom Properties Foundation
```css
:root {
/* Depth parallax factors */
--depth-0-factor: 0.10;
--depth-1-factor: 0.25;
--depth-2-factor: 0.50;
--depth-3-factor: 0.80;
--depth-4-factor: 1.00;
--depth-5-factor: 1.20;
/* Depth blur values */
--depth-0-blur: 8px;
--depth-1-blur: 4px;
--depth-2-blur: 0px;
--depth-3-blur: 0px;
--depth-4-blur: 0px;
--depth-5-blur: 0px;
/* Depth scale values */
--depth-0-scale: 0.70;
--depth-1-scale: 0.85;
--depth-2-scale: 1.00;
--depth-3-scale: 1.05;
--depth-4-scale: 1.00;
--depth-5-scale: 1.10;
/* Live scroll value (updated by JS) */
--scroll-y: 0;
}
/* Base layer class */
.layer {
position: absolute;
inset: 0;
will-change: transform;
transform-origin: center center;
}
/* Depth-specific classes */
.depth-0 {
filter: blur(var(--depth-0-blur));
transform: scale(var(--depth-0-scale))
translateY(calc(var(--scroll-y) * var(--depth-0-factor) * -1px));
z-index: 0;
}
.depth-1 {
filter: blur(var(--depth-1-blur));
transform: scale(var(--depth-1-scale))
translateY(calc(var(--scroll-y) * var(--depth-1-factor) * -1px));
z-index: 1;
mix-blend-mode: screen; /* glow layers blend additively */
}
.depth-2 {
transform: scale(var(--depth-2-scale))
translateY(calc(var(--scroll-y) * var(--depth-2-factor) * -1px));
z-index: 2;
}
.depth-3 {
transform: scale(var(--depth-3-scale))
translateY(calc(var(--scroll-y) * var(--depth-3-factor) * -1px));
z-index: 3;
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.35));
}
.depth-4 {
transform: translateY(calc(var(--scroll-y) * var(--depth-4-factor) * -1px));
z-index: 4;
}
.depth-5 {
transform: scale(var(--depth-5-scale))
translateY(calc(var(--scroll-y) * var(--depth-5-factor) * -1px));
z-index: 5;
}
```
### JavaScript — Scroll Driver
```javascript
// Throttled scroll listener using requestAnimationFrame
let ticking = false;
let lastScrollY = 0;
function updateDepthLayers() {
const scrollY = window.scrollY;
document.documentElement.style.setProperty('--scroll-y', scrollY);
ticking = false;
}
window.addEventListener('scroll', () => {
lastScrollY = window.scrollY;
if (!ticking) {
requestAnimationFrame(updateDepthLayers);
ticking = true;
}
}, { passive: true });
```
---
## Asset Assignment Rules
### What Goes in Each Depth Level
**Depth 0 — Far Background**
- Full-width background images (sky, gradient, texture)
- Very large PNGs (1920×1080+), file size 80150KB max
- Heavily blurred by CSS — low detail is fine and preferred
- Examples: skyscape, abstract color wash, noise texture
**Depth 1 — Glow / Atmosphere**
- Radial gradient blobs, lens flare PNGs, soft light overlays
- Size: 6001000px, file size: 3060KB max
- Always use `mix-blend-mode: screen` or `mix-blend-mode: lighten`
- Always `filter: blur(40px100px)` applied on top of CSS blur
- Examples: orange glow blob behind product, atmospheric haze
**Depth 2 — Mid Decorations**
- Abstract shapes, geometric patterns, floating decorative elements
- Size: 200400px, file size: 2050KB max
- Moderate shadow, no blur
- Examples: floating geometric shapes, brand pattern elements
**Depth 3 — Main Objects (The Star)**
- Hero product images, characters, featured illustrations
- Size: 8001200px, file size: 50120KB max
- High detail, clean cutout (transparent PNG background)
- Strong drop shadow: `filter: drop-shadow(0 30px 60px rgba(0,0,0,0.4))`
- This is the element users look at — give it the most visual weight
- Examples: juice bottle, product shot, hero character
**Depth 4 — UI / Text**
- Headlines, body copy, buttons, cards, navigation
- Always crisp, never blurred
- Text elements get animation data attributes (see text-animations.md)
- Examples: `<h1>`, `<p>`, `<button>`, card components
**Depth 5 — Foreground Particles / FX**
- Sparkles, floating dots, light particles, decorative splashes
- Small (32128px), file size: 210KB
- High contrast, sharp edges
- Multiple instances scattered with different animation delays
- Examples: star sparkles, liquid splash dots, highlight flares
---
## Compositional Hierarchy — Size Relationships Between Assets
The most common mistake in 2.5D design is treating all assets as the same size.
Real cinematic depth requires deliberate, intentional size contrast.
### The Rule of One Hero
Every scene has exactly ONE dominant asset. Everything else serves it.
| Role | Display Size | Depth |
|---|---|---|
| Hero / star element | 5085vw | depth-3 |
| Primary companion | 815vw | depth-2 |
| Secondary companion | 510vw | depth-2 |
| Accent / particle | 14vw | depth-5 |
| Background fill | 100vw | depth-0 |
### Positioning Companions Close to the Hero
Never scatter companions in random corners. Position them relative to the hero's edge:
```css
/*
Hero width: clamp(600px, 70vw, 1000px)
Hero half-width: clamp(300px, 35vw, 500px)
*/
.companion-right {
position: absolute;
right: calc(50% - clamp(300px, 35vw, 500px) - 20px);
/* negative gap value = slightly overlaps the hero */
}
.companion-left {
position: absolute;
left: calc(50% - clamp(300px, 35vw, 500px) - 20px);
}
```
Vertical placement:
- Upper shoulder: `top: 35%; transform: translateY(-50%)`
- Mid waist: `top: 55%; transform: translateY(-50%)`
- Lower base: `top: 72%; transform: translateY(-50%)`
### Scatter Rule on Hero Scroll-Out
When the hero grows or exits, companions scatter outward — not just fade.
This reinforces they were "held in orbit" by the hero.
```javascript
heroScrollTimeline
.to('.companion-right', { x: 80, y: -50, scale: 1.3 }, scrollPos)
.to('.companion-left', { x: -70, y: 40, scale: 1.25 }, scrollPos)
.to('.companion-lower', { x: 30, y: 80, scale: 1.1 }, scrollPos)
```
### Pre-Build Size Checklist
Before assigning sizes, answer these for every asset:
1. Is this the hero? → make it large enough to command the viewport
2. Is this a companion? → it should be 1525% of the hero's display size
3. Would this read better bigger or smaller than my first instinct?
4. Is there enough size contrast between depth layers to read as real depth?
5. Does the composition feel balanced, or does everything look the same size?
---
## Floating Loop Animation
Every element at depth 25 should have a floating animation. Nothing should be perfectly static — it kills the 3D illusion.
```css
/* Float variants — apply different ones to different elements */
@keyframes float-y {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-18px); }
}
@keyframes float-rotate {
0%, 100% { transform: translateY(0px) rotate(0deg); }
33% { transform: translateY(-12px) rotate(2deg); }
66% { transform: translateY(-6px) rotate(-1deg); }
}
@keyframes float-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
@keyframes float-orbit {
0% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(8px, -12px) rotate(2deg); }
50% { transform: translate(0, -20px) rotate(0deg); }
75% { transform: translate(-8px, -12px) rotate(-2deg); }
100% { transform: translate(0, 0) rotate(0deg); }
}
/* Depth-appropriate durations */
.depth-2 .float-loop { animation: float-y 10s ease-in-out infinite; }
.depth-3 .float-loop { animation: float-orbit 8s ease-in-out infinite; }
.depth-5 .float-loop { animation: float-rotate 6s ease-in-out infinite; }
/* Stagger delays for multiple elements at same depth */
.float-loop:nth-child(2) { animation-delay: -2s; }
.float-loop:nth-child(3) { animation-delay: -4s; }
.float-loop:nth-child(4) { animation-delay: -1.5s; }
```
---
## Shadow Depth Enhancement
Stronger shadows on closer elements amplify depth perception:
```css
/* Depth shadow system */
.depth-2 img { filter: drop-shadow(0 10px 20px rgba(0,0,0,0.20)); }
.depth-3 img { filter: drop-shadow(0 25px 50px rgba(0,0,0,0.35)); }
.depth-5 img { filter: drop-shadow(0 5px 15px rgba(0,0,0,0.50)); }
```
## Glow Layer Pattern (Depth 1)
The glow layer is critical for the "product floating in light" premium feel:
```css
/* Glow blob behind the main product */
.glow-blob {
position: absolute;
width: 600px;
height: 600px;
border-radius: 50%;
background: radial-gradient(circle, var(--brand-color) 0%, transparent 70%);
filter: blur(80px);
opacity: 0.45;
mix-blend-mode: screen;
/* Position behind depth-3 product */
z-index: 1;
/* Slow drift */
animation: float-breathe 12s ease-in-out infinite;
}
```
---
## HTML Scaffold Template
```html
<section class="scene" data-scene="[name]">
<div class="scene-inner">
<!-- DEPTH 0: Far background -->
<div class="layer depth-0" aria-hidden="true">
<div class="bg-gradient"></div>
<!-- OR: <img src="bg-texture.png" alt=""> -->
</div>
<!-- DEPTH 1: Glow atmosphere -->
<div class="layer depth-1" aria-hidden="true">
<div class="glow-blob glow-primary"></div>
<div class="glow-blob glow-secondary"></div>
</div>
<!-- DEPTH 2: Mid decorations -->
<div class="layer depth-2" aria-hidden="true">
<img class="deco float-loop" src="shape-1.png" alt="">
<img class="deco float-loop" src="shape-2.png" alt="">
</div>
<!-- DEPTH 3: Main product/hero -->
<div class="layer depth-3">
<img class="product-hero float-loop" src="product.png"
alt="[Meaningful description of product]" />
</div>
<!-- DEPTH 4: Text & UI -->
<div class="layer depth-4">
<h1 class="hero-title split-text" data-animate="converge">
Your Headline
</h1>
<p class="hero-sub" data-animate="fade-up">Supporting copy here</p>
<a class="cta-btn" href="#" data-animate="scale-in">Get Started</a>
</div>
<!-- DEPTH 5: Foreground particles -->
<div class="layer depth-5" aria-hidden="true">
<img class="particle float-loop" src="sparkle.png" alt="">
<img class="particle float-loop" src="sparkle.png" alt="">
<img class="particle float-loop" src="sparkle.png" alt="">
</div>
</div>
</section>
```

View File

@@ -0,0 +1,455 @@
# Directional Reveals Reference
Elements and sections don't always enter from the bottom. Premium sites use **directional births** — sections that drop from the top, iris open from center, peel away like wallpaper, or unfold diagonally. This file covers all 8 directional reveal patterns.
## Table of Contents
1. [Top-Down Clip Birth](#top-down)
2. [Window Pane Iris Open](#iris-open)
3. [Curtain Panel Roll-Up](#curtain-rollup)
4. [SVG Morph Border](#svg-morph)
5. [Diagonal Wipe Birth](#diagonal-wipe)
6. [Circle Iris Expand](#circle-iris)
7. [Multi-Directional Stagger Grid](#multi-direction)
8. [Loading Screen Curtain Lift](#loading-screen)
---
## Pattern 1: Top-Down Clip Birth {#top-down}
The section is born from the top edge and grows **downward**. Instead of rising from below, it drops and unfolds from above. This is the opposite of the conventional bottom-up reveal and creates a striking "curtain drop" feeling.
```css
/* Starting state — section is fully clipped (invisible) */
.top-drop-section {
/* Section exists in DOM but is invisible */
clip-path: inset(0 0 100% 0);
/*
inset(top right bottom left):
- top: 0 → clip starts at top edge
- bottom: 100% → clips 100% from bottom = nothing visible
*/
}
/* Revealed state */
.top-drop-section.revealed {
clip-path: inset(0 0 0% 0);
transition: clip-path 1.2s cubic-bezier(0.16, 1, 0.3, 1);
}
```
```javascript
// GSAP scroll-driven version with scrub
function initTopDownBirth(sectionEl) {
gsap.fromTo(sectionEl,
{ clipPath: 'inset(0 0 100% 0)' },
{
clipPath: 'inset(0 0 0% 0)',
ease: 'power2.out',
scrollTrigger: {
trigger: sectionEl.previousElementSibling, // previous section is the trigger
start: 'bottom 80%',
end: 'bottom 20%',
scrub: 1.5,
}
}
);
}
// Exit: section retracts back upward (born from top, dies back up)
function addTopRetractExit(sectionEl) {
gsap.to(sectionEl, {
clipPath: 'inset(100% 0 0% 0)', // now clips from TOP — retracts upward
ease: 'power2.in',
scrollTrigger: {
trigger: sectionEl,
start: 'bottom 20%',
end: 'bottom top',
scrub: 1,
}
});
}
```
**Key insight:** Enter = `inset(0 0 100% 0)``inset(0 0 0% 0)` (bottom clips away downward).
Exit = `inset(0)``inset(100% 0 0 0)` (top clips away upward = retracts back where it came from).
---
## Pattern 2: Window Pane Iris Open {#iris-open}
An entire section starts as a tiny centered rectangle — like a keyhole or portal — and expands outward to fill the viewport. Creates a cinematic "opening shot" feeling.
```javascript
function initWindowPaneIris(sectionEl) {
// The section starts as a small centered window
gsap.fromTo(sectionEl,
{
clipPath: 'inset(42% 35% 42% 35% round 12px)',
// 42% from top AND bottom = only 16% of height visible
// 35% from left AND right = only 30% of width visible
// Centered rectangle peek
},
{
clipPath: 'inset(0% 0% 0% 0% round 0px)',
ease: 'none',
scrollTrigger: {
trigger: sectionEl,
start: 'top 90%',
end: 'top 10%',
scrub: 1.2,
}
}
);
// Also scale/zoom the content inside for parallax depth
gsap.fromTo(sectionEl.querySelector('.iris-content'),
{ scale: 1.4 },
{
scale: 1,
ease: 'none',
scrollTrigger: {
trigger: sectionEl,
start: 'top 90%',
end: 'top 10%',
scrub: 1.2,
}
}
);
}
```
**Variation — horizontal bar open (blinds effect):**
```javascript
// Two bars that slide apart (one from top, one from bottom)
function initBlindsOpen(topBar, bottomBar, revealEl) {
const tl = gsap.timeline({
scrollTrigger: {
trigger: revealEl,
start: 'top 70%',
toggleActions: 'play none none reverse',
}
});
tl.to(topBar, { yPercent: -100, duration: 1.0, ease: 'power3.inOut' })
.to(bottomBar, { yPercent: 100, duration: 1.0, ease: 'power3.inOut' }, 0);
}
```
---
## Pattern 3: Curtain Panel Roll-Up {#curtain-rollup}
Multiple layered panels. Each one "rolls up" from top, exposing the panel beneath. Like peeling back wallpaper layers to reveal what's underneath. Uses z-index stacking.
```css
.curtain-stack {
position: relative;
height: 100vh;
overflow: hidden;
}
.curtain-panel {
position: absolute;
inset: 0;
/* Stack panels — panel 1 on top, panel N on bottom */
}
.curtain-panel:nth-child(1) { z-index: 5; background: #0f0f0f; }
.curtain-panel:nth-child(2) { z-index: 4; background: #1a0a2e; }
.curtain-panel:nth-child(3) { z-index: 3; background: #2d0b4e; }
.curtain-panel:nth-child(4) { z-index: 2; background: #1e3a8a; }
/* Final revealed content at z-index 1 */
```
```javascript
function initCurtainRollUp(containerEl) {
const panels = gsap.utils.toArray('.curtain-panel', containerEl);
const tl = gsap.timeline({
scrollTrigger: {
trigger: containerEl,
start: 'top top',
end: `+=${panels.length * 120}%`,
pin: true,
scrub: 1,
}
});
panels.forEach((panel, i) => {
const segmentDuration = 1 / panels.length;
const segmentStart = i * segmentDuration;
// Each panel rolls up — clip from bottom rises to top
tl.to(panel, {
clipPath: 'inset(100% 0 0% 0)', // rolls up: bottom clips first, rising to 100%
duration: segmentDuration,
ease: 'power2.inOut',
}, segmentStart);
// Heading for this panel fades in
const heading = panel.querySelector('.panel-heading');
if (heading) {
tl.from(heading, {
opacity: 0,
y: 30,
duration: segmentDuration * 0.4,
}, segmentStart + segmentDuration * 0.1);
}
});
return tl;
}
```
---
## Pattern 4: SVG Morph Border {#svg-morph}
The section's edge is not a hard straight line — it morphs between shapes (rectangle → wave → diagonal → organic curve) as the user scrolls. Makes sections feel alive and fluid.
```html
<!-- SVG clipPath element -->
<svg width="0" height="0" style="position:absolute">
<defs>
<clipPath id="morphClip" clipPathUnits="objectBoundingBox">
<path id="morphPath" d="M0,0 L1,0 L1,0.95 Q0.5,1.05 0,0.95 Z"/>
</clipPath>
</defs>
</svg>
<section class="morphed-section" style="clip-path: url(#morphClip)">
<!-- section content -->
</section>
```
```javascript
function initSVGMorphBorder() {
const morphPath = document.getElementById('morphPath');
const paths = {
straight: 'M0,0 L1,0 L1,1 L0,1 Z',
wave: 'M0,0 L1,0 L1,0.95 Q0.75,1.05 0.5,0.95 Q0.25,0.85 0,0.95 Z',
diagonal: 'M0,0 L1,0 L1,0.88 L0,1.0 Z',
organic: 'M0,0 L1,0 L1,0.92 C0.8,1.04 0.6,0.88 0.4,1.0 C0.2,1.12 0.1,0.90 0,0.96 Z',
};
ScrollTrigger.create({
trigger: '.morphed-section',
start: 'top 80%',
end: 'bottom 20%',
scrub: 2,
onUpdate: (self) => {
const p = self.progress;
// Morph between straight → wave → diagonal as scroll progresses
if (p < 0.5) {
// Interpolate straight → wave
morphPath.setAttribute('d', p < 0.25 ? paths.straight : paths.wave);
} else {
morphPath.setAttribute('d', p < 0.75 ? paths.wave : paths.diagonal);
}
}
});
}
```
---
## Pattern 5: Diagonal Wipe Birth {#diagonal-wipe}
Content is revealed by a diagonal sweep across the screen — from top-left corner to bottom-right (or any corner combination). Feels cinematic and directional.
```javascript
function initDiagonalWipe(el, direction = 'top-left') {
const clipPaths = {
'top-left': {
from: 'polygon(0 0, 0 0, 0 0)',
to: 'polygon(0 0, 120% 0, 0 120%)',
},
'top-right': {
from: 'polygon(100% 0, 100% 0, 100% 0)',
to: 'polygon(-20% 0, 100% 0, 100% 120%)',
},
'center-out': {
from: 'polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)',
to: 'polygon(-10% -10%, 110% -10%, 110% 110%, -10% 110%)',
},
};
const { from, to } = clipPaths[direction];
gsap.fromTo(el,
{ clipPath: from },
{
clipPath: to,
duration: 1.4,
ease: 'power3.inOut',
scrollTrigger: {
trigger: el,
start: 'top 70%',
}
}
);
}
```
---
## Pattern 6: Circle Iris Expand {#circle-iris}
The most dramatic reveal: a perfect circle expands from the center of the section outward, like an aperture opening or a spotlight switching on.
```javascript
function initCircleIris(el, originX = '50%', originY = '50%') {
gsap.fromTo(el,
{ clipPath: `circle(0% at ${originX} ${originY})` },
{
clipPath: `circle(80% at ${originX} ${originY})`,
ease: 'none',
scrollTrigger: {
trigger: el,
start: 'top 75%',
end: 'top 25%',
scrub: 1,
}
}
);
}
// Variant: iris opens from cursor position on hover
function initHoverIris(el) {
el.addEventListener('mouseenter', (e) => {
const rect = el.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width * 100).toFixed(1) + '%';
const y = ((e.clientY - rect.top) / rect.height * 100).toFixed(1) + '%';
gsap.fromTo(el,
{ clipPath: `circle(0% at ${x} ${y})` },
{ clipPath: `circle(100% at ${x} ${y})`, duration: 0.6, ease: 'power2.out' }
);
});
}
```
---
## Pattern 7: Multi-Directional Stagger Grid {#multi-direction}
When a grid or set of cards appears, each item enters from a different edge/direction — creating a dynamic assembly effect instead of uniform fade-ups.
```javascript
function initMultiDirectionalGrid(gridEl) {
const items = gsap.utils.toArray('.grid-item', gridEl);
const directions = [
{ x: -80, y: 0 }, // from left
{ x: 0, y: -80 }, // from top
{ x: 80, y: 0 }, // from right
{ x: 0, y: 80 }, // from bottom
{ x: -60, y: -60 }, // from top-left
{ x: 60, y: -60 }, // from top-right
{ x: -60, y: 60 }, // from bottom-left
{ x: 60, y: 60 }, // from bottom-right
];
items.forEach((item, i) => {
const dir = directions[i % directions.length];
gsap.from(item, {
x: dir.x,
y: dir.y,
opacity: 0,
duration: 0.8,
ease: 'power3.out',
scrollTrigger: {
trigger: gridEl,
start: 'top 75%',
},
delay: i * 0.08, // stagger
});
});
}
```
---
## Pattern 8: Loading Screen Curtain Lift {#loading-screen}
A full-viewport branded intro screen that physically lifts off the page on load, revealing the site beneath. Sets cinematic expectations before any scroll animation begins.
```css
.loading-curtain {
position: fixed;
inset: 0;
z-index: 9999;
background: #0a0a0a; /* or brand color */
display: flex;
align-items: center;
justify-content: center;
/* Split into two halves for dramatic split-open effect */
}
.curtain-top {
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: inherit;
transform-origin: top center;
}
.curtain-bottom {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 50%;
background: inherit;
transform-origin: bottom center;
}
```
```javascript
function initLoadingCurtain() {
const curtainTop = document.querySelector('.curtain-top');
const curtainBottom = document.querySelector('.curtain-bottom');
const curtainLogo = document.querySelector('.curtain-logo');
const loadingScreen = document.querySelector('.loading-curtain');
// Prevent scroll during loading
document.body.style.overflow = 'hidden';
const tl = gsap.timeline({
delay: 0.5,
onComplete: () => {
document.body.style.overflow = '';
loadingScreen.style.display = 'none';
// Init all scroll animations AFTER curtain lifts
initAllAnimations();
}
});
// Logo appears first
tl.from(curtainLogo, { opacity: 0, scale: 0.8, duration: 0.6, ease: 'power2.out' })
// Brief hold
.to({}, { duration: 0.4 })
// Logo fades out
.to(curtainLogo, { opacity: 0, scale: 1.1, duration: 0.4, ease: 'power2.in' })
// Curtain splits: top goes up, bottom goes down
.to(curtainTop, { yPercent: -100, duration: 0.9, ease: 'power4.inOut' }, '-=0.1')
.to(curtainBottom, { yPercent: 100, duration: 0.9, ease: 'power4.inOut' }, '<');
}
window.addEventListener('load', initLoadingCurtain);
```
---
## Combining Directional Reveals
For maximum cinematic impact, chain directional reveals between sections:
```
Section 1 → Section 2: Window pane iris (section 2 peeks through a keyhole)
Section 2 → Section 3: Top-down clip birth (section 3 drops from top)
Section 3 → Section 4: Diagonal wipe (section 4 sweeps in from corner)
Section 4 → Section 5: Circle iris (section 5 opens from center)
Section 5 → Section 6: Curtain panel roll-up (exposes multiple layers)
```
Each transition feels distinct, keeping the user engaged across the full scroll experience.

View File

@@ -0,0 +1,344 @@
# Real-World Examples Reference
Five complete implementation blueprints. Each describes exactly which techniques to combine, in what order, with key code patterns.
## Table of Contents
1. [Juice/Beverage Brand Launch](#juice-brand)
2. [Tech SaaS Landing Page](#saas)
3. [Creative Portfolio](#portfolio)
4. [Gaming Website](#gaming)
5. [Luxury Product E-Commerce](#ecommerce)
---
## Example 1: Juice/Beverage Brand Launch {#juice-brand}
**Brief:** Premium juice brand. Hero has floating glass. Sections transition smoothly with the product "rising" between them.
**Techniques Used:**
- Loading screen curtain lift
- 6-layer depth parallax in hero
- Floating product between sections (THE signature move)
- Top-down clip birth for ingredients section
- Word-by-word scroll lighting for tagline
- Cascading card stack for flavors
- Split converge title exit
**Section Architecture:**
```
[LOADING SCREEN — brand logo on black, splits open]
[HERO — dark purple gradient]
depth-0: purple/dark gradient background
depth-1: orange glow blob (brand color)
depth-2: floating citrus slice PNGs (scattered, decorative)
depth-3: juice glass PNG (main product, float-loop)
depth-4: headline "Pure. Fresh. Electric." (split converge on enter)
depth-5: liquid splash particle PNGs
[FLOATING PRODUCT BRIDGE — glass hovers between sections]
[INGREDIENTS — warm cream/yellow section]
Entry: top-down clip birth (section drops from top)
depth-0: warm gradient background
depth-3: large orange PNG illustration
depth-4: "Word by word" ingredient callouts (scroll-lit)
Floating text: ingredient names fade in one by one
[FLAVORS — cascading card stack, 3 cards]
Card 1: Orange — scales down as Card 2 arrives
Card 2: Mango — scales down as Card 3 arrives
Card 3: Berry — stays full screen
Each card: full-bleed color + depth-3 bottle + depth-4 title
[CTA — minimal, dark]
Circle iris expand reveal
Oversized bleed typography: "DRINK DIFFERENT"
Simple form/button
```
**Key Code Pattern — The Glass Journey:**
```javascript
// Glass starts in hero depth-3, floats between sections,
// then descends into ingredients section
initFloatingProduct(); // from inter-section-effects.md
// On arrival in ingredients section, glass triggers
// the ingredient words to light up one by one
ScrollTrigger.create({
trigger: '.ingredients-section',
start: 'top 50%',
onEnter: () => {
initWordScrollLighting(
'.ingredients-section',
'.ingredients-tagline'
);
}
});
```
**Color Palette:**
- Hero: `#0a0014` (deep purple) → `#2d0b4e`
- Glow: `#ff6b00` (orange), `#ff9900` (amber)
- Ingredients: `#fdf4e7` (warm cream)
- Flavors: Brand-specific per flavor
- CTA: `#0a0014` (returns to hero dark)
---
## Example 2: Tech SaaS Landing Page {#saas}
**Brief:** B2B SaaS product — analytics dashboard. Premium, modern, tech-forward. Animated product screenshots.
**Techniques Used:**
- Window pane iris open (hero reveals from keyhole)
- DJI-style scale-in pin (dashboard screenshot fills viewport)
- Scrub timeline (features appear one by one)
- Curtain panel roll-up (pricing tiers reveal)
- Character cylinder rotation (headline numbers: "10x faster")
- Line clip wipe (feature descriptions)
- Horizontal scroll (integration logos)
**Section Architecture:**
```
[HERO — midnight blue]
Entry: window pane iris — site reveals from tiny centered rectangle
depth-0: mesh gradient (dark blue/purple)
depth-1: subtle grid pattern (CSS, not PNG) with opacity 0.15
depth-2: floating abstract geometric shapes (low opacity)
depth-3: dashboard screenshot PNG (float-loop subtle)
depth-4: headline with CYLINDER ROTATION on "10x"
"Make your analytics 10x smarter"
depth-5: small glow dots/particles
[FEATURE ZOOM — pinned section, 300vh scroll distance]
DJI-style: Dashboard screenshot starts small, expands to full viewport
Scrub timeline reveals 3 features as user scrolls through pin:
- Feature 1: "Real-time insights" fades in left
- Feature 2: "AI-powered" fades in right
- Feature 3: "Zero setup" fades in center
Each feature: line clip wipe on description text
[HOW IT WORKS — top-down clip birth]
3-step process
Each step: multi-directional stagger (step 1 from left, step 2 from top, step 3 from right)
Numbered steps with variable font weight animation
[INTEGRATIONS — horizontal scroll]
Pin section, logos scroll horizontally
Speed reactive marquee for "works with everything you use"
[PRICING — curtain panel roll-up]
3 pricing tiers as curtain panels
Free → Pro → Enterprise reveals one by one
Each reveal: scramble text on price number
[CTA — circle iris]
Dark background
Bleed typography: "START FREE TODAY"
Magnetic button (cursor-attracted)
```
---
## Example 3: Creative Portfolio {#portfolio}
**Brief:** Designer/developer portfolio. Bold, experimental, Awwwards-worthy. The work is the hero.
**Techniques Used:**
- Offset diagonal layout for name/title
- Theatrical enter+exit for all section content
- Horizontal scroll for project showcase
- GSAP Flip cross-section for project previews
- Scroll-speed reactive marquee for skills
- Bleed typography throughout
- Diagonal wipe births
- Cursor spotlight
**Section Architecture:**
```
[INTRO — stark black]
NO loading screen — shock with immediate bold text
depth-0: pure black (#000)
depth-4: MASSIVE bleed title — name in 180px+ font
offset diagonal layout:
Line 1: "ALEX" — top-left, x: 5%
Line 2: "MORENO" — lower-right, x: 40%
Line 3: "Designer" — far right, smaller, italic
Cursor spotlight effect follows mouse
CTA: "See Work ↓" — subtle, bottom-right
[MARQUEE DIVIDER]
Scroll-speed reactive marquee:
"AVAILABLE FOR WORK · BASED IN LONDON · OPEN TO REMOTE ·"
Speed up when user scrolls fast
[PROJECTS — horizontal scroll, 4 projects]
Pinned container, horizontal scroll
Each panel: full-bleed project image
project title via line clip wipe
brief description via theatrical enter
On hover: project image scale(1.03), cursor becomes "View →"
Between projects: diagonal wipe transition
[ABOUT — section peel]
Upper section peels away to reveal about section
depth-3: portrait photo (clip-path circle iris, expands to full)
depth-4: about text — curtain line reveal
Skills: variable font wave animation
[PROCESS — pinned scrub timeline]
3 process stages animate through scroll:
Each stage: top-down clip birth reveals content
Numbers: character cylinder rotation
[CONTACT — minimal]
Circle iris expand
Email address: scramble text effect on hover
Social links: skew + bounce on scroll in
```
---
## Example 4: Gaming Website {#gaming}
**Brief:** Game launch page. Dark, cinematic, intense. Character reveals, environment depth.
**Techniques Used:**
- Curved path travel (character moves across page)
- Perspective zoom fly-through (fly into the game world)
- Full layered parallax (6 levels deep)
- SVG morph borders (organic landscape edges)
- Cascading card stacks (character select)
- Word-by-word scroll lighting (lore text)
- Particle trails (cursor leaves sparks)
- Multiple floating loops (atmospheric)
**Section Architecture:**
```
[LOADING SCREEN — game-style]
Loading bar fills
Logo does cylinder rotation
Splits open with curtain top/bottom
[HERO — extreme depth parallax]
depth-0: distant mountains/sky PNG (very slow, heavily blurred)
depth-1: mid-distance fog layer (slightly blurred, mix-blend: screen)
depth-2: closer terrain elements (decorative)
depth-3: CHARACTER PNG — hero character (main float-loop)
depth-4: game title — "SHADOWREALM" (split converge from sides)
depth-5: foreground particles — embers/sparks (fast float)
Cursor: particle trail (sparks follow cursor)
[FLY-THROUGH — perspective zoom, 300vh]
Pinned section
Camera appears to fly INTO the game world
Background rushes toward viewer (scale 0.3 → 1.4)
Character appears from far (scale 0.05 → 1)
Title resolves via scramble text
[LORE — word scroll lighting, pinned 400vh]
Dark section, long block of atmospheric text
Words light up as user scrolls
Atmospheric background particles drift slowly
Character silhouette visible at depth-1 (very faint)
[CHARACTERS — cascading card stack, 4 characters]
Each card: character art full-bleed
Character name: cylinder rotation
Class/description: line clip wipe
Stats: stagger animate (bars fill on enter)
Each card buried: scale(0.88), blur, pushed back
[WORLD MAP — horizontal scroll]
5 zones scroll horizontally
Zone titles: offset diagonal layout
Environment art at different parallax speeds
[PRE-ORDER — window pane iris]
Iris opens revealing pre-order section
Bleed typography: "ENTER THE REALM"
Magnetic CTA button
```
---
## Example 5: Luxury Product E-Commerce {#ecommerce}
**Brief:** High-end watch/jewelry brand. Understated elegance. Every animation whispers, not shouts. The product is the hero.
**Techniques Used:**
- DJI-style scale-in (product fills viewport, slowly)
- GSAP Flip (watch travels from hero to detail view)
- Section peel reveal (product details peel open)
- Masked line curtain reveal (all body text)
- Clip-path section birth (materials section)
- Floating product between sections
- Subtle parallax (depth factors halved for elegance)
- Bleed typography (collection names)
**Section Architecture:**
```
[HERO — pure white or cream]
No loading screen — immediate elegance
depth-0: pure white / soft cream gradient
depth-1: VERY subtle warm glow (opacity 0.2 only)
depth-2: minimal geometric line decoration (thin, opacity 0.3)
depth-3: WATCH PNG — centered, generous space, slow float (14s loop, tiny movement)
depth-4: brand name — thin weight, large tracking
"Est. 1887" — tiny, centered below
Parallax factors reduced: depth-3 factor = 0.3 (elegant, not dramatic)
[PRODUCT TRANSITION — GSAP Flip]
Watch morphs from hero center to detail view (left side)
Detail text reveals via masked line curtain (right side)
Flip duration: 1.4s (luxury = slow, unhurried)
[MATERIALS — clip-path section birth]
Cream/beige section
Product rises up through the section boundary
Material close-ups: stagger fade in from bottom (gentle)
Text: curtain line reveal (one line at a time, 0.2s stagger)
[CRAFTSMANSHIP — top-down clip birth, then peel]
Section drops from top (elegant, not dramatic)
Video/image of watchmaker — DJI scale-in at reduced intensity
Text: word-by-word scroll lighting (VERY slow, meditative)
[COLLECTION — section peel + horizontal scroll]
Peel reveals horizontal scroll gallery
4 watch variants scroll horizontally
Each: full-bleed product + minimal text (clip wipe)
[PURCHASE — circle iris (small, elegant)]
Circle opens from center, but slowly (2s duration)
Minimal layout: price, materials, add to cart
CTA: subtle skew + bounce (barely perceptible)
Trust signals: line-by-line curtain reveal
```
---
## Combining Patterns — Quick Reference
These combinations appear most often across successful premium sites:
**The "Product Hero" Combination:**
Floating product between sections + Top-down clip birth + Split converge title + Word scroll lighting
**The "Cinematic Chapter" Combination:**
Pinned sticky + Scrub timeline + Curtain panel roll-up + Theatrical enter/exit
**The "Tech Premium" Combination:**
Window pane iris + DJI scale-in + Line clip wipe + Cylinder rotation
**The "Editorial" Combination:**
Bleed typography + Offset diagonal + Horizontal scroll + Diagonal wipe
**The "Minimal Luxury" Combination:**
GSAP Flip + Section peel + Masked line curtain + Reduced parallax factors

View File

@@ -0,0 +1,493 @@
# Inter-Section Effects Reference
These are the most premium techniques — effects where elements **persist, travel, or transition between sections**, creating a seamless narrative thread across the entire page.
## Table of Contents
1. [Floating Product Between Sections](#floating-product)
2. [GSAP Flip Cross-Section Morph](#flip-morph)
3. [Clip-Path Section Birth (Product Grows from Border)](#clip-birth)
4. [DJI-Style Scale-In Pin](#dji-scale)
5. [Element Curved Path Travel](#curved-path)
6. [Section Peel Reveal](#section-peel)
---
## Technique 1: Floating Product Between Sections {#floating-product}
This is THE signature technique for product brands. A product image (juice bottle, phone, sneaker) starts inside the hero section. As you scroll, it appears to "rise up" through the section boundary and hover between two differently-colored sections — partially owned by neither. Then as you continue scrolling, it gracefully descends back in.
**The Visual Story:**
- Hero section: product sitting naturally inside
- Mid-scroll: product "floating" in space, section colors visible above and below it
- Continue scroll: product becomes part of the next section
```css
/* The product is positioned in a sticky wrapper */
.inter-section-product-wrapper {
/* This wrapper spans BOTH sections */
position: relative;
z-index: 100;
pointer-events: none;
height: 0; /* no height — just a position anchor */
}
.inter-section-product {
position: sticky;
top: 50vh; /* stick to vertical center of viewport */
transform: translateY(-50%); /* true center */
width: 100%;
display: flex;
justify-content: center;
pointer-events: none;
}
.inter-section-product img {
width: clamp(280px, 35vw, 560px);
/* The product will be exactly at the section boundary
when the page is scrolled to that point */
}
```
```javascript
function initFloatingProduct() {
const wrapper = document.querySelector('.inter-section-product-wrapper');
const productImg = wrapper.querySelector('img');
const heroSection = document.querySelector('.hero-section');
const nextSection = document.querySelector('.feature-section');
// Create a ScrollTrigger timeline for the product's journey
const tl = gsap.timeline({
scrollTrigger: {
trigger: heroSection,
start: 'bottom 80%', // starts rising as hero bottom approaches viewport
end: 'bottom 20%', // completes rise when hero fully exited
scrub: 1.5,
}
});
// Phase 1: Product rises up from hero (scale grows, shadow intensifies)
tl.fromTo(productImg,
{
y: 0,
scale: 0.85,
filter: 'drop-shadow(0 10px 20px rgba(0,0,0,0.2))',
},
{
y: '-8vh',
scale: 1.05,
filter: 'drop-shadow(0 40px 80px rgba(0,0,0,0.5))',
duration: 0.5,
}
);
// Phase 2: Product fully "between" sections — peak visibility
tl.to(productImg, {
y: '-5vh',
scale: 1.1,
duration: 0.3,
});
// Phase 3: Product descends into next section
ScrollTrigger.create({
trigger: nextSection,
start: 'top 60%',
end: 'top 20%',
scrub: 1.5,
onUpdate: (self) => {
gsap.to(productImg, {
y: `${self.progress * 8}vh`,
scale: 1.1 - (self.progress * 0.2),
duration: 0.1,
overwrite: true,
});
}
});
}
```
### Required HTML Structure
```html
<!-- SECTION 1: Hero (dark background) -->
<section class="hero-section" style="background: #0a0014; min-height: 100vh; position: relative; z-index: 1;">
<!-- depth layers 0-2 (bg, glow, decorations) -->
<!-- NO product image here — it's in the inter-section wrapper -->
<div class="layer depth-4">
<h1>Your Headline</h1>
<p>Hero subtext here</p>
</div>
</section>
<!-- THE FLOATING PRODUCT — outside both sections, between them -->
<div class="inter-section-product-wrapper">
<div class="inter-section-product">
<img
src="product.png"
alt="Product Name — floating between hero and features"
class="float-loop"
/>
</div>
</div>
<!-- SECTION 2: Features (lighter background) -->
<section class="feature-section" style="background: #f5f0ff; min-height: 100vh; position: relative; z-index: 2; padding-top: 15vh;">
<!-- Product appears to "land" into this section -->
<div class="feature-content">
<h2>Features Headline</h2>
</div>
</section>
```
---
## Technique 2: GSAP Flip Cross-Section Morph {#flip-morph}
The same DOM element appears to travel between completely different layout positions across sections. In the hero it's large and centered; in the feature section it's small and left-aligned; in the detail section it's full-width. One smooth morph connects them all.
```javascript
function initFlipMorphSections() {
gsap.registerPlugin(Flip);
// The product element exists in one place in the DOM
// but we have "ghost" placeholder positions in other sections
const product = document.querySelector('.traveling-product');
const positions = {
hero: document.querySelector('.product-position-hero'),
feature: document.querySelector('.product-position-feature'),
detail: document.querySelector('.product-position-detail'),
};
function morphToPosition(positionEl, options = {}) {
// Capture current state
const state = Flip.getState(product);
// Move element to new position
positionEl.appendChild(product);
// Animate from captured state to new position
Flip.from(state, {
duration: 0.9,
ease: 'power3.inOut',
...options
});
}
// Trigger morphs on scroll
ScrollTrigger.create({
trigger: '.feature-section',
start: 'top 60%',
onEnter: () => morphToPosition(positions.feature),
onLeaveBack: () => morphToPosition(positions.hero),
});
ScrollTrigger.create({
trigger: '.detail-section',
start: 'top 60%',
onEnter: () => morphToPosition(positions.detail),
onLeaveBack: () => morphToPosition(positions.feature),
});
}
```
### Ghost Position Placeholders HTML
```html
<!-- Hero section: large, centered position -->
<section class="hero-section">
<div class="product-position-hero" style="width: 500px; height: 500px; margin: 0 auto;">
<!-- Product starts here -->
<img class="traveling-product" src="product.png" alt="Product" style="width:100%;">
</div>
</section>
<!-- Feature section: medium, left-side position -->
<section class="feature-section">
<div class="feature-layout">
<div class="product-position-feature" style="width: 280px; height: 280px;">
<!-- Product morphs to here -->
</div>
<div class="feature-text">...</div>
</div>
</section>
```
---
## Technique 3: Clip-Path Section Birth (Product Grows from Border) {#clip-birth}
The product image starts completely hidden below the section's bottom border — clipped out of existence. As the user scrolls into the section boundary, the product "grows up" through the border like a plant emerging from soil. This is distinct from the floating product — here, the section itself is the stage.
```css
.birth-section {
position: relative;
overflow: hidden; /* hard clip at section border */
min-height: 100vh;
}
.birth-product {
position: absolute;
bottom: -20%; /* starts 20% below the section — invisible */
left: 50%;
transform: translateX(-50%);
width: clamp(300px, 40vw, 600px);
/* Will animate up through the section boundary */
}
```
```javascript
function initClipPathBirth(sectionEl, productEl) {
const tl = gsap.timeline({
scrollTrigger: {
trigger: sectionEl,
start: 'top 80%',
end: 'top 20%',
scrub: 1.2,
}
});
// Product rises from below section boundary
tl.fromTo(productEl,
{
y: '120%', // fully below section
scale: 0.7,
opacity: 0,
filter: 'blur(8px)'
},
{
y: '0%', // sits naturally in section
scale: 1,
opacity: 1,
filter: 'blur(0px)',
ease: 'power3.out',
duration: 1,
}
);
// Continue scroll → product rises further and becomes full height
// then disappears back below as section exits
ScrollTrigger.create({
trigger: sectionEl,
start: 'bottom 60%',
end: 'bottom top',
scrub: 1,
onUpdate: (self) => {
gsap.to(productEl, {
y: `${-self.progress * 50}%`,
opacity: 1 - self.progress,
scale: 1 + self.progress * 0.2,
duration: 0.1,
overwrite: true,
});
}
});
}
```
---
## Technique 4: DJI-Style Scale-In Pin {#dji-scale}
Made famous by DJI drone product pages. A section starts with a small, contained image. As the user scrolls, the image scales up to fill the entire viewport — THEN the section unpins and the next content reveals. Creates a "zoom into the world" feeling.
```javascript
function initDJIScaleIn(sectionEl) {
const heroMedia = sectionEl.querySelector('.dji-media');
const heroContent = sectionEl.querySelector('.dji-content');
const overlay = sectionEl.querySelector('.dji-overlay');
const tl = gsap.timeline({
scrollTrigger: {
trigger: sectionEl,
start: 'top top',
end: '+=300%',
pin: true,
scrub: 1.5,
}
});
// Stage 1: Small image scales up to fill viewport
tl.fromTo(heroMedia,
{
borderRadius: '20px',
scale: 0.3,
width: '60%',
left: '20%',
top: '20%',
},
{
borderRadius: '0px',
scale: 1,
width: '100%',
left: '0%',
top: '0%',
duration: 0.4,
ease: 'power2.inOut',
}
)
// Stage 2: Overlay fades in over the full-viewport image
.fromTo(overlay,
{ opacity: 0 },
{ opacity: 0.6, duration: 0.2 },
0.35
)
// Stage 3: Content text appears over the overlay
.from(heroContent.querySelectorAll('.dji-line'),
{
y: 40,
opacity: 0,
stagger: 0.08,
duration: 0.25,
},
0.45
);
return tl;
}
```
```css
.dji-section {
position: relative;
height: 100vh;
overflow: hidden;
}
.dji-media {
position: absolute;
height: 100%;
object-fit: cover;
/* Will be animated to full coverage */
}
.dji-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.8));
opacity: 0;
}
.dji-content {
position: absolute;
bottom: 15%;
left: 8%;
right: 8%;
color: white;
}
```
---
## Technique 5: Element Curved Path Travel {#curved-path}
The most advanced technique. A product element travels along a smooth, curved Bezier path across the page as the user scrolls — arcing through space like it's floating or being thrown, rather than just translating in a straight line.
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js"></script>
```
```javascript
function initCurvedPathTravel(productEl) {
gsap.registerPlugin(MotionPathPlugin);
// Define the curved path as SVG coordinates
// Relative to the product's parent container
const path = [
{ x: 0, y: 0 }, // Start: hero center
{ x: -200, y: -100 }, // Arc left and up
{ x: 100, y: -300 }, // Continue arcing
{ x: 300, y: -150 }, // Swing right
{ x: 200, y: 50 }, // Land into feature section
];
gsap.to(productEl, {
motionPath: {
path: path,
curviness: 1.4, // How curvy (0 = straight lines, 2 = very curved)
autoRotate: false, // Don't rotate along path (keep product upright)
},
scale: gsap.utils.interpolate([0.8, 1.1, 0.9, 1.0, 1.2]),
ease: 'none',
scrollTrigger: {
trigger: '.journey-container',
start: 'top top',
end: '+=400%',
pin: true,
scrub: 1.5,
}
});
}
```
---
## Technique 6: Section Peel Reveal {#section-peel}
The section below is revealed by the section above peeling away — like turning a page. Uses `sticky: bottom: 0` so the lower section sticks to the screen bottom while the upper section scrolls away.
```css
.peel-upper {
position: relative;
z-index: 2;
min-height: 100vh;
/* This section scrolls away normally */
}
.peel-lower {
position: sticky;
bottom: 0; /* sticks to BOTTOM of viewport */
z-index: 1;
min-height: 100vh;
/* This section waits at the bottom as upper section peels away */
}
/* Container wraps both */
.peel-container {
position: relative;
}
```
```javascript
function initSectionPeel() {
const upper = document.querySelector('.peel-upper');
const lower = document.querySelector('.peel-lower');
// As upper section scrolls, reveal lower by reducing clip
gsap.fromTo(upper,
{ clipPath: 'inset(0 0 0 0)' },
{
clipPath: 'inset(0 0 100% 0)', // upper peels up and away
ease: 'none',
scrollTrigger: {
trigger: '.peel-container',
start: 'top top',
end: 'center top',
scrub: true,
}
}
);
// Lower section content animates in as it's revealed
gsap.from(lower.querySelectorAll('.peel-content > *'), {
y: 30,
opacity: 0,
stagger: 0.1,
duration: 0.6,
scrollTrigger: {
trigger: '.peel-container',
start: '30% top',
toggleActions: 'play none none reverse',
}
});
}
```
---
## Choosing the Right Inter-Section Technique
| Situation | Best Technique |
|-----------|---------------|
| Brand/product site with hero image | Floating Product Between Sections |
| Product appears in multiple contexts | GSAP Flip Cross-Section Morph |
| Product "rises" from section boundary | Clip-Path Section Birth |
| Cinematic "enter the world" feeling | DJI-Style Scale-In Pin |
| Product travels a journey narrative | Curved Path Travel |
| Elegant section-to-section transition | Section Peel Reveal |
| Dark → light section transition | Floating Product (section backgrounds change beneath) |

View File

@@ -0,0 +1,531 @@
# Motion System Reference
## Table of Contents
1. [GSAP Setup & CDN](#gsap-setup)
2. [Pattern 1: Multi-Layer Parallax](#pattern-1)
3. [Pattern 2: Pinned Sticky Sections](#pattern-2)
4. [Pattern 3: Cascading Card Stack](#pattern-3)
5. [Pattern 4: Scrub Timeline](#pattern-4)
6. [Pattern 5: Clip-Path Wipe Reveals](#pattern-5)
7. [Pattern 6: Horizontal Scroll Conversion](#pattern-6)
8. [Pattern 7: Perspective Zoom Fly-Through](#pattern-7)
9. [Pattern 8: Snap-to-Section](#pattern-8)
10. [Lenis Smooth Scroll](#lenis)
11. [IntersectionObserver Activation](#intersection-observer)
---
## GSAP Setup & CDN {#gsap-setup}
Always load from jsDelivr CDN:
```html
<!-- Core GSAP -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<!-- ScrollTrigger plugin — required for all scroll patterns -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
<!-- ScrollSmoother — optional, pairs with ScrollTrigger -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollSmoother.min.js"></script>
<!-- Flip plugin — for cross-section element morphing -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>
<!-- MotionPathPlugin — for curved element paths -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js"></script>
<script>
// Always register plugins immediately
gsap.registerPlugin(ScrollTrigger, Flip, MotionPathPlugin);
// Respect prefers-reduced-motion
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReduced) {
gsap.globalTimeline.timeScale(0); // Freeze all animations
}
</script>
```
---
## Pattern 1: Multi-Layer Parallax {#pattern-1}
The foundation of all 2.5D depth. Different layers scroll at different speeds.
```javascript
function initParallax() {
const layers = document.querySelectorAll('[data-depth]');
const depthFactors = {
'0': 0.10, '1': 0.25, '2': 0.50,
'3': 0.80, '4': 1.00, '5': 1.20
};
layers.forEach(layer => {
const depth = layer.dataset.depth;
const factor = depthFactors[depth] || 1.0;
gsap.to(layer, {
yPercent: -15 * factor, // adjust multiplier for desired effect intensity
ease: 'none',
scrollTrigger: {
trigger: layer.closest('.scene'),
start: 'top bottom',
end: 'bottom top',
scrub: true, // 1:1 scroll-to-animation
}
});
});
}
```
**When to use:** Every project. This is always on.
---
## Pattern 2: Pinned Sticky Sections {#pattern-2}
A section stays fixed while its content animates. Other sections slide over/under it. The "window over window" effect.
```javascript
function initPinnedSection(sceneEl) {
// The section stays pinned for `duration` scroll pixels
// while inner content animates on a scrubbed timeline
const tl = gsap.timeline({
scrollTrigger: {
trigger: sceneEl,
start: 'top top',
end: '+=150%', // stay pinned for 1.5x viewport of scroll
pin: true, // THIS is what pins the section
scrub: 1, // 1 second smoothing
anticipatePin: 1, // prevents jump on pin
}
});
// Inner content animations while pinned
// These play out over the scroll distance
tl.from('.pinned-title', { opacity: 0, y: 60, duration: 0.3 })
.from('.pinned-image', { scale: 0.8, opacity: 0, duration: 0.4 })
.to('.pinned-bg', { backgroundColor: '#1a0a2e', duration: 0.3 })
.from('.pinned-sub', { opacity: 0, x: -40, duration: 0.3 });
return tl;
}
```
**Visual result:** Section feels like a chapter — the page "lives inside it" for a while, then moves on.
---
## Pattern 3: Cascading Card Stack {#pattern-3}
New sections slide over previous ones. Each buried section scales down and darkens, feeling like it's receding.
```css
/* CSS Setup */
.card-stack-section {
position: sticky;
top: 0;
height: 100vh;
/* Each subsequent section has higher z-index */
}
.card-stack-section:nth-child(1) { z-index: 1; }
.card-stack-section:nth-child(2) { z-index: 2; }
.card-stack-section:nth-child(3) { z-index: 3; }
.card-stack-section:nth-child(4) { z-index: 4; }
```
```javascript
function initCardStack() {
const cards = gsap.utils.toArray('.card-stack-section');
cards.forEach((card, i) => {
// Each card (except last) gets buried as next one enters
if (i < cards.length - 1) {
gsap.to(card, {
scale: 0.88,
filter: 'brightness(0.5) blur(3px)',
borderRadius: '20px',
ease: 'none',
scrollTrigger: {
trigger: cards[i + 1], // fires when NEXT card enters
start: 'top bottom',
end: 'top top',
scrub: true,
}
});
}
});
}
```
---
## Pattern 4: Scrub Timeline {#pattern-4}
The most powerful pattern. Elements transform EXACTLY in sync with scroll position. One pixel of scroll = one frame of animation.
```javascript
function initScrubTimeline(sceneEl) {
const tl = gsap.timeline({
scrollTrigger: {
trigger: sceneEl,
start: 'top top',
end: '+=200%',
pin: true,
scrub: 1.5, // 1.5s lag for smooth, dreamy feel (use 0 for precise 1:1)
}
});
// Sequences play out as user scrolls
// 0.0 to 0.25 → first 25% of scroll
tl.fromTo('.hero-product',
{ scale: 0.6, opacity: 0, y: 100 },
{ scale: 1, opacity: 1, y: 0, duration: 0.25 }
)
// 0.25 to 0.5 → second quarter
.to('.hero-title span:first-child', {
x: '-30vw', opacity: 0, duration: 0.25
}, 0.25)
.to('.hero-title span:last-child', {
x: '30vw', opacity: 0, duration: 0.25
}, 0.25)
// 0.5 to 0.75 → third quarter
.to('.hero-product', {
scale: 1.3, y: -50, duration: 0.25
}, 0.5)
.fromTo('.next-section-content',
{ opacity: 0, y: 80 },
{ opacity: 1, y: 0, duration: 0.25 },
0.5
)
// 0.75 to 1.0 → final quarter
.to('.hero-product', {
opacity: 0, scale: 1.6, duration: 0.25
}, 0.75);
return tl;
}
```
---
## Pattern 5: Clip-Path Wipe Reveals {#pattern-5}
Content is hidden behind a clip-path mask that animates away to reveal the content beneath. GPU-accelerated, buttery smooth.
```javascript
// Left-to-right horizontal wipe
function initHorizontalWipe(el) {
gsap.fromTo(el,
{ clipPath: 'inset(0 100% 0 0)' },
{
clipPath: 'inset(0 0% 0 0)',
duration: 1.2,
ease: 'power3.out',
scrollTrigger: { trigger: el, start: 'top 80%' }
}
);
}
// Top-to-bottom drop reveal
function initTopDropReveal(el) {
gsap.fromTo(el,
{ clipPath: 'inset(0 0 100% 0)' },
{
clipPath: 'inset(0 0 0% 0)',
duration: 1.0,
ease: 'power2.out',
scrollTrigger: { trigger: el, start: 'top 75%' }
}
);
}
// Circle iris expand
function initCircleIris(el) {
gsap.fromTo(el,
{ clipPath: 'circle(0% at 50% 50%)' },
{
clipPath: 'circle(75% at 50% 50%)',
duration: 1.4,
ease: 'power2.inOut',
scrollTrigger: { trigger: el, start: 'top 60%' }
}
);
}
// Window pane iris (tiny box expands to full)
function initWindowPaneIris(sceneEl) {
gsap.fromTo(sceneEl,
{ clipPath: 'inset(45% 30% 45% 30% round 8px)' },
{
clipPath: 'inset(0% 0% 0% 0% round 0px)',
ease: 'none',
scrollTrigger: {
trigger: sceneEl,
start: 'top 80%',
end: 'top 20%',
scrub: 1,
}
}
);
}
```
---
## Pattern 6: Horizontal Scroll Conversion {#pattern-6}
Vertical scrolling drives horizontal movement through panels. Classic premium technique.
```javascript
function initHorizontalScroll(containerEl) {
const panels = gsap.utils.toArray('.h-panel', containerEl);
gsap.to(panels, {
xPercent: -100 * (panels.length - 1),
ease: 'none',
scrollTrigger: {
trigger: containerEl,
pin: true,
scrub: 1,
end: () => `+=${containerEl.offsetWidth * (panels.length - 1)}`,
snap: 1 / (panels.length - 1), // auto-snap to each panel
}
});
}
```
```css
.h-scroll-container {
display: flex;
width: calc(300vw); /* 3 panels × 100vw */
height: 100vh;
overflow: hidden;
}
.h-panel {
width: 100vw;
height: 100vh;
flex-shrink: 0;
}
```
---
## Pattern 7: Perspective Zoom Fly-Through {#pattern-7}
User appears to fly toward content. Combines scale, Z-axis, and opacity on a scrubbed pin.
```javascript
function initPerspectiveZoom(sceneEl) {
const tl = gsap.timeline({
scrollTrigger: {
trigger: sceneEl,
start: 'top top',
end: '+=300%',
pin: true,
scrub: 2,
}
});
// Background "rushes toward" viewer
tl.fromTo('.zoom-bg',
{ scale: 0.4, filter: 'blur(20px)', opacity: 0.3 },
{ scale: 1.2, filter: 'blur(0px)', opacity: 1, duration: 0.6 }
)
// Product appears from far
.fromTo('.zoom-product',
{ scale: 0.1, z: -2000, opacity: 0 },
{ scale: 1, z: 0, opacity: 1, duration: 0.5, ease: 'power2.out' },
0.2
)
// Text fades in after product arrives
.fromTo('.zoom-title',
{ opacity: 0, letterSpacing: '2em' },
{ opacity: 1, letterSpacing: '0.05em', duration: 0.3 },
0.55
);
}
```
```css
.zoom-scene {
perspective: 1200px;
perspective-origin: 50% 50%;
transform-style: preserve-3d;
overflow: hidden;
}
```
---
## Pattern 8: Snap-to-Section {#pattern-8}
Full-page scroll snapping between sections — creates a chapter-like book feeling.
```javascript
// Using GSAP Observer for smooth snapping
function initSectionSnap() {
// Register Observer plugin
gsap.registerPlugin(Observer);
const sections = gsap.utils.toArray('.snap-section');
let currentIndex = 0;
let animating = false;
function goTo(index) {
if (animating || index === currentIndex) return;
animating = true;
const direction = index > currentIndex ? 1 : -1;
const current = sections[currentIndex];
const next = sections[index];
const tl = gsap.timeline({
onComplete: () => {
currentIndex = index;
animating = false;
}
});
// Current section exits upward
tl.to(current, {
yPercent: -100 * direction,
opacity: 0,
duration: 0.8,
ease: 'power2.inOut'
})
// Next section enters from below/above
.fromTo(next,
{ yPercent: 100 * direction, opacity: 0 },
{ yPercent: 0, opacity: 1, duration: 0.8, ease: 'power2.inOut' },
0
);
}
Observer.create({
type: 'wheel,touch',
onDown: () => goTo(Math.min(currentIndex + 1, sections.length - 1)),
onUp: () => goTo(Math.max(currentIndex - 1, 0)),
tolerance: 100,
preventDefault: true,
});
}
```
---
## Lenis Smooth Scroll {#lenis}
Lenis replaces native browser scroll with silky-smooth physics-based scrolling. Always pair with GSAP ScrollTrigger.
```html
<script src="https://cdn.jsdelivr.net/npm/@studio-freight/lenis@1.0.45/dist/lenis.min.js"></script>
```
```javascript
function initLenis() {
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
orientation: 'vertical',
smoothWheel: true,
});
// CRITICAL: Connect Lenis to GSAP ticker
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
return lenis;
}
```
---
## IntersectionObserver Activation {#intersection-observer}
Only animate elements that are currently visible. Critical for performance.
```javascript
function initRevealObserver() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
// Trigger GSAP animation
const animType = entry.target.dataset.animate;
if (animType) triggerAnimation(entry.target, animType);
// Stop observing after first trigger
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.15,
rootMargin: '0px 0px -50px 0px'
});
document.querySelectorAll('[data-animate]').forEach(el => observer.observe(el));
}
function triggerAnimation(el, type) {
const animations = {
'fade-up': () => gsap.from(el, { y: 60, opacity: 0, duration: 0.8, ease: 'power3.out' }),
'fade-in': () => gsap.from(el, { opacity: 0, duration: 1.0, ease: 'power2.out' }),
'scale-in': () => gsap.from(el, { scale: 0.8, opacity: 0, duration: 0.7, ease: 'back.out(1.7)' }),
'slide-left': () => gsap.from(el, { x: -80, opacity: 0, duration: 0.8, ease: 'power3.out' }),
'slide-right':() => gsap.from(el, { x: 80, opacity: 0, duration: 0.8, ease: 'power3.out' }),
'converge': () => animateSplitConverge(el), // See text-animations.md
};
animations[type]?.();
}
```
---
## Pattern 9: Elastic Drop with Impact Shake {#elastic-drop}
An element falls from above with an elastic overshoot, then a rapid
micro-rotation shake fires on landing — simulating physical weight and impact.
```javascript
function initElasticDrop(productEl, wrapperEl) {
const tl = gsap.timeline({ delay: 0.3 });
// Phase 1: element drops with elastic bounce
tl.from(productEl, {
y: -180,
opacity: 0,
scale: 1.1,
duration: 1.3,
ease: 'elastic.out(1, 0.65)',
})
// Phase 2: shake fires just as the elastic settles
// Apply to the WRAPPER not the element — avoids transform conflicts
.to(wrapperEl, {
keyframes: [
{ rotation: -2, duration: 0.08 },
{ rotation: 2, duration: 0.08 },
{ rotation: -1.5, duration: 0.07 },
{ rotation: 1, duration: 0.07 },
{ rotation: 0, duration: 0.10 },
],
ease: 'power1.inOut',
}, '-=0.35');
return tl;
}
```
```html
<!-- Wrapper and product must be separate elements -->
<div class="drop-wrapper" id="dropWrapper">
<img class="drop-product" id="dropProduct" src="product.png" alt="..." />
</div>
```
Ease variants:
- `elastic.out(1, 0.65)` — standard product, moderate bounce
- `elastic.out(1.2, 0.5)` — heavier object, more overshoot
- `elastic.out(0.8, 0.8)` — lighter, quicker settle
- `back.out(2.5)` — no oscillation, one clean overshoot
Do NOT use for: gentle floaters, airy elements (flowers, feathers) — use `power3.out` instead.

View File

@@ -0,0 +1,261 @@
# Performance Reference
## The Golden Rule
**Only animate properties that the browser can handle on the GPU compositor thread:**
```
✅ SAFE (GPU composited): transform, opacity, filter, clip-path, will-change
❌ AVOID (triggers layout): width, height, top, left, right, bottom, margin, padding,
font-size, border-width, background-size (avoid)
```
Animating layout properties causes the browser to recalculate the entire page layout on every frame — this is called "layout thrash" and causes jank.
---
## requestAnimationFrame Pattern
Never put animation logic directly in event listeners. Always batch through rAF:
```javascript
let rafId = null;
let pendingScrollY = 0;
function onScroll() {
pendingScrollY = window.scrollY;
if (!rafId) {
rafId = requestAnimationFrame(processScroll);
}
}
function processScroll() {
rafId = null;
document.documentElement.style.setProperty('--scroll-y', pendingScrollY);
// update other values...
}
window.addEventListener('scroll', onScroll, { passive: true });
// passive: true is CRITICAL — tells browser scroll handler won't preventDefault
// allows browser to scroll on a separate thread
```
---
## will-change Usage Rules
`will-change` promotes an element to its own GPU layer. Powerful but dangerous if overused.
```css
/* DO: Only apply when animation is about to start */
.element-about-to-animate {
will-change: transform, opacity;
}
/* DO: Remove after animation completes */
element.addEventListener('animationend', () => {
element.style.willChange = 'auto';
});
/* DON'T: Apply globally */
* { will-change: transform; } /* WRONG — massive GPU memory usage */
/* DON'T: Apply statically on all animated elements */
.animated-thing { will-change: transform; } /* Wrong if there are many of these */
```
### GSAP handles this automatically
GSAP applies `will-change` during animations and removes it after. If using GSAP, you generally don't need to manage `will-change` yourself.
---
## IntersectionObserver Pattern
Never animate all elements all the time. Only animate what's currently visible.
```javascript
class AnimationManager {
constructor() {
this.activeAnimations = new Set();
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{ threshold: 0.1, rootMargin: '50px 0px' }
);
}
observe(el) {
this.observer.observe(el);
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.activateElement(entry.target);
} else {
this.deactivateElement(entry.target);
}
});
}
activateElement(el) {
// Start GSAP animation / add floating class
el.classList.add('animate-active');
this.activeAnimations.add(el);
}
deactivateElement(el) {
// Pause or stop animation
el.classList.remove('animate-active');
this.activeAnimations.delete(el);
}
}
const animManager = new AnimationManager();
document.querySelectorAll('.animated-layer').forEach(el => animManager.observe(el));
```
---
## content-visibility: auto
For pages with many off-screen sections, this dramatically improves initial load and scroll performance:
```css
/* Apply to every major section except the first (which is immediately visible) */
.scene:not(:first-child) {
content-visibility: auto;
/* Tells browser: don't render this until it's near the viewport */
contain-intrinsic-size: 0 100vh;
/* Gives browser an estimated height so scrollbar is correct */
}
```
**Note:** Don't apply to the first section — it causes a flash of invisible content.
---
## Asset Optimization Rules
### PNG File Size Targets (Maximum)
| Depth Level | Element Type | Max File Size | Max Dimensions |
|-------------|---------------------|---------------|----------------|
| Depth 0 | Background | 150KB | 1920×1080 |
| Depth 1 | Glow layer | 60KB | 1000×1000 |
| Depth 2 | Decorations | 50KB | 400×400 |
| Depth 3 | Main product/hero | 120KB | 1200×1200 |
| Depth 4 | UI components | 40KB | 800×800 |
| Depth 5 | Particles | 10KB | 128×128 |
**Total page weight target: Under 2MB for all assets combined.**
### Image Loading Strategy
```html
<!-- Hero image: preload immediately -->
<link rel="preload" as="image" href="hero-product.png">
<!-- Above-fold images: eager loading -->
<img src="hero-bg.png" loading="eager" fetchpriority="high" alt="">
<!-- Below-fold images: lazy loading -->
<img src="section-2-bg.png" loading="lazy" alt="">
<!-- Use srcset for responsive images -->
<img
src="product-800.png"
srcset="product-400.png 400w, product-800.png 800w, product-1200.png 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Product description"
loading="eager"
>
```
---
## Mobile Performance
Touch devices have less GPU power. Always detect and reduce effects:
```javascript
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches;
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const isLowPower = navigator.hardwareConcurrency <= 4; // heuristic for low-end devices
const performanceMode = (isTouchDevice || prefersReduced || isLowPower) ? 'lite' : 'full';
function initForPerformanceMode() {
if (performanceMode === 'lite') {
// Disable: mouse tracking, floating loops, particles, perspective zoom
document.documentElement.classList.add('perf-lite');
// Keep: basic scroll fade-ins, curtain reveals (CSS only)
} else {
// Full experience
initParallaxLayers();
initFloatingLoops();
initParticles();
initMouseTracking();
}
}
```
```css
/* Disable GPU-heavy effects in lite mode */
.perf-lite .depth-0,
.perf-lite .depth-1,
.perf-lite .depth-5 {
transform: none !important;
will-change: auto !important;
}
.perf-lite .float-loop {
animation: none !important;
}
.perf-lite .glow-blob {
display: none;
}
```
---
## Chrome DevTools Performance Checklist
Before shipping, verify:
1. **Layers panel**: Check `chrome://settings` → DevTools → "Show Composited Layer Borders" — should not show excessive layer count (target: under 20 promoted layers)
2. **Performance tab**: Record scroll at 60fps. Look for long frames (>16ms)
3. **Memory tab**: Heap snapshot — should not grow during scroll (no leaks)
4. **Coverage tab**: Check unused CSS/JS — strip unused animation classes
---
## GSAP Performance Tips
```javascript
// BAD: Creates new tween every scroll event
window.addEventListener('scroll', () => {
gsap.to(element, { y: window.scrollY * 0.5 }); // creates new tween each frame!
});
// GOOD: Use scrub — GSAP manages timing internally
gsap.to(element, {
y: 200,
ease: 'none',
scrollTrigger: {
scrub: true, // GSAP handles this efficiently
}
});
// GOOD: Kill ScrollTriggers when not needed
const trigger = ScrollTrigger.create({ ... });
// Later:
trigger.kill();
// GOOD: Use gsap.set() for instant placement (no tween overhead)
gsap.set('.element', { x: 0, opacity: 1 });
// GOOD: Batch DOM reads/writes
gsap.utils.toArray('.elements').forEach(el => {
// GSAP batches these reads automatically
gsap.from(el, { ... });
});
```

View File

@@ -0,0 +1,709 @@
# Text Animation Reference
## Table of Contents
1. [Setup: SplitText & Dependencies](#setup)
2. [Technique 1: Split Converge (Left+Right Merge)](#split-converge)
3. [Technique 2: Masked Line Curtain Reveal](#masked-line)
4. [Technique 3: Character Cylinder Rotation](#cylinder)
5. [Technique 4: Word-by-Word Scroll Lighting](#word-lighting)
6. [Technique 5: Scramble Text](#scramble)
7. [Technique 6: Skew + Elastic Bounce Entry](#skew-bounce)
8. [Technique 7: Theatrical Enter + Auto Exit](#theatrical)
9. [Technique 8: Offset Diagonal Layout](#offset-diagonal)
10. [Technique 9: Line Clip Wipe](#line-clip-wipe)
11. [Technique 10: Scroll-Speed Reactive Marquee](#marquee)
12. [Technique 11: Variable Font Wave](#variable-font)
13. [Technique 12: Bleed Typography](#bleed-type)
---
## Setup: SplitText & Dependencies {#setup}
```html
<!-- GSAP SplitText (free in GSAP 3.12+) -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/SplitText.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
<script>
gsap.registerPlugin(SplitText, ScrollTrigger);
</script>
```
### Universal Text Setup CSS
```css
/* All text elements that animate need this */
.anim-text {
overflow: hidden; /* Contains line mask reveals */
line-height: 1.15;
}
/* Screen reader: preserve meaning even when SplitText fragments it */
.anim-text[aria-label] > * {
aria-hidden: true;
}
```
---
## Technique 1: Split Converge (Left+Right Merge) {#split-converge}
The signature effect: two halves of a title fly in from opposite sides, converge to form the complete title, hold, then diverge and disappear on scroll exit. Exactly what the user described.
```css
.hero-title {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
overflow: visible; /* allow parts to fly from outside viewport */
}
.hero-title .word-left {
display: inline-block;
/* starts at far left */
}
.hero-title .word-right {
display: inline-block;
/* starts at far right */
}
```
```javascript
function initSplitConverge(titleEl) {
// Preserve accessibility
const fullText = titleEl.textContent;
titleEl.setAttribute('aria-label', fullText);
const words = titleEl.querySelectorAll('.word');
const midpoint = Math.floor(words.length / 2);
const leftWords = Array.from(words).slice(0, midpoint);
const rightWords = Array.from(words).slice(midpoint);
const tl = gsap.timeline({
scrollTrigger: {
trigger: titleEl.closest('.scene'),
start: 'top top',
end: '+=250%',
pin: true,
scrub: 1.2,
}
});
// Phase 1 — ENTER (0% → 25%): Words converge from sides
tl.fromTo(leftWords,
{ x: '-120vw', opacity: 0 },
{ x: 0, opacity: 1, duration: 0.25, ease: 'power3.out', stagger: 0.03 },
0
)
.fromTo(rightWords,
{ x: '120vw', opacity: 0 },
{ x: 0, opacity: 1, duration: 0.25, ease: 'power3.out', stagger: -0.03 },
0
)
// Phase 2 — HOLD (25% → 70%): Nothing — words are readable, section pinned
// (empty duration keeps the scrub paused here)
.to({}, { duration: 0.45 }, 0.25)
// Phase 3 — EXIT (70% → 100%): Words diverge back out
.to(leftWords,
{ x: '-120vw', opacity: 0, duration: 0.28, ease: 'power3.in', stagger: 0.02 },
0.70
)
.to(rightWords,
{ x: '120vw', opacity: 0, duration: 0.28, ease: 'power3.in', stagger: -0.02 },
0.70
);
return tl;
}
```
### HTML Template
```html
<h1 class="hero-title anim-text" aria-label="Your Brand Name">
<span class="word word-left">Your</span>
<span class="word word-left">Brand</span>
<span class="word word-right">Name</span>
<span class="word word-right">Here</span>
</h1>
```
---
## Technique 2: Masked Line Curtain Reveal {#masked-line}
Lines slide upward from behind an invisible curtain. Each line is hidden in an `overflow: hidden` container and translates up into view.
```css
.curtain-text .line-mask {
overflow: hidden;
line-height: 1.2;
/* The mask — content starts below and slides up into view */
}
.curtain-text .line-inner {
display: block;
/* Starts translated down below the mask */
transform: translateY(110%);
}
```
```javascript
function initCurtainReveal(textEl) {
// SplitText splits into lines automatically
const split = new SplitText(textEl, {
type: 'lines',
linesClass: 'line-inner',
// Wraps each line in overflow:hidden container
lineThreshold: 0.1,
});
// Wrap each line in a mask container
split.lines.forEach(line => {
const mask = document.createElement('div');
mask.className = 'line-mask';
line.parentNode.insertBefore(mask, line);
mask.appendChild(line);
});
gsap.from(split.lines, {
y: '110%',
duration: 0.9,
ease: 'power4.out',
stagger: 0.12,
scrollTrigger: {
trigger: textEl,
start: 'top 80%',
}
});
}
```
---
## Technique 3: Character Cylinder Rotation {#cylinder}
Letters rotate in on a 3D cylinder axis — like a slot machine or odometer rolling into place. Premium, memorable.
```css
.cylinder-text {
perspective: 800px;
}
.cylinder-text .char {
display: inline-block;
transform-origin: center center -60px; /* pivot point BEHIND the letter */
transform-style: preserve-3d;
}
```
```javascript
function initCylinderRotation(titleEl) {
const split = new SplitText(titleEl, { type: 'chars' });
gsap.from(split.chars, {
rotateX: -90,
opacity: 0,
duration: 0.6,
ease: 'back.out(1.5)',
stagger: {
each: 0.04,
from: 'start'
},
scrollTrigger: {
trigger: titleEl,
start: 'top 75%',
}
});
}
```
---
## Technique 4: Word-by-Word Scroll Lighting {#word-lighting}
Words appear to light up one at a time, driven by scroll position. Apple's signature prose technique.
```css
.scroll-lit-text {
/* Start all words dim */
}
.scroll-lit-text .word {
display: inline-block;
color: rgba(255, 255, 255, 0.15); /* dim unlit state */
transition: color 0.1s ease;
}
.scroll-lit-text .word.lit {
color: rgba(255, 255, 255, 1.0); /* bright lit state */
}
```
```javascript
function initWordScrollLighting(containerEl, textEl) {
const split = new SplitText(textEl, { type: 'words' });
const words = split.words;
const totalWords = words.length;
// Pin the section and light words as user scrolls
ScrollTrigger.create({
trigger: containerEl,
start: 'top top',
end: `+=${totalWords * 80}px`, // ~80px per word
pin: true,
scrub: 0.5,
onUpdate: (self) => {
const progress = self.progress;
const litCount = Math.round(progress * totalWords);
words.forEach((word, i) => {
word.classList.toggle('lit', i < litCount);
});
}
});
}
```
---
## Technique 5: Scramble Text {#scramble}
Characters cycle through random values before resolving to real text. Feels digital, techy, premium.
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/TextPlugin.min.js"></script>
```
```javascript
// Custom scramble implementation (no plugin needed)
function scrambleText(el, finalText, duration = 1.5) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%';
let startTime = null;
const originalText = finalText;
function step(timestamp) {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / (duration * 1000), 1);
let result = '';
for (let i = 0; i < originalText.length; i++) {
if (originalText[i] === ' ') {
result += ' ';
} else if (i / originalText.length < progress) {
// This character has resolved
result += originalText[i];
} else {
// Still scrambling
result += chars[Math.floor(Math.random() * chars.length)];
}
}
el.textContent = result;
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// Trigger on scroll
ScrollTrigger.create({
trigger: '.scramble-title',
start: 'top 80%',
once: true,
onEnter: () => {
scrambleText(
document.querySelector('.scramble-title'),
document.querySelector('.scramble-title').dataset.text,
1.8
);
}
});
```
---
## Technique 6: Skew + Elastic Bounce Entry {#skew-bounce}
Elements enter with a skew that corrects itself, combined with a slight overshoot. Feels physical and energetic.
```javascript
function initSkewBounce(elements) {
gsap.from(elements, {
y: 80,
skewY: 7,
opacity: 0,
duration: 0.9,
ease: 'back.out(1.7)',
stagger: 0.1,
scrollTrigger: {
trigger: elements[0],
start: 'top 85%',
}
});
}
```
---
## Technique 7: Theatrical Enter + Auto Exit {#theatrical}
Element automatically animates in when entering the viewport AND animates out when leaving — zero JavaScript needed.
```css
/* Enter animation */
@keyframes theatrical-enter {
from {
opacity: 0;
transform: translateY(60px);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0px);
}
}
/* Exit animation */
@keyframes theatrical-exit {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-60px);
}
}
.theatrical {
/* Enter when element comes into view */
animation: theatrical-enter linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
.theatrical-with-exit {
animation: theatrical-enter linear both, theatrical-exit linear both;
animation-timeline: view(), view();
animation-range: entry 0% entry 30%, exit 60% exit 100%;
}
```
**Zero JavaScript required.** Just add `.theatrical` or `.theatrical-with-exit` class.
---
## Technique 8: Offset Diagonal Layout {#offset-diagonal}
Lines of a title start at offset positions (one top-left, one lower-right), then animate FROM their natural offset positions FROM opposite directions. Creates a staircase visual composition that feels dynamic even before animation.
```css
.offset-title {
position: relative;
/* Don't center — let offset do the work */
}
.offset-title .line-1 {
/* Top-left */
display: block;
text-align: left;
padding-left: 5%;
font-size: clamp(48px, 8vw, 100px);
}
.offset-title .line-2 {
/* Lower-right — drops down and shifts right */
display: block;
text-align: right;
padding-right: 5%;
margin-top: 0.4em;
font-size: clamp(48px, 8vw, 100px);
}
```
```javascript
function initOffsetDiagonal(titleEl) {
const line1 = titleEl.querySelector('.line-1');
const line2 = titleEl.querySelector('.line-2');
gsap.from(line1, {
x: '-15vw',
opacity: 0,
duration: 1.0,
ease: 'power4.out',
scrollTrigger: { trigger: titleEl, start: 'top 75%' }
});
gsap.from(line2, {
x: '15vw',
opacity: 0,
duration: 1.0,
ease: 'power4.out',
delay: 0.15,
scrollTrigger: { trigger: titleEl, start: 'top 75%' }
});
}
```
---
## Technique 9: Line Clip Wipe {#line-clip-wipe}
Each line of text reveals from left to right, like a typewriter but with a clean clip-path sweep.
```javascript
function initLineClipWipe(textEl) {
const split = new SplitText(textEl, { type: 'lines' });
split.lines.forEach((line, i) => {
gsap.fromTo(line,
{ clipPath: 'inset(0 100% 0 0)' },
{
clipPath: 'inset(0 0% 0 0)',
duration: 0.8,
ease: 'power3.out',
delay: i * 0.12, // stagger between lines
scrollTrigger: {
trigger: textEl,
start: 'top 80%',
}
}
);
});
}
```
---
## Technique 10: Scroll-Speed Reactive Marquee {#marquee}
Infinite scrolling text. Speed scales with scroll velocity — fast scroll = fast marquee. Slow scroll = slow/paused.
```css
.marquee-wrapper {
overflow: hidden;
white-space: nowrap;
}
.marquee-track {
display: inline-flex;
gap: 4rem;
/* Two copies side by side for seamless loop */
}
.marquee-track .marquee-item {
display: inline-block;
font-size: clamp(2rem, 5vw, 5rem);
font-weight: 700;
letter-spacing: -0.02em;
}
```
```javascript
function initReactiveMarquee(wrapperEl) {
const track = wrapperEl.querySelector('.marquee-track');
let currentX = 0;
let velocity = 0;
let baseSpeed = 0.8; // px per frame base speed
let lastScrollY = window.scrollY;
let lastTime = performance.now();
// Track scroll velocity
window.addEventListener('scroll', () => {
const now = performance.now();
const dt = now - lastTime;
const dy = window.scrollY - lastScrollY;
velocity = Math.abs(dy / dt) * 30; // scale to marquee speed
lastScrollY = window.scrollY;
lastTime = now;
}, { passive: true });
function animate() {
velocity = Math.max(0, velocity - 0.3); // decay
const speed = baseSpeed + velocity;
currentX -= speed;
// Reset when first copy exits viewport
const trackWidth = track.children[0].offsetWidth * track.children.length / 2;
if (Math.abs(currentX) >= trackWidth) {
currentX += trackWidth;
}
track.style.transform = `translateX(${currentX}px)`;
requestAnimationFrame(animate);
}
animate();
}
```
---
## Technique 11: Variable Font Wave {#variable-font}
If the font supports variable axes (weight, width), animate them per-character for a wave/ripple effect.
```javascript
function initVariableFontWave(titleEl) {
const split = new SplitText(titleEl, { type: 'chars' });
// Wave through characters using weight axis
gsap.to(split.chars, {
fontVariationSettings: '"wght" 800',
duration: 0.4,
ease: 'power2.inOut',
stagger: {
each: 0.06,
yoyo: true,
repeat: -1, // infinite loop
}
});
}
```
**Note:** Requires a variable font. Free options: Inter Variable, Fraunces, Recursive. Load from Google Fonts with `?display=swap&axes=wght`.
---
## Technique 12: Bleed Typography {#bleed-type}
Oversized headline that intentionally exceeds section boundaries. Creates drama, depth, and visual tension.
```css
.bleed-title {
font-size: clamp(80px, 18vw, 220px);
font-weight: 900;
line-height: 0.9;
letter-spacing: -0.04em;
/* Allow bleeding outside section */
position: relative;
z-index: 10;
pointer-events: none;
/* Negative margins to bleed out */
margin-left: -0.05em;
margin-right: -0.05em;
/* Optionally: half above, half below section boundary */
transform: translateY(30%);
}
/* Parent section allows overflow */
.bleed-section {
overflow: visible;
position: relative;
z-index: 2;
}
/* Next section needs to be higher to "trap" the bleed */
.bleed-section + .next-section {
position: relative;
z-index: 3;
}
```
```javascript
// Parallax on the bleed title — moves at slightly different rate
// to emphasize that it belongs to a different depth than content
gsap.to('.bleed-title', {
y: '-12%',
ease: 'none',
scrollTrigger: {
trigger: '.bleed-section',
start: 'top bottom',
end: 'bottom top',
scrub: true,
}
});
```
---
## Technique 13: Ghost Outlined Background Text {#ghost-text}
Massive atmospheric text sitting BEHIND the main product using only a thin stroke
with transparent fill. Supports the scene without competing with the content.
```css
.ghost-bg-text {
color: transparent;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.15); /* light sites */
/* dark sites: -webkit-text-stroke: 1px rgba(255, 106, 26, 0.18); */
font-size: clamp(5rem, 15vw, 18rem);
font-weight: 900;
line-height: 0.85;
letter-spacing: -0.04em;
white-space: nowrap;
z-index: 2; /* must be lower than the hero product (depth-3 = z-index 3+) */
pointer-events: none;
user-select: none;
}
```
```javascript
// Entrance: lines slide up from a masked overflow:hidden parent
function initGhostTextEntrance(lines) {
gsap.set(lines, { y: '110%' });
gsap.to(lines, {
y: '0%',
stagger: 0.1,
duration: 1.1,
ease: 'power4.out',
delay: 0.2,
});
}
// Exit: lines drift apart as hero scrolls out
function addGhostTextExit(scrubTimeline, line1, line2) {
scrubTimeline
.to(line1, { x: '-12vw', opacity: 0.06, duration: 0.3 }, 0)
.to(line2, { x: '12vw', opacity: 0.06, duration: 0.3 }, 0)
.to(line1, { x: '-40vw', opacity: 0, duration: 0.25 }, 0.4)
.to(line2, { x: '40vw', opacity: 0, duration: 0.25 }, 0.4);
}
```
Stroke opacity guide:
- `0.080.12` → barely-there atmosphere
- `0.150.22` → readable on inspection, still subtle
- `0.250.35` → prominently visible — only if it IS the visual focus
Rules:
1. Always `aria-hidden="true"` — never the real heading
2. A real `<h1>` must exist elsewhere for SEO/screen readers
3. Only works on dark backgrounds — thin strokes vanish on light ones
4. Maximum 2 lines — 3+ becomes noise
5. Best with ultra-heavy weights (800900) and tight letter-spacing
---
## Combining Techniques
The most premium results come from layering multiple text techniques in the same section:
```javascript
// Example: Full hero text sequence
function initHeroTextSequence() {
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.hero-scene',
start: 'top top',
end: '+=300%',
pin: true,
scrub: 1,
}
});
// 1. Bleed title already visible via CSS
// 2. Subtitle curtain reveal
tl.from('.hero-sub .line-inner', {
y: '110%', duration: 0.2, stagger: 0.05
}, 0)
// 3. CTA skew bounce
.from('.hero-cta', {
y: 40, skewY: 5, opacity: 0, duration: 0.15, ease: 'back.out'
}, 0.15)
// 4. On scroll-through: title exits via split converge reverse
.to('.hero-title .word-left', {
x: '-80vw', opacity: 0, duration: 0.25, stagger: 0.03
}, 0.7)
.to('.hero-title .word-right', {
x: '80vw', opacity: 0, duration: 0.25, stagger: -0.03
}, 0.7);
}
```

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""
2.5D Asset Inspector
Usage: python scripts/inspect-assets.py image1.png image2.jpg ...
or: python scripts/inspect-assets.py path/to/folder/
Checks each image and reports:
- Format and mode
- Whether it has a real transparent background
- Background type if not transparent (dark, light, complex)
- Recommended depth level based on image characteristics
- Whether the background is likely a problem (product shot vs scene/artwork)
The AI reads this output and uses it to inform the user.
The script NEVER modifies images — inspect only.
"""
import sys
import os
try:
from PIL import Image
except ImportError:
print("PIL not found. Install with: pip install Pillow")
sys.exit(1)
def analyse_image(path):
result = {
"path": path,
"filename": os.path.basename(path),
"status": None,
"format": None,
"mode": None,
"size": None,
"bg_type": None,
"bg_colour": None,
"likely_needs_removal": None,
"notes": [],
}
try:
img = Image.open(path)
result["format"] = img.format or os.path.splitext(path)[1].upper().strip(".")
result["mode"] = img.mode
result["size"] = img.size
w, h = img.size
except Exception as e:
result["status"] = "ERROR"
result["notes"].append(f"Could not open: {e}")
return result
# --- Alpha / transparency check ---
if img.mode == "RGBA":
extrema = img.getextrema()
alpha_min = extrema[3][0] # 0 = has real transparency, 255 = fully opaque
if alpha_min == 0:
result["status"] = "CLEAN"
result["bg_type"] = "transparent"
result["notes"].append("Real alpha channel with transparent pixels — clean cutout")
result["likely_needs_removal"] = False
return result
else:
result["notes"].append("RGBA mode but alpha is fully opaque — background was never removed")
img = img.convert("RGB") # treat as solid for analysis below
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
# --- Sample corners and edges to detect background colour ---
pixels = img.load()
sample_points = [
(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1), # corners
(w // 2, 0), (w // 2, h - 1), # top/bottom center
(0, h // 2), (w - 1, h // 2), # left/right center
]
samples = []
for x, y in sample_points:
try:
px = pixels[x, y]
if isinstance(px, int):
px = (px, px, px)
samples.append(px[:3])
except Exception:
pass
if not samples:
result["status"] = "UNKNOWN"
result["notes"].append("Could not sample pixels")
return result
# --- Classify background ---
avg_r = sum(s[0] for s in samples) / len(samples)
avg_g = sum(s[1] for s in samples) / len(samples)
avg_b = sum(s[2] for s in samples) / len(samples)
avg_brightness = (avg_r + avg_g + avg_b) / 3
# Check colour consistency (low variance = solid bg, high variance = scene/complex bg)
max_r = max(s[0] for s in samples)
max_g = max(s[1] for s in samples)
max_b = max(s[2] for s in samples)
min_r = min(s[0] for s in samples)
min_g = min(s[1] for s in samples)
min_b = min(s[2] for s in samples)
variance = max(max_r - min_r, max_g - min_g, max_b - min_b)
result["bg_colour"] = (int(avg_r), int(avg_g), int(avg_b))
if variance > 80:
result["status"] = "COMPLEX_BG"
result["bg_type"] = "complex or scene"
result["notes"].append(
"Background varies significantly across edges — likely a scene, "
"photograph, or artwork background rather than a solid colour"
)
result["likely_needs_removal"] = False # complex bg = probably intentional content
result["notes"].append(
"JUDGMENT: Complex backgrounds usually mean this image IS the content "
"(site screenshot, artwork, section bg). Background likely should be KEPT."
)
elif avg_brightness < 40:
result["status"] = "DARK_BG"
result["bg_type"] = "solid dark/black"
result["notes"].append(
f"Solid dark background detected — average edge brightness: {avg_brightness:.0f}/255"
)
result["likely_needs_removal"] = True
result["notes"].append(
"JUDGMENT: Dark studio backgrounds on product shots typically need removal. "
"BUT if this is a screenshot, artwork, or intentionally dark composition, keep it."
)
elif avg_brightness > 210:
result["status"] = "LIGHT_BG"
result["bg_type"] = "solid white/light"
result["notes"].append(
f"Solid light background detected — average edge brightness: {avg_brightness:.0f}/255"
)
result["likely_needs_removal"] = True
result["notes"].append(
"JUDGMENT: White studio backgrounds on product shots typically need removal. "
"BUT if this is a screenshot, UI mockup, or document, keep it."
)
else:
result["status"] = "MIDTONE_BG"
result["bg_type"] = "solid mid-tone colour"
result["notes"].append(
f"Solid mid-tone background detected — avg colour: RGB{result['bg_colour']}"
)
result["likely_needs_removal"] = None # ambiguous — let AI judge
result["notes"].append(
"JUDGMENT: Ambiguous — could be a branded background (keep) or a "
"studio colour backdrop (remove). AI must judge based on context."
)
# --- JPEG format warning ---
if result["format"] in ("JPEG", "JPG"):
result["notes"].append(
"JPEG format — cannot store transparency. "
"If bg removal is needed, user must provide a PNG version or approve CSS workaround."
)
# --- Size note ---
if w > 2000 or h > 2000:
result["notes"].append(
f"Large image ({w}x{h}px) — resize before embedding. "
"See references/asset-pipeline.md Step 3 for depth-appropriate targets."
)
return result
def print_report(results):
print("\n" + "" * 55)
print(" 2.5D Asset Inspector Report")
print("" * 55)
for r in results:
print(f"\n📁 {r['filename']}")
print(f" Format : {r['format']} | Mode: {r['mode']} | Size: {r['size']}")
status_icons = {
"CLEAN": "",
"DARK_BG": "⚠️ ",
"LIGHT_BG": "⚠️ ",
"COMPLEX_BG": "🔵",
"MIDTONE_BG": "",
"UNKNOWN": "",
"ERROR": "",
}
icon = status_icons.get(r["status"], "")
print(f" Status : {icon} {r['status']}")
if r["bg_type"]:
print(f" Bg type: {r['bg_type']}")
if r["likely_needs_removal"] is True:
print(" Removal: Likely needed (product/object shot)")
elif r["likely_needs_removal"] is False:
print(" Removal: Likely NOT needed (scene/artwork/content image)")
else:
print(" Removal: Ambiguous — AI must judge from context")
for note in r["notes"]:
print(f"{note}")
print("\n" + "" * 55)
clean = sum(1 for r in results if r["status"] == "CLEAN")
flagged = sum(1 for r in results if r["status"] in ("DARK_BG", "LIGHT_BG", "MIDTONE_BG"))
complex_bg = sum(1 for r in results if r["status"] == "COMPLEX_BG")
errors = sum(1 for r in results if r["status"] == "ERROR")
print(f" Clean: {clean} | Flagged: {flagged} | Complex/Scene: {complex_bg} | Errors: {errors}")
print("" * 55)
print("\nNext step: Read JUDGMENT notes above and inform the user.")
print("See references/asset-pipeline.md for the exact notification format.\n")
def collect_paths(args):
paths = []
for arg in args:
if os.path.isdir(arg):
for f in os.listdir(arg):
if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".avif")):
paths.append(os.path.join(arg, f))
elif os.path.isfile(arg):
paths.append(arg)
else:
print(f"⚠️ Not found: {arg}")
return paths
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
print("\nUsage:")
print(" python scripts/inspect-assets.py image.png")
print(" python scripts/inspect-assets.py image1.jpg image2.png")
print(" python scripts/inspect-assets.py path/to/folder/\n")
if len(sys.argv) < 2:
sys.exit(1)
else:
sys.exit(0)
paths = collect_paths(sys.argv[1:])
if not paths:
print("No valid image files found.")
sys.exit(1)
results = [analyse_image(p) for p in paths]
print_report(results)

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env node
/**
* 2.5D Layer Validator
* Usage: node scripts/validate-layers.js path/to/your/index.html
*
* Checks:
* 1. Every animated element has a data-depth attribute
* 2. Decorative elements have aria-hidden="true"
* 3. prefers-reduced-motion is implemented in CSS
* 4. Product images have alt text
* 5. SplitText elements have aria-label
* 6. No more than 80 animated elements (performance)
* 7. Will-change is not applied globally
*/
const fs = require('fs');
const path = require('path');
const filePath = process.argv[2];
if (!filePath) {
console.error('\n❌ Usage: node validate-layers.js path/to/index.html\n');
process.exit(1);
}
const html = fs.readFileSync(path.resolve(filePath), 'utf8');
let passed = 0;
let failed = 0;
const results = [];
function check(label, condition, suggestion) {
if (condition) {
passed++;
results.push({ status: '✅', label });
} else {
failed++;
results.push({ status: '❌', label, suggestion });
}
}
function warn(label, condition, suggestion) {
if (!condition) {
results.push({ status: '⚠️ ', label, suggestion });
}
}
// --- CHECKS ---
// 1. Scene elements present
check(
'Scene elements found (.scene)',
html.includes('class="scene') || html.includes("class='scene"),
'Wrap each major section in <section class="scene"> for the depth system to work.'
);
// 2. Depth layers present
const depthMatches = html.match(/data-depth=["']\d["']/g) || [];
check(
`Depth attributes found (${depthMatches.length} elements)`,
depthMatches.length >= 3,
'Each scene needs at least 3 elements with data-depth="0" through data-depth="5".'
);
// 3. prefers-reduced-motion in linked CSS
const hasReducedMotionInline = html.includes('prefers-reduced-motion');
check(
'prefers-reduced-motion implemented',
hasReducedMotionInline || html.includes('hero-section.css'),
'Add @media (prefers-reduced-motion: reduce) { } block. See references/accessibility.md.'
);
// 4. Decorative elements have aria-hidden
const decorativeElements = (html.match(/class="[^"]*(?:depth-0|depth-1|depth-5|glow-blob|particle|deco)[^"]*"/g) || []).length;
const ariaHiddenCount = (html.match(/aria-hidden="true"/g) || []).length;
check(
`Decorative elements have aria-hidden (found ${ariaHiddenCount})`,
ariaHiddenCount >= 1,
'Add aria-hidden="true" to all decorative layers (depth-0, depth-1, particles, glows).'
);
// 5. Images have alt text
const imgTags = html.match(/<img[^>]*>/g) || [];
const imgsWithoutAlt = imgTags.filter(tag => !tag.includes('alt=')).length;
check(
`All images have alt attributes (${imgTags.length} images found)`,
imgsWithoutAlt === 0,
`${imgsWithoutAlt} image(s) missing alt attribute. Decorative images use alt="", meaningful images need descriptive alt text.`
);
// 6. Skip link present
check(
'Skip-to-content link present',
html.includes('skip-link') || html.includes('Skip to'),
'Add <a href="#main-content" class="skip-link">Skip to main content</a> as first element in <body>.'
);
// 7. GSAP script loaded
check(
'GSAP script included',
html.includes('gsap') || html.includes('gsap.min.js'),
'Include GSAP from CDN: <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>'
);
// 8. ScrollTrigger plugin loaded
warn(
'ScrollTrigger plugin loaded',
html.includes('ScrollTrigger'),
'Add ScrollTrigger plugin for scroll animations: <script src=".../ScrollTrigger.min.js"></script>'
);
// 9. Performance: too many animated elements
const animatedElements = (html.match(/data-animate=/g) || []).length + depthMatches.length;
check(
`Animated element count acceptable (${animatedElements} total)`,
animatedElements <= 80,
`${animatedElements} animated elements found. Target is under 80 for smooth 60fps performance.`
);
// 10. Main landmark present
check(
'<main> landmark present',
html.includes('<main'),
'Wrap page content in <main id="main-content"> for accessibility and skip link target.'
);
// 11. Heading hierarchy
const h1Count = (html.match(/<h1[\s>]/g) || []).length;
check(
`Single <h1> present (found ${h1Count})`,
h1Count === 1,
h1Count === 0
? 'Add one <h1> element as the main page heading.'
: `Multiple <h1> elements found (${h1Count}). Each page should have exactly one <h1>.`
);
// 12. lang attribute on html
check(
'<html lang=""> attribute present',
html.includes('lang='),
'Add lang="en" (or your language) to the <html> element: <html lang="en">'
);
// --- REPORT ---
console.log('\n📋 2.5D Layer Validator Report');
console.log('═══════════════════════════════════════');
console.log(`File: ${filePath}\n`);
results.forEach(r => {
console.log(`${r.status} ${r.label}`);
if (r.suggestion) {
console.log(`${r.suggestion}`);
}
});
console.log('\n═══════════════════════════════════════');
console.log(`Passed: ${passed} | Failed: ${failed}`);
if (failed === 0) {
console.log('\n🎉 All checks passed! Your 2.5D site is ready.\n');
} else {
console.log(`\n🔧 Fix the ${failed} issue(s) above before shipping.\n`);
process.exit(1);
}

View File

@@ -0,0 +1,486 @@
#!/usr/bin/env python3
"""
Root Cause Analyzer - Structured root cause analysis for CAPA investigations.
Supports multiple analysis methodologies:
- 5-Why Analysis
- Fishbone (Ishikawa) Diagram
- Fault Tree Analysis
- Kepner-Tregoe Problem Analysis
Generates structured root cause reports and CAPA recommendations.
Usage:
python root_cause_analyzer.py --method 5why --problem "High defect rate in assembly line"
python root_cause_analyzer.py --interactive
python root_cause_analyzer.py --data investigation.json --output json
"""
import argparse
import json
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional
from enum import Enum
from datetime import datetime
class AnalysisMethod(Enum):
FIVE_WHY = "5-Why"
FISHBONE = "Fishbone"
FAULT_TREE = "Fault Tree"
KEPNER_TREGOE = "Kepner-Tregoe"
class RootCauseCategory(Enum):
MAN = "Man (People)"
MACHINE = "Machine (Equipment)"
MATERIAL = "Material"
METHOD = "Method (Process)"
MEASUREMENT = "Measurement"
ENVIRONMENT = "Environment"
MANAGEMENT = "Management (Policy)"
SOFTWARE = "Software/Data"
class SeverityLevel(Enum):
LOW = "Low"
MEDIUM = "Medium"
HIGH = "High"
CRITICAL = "Critical"
@dataclass
class WhyStep:
"""A single step in 5-Why analysis."""
level: int
question: str
answer: str
evidence: str = ""
verified: bool = False
@dataclass
class FishboneCause:
"""A cause in fishbone analysis."""
category: str
cause: str
sub_causes: List[str] = field(default_factory=list)
is_root: bool = False
evidence: str = ""
@dataclass
class FaultEvent:
"""An event in fault tree analysis."""
event_id: str
description: str
is_basic: bool = True # Basic events have no children
gate_type: str = "OR" # OR, AND
children: List[str] = field(default_factory=list)
probability: Optional[float] = None
@dataclass
class RootCauseFinding:
"""Identified root cause with evidence."""
cause_id: str
description: str
category: str
evidence: List[str] = field(default_factory=list)
contributing_factors: List[str] = field(default_factory=list)
systemic: bool = False # Whether it's a systemic vs. local issue
@dataclass
class CAPARecommendation:
"""Corrective or preventive action recommendation."""
action_id: str
action_type: str # "Corrective" or "Preventive"
description: str
addresses_cause: str # cause_id
priority: str
estimated_effort: str
responsible_role: str
effectiveness_criteria: List[str] = field(default_factory=list)
@dataclass
class RootCauseAnalysis:
"""Complete root cause analysis result."""
investigation_id: str
problem_statement: str
analysis_method: str
root_causes: List[RootCauseFinding]
recommendations: List[CAPARecommendation]
analysis_details: Dict
confidence_level: float
investigator_notes: List[str] = field(default_factory=list)
class RootCauseAnalyzer:
"""Performs structured root cause analysis."""
def __init__(self):
self.analysis_steps = []
self.findings = []
def analyze_5why(self, problem: str, whys: List[Dict] = None) -> Dict:
"""Perform 5-Why analysis."""
steps = []
if whys:
for i, w in enumerate(whys, 1):
steps.append(WhyStep(
level=i,
question=w.get("question", f"Why did this occur? (Level {i})"),
answer=w.get("answer", ""),
evidence=w.get("evidence", ""),
verified=w.get("verified", False)
))
# Analyze depth and quality
depth = len(steps)
has_root = any(
s.answer and ("system" in s.answer.lower() or "policy" in s.answer.lower() or "process" in s.answer.lower())
for s in steps
)
return {
"method": "5-Why Analysis",
"steps": [asdict(s) for s in steps],
"depth": depth,
"reached_systemic_cause": has_root,
"quality_score": min(100, depth * 20 + (20 if has_root else 0))
}
def analyze_fishbone(self, problem: str, causes: List[Dict] = None) -> Dict:
"""Perform fishbone (Ishikawa) analysis."""
categories = {}
fishbone_causes = []
if causes:
for c in causes:
cat = c.get("category", "Method")
cause = c.get("cause", "")
sub = c.get("sub_causes", [])
if cat not in categories:
categories[cat] = []
categories[cat].append({
"cause": cause,
"sub_causes": sub,
"is_root": c.get("is_root", False),
"evidence": c.get("evidence", "")
})
fishbone_causes.append(FishboneCause(
category=cat,
cause=cause,
sub_causes=sub,
is_root=c.get("is_root", False),
evidence=c.get("evidence", "")
))
root_causes = [fc for fc in fishbone_causes if fc.is_root]
return {
"method": "Fishbone (Ishikawa) Analysis",
"problem": problem,
"categories": categories,
"total_causes": len(fishbone_causes),
"root_causes_identified": len(root_causes),
"categories_covered": list(categories.keys()),
"recommended_categories": [c.value for c in RootCauseCategory],
"missing_categories": [c.value for c in RootCauseCategory if c.value.split(" (")[0] not in categories]
}
def analyze_fault_tree(self, top_event: str, events: List[Dict] = None) -> Dict:
"""Perform fault tree analysis."""
fault_events = {}
if events:
for e in events:
fault_events[e["event_id"]] = FaultEvent(
event_id=e["event_id"],
description=e.get("description", ""),
is_basic=e.get("is_basic", True),
gate_type=e.get("gate_type", "OR"),
children=e.get("children", []),
probability=e.get("probability")
)
# Find basic events (root causes)
basic_events = {eid: ev for eid, ev in fault_events.items() if ev.is_basic}
intermediate_events = {eid: ev for eid, ev in fault_events.items() if not ev.is_basic}
return {
"method": "Fault Tree Analysis",
"top_event": top_event,
"total_events": len(fault_events),
"basic_events": len(basic_events),
"intermediate_events": len(intermediate_events),
"basic_event_details": [asdict(e) for e in basic_events.values()],
"cut_sets": self._find_cut_sets(fault_events)
}
def _find_cut_sets(self, events: Dict[str, FaultEvent]) -> List[List[str]]:
"""Find minimal cut sets (combinations of basic events that cause top event)."""
# Simplified cut set analysis
cut_sets = []
for eid, event in events.items():
if not event.is_basic and event.gate_type == "AND":
cut_sets.append(event.children)
return cut_sets[:5] # Return top 5
def generate_recommendations(
self,
root_causes: List[RootCauseFinding],
problem: str
) -> List[CAPARecommendation]:
"""Generate CAPA recommendations based on root causes."""
recommendations = []
for i, cause in enumerate(root_causes, 1):
# Corrective action (fix the immediate cause)
recommendations.append(CAPARecommendation(
action_id=f"CA-{i:03d}",
action_type="Corrective",
description=f"Address immediate cause: {cause.description}",
addresses_cause=cause.cause_id,
priority=self._assess_priority(cause),
estimated_effort=self._estimate_effort(cause),
responsible_role=self._suggest_responsible(cause),
effectiveness_criteria=[
f"Elimination of {cause.description} confirmed by audit",
"No recurrence within 90 days",
"Metrics return to acceptable range"
]
))
# Preventive action (prevent recurrence in other areas)
if cause.systemic:
recommendations.append(CAPARecommendation(
action_id=f"PA-{i:03d}",
action_type="Preventive",
description=f"Systemic prevention: Update process/procedure to prevent similar issues",
addresses_cause=cause.cause_id,
priority="Medium",
estimated_effort="2-4 weeks",
responsible_role="Quality Manager",
effectiveness_criteria=[
"Updated procedure approved and implemented",
"Training completed for affected personnel",
"No similar issues in related processes within 6 months"
]
))
return recommendations
def _assess_priority(self, cause: RootCauseFinding) -> str:
if cause.systemic or "safety" in cause.description.lower():
return "High"
elif "quality" in cause.description.lower():
return "Medium"
return "Low"
def _estimate_effort(self, cause: RootCauseFinding) -> str:
if cause.systemic:
return "4-8 weeks"
elif len(cause.contributing_factors) > 3:
return "2-4 weeks"
return "1-2 weeks"
def _suggest_responsible(self, cause: RootCauseFinding) -> str:
category_roles = {
"Man": "Training Manager",
"Machine": "Engineering Manager",
"Material": "Supply Chain Manager",
"Method": "Process Owner",
"Measurement": "Quality Engineer",
"Environment": "Facilities Manager",
"Management": "Department Head",
"Software": "IT/Software Manager"
}
cat_key = cause.category.split(" (")[0] if "(" in cause.category else cause.category
return category_roles.get(cat_key, "Quality Manager")
def full_analysis(
self,
problem: str,
method: str = "5-Why",
analysis_data: Dict = None
) -> RootCauseAnalysis:
"""Perform complete root cause analysis."""
investigation_id = f"RCA-{datetime.now().strftime('%Y%m%d-%H%M')}"
analysis_details = {}
root_causes = []
if method == "5-Why" and analysis_data:
analysis_details = self.analyze_5why(problem, analysis_data.get("whys", []))
# Extract root cause from deepest why
steps = analysis_details.get("steps", [])
if steps:
last_step = steps[-1]
root_causes.append(RootCauseFinding(
cause_id="RC-001",
description=last_step.get("answer", "Unknown"),
category="Systemic",
evidence=[s.get("evidence", "") for s in steps if s.get("evidence")],
systemic=analysis_details.get("reached_systemic_cause", False)
))
elif method == "Fishbone" and analysis_data:
analysis_details = self.analyze_fishbone(problem, analysis_data.get("causes", []))
for i, cat in enumerate(analysis_data.get("causes", [])):
if cat.get("is_root"):
root_causes.append(RootCauseFinding(
cause_id=f"RC-{i+1:03d}",
description=cat.get("cause", ""),
category=cat.get("category", ""),
evidence=[cat.get("evidence", "")] if cat.get("evidence") else [],
sub_causes=cat.get("sub_causes", []),
systemic=True
))
recommendations = self.generate_recommendations(root_causes, problem)
# Confidence based on evidence and method
confidence = 0.7
if root_causes and any(rc.evidence for rc in root_causes):
confidence = 0.85
if len(root_causes) > 1:
confidence = min(0.95, confidence + 0.05)
return RootCauseAnalysis(
investigation_id=investigation_id,
problem_statement=problem,
analysis_method=method,
root_causes=root_causes,
recommendations=recommendations,
analysis_details=analysis_details,
confidence_level=confidence
)
def format_rca_text(rca: RootCauseAnalysis) -> str:
"""Format RCA report as text."""
lines = [
"=" * 70,
"ROOT CAUSE ANALYSIS REPORT",
"=" * 70,
f"Investigation ID: {rca.investigation_id}",
f"Analysis Method: {rca.analysis_method}",
f"Confidence Level: {rca.confidence_level:.0%}",
"",
"PROBLEM STATEMENT",
"-" * 40,
f" {rca.problem_statement}",
"",
"ROOT CAUSES IDENTIFIED",
"-" * 40,
]
for rc in rca.root_causes:
lines.extend([
f"",
f" [{rc.cause_id}] {rc.description}",
f" Category: {rc.category}",
f" Systemic: {'Yes' if rc.systemic else 'No'}",
])
if rc.evidence:
lines.append(f" Evidence:")
for ev in rc.evidence:
if ev:
lines.append(f"{ev}")
if rc.contributing_factors:
lines.append(f" Contributing Factors:")
for cf in rc.contributing_factors:
lines.append(f" - {cf}")
lines.extend([
"",
"RECOMMENDED ACTIONS",
"-" * 40,
])
for rec in rca.recommendations:
lines.extend([
f"",
f" [{rec.action_id}] {rec.action_type}: {rec.description}",
f" Priority: {rec.priority} | Effort: {rec.estimated_effort}",
f" Responsible: {rec.responsible_role}",
f" Effectiveness Criteria:",
])
for ec in rec.effectiveness_criteria:
lines.append(f"{ec}")
if "steps" in rca.analysis_details:
lines.extend([
"",
"5-WHY CHAIN",
"-" * 40,
])
for step in rca.analysis_details["steps"]:
lines.extend([
f"",
f" Why {step['level']}: {step['question']}",
f"{step['answer']}",
])
if step.get("evidence"):
lines.append(f" Evidence: {step['evidence']}")
lines.append("=" * 70)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Root Cause Analyzer for CAPA Investigations")
parser.add_argument("--problem", type=str, help="Problem statement")
parser.add_argument("--method", choices=["5why", "fishbone", "fault-tree", "kt"],
default="5why", help="Analysis method")
parser.add_argument("--data", type=str, help="JSON file with analysis data")
parser.add_argument("--output", choices=["text", "json"], default="text", help="Output format")
parser.add_argument("--interactive", action="store_true", help="Interactive mode")
args = parser.parse_args()
analyzer = RootCauseAnalyzer()
if args.data:
with open(args.data) as f:
data = json.load(f)
problem = data.get("problem", "Unknown problem")
method = data.get("method", "5-Why")
rca = analyzer.full_analysis(problem, method, data)
elif args.problem:
method_map = {"5why": "5-Why", "fishbone": "Fishbone", "fault-tree": "Fault Tree", "kt": "Kepner-Tregoe"}
rca = analyzer.full_analysis(args.problem, method_map.get(args.method, "5-Why"))
else:
# Demo
demo_data = {
"method": "5-Why",
"whys": [
{"question": "Why did the product fail inspection?", "answer": "Surface defect detected on 15% of units", "evidence": "QC inspection records"},
{"question": "Why did surface defects occur?", "answer": "Injection molding temperature was outside spec", "evidence": "Process monitoring data"},
{"question": "Why was temperature outside spec?", "answer": "Temperature controller calibration drift", "evidence": "Calibration log"},
{"question": "Why did calibration drift go undetected?", "answer": "No automated alert for drift, manual checks missed it", "evidence": "SOP review"},
{"question": "Why was there no automated alert?", "answer": "Process monitoring system lacks drift detection capability - systemic gap", "evidence": "System requirements review"}
]
}
rca = analyzer.full_analysis("High defect rate in injection molding process", "5-Why", demo_data)
if args.output == "json":
result = {
"investigation_id": rca.investigation_id,
"problem": rca.problem_statement,
"method": rca.analysis_method,
"root_causes": [asdict(rc) for rc in rca.root_causes],
"recommendations": [asdict(rec) for rec in rca.recommendations],
"analysis_details": rca.analysis_details,
"confidence": rca.confidence_level
}
print(json.dumps(result, indent=2, default=str))
else:
print(format_rca_text(rca))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,466 @@
#!/usr/bin/env python3
"""
Document Version Control for Quality Documentation
Manages document lifecycle for quality manuals, SOPs, work instructions, and forms.
Tracks versions, approvals, revisions, change history, electronic signatures per 21 CFR Part 11.
Features:
- Version numbering (Major.Minor.Edit, e.g., 2.1.3)
- Change control with impact assessment
- Review/approval workflows
- Electronic signature capture
- Document distribution tracking
- Training record integration
- Expiry/obsolete management
Usage:
python document_version_control.py --create new_sop.md
python document_version_control.py --revise existing_sop.md --reason "Regulatory update"
python document_version_control.py --status
python document_version_control.py --matrix --output json
"""
import argparse
import json
import os
import hashlib
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional, Tuple
from datetime import datetime, timedelta
from pathlib import Path
import re
@dataclass
class DocumentVersion:
"""A single document version."""
doc_id: str
title: str
version: str
revision_date: str
author: str
status: str # "Draft", "Under Review", "Approved", "Obsolete"
change_summary: str = ""
next_review_date: str = ""
approved_by: List[str] = field(default_factory=list)
signed_by: List[Dict] = field(default_factory=list) # electronic signatures
attachments: List[str] = field(default_factory=list)
checksum: str = ""
template_version: str = "1.0"
@dataclass
class ChangeControl:
"""Change control record."""
change_id: str
document_id: str
change_type: str # "New", "Revision", "Withdrawal"
reason: str
impact_assessment: Dict # Quality, Regulatory, Training, etc.
risk_assessment: str
notifications: List[str]
effective_date: str
change_author: str
class DocumentVersionControl:
"""Manages quality document lifecycle and version control."""
VERSION_PATTERN = re.compile(r'^(\d+)\.(\d+)\.(\d+)$')
DOCUMENT_TYPES = {
'QMSM': 'Quality Management System Manual',
'SOP': 'Standard Operating Procedure',
'WI': 'Work Instruction',
'FORM': 'Form/Template',
'REC': 'Record',
'POL': 'Policy'
}
def __init__(self, doc_store_path: str = "./doc_store"):
self.doc_store = Path(doc_store_path)
self.doc_store.mkdir(parents=True, exist_ok=True)
self.metadata_file = self.doc_store / "metadata.json"
self.documents = self._load_metadata()
def _load_metadata(self) -> Dict[str, DocumentVersion]:
"""Load document metadata from storage."""
if self.metadata_file.exists():
with open(self.metadata_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return {
doc_id: DocumentVersion(**doc_data)
for doc_id, doc_data in data.items()
}
return {}
def _save_metadata(self):
"""Save document metadata to storage."""
with open(self.metadata_file, 'w', encoding='utf-8') as f:
json.dump({
doc_id: asdict(doc)
for doc_id, doc in self.documents.items()
}, f, indent=2, ensure_ascii=False)
def _generate_doc_id(self, title: str, doc_type: str) -> str:
"""Generate unique document ID."""
# Extract first letters of words, append type code
words = re.findall(r'\b\w', title.upper())
prefix = ''.join(words[:3]) if words else 'DOC'
timestamp = datetime.now().strftime('%y%m%d%H%M')
return f"{prefix}-{doc_type}-{timestamp}"
def _parse_version(self, version: str) -> Tuple[int, int, int]:
"""Parse semantic version string."""
match = self.VERSION_PATTERN.match(version)
if match:
return tuple(int(x) for x in match.groups())
raise ValueError(f"Invalid version format: {version}")
def _increment_version(self, current: str, change_type: str) -> str:
"""Increment version based on change type."""
major, minor, edit = self._parse_version(current)
if change_type == "Major":
return f"{major+1}.0.0"
elif change_type == "Minor":
return f"{major}.{minor+1}.0"
else: # Edit
return f"{major}.{minor}.{edit+1}"
def _calculate_checksum(self, filepath: Path) -> str:
"""Calculate SHA256 checksum of document file."""
with open(filepath, 'rb') as f:
return hashlib.sha256(f.read()).hexdigest()
def create_document(
self,
title: str,
content: str,
author: str,
doc_type: str,
change_summary: str = "Initial release",
attachments: List[str] = None
) -> DocumentVersion:
"""Create a new document version."""
if doc_type not in self.DOCUMENT_TYPES:
raise ValueError(f"Invalid document type. Choose from: {list(self.DOCUMENT_TYPES.keys())}")
doc_id = self._generate_doc_id(title, doc_type)
version = "1.0.0"
revision_date = datetime.now().strftime('%Y-%m-%d')
next_review = (datetime.now() + timedelta(days=365)).strftime('%Y-%m-%d')
# Save document content
doc_path = self.doc_store / f"{doc_id}_v{version}.md"
with open(doc_path, 'w', encoding='utf-8') as f:
f.write(content)
doc = DocumentVersion(
doc_id=doc_id,
title=title,
version=version,
revision_date=revision_date,
author=author,
status="Approved", # Initially approved for simplicity
change_summary=change_summary,
next_review_date=next_review,
attachments=attachments or [],
checksum=self._calculate_checksum(doc_path)
)
self.documents[doc_id] = doc
self._save_metadata()
return doc
def revise_document(
self,
doc_id: str,
new_content: str,
change_author: str,
change_type: str = "Edit",
change_summary: str = "",
attachments: List[str] = None
) -> Optional[DocumentVersion]:
"""Create a new revision of an existing document."""
if doc_id not in self.documents:
return None
old_doc = self.documents[doc_id]
new_version = self._increment_version(old_doc.version, change_type)
revision_date = datetime.now().strftime('%Y-%m-%d')
# Archive old version
old_path = self.doc_store / f"{doc_id}_v{old_doc.version}.md"
archive_path = self.doc_store / "archive" / f"{doc_id}_v{old_doc.version}_{revision_date}.md"
archive_path.parent.mkdir(exist_ok=True)
if old_path.exists():
os.rename(old_path, archive_path)
# Save new content
doc_path = self.doc_store / f"{doc_id}_v{new_version}.md"
with open(doc_path, 'w', encoding='utf-8') as f:
f.write(new_content)
# Create new document record
new_doc = DocumentVersion(
doc_id=doc_id,
title=old_doc.title,
version=new_version,
revision_date=revision_date,
author=change_author,
status="Draft", # Needs re-approval
change_summary=change_summary or f"Revision {new_version}",
next_review_date=(datetime.now() + timedelta(days=365)).strftime('%Y-%m-%d'),
attachments=attachments or old_doc.attachments,
checksum=self._calculate_checksum(doc_path)
)
self.documents[doc_id] = new_doc
self._save_metadata()
return new_doc
def approve_document(
self,
doc_id: str,
approver_name: str,
approver_title: str,
comments: str = ""
) -> bool:
"""Approve a document with electronic signature."""
if doc_id not in self.documents:
return False
doc = self.documents[doc_id]
if doc.status != "Draft":
return False
signature = {
"name": approver_name,
"title": approver_title,
"date": datetime.now().strftime('%Y-%m-%d %H:%M'),
"comments": comments,
"signature_hash": hashlib.sha256(f"{doc_id}{doc.version}{approver_name}".encode()).hexdigest()[:16]
}
doc.approved_by.append(approver_name)
doc.signed_by.append(signature)
# Approve if enough approvers (simplified: 1 is enough for demo)
doc.status = "Approved"
self._save_metadata()
return True
def withdraw_document(self, doc_id: str, reason: str, withdrawn_by: str) -> bool:
"""Withdraw/obsolete a document."""
if doc_id not in self.documents:
return False
doc = self.documents[doc_id]
doc.status = "Obsolete"
doc.change_summary = f"OBsolete: {reason}"
# Add withdrawal signature
signature = {
"name": withdrawn_by,
"title": "QMS Manager",
"date": datetime.now().strftime('%Y-%m-%d %H:%M'),
"comments": reason,
"signature_hash": hashlib.sha256(f"{doc_id}OB{withdrawn_by}".encode()).hexdigest()[:16]
}
doc.signed_by.append(signature)
self._save_metadata()
return True
def get_document_history(self, doc_id: str) -> List[Dict]:
"""Get version history for a document."""
history = []
pattern = f"{doc_id}_v*.md"
for file in self.doc_store.glob(pattern):
match = re.search(r'_v(\d+\.\d+\.\d+)\.md$', file.name)
if match:
version = match.group(1)
stat = file.stat()
history.append({
"version": version,
"filename": file.name,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M')
})
# Check archive
for file in (self.doc_store / "archive").glob(f"{doc_id}_v*.md"):
match = re.search(r'_v(\d+\.\d+\.\d+)_(\d{4}-\d{2}-\d{2})\.md$', file.name)
if match:
version, date = match.groups()
history.append({
"version": version,
"filename": file.name,
"status": "archived",
"archived_date": date
})
return sorted(history, key=lambda x: x["version"])
def generate_document_matrix(self) -> Dict:
"""Generate document matrix report."""
matrix = {
"total_documents": len(self.documents),
"by_status": {},
"by_type": {},
"documents": []
}
for doc in self.documents.values():
# By status
matrix["by_status"][doc.status] = matrix["by_status"].get(doc.status, 0) + 1
# By type (from doc_id)
doc_type = doc.doc_id.split('-')[1] if '-' in doc.doc_id else "Unknown"
matrix["by_type"][doc_type] = matrix["by_type"].get(doc_type, 0) + 1
matrix["documents"].append({
"doc_id": doc.doc_id,
"title": doc.title,
"type": doc_type,
"version": doc.version,
"status": doc.status,
"author": doc.author,
"last_modified": doc.revision_date,
"next_review": doc.next_review_date,
"approved_by": doc.approved_by
})
matrix["documents"].sort(key=lambda x: (x["type"], x["title"]))
return matrix
def format_matrix_text(matrix: Dict) -> str:
"""Format document matrix as text."""
lines = [
"=" * 80,
"QUALITY DOCUMENTATION MATRIX",
"=" * 80,
f"Total Documents: {matrix['total_documents']}",
"",
"BY STATUS",
"-" * 40,
]
for status, count in matrix["by_status"].items():
lines.append(f" {status}: {count}")
lines.extend([
"",
"BY TYPE",
"-" * 40,
])
for dtype, count in matrix["by_type"].items():
lines.append(f" {dtype}: {count}")
lines.extend([
"",
"DOCUMENT LIST",
"-" * 40,
f"{'ID':<20} {'Type':<8} {'Version':<10} {'Status':<12} {'Title':<30}",
"-" * 80,
])
for doc in matrix["documents"]:
lines.append(f"{doc['doc_id'][:19]:<20} {doc['type']:<8} {doc['version']:<10} {doc['status']:<12} {doc['title'][:29]:<30}")
lines.append("=" * 80)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Document Version Control for Quality Documentation")
parser.add_argument("--create", type=str, help="Create new document from template")
parser.add_argument("--title", type=str, help="Document title (required with --create)")
parser.add_argument("--type", choices=list(DocumentVersionControl.DOCUMENT_TYPES.keys()), help="Document type")
parser.add_argument("--author", type=str, default="QMS Manager", help="Document author")
parser.add_argument("--revise", type=str, help="Revise existing document (doc_id)")
parser.add_argument("--reason", type=str, help="Reason for revision")
parser.add_argument("--approve", type=str, help="Approve document (doc_id)")
parser.add_argument("--approver", type=str, help="Approver name")
parser.add_argument("--withdraw", type=str, help="Withdraw document (doc_id)")
parser.add_argument("--reason", type=str, help="Withdrawal reason")
parser.add_argument("--status", action="store_true", help="Show document status")
parser.add_argument("--matrix", action="store_true", help="Generate document matrix")
parser.add_argument("--output", choices=["text", "json"], default="text")
parser.add_argument("--interactive", action="store_true", help="Interactive mode")
args = parser.parse_args()
dvc = DocumentVersionControl()
if args.create and args.title and args.type:
# Create new document with default content
template = f"""# {args.title}
**Document ID:** [auto-generated]
**Version:** 1.0.0
**Date:** {datetime.now().strftime('%Y-%m-%d')}
**Author:** {args.author}
## Purpose
[Describe the purpose and scope of this document]
## Responsibility
[List roles and responsibilities]
## Procedure
[Detailed procedure steps]
## References
[List referenced documents]
## Revision History
| Version | Date | Author | Change Summary |
|---------|------|--------|----------------|
| 1.0.0 | {datetime.now().strftime('%Y-%m-%d')} | {args.author} | Initial release |
"""
doc = dvc.create_document(
title=args.title,
content=template,
author=args.author,
doc_type=args.type,
change_summary=args.reason or "Initial release"
)
print(f"✅ Created document {doc.doc_id} v{doc.version}")
print(f" File: doc_store/{doc.doc_id}_v{doc.version}.md")
elif args.revise and args.reason:
# Add revision reason to the content (would normally modify the file)
print(f"📝 Would revise document {args.revise} - reason: {args.reason}")
print(" Note: In production, this would load existing content, make changes, and create new revision")
elif args.approve and args.approver:
success = dvc.approve_document(args.approve, args.approver, "QMS Manager")
print(f"{'✅ Approved' if success else '❌ Failed'} document {args.approve}")
elif args.withdraw and args.reason:
success = dvc.withdraw_document(args.withdraw, args.reason, "QMS Manager")
print(f"{'✅ Withdrawn' if success else '❌ Failed'} document {args.withdraw}")
elif args.matrix:
matrix = dvc.generate_document_matrix()
if args.output == "json":
print(json.dumps(matrix, indent=2))
else:
print(format_matrix_text(matrix))
elif args.status:
print("📋 Document Status:")
for doc_id, doc in dvc.documents.items():
print(f" {doc_id} v{doc.version} - {doc.title} [{doc.status}]")
else:
# Demo
print("📁 Document Version Control System Demo")
print(" Repository contains", len(dvc.documents), "documents")
if dvc.documents:
print("\n Existing documents:")
for doc in dvc.documents.values():
print(f" {doc.doc_id} v{doc.version} - {doc.title} ({doc.status})")
print("\n💡 Usage:")
print(" --create \"SOP-001\" --title \"Document Title\" --type SOP --author \"Your Name\"")
print(" --revise DOC-001 --reason \"Regulatory update\"")
print(" --approve DOC-001 --approver \"Approver Name\"")
print(" --matrix --output text/json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,481 @@
#!/usr/bin/env python3
"""
Quality Management System Effectiveness Monitor
Quantitatively assess QMS effectiveness using leading and lagging indicators.
Tracks trends, calculates control limits, and predicts potential quality issues
before they become failures. Integrates with CAPA and management review processes.
Supports metrics:
- Complaint rates, defect rates, rework rates
- Supplier performance
- CAPA effectiveness
- Audit findings trends
- Non-conformance statistics
Usage:
python quality_effectiveness_monitor.py --metrics metrics.csv --dashboard
python quality_effectiveness_monitor.py --qms-data qms_data.json --predict
python quality_effectiveness_monitor.py --interactive
"""
import argparse
import json
import csv
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional, Tuple
from datetime import datetime, timedelta
from statistics import mean, stdev, median
@dataclass
class QualityMetric:
"""A single quality metric data point."""
metric_id: str
metric_name: str
category: str
date: str
value: float
unit: str
target: float
upper_limit: float
lower_limit: float
trend_direction: str = "" # "up", "down", "stable"
sigma_level: float = 0.0
is_alert: bool = False
is_critical: bool = False
@dataclass
class QMSReport:
"""QMS effectiveness report."""
report_period: Tuple[str, str]
overall_effectiveness_score: float
metrics_count: int
metrics_in_control: int
metrics_out_of_control: int
critical_alerts: int
trends_analysis: Dict
predictive_alerts: List[Dict]
improvement_opportunities: List[Dict]
management_review_summary: str
class QMSEffectivenessMonitor:
"""Monitors and analyzes QMS effectiveness."""
SIGNAL_INDICATORS = {
"complaint_rate": {"unit": "per 1000 units", "target": 0, "upper_limit": 1.5},
"defect_rate": {"unit": "PPM", "target": 100, "upper_limit": 500},
"rework_rate": {"unit": "%", "target": 2.0, "upper_limit": 5.0},
"on_time_delivery": {"unit": "%", "target": 98, "lower_limit": 95},
"audit_findings": {"unit": "count/month", "target": 0, "upper_limit": 3},
"capa_closure_rate": {"unit": "% within target", "target": 100, "lower_limit": 90},
"supplier_defect_rate": {"unit": "PPM", "target": 200, "upper_limit": 1000}
}
def __init__(self):
self.metrics = []
def load_csv(self, csv_path: str) -> List[QualityMetric]:
"""Load metrics from CSV file."""
metrics = []
with open(csv_path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
metric = QualityMetric(
metric_id=row.get('metric_id', ''),
metric_name=row.get('metric_name', ''),
category=row.get('category', 'General'),
date=row.get('date', ''),
value=float(row.get('value', 0)),
unit=row.get('unit', ''),
target=float(row.get('target', 0)),
upper_limit=float(row.get('upper_limit', 0)),
lower_limit=float(row.get('lower_limit', 0)),
)
metrics.append(metric)
self.metrics = metrics
return metrics
def calculate_sigma_level(self, metric: QualityMetric, historical_values: List[float]) -> float:
"""Calculate process sigma level based on defect rate."""
if metric.unit == "PPM" or "rate" in metric.metric_name.lower():
# For defect rates, DPMO = defects_per_million_opportunities
if historical_values:
avg_defect_rate = mean(historical_values)
if avg_defect_rate > 0:
dpmo = avg_defect_rate
# Simplified sigma conversion (actual uses 1.5σ shift)
sigma_map = {
330000: 1.0, 620000: 2.0, 110000: 3.0, 27000: 4.0,
6200: 5.0, 230: 6.0, 3.4: 6.0
}
# Rough sigma calculation
sigma = 6.0 - (dpmo / 1000000) * 10
return max(0.0, min(6.0, sigma))
return 0.0
def analyze_trend(self, values: List[float]) -> Tuple[str, float]:
"""Analyze trend direction and significance."""
if len(values) < 3:
return "insufficient_data", 0.0
x = list(range(len(values)))
y = values
# Linear regression
n = len(x)
sum_x = sum(x)
sum_y = sum(y)
sum_xy = sum(x[i] * y[i] for i in range(n))
sum_x2 = sum(xi * xi for xi in x)
slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x) if (n * sum_x2 - sum_x * sum_x) != 0 else 0
# Determine trend direction
if slope > 0.01:
direction = "up"
elif slope < -0.01:
direction = "down"
else:
direction = "stable"
# Calculate R-squared
if slope != 0:
intercept = (sum_y - slope * sum_x) / n
y_pred = [slope * xi + intercept for xi in x]
ss_res = sum((y[i] - y_pred[i])**2 for i in range(n))
ss_tot = sum((y[i] - mean(y))**2 for i in range(n))
r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
else:
r2 = 0
return direction, r2
def detect_alerts(self, metrics: List[QualityMetric]) -> List[Dict]:
"""Detect metrics that require attention."""
alerts = []
for metric in metrics:
# Check immediate control limit violation
if metric.upper_limit and metric.value > metric.upper_limit:
alerts.append({
"metric_id": metric.metric_id,
"metric_name": metric.metric_name,
"issue": "exceeds_upper_limit",
"value": metric.value,
"limit": metric.upper_limit,
"severity": "critical" if metric.category in ["Customer", "Regulatory"] else "high"
})
if metric.lower_limit and metric.value < metric.lower_limit:
alerts.append({
"metric_id": metric.metric_id,
"metric_name": metric.metric_name,
"issue": "below_lower_limit",
"value": metric.value,
"limit": metric.lower_limit,
"severity": "critical" if metric.category in ["Customer", "Regulatory"] else "high"
})
# Check for adverse trend (3+ points in same direction)
# Need to group by metric_name and check historical data
# Simplified: check trend_direction flag if set
if metric.trend_direction in ["up", "down"] and metric.sigma_level > 3:
alerts.append({
"metric_id": metric.metric_id,
"metric_name": metric.metric_name,
"issue": f"adverse_trend_{metric.trend_direction}",
"value": metric.value,
"severity": "medium"
})
return alerts
def predict_failures(self, metrics: List[QualityMetric], forecast_days: int = 30) -> List[Dict]:
"""Predict potential failures based on trends."""
predictions = []
# Group metrics by name to get time series
grouped = {}
for m in metrics:
if m.metric_name not in grouped:
grouped[m.metric_name] = []
grouped[m.metric_name].append(m)
for metric_name, metric_list in grouped.items():
if len(metric_list) < 5:
continue
# Sort by date
metric_list.sort(key=lambda m: m.date)
values = [m.value for m in metric_list]
# Simple linear extrapolation
x = list(range(len(values)))
y = values
n = len(x)
sum_x = sum(x)
sum_y = sum(y)
sum_xy = sum(x[i] * y[i] for i in range(n))
sum_x2 = sum(xi * xi for xi in x)
slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x) if (n * sum_x2 - sum_x * sum_x) != 0 else 0
if slope != 0:
# Forecast next value
next_value = y[-1] + slope
target = metric_list[0].target
upper_limit = metric_list[0].upper_limit
if (target and next_value > target * 1.2) or (upper_limit and next_value > upper_limit * 0.9):
predictions.append({
"metric": metric_name,
"current_value": y[-1],
"forecast_value": round(next_value, 2),
"forecast_days": forecast_days,
"trend_slope": round(slope, 3),
"risk_level": "high" if upper_limit and next_value > upper_limit else "medium"
})
return predictions
def calculate_effectiveness_score(self, metrics: List[QualityMetric]) -> float:
"""Calculate overall QMS effectiveness score (0-100)."""
if not metrics:
return 0.0
scores = []
for m in metrics:
# Score based on distance to target
if m.target != 0:
deviation = abs(m.value - m.target) / max(abs(m.target), 1)
score = max(0, 100 - deviation * 100)
else:
# For metrics where lower is better (defects, etc.)
if m.upper_limit:
score = max(0, 100 - (m.value / m.upper_limit) * 100 * 0.5)
else:
score = 50 # Neutral if no target
scores.append(score)
# Penalize for alerts
alerts = self.detect_alerts(metrics)
penalty = len([a for a in alerts if a["severity"] in ["critical", "high"]]) * 5
return max(0, min(100, mean(scores) - penalty))
def identify_improvement_opportunities(self, metrics: List[QualityMetric]) -> List[Dict]:
"""Identify metrics with highest improvement potential."""
opportunities = []
for m in metrics:
if m.upper_limit and m.value > m.upper_limit * 0.8:
gap = m.upper_limit - m.value
if gap > 0:
improvement_pct = (gap / m.upper_limit) * 100
opportunities.append({
"metric": m.metric_name,
"current": m.value,
"target": m.upper_limit,
"gap": round(gap, 2),
"improvement_potential_pct": round(improvement_pct, 1),
"recommended_action": f"Reduce {m.metric_name} by at least {round(gap, 2)} {m.unit}",
"impact": "High" if m.category in ["Customer", "Regulatory"] else "Medium"
})
# Sort by improvement potential
opportunities.sort(key=lambda x: x["improvement_potential_pct"], reverse=True)
return opportunities[:10]
def generate_management_review_summary(self, report: QMSReport) -> str:
"""Generate executive summary for management review."""
summary = [
f"QMS EFFECTIVENESS REVIEW - {report.report_period[0]} to {report.report_period[1]}",
"",
f"Overall Effectiveness Score: {report.overall_effectiveness_score:.1f}/100",
f"Metrics Tracked: {report.metrics_count} | In Control: {report.metrics_in_control} | Alerts: {report.critical_alerts}",
""
]
if report.critical_alerts > 0:
summary.append("🔴 CRITICAL ALERTS REQUIRING IMMEDIATE ATTENTION:")
for alert in [a for a in report.predictive_alerts if a.get("risk_level") == "high"]:
summary.append(f"{alert['metric']}: forecast {alert['forecast_value']} (from {alert['current_value']})")
summary.append("")
summary.append("📈 TOP IMPROVEMENT OPPORTUNITIES:")
for i, opp in enumerate(report.improvement_opportunities[:3], 1):
summary.append(f" {i}. {opp['metric']}: {opp['recommended_action']} (Impact: {opp['impact']})")
summary.append("")
summary.append("🎯 RECOMMENDED ACTIONS:")
summary.append(" 1. Address all high-severity alerts within 30 days")
summary.append(" 2. Launch improvement projects for top 3 opportunities")
summary.append(" 3. Review CAPA effectiveness for recurring issues")
summary.append(" 4. Update risk assessments based on predictive trends")
return "\n".join(summary)
def analyze(
self,
metrics: List[QualityMetric],
start_date: str = None,
end_date: str = None
) -> QMSReport:
"""Perform comprehensive QMS effectiveness analysis."""
in_control = 0
for m in metrics:
if not m.is_alert and not m.is_critical:
in_control += 1
out_of_control = len(metrics) - in_control
alerts = self.detect_alerts(metrics)
critical_alerts = len([a for a in alerts if a["severity"] in ["critical", "high"]])
predictions = self.predict_failures(metrics)
improvement_opps = self.identify_improvement_opportunities(metrics)
effectiveness = self.calculate_effectiveness_score(metrics)
# Trend analysis by category
trends = {}
categories = set(m.category for m in metrics)
for cat in categories:
cat_metrics = [m for m in metrics if m.category == cat]
if len(cat_metrics) >= 2:
avg_values = [mean([m.value for m in cat_metrics])] # Simplistic - would need time series
trends[cat] = {
"metric_count": len(cat_metrics),
"avg_value": round(mean([m.value for m in cat_metrics]), 2),
"alerts": len([a for a in alerts if any(m.metric_name == a["metric_name"] for m in cat_metrics)])
}
period = (start_date or metrics[0].date, end_date or metrics[-1].date) if metrics else ("", "")
report = QMSReport(
report_period=period,
overall_effectiveness_score=effectiveness,
metrics_count=len(metrics),
metrics_in_control=in_control,
metrics_out_of_control=out_of_control,
critical_alerts=critical_alerts,
trends_analysis=trends,
predictive_alerts=predictions,
improvement_opportunities=improvement_opps,
management_review_summary="" # Filled later
)
report.management_review_summary = self.generate_management_review_summary(report)
return report
def format_qms_report(report: QMSReport) -> str:
"""Format QMS report as text."""
lines = [
"=" * 80,
"QMS EFFECTIVENESS MONITORING REPORT",
"=" * 80,
f"Period: {report.report_period[0]} to {report.report_period[1]}",
f"Overall Score: {report.overall_effectiveness_score:.1f}/100",
"",
"METRIC STATUS",
"-" * 40,
f" Total Metrics: {report.metrics_count}",
f" In Control: {report.metrics_in_control}",
f" Out of Control: {report.metrics_out_of_control}",
f" Critical Alerts: {report.critical_alerts}",
"",
"TREND ANALYSIS BY CATEGORY",
"-" * 40,
]
for category, data in report.trends_analysis.items():
lines.append(f" {category}: {data['avg_value']} (alerts: {data['alerts']})")
if report.predictive_alerts:
lines.extend([
"",
"PREDICTIVE ALERTS (Next 30 days)",
"-" * 40,
])
for alert in report.predictive_alerts[:5]:
lines.append(f"{alert['metric']}: {alert['current_value']}{alert['forecast_value']} ({alert['risk_level']})")
if report.improvement_opportunities:
lines.extend([
"",
"TOP IMPROVEMENT OPPORTUNITIES",
"-" * 40,
])
for i, opp in enumerate(report.improvement_opportunities[:5], 1):
lines.append(f" {i}. {opp['metric']}: {opp['recommended_action']}")
lines.extend([
"",
"MANAGEMENT REVIEW SUMMARY",
"-" * 40,
report.management_review_summary,
"=" * 80
])
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="QMS Effectiveness Monitor")
parser.add_argument("--metrics", type=str, help="CSV file with quality metrics")
parser.add_argument("--qms-data", type=str, help="JSON file with QMS data")
parser.add_argument("--dashboard", action="store_true", help="Generate dashboard summary")
parser.add_argument("--predict", action="store_true", help="Include predictive analytics")
parser.add_argument("--output", choices=["text", "json"], default="text")
parser.add_argument("--interactive", action="store_true", help="Interactive mode")
args = parser.parse_args()
monitor = QMSEffectivenessMonitor()
if args.metrics:
metrics = monitor.load_csv(args.metrics)
report = monitor.analyze(metrics)
elif args.qms_data:
with open(args.qms_data) as f:
data = json.load(f)
# Convert to QualityMetric objects
metrics = [QualityMetric(**m) for m in data.get("metrics", [])]
report = monitor.analyze(metrics)
else:
# Demo data
demo_metrics = [
QualityMetric("M001", "Customer Complaint Rate", "Customer", "2026-03-01", 0.8, "per 1000", 1.0, 1.5, 0.5),
QualityMetric("M002", "Defect Rate PPM", "Quality", "2026-03-01", 125, "PPM", 100, 500, 0, trend_direction="down", sigma_level=4.2),
QualityMetric("M003", "On-Time Delivery", "Operations", "2026-03-01", 96.5, "%", 98, 0, 95, trend_direction="down"),
QualityMetric("M004", "CAPA Closure Rate", "Quality", "2026-03-01", 92.0, "%", 100, 0, 90, is_alert=True),
QualityMetric("M005", "Supplier Defect Rate", "Supplier", "2026-03-01", 450, "PPM", 200, 1000, 0, is_critical=True),
]
# Simulate time series
all_metrics = []
for i in range(30):
for dm in demo_metrics:
new_metric = QualityMetric(
metric_id=dm.metric_id,
metric_name=dm.metric_name,
category=dm.category,
date=f"2026-03-{i+1:02d}",
value=dm.value + (i * 0.1) if dm.metric_name == "Customer Complaint Rate" else dm.value,
unit=dm.unit,
target=dm.target,
upper_limit=dm.upper_limit,
lower_limit=dm.lower_limit
)
all_metrics.append(new_metric)
report = monitor.analyze(all_metrics)
if args.output == "json":
result = asdict(report)
print(json.dumps(result, indent=2))
else:
print(format_qms_report(report))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,557 @@
#!/usr/bin/env python3
"""
Regulatory Pathway Analyzer - Determines optimal regulatory pathway for medical devices.
Analyzes device characteristics and recommends the most efficient regulatory pathway
across multiple markets (FDA, EU MDR, UK UKCA, Health Canada, TGA, PMDA).
Supports:
- FDA: 510(k), De Novo, PMA, Breakthrough Device
- EU MDR: Class I, IIa, IIb, III, AIMDD
- UK: UKCA marking
- Health Canada: Class I-IV
- TGA: Class I, IIa, IIb, III
- Japan PMDA: Class I-IV
Usage:
python regulatory_pathway_analyzer.py --device-class II --predicate yes --market all
python regulatory_pathway_analyzer.py --interactive
python regulatory_pathway_analyzer.py --data device_profile.json --output json
"""
import argparse
import json
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional, Tuple
from enum import Enum
class RiskClass(Enum):
CLASS_I = "I"
CLASS_IIA = "IIa"
CLASS_IIB = "IIb"
CLASS_III = "III"
CLASS_IV = "IV"
class MarketRegion(Enum):
US_FDA = "US-FDA"
EU_MDR = "EU-MDR"
UK_UKCA = "UK-UKCA"
HEALTH_CANADA = "Health-Canada"
AUSTRALIA_TGA = "Australia-TGA"
JAPAN_PMDA = "Japan-PMDA"
@dataclass
class DeviceProfile:
"""Medical device profile for pathway analysis."""
device_name: str
intended_use: str
device_class: str # I, IIa, IIb, III
novel_technology: bool = False
predicate_available: bool = True
implantable: bool = False
life_sustaining: bool = False
software_component: bool = False
ai_ml_component: bool = False
sterile: bool = False
measuring_function: bool = False
target_markets: List[str] = field(default_factory=lambda: ["US-FDA", "EU-MDR"])
@dataclass
class PathwayOption:
"""A regulatory pathway option."""
pathway_name: str
market: str
estimated_timeline_months: Tuple[int, int]
estimated_cost_usd: Tuple[int, int]
key_requirements: List[str]
advantages: List[str]
risks: List[str]
recommendation_level: str # "Recommended", "Alternative", "Not Recommended"
@dataclass
class PathwayAnalysis:
"""Complete pathway analysis result."""
device: DeviceProfile
recommended_pathways: List[PathwayOption]
optimal_sequence: List[str] # Recommended submission order
total_timeline_months: Tuple[int, int]
total_estimated_cost: Tuple[int, int]
critical_success_factors: List[str]
warnings: List[str]
class RegulatoryPathwayAnalyzer:
"""Analyzes and recommends regulatory pathways for medical devices."""
# FDA pathway decision matrix
FDA_PATHWAYS = {
"I": {
"pathway": "510(k) Exempt / Registration & Listing",
"timeline": (1, 3),
"cost": (5000, 15000),
"requirements": ["Establishment registration", "Device listing", "GMP compliance (if non-exempt)"]
},
"II": {
"pathway": "510(k)",
"timeline": (6, 12),
"cost": (50000, 250000),
"requirements": ["Predicate device identification", "Substantial equivalence demonstration", "Performance testing", "Biocompatibility (if applicable)", "Software documentation (if applicable)"]
},
"II-novel": {
"pathway": "De Novo",
"timeline": (12, 18),
"cost": (150000, 400000),
"requirements": ["Risk-based classification request", "Special controls development", "Performance testing", "Clinical data (potentially)"]
},
"III": {
"pathway": "PMA",
"timeline": (18, 36),
"cost": (500000, 2000000),
"requirements": ["Clinical investigations", "Manufacturing information", "Performance testing", "Risk-benefit analysis", "Post-approval studies"]
},
"III-breakthrough": {
"pathway": "Breakthrough Device Program + PMA",
"timeline": (12, 24),
"cost": (500000, 2000000),
"requirements": ["Breakthrough designation request", "More flexible clinical evidence", "Iterative FDA engagement", "Post-market data collection"]
}
}
# EU MDR pathway decision matrix
EU_MDR_PATHWAYS = {
"I": {
"pathway": "Self-declaration (Class I)",
"timeline": (2, 4),
"cost": (10000, 30000),
"requirements": ["Technical documentation", "EU Declaration of Conformity", "UDI assignment", "EUDAMED registration", "Authorized Representative (if non-EU)"]
},
"IIa": {
"pathway": "Notified Body assessment (Class IIa)",
"timeline": (12, 18),
"cost": (80000, 200000),
"requirements": ["QMS certification (ISO 13485)", "Technical documentation", "Clinical evaluation", "Notified Body audit", "Post-market surveillance plan"]
},
"IIb": {
"pathway": "Notified Body assessment (Class IIb)",
"timeline": (15, 24),
"cost": (150000, 400000),
"requirements": ["Full QMS certification", "Comprehensive technical documentation", "Clinical evaluation (may need clinical investigation)", "Type examination or product verification", "Notified Body scrutiny"]
},
"III": {
"pathway": "Notified Body assessment (Class III)",
"timeline": (18, 30),
"cost": (300000, 800000),
"requirements": ["Full QMS certification", "Complete technical documentation", "Clinical investigation (typically required)", "Notified Body clinical evaluation review", "Scrutiny procedure (possible)", "PMCF plan"]
}
}
def __init__(self):
self.analysis_warnings = []
def analyze_fda_pathway(self, device: DeviceProfile) -> PathwayOption:
"""Determine optimal FDA pathway."""
device_class = device.device_class.upper().replace("IIA", "II").replace("IIB", "II")
if device_class == "I":
pathway_data = self.FDA_PATHWAYS["I"]
return PathwayOption(
pathway_name=pathway_data["pathway"],
market="US-FDA",
estimated_timeline_months=pathway_data["timeline"],
estimated_cost_usd=pathway_data["cost"],
key_requirements=pathway_data["requirements"],
advantages=["Fastest path to market", "Minimal regulatory burden", "No premarket submission required (if exempt)"],
risks=["Limited to exempt product codes", "Still requires GMP compliance"],
recommendation_level="Recommended"
)
elif device_class == "III" or device.implantable or device.life_sustaining:
if device.novel_technology:
pathway_data = self.FDA_PATHWAYS["III-breakthrough"]
rec_level = "Recommended" if device.novel_technology else "Alternative"
else:
pathway_data = self.FDA_PATHWAYS["III"]
rec_level = "Recommended"
else: # Class II
if device.predicate_available and not device.novel_technology:
pathway_data = self.FDA_PATHWAYS["II"]
rec_level = "Recommended"
else:
pathway_data = self.FDA_PATHWAYS["II-novel"]
rec_level = "Recommended"
return PathwayOption(
pathway_name=pathway_data["pathway"],
market="US-FDA",
estimated_timeline_months=pathway_data["timeline"],
estimated_cost_usd=pathway_data["cost"],
key_requirements=pathway_data["requirements"],
advantages=self._get_fda_advantages(pathway_data["pathway"], device),
risks=self._get_fda_risks(pathway_data["pathway"], device),
recommendation_level=rec_level
)
def analyze_eu_mdr_pathway(self, device: DeviceProfile) -> PathwayOption:
"""Determine optimal EU MDR pathway."""
device_class = device.device_class.lower().replace("iia", "IIa").replace("iib", "IIb")
if device_class in ["i", "1"]:
pathway_data = self.EU_MDR_PATHWAYS["I"]
class_key = "I"
elif device_class in ["iia", "2a"]:
pathway_data = self.EU_MDR_PATHWAYS["IIa"]
class_key = "IIa"
elif device_class in ["iib", "2b"]:
pathway_data = self.EU_MDR_PATHWAYS["IIb"]
class_key = "IIb"
else:
pathway_data = self.EU_MDR_PATHWAYS["III"]
class_key = "III"
# Adjust for implantables
if device.implantable and class_key in ["IIa", "IIb"]:
pathway_data = self.EU_MDR_PATHWAYS["III"]
self.analysis_warnings.append(
f"Implantable devices are typically upclassified to Class III under EU MDR"
)
return PathwayOption(
pathway_name=pathway_data["pathway"],
market="EU-MDR",
estimated_timeline_months=pathway_data["timeline"],
estimated_cost_usd=pathway_data["cost"],
key_requirements=pathway_data["requirements"],
advantages=self._get_eu_advantages(pathway_data["pathway"], device),
risks=self._get_eu_risks(pathway_data["pathway"], device),
recommendation_level="Recommended"
)
def _get_fda_advantages(self, pathway: str, device: DeviceProfile) -> List[str]:
advantages = []
if "510(k)" in pathway:
advantages.extend([
"Well-established pathway with clear guidance",
"Predictable review timeline",
"Lower clinical evidence requirements vs PMA"
])
if device.predicate_available:
advantages.append("Predicate device identified - streamlined review")
elif "De Novo" in pathway:
advantages.extend([
"Creates new predicate for future 510(k) submissions",
"Appropriate for novel low-moderate risk devices",
"Can result in Class I or II classification"
])
elif "PMA" in pathway:
advantages.extend([
"Strongest FDA approval - highest market credibility",
"Difficult for competitors to challenge",
"May qualify for breakthrough device benefits"
])
elif "Breakthrough" in pathway:
advantages.extend([
"Priority review and interactive FDA engagement",
"Flexible clinical evidence requirements",
"Faster iterative development with FDA feedback"
])
return advantages
def _get_fda_risks(self, pathway: str, device: DeviceProfile) -> List[str]:
risks = []
if "510(k)" in pathway:
risks.extend([
"Predicate device may be challenged",
"SE determination can be subjective"
])
if device.software_component:
risks.append("Software documentation requirements increasing (Cybersecurity, AI/ML)")
elif "De Novo" in pathway:
risks.extend([
"Less predictable than 510(k)",
"May require more clinical data than expected",
"New special controls may be imposed"
])
elif "PMA" in pathway:
risks.extend([
"Very expensive and time-consuming",
"Clinical trial risks and delays",
"Post-approval study requirements"
])
if device.ai_ml_component:
risks.append("AI/ML components face evolving regulatory requirements")
return risks
def _get_eu_advantages(self, pathway: str, device: DeviceProfile) -> List[str]:
advantages = ["Access to entire EU/EEA market (27+ countries)"]
if "Self-declaration" in pathway:
advantages.extend([
"No Notified Body involvement required",
"Fastest path to EU market",
"Lowest cost option"
])
elif "IIa" in pathway:
advantages.append("Moderate regulatory burden with broad market access")
elif "IIb" in pathway or "III" in pathway:
advantages.extend([
"Strong market credibility with NB certification",
"Recognized globally for regulatory quality"
])
return advantages
def _get_eu_risks(self, pathway: str, device: DeviceProfile) -> List[str]:
risks = []
if "Self-declaration" not in pathway:
risks.extend([
"Limited Notified Body capacity - long wait times",
"Notified Body costs increasing under MDR"
])
risks.append("MDR transition still creating uncertainty")
if device.software_component:
risks.append("EU AI Act may apply to AI/ML medical devices")
return risks
def determine_optimal_sequence(self, pathways: List[PathwayOption], device: DeviceProfile) -> List[str]:
"""Determine optimal submission sequence across markets."""
# General principle: Start with fastest/cheapest, use data for subsequent submissions
sequence = []
# Sort by timeline (fastest first)
sorted_pathways = sorted(pathways, key=lambda p: p.estimated_timeline_months[0])
# FDA first if 510(k) - well recognized globally
fda_pathway = next((p for p in pathways if p.market == "US-FDA"), None)
eu_pathway = next((p for p in pathways if p.market == "EU-MDR"), None)
if fda_pathway and "510(k)" in fda_pathway.pathway_name:
sequence.append("1. US-FDA 510(k) first - clearance recognized globally, data reusable")
if eu_pathway:
sequence.append("2. EU-MDR - use FDA data in clinical evaluation")
elif eu_pathway and "Self-declaration" in eu_pathway.pathway_name:
sequence.append("1. EU-MDR (Class I self-declaration) - fastest market entry")
if fda_pathway:
sequence.append("2. US-FDA - use EU experience and data")
else:
for i, p in enumerate(sorted_pathways, 1):
sequence.append(f"{i}. {p.market} ({p.pathway_name})")
return sequence
def analyze(self, device: DeviceProfile) -> PathwayAnalysis:
"""Perform complete pathway analysis."""
self.analysis_warnings = []
pathways = []
for market in device.target_markets:
if "FDA" in market or "US" in market:
pathways.append(self.analyze_fda_pathway(device))
elif "MDR" in market or "EU" in market:
pathways.append(self.analyze_eu_mdr_pathway(device))
# Additional markets can be added here
sequence = self.determine_optimal_sequence(pathways, device)
total_timeline_min = sum(p.estimated_timeline_months[0] for p in pathways)
total_timeline_max = sum(p.estimated_timeline_months[1] for p in pathways)
total_cost_min = sum(p.estimated_cost_usd[0] for p in pathways)
total_cost_max = sum(p.estimated_cost_usd[1] for p in pathways)
csf = [
"Early engagement with regulators (Pre-Sub/Scientific Advice)",
"Robust QMS (ISO 13485) in place before submissions",
"Clinical evidence strategy aligned with target markets",
"Cybersecurity and software documentation (if applicable)"
]
if device.ai_ml_component:
csf.append("AI/ML transparency and bias documentation")
return PathwayAnalysis(
device=device,
recommended_pathways=pathways,
optimal_sequence=sequence,
total_timeline_months=(total_timeline_min, total_timeline_max),
total_estimated_cost=(total_cost_min, total_cost_max),
critical_success_factors=csf,
warnings=self.analysis_warnings
)
def format_analysis_text(analysis: PathwayAnalysis) -> str:
"""Format analysis as readable text report."""
lines = [
"=" * 70,
"REGULATORY PATHWAY ANALYSIS REPORT",
"=" * 70,
f"Device: {analysis.device.device_name}",
f"Intended Use: {analysis.device.intended_use}",
f"Device Class: {analysis.device.device_class}",
f"Target Markets: {', '.join(analysis.device.target_markets)}",
"",
"DEVICE CHARACTERISTICS",
"-" * 40,
f" Novel Technology: {'Yes' if analysis.device.novel_technology else 'No'}",
f" Predicate Available: {'Yes' if analysis.device.predicate_available else 'No'}",
f" Implantable: {'Yes' if analysis.device.implantable else 'No'}",
f" Life-Sustaining: {'Yes' if analysis.device.life_sustaining else 'No'}",
f" Software/AI Component: {'Yes' if analysis.device.software_component or analysis.device.ai_ml_component else 'No'}",
f" Sterile: {'Yes' if analysis.device.sterile else 'No'}",
"",
"RECOMMENDED PATHWAYS",
"-" * 40,
]
for pathway in analysis.recommended_pathways:
lines.extend([
"",
f" [{pathway.market}] {pathway.pathway_name}",
f" Recommendation: {pathway.recommendation_level}",
f" Timeline: {pathway.estimated_timeline_months[0]}-{pathway.estimated_timeline_months[1]} months",
f" Estimated Cost: ${pathway.estimated_cost_usd[0]:,} - ${pathway.estimated_cost_usd[1]:,}",
f" Key Requirements:",
])
for req in pathway.key_requirements:
lines.append(f"{req}")
lines.append(f" Advantages:")
for adv in pathway.advantages:
lines.append(f" + {adv}")
lines.append(f" Risks:")
for risk in pathway.risks:
lines.append(f" ! {risk}")
lines.extend([
"",
"OPTIMAL SUBMISSION SEQUENCE",
"-" * 40,
])
for step in analysis.optimal_sequence:
lines.append(f" {step}")
lines.extend([
"",
"TOTAL ESTIMATES",
"-" * 40,
f" Combined Timeline: {analysis.total_timeline_months[0]}-{analysis.total_timeline_months[1]} months",
f" Combined Cost: ${analysis.total_estimated_cost[0]:,} - ${analysis.total_estimated_cost[1]:,}",
"",
"CRITICAL SUCCESS FACTORS",
"-" * 40,
])
for i, factor in enumerate(analysis.critical_success_factors, 1):
lines.append(f" {i}. {factor}")
if analysis.warnings:
lines.extend([
"",
"WARNINGS",
"-" * 40,
])
for warning in analysis.warnings:
lines.append(f"{warning}")
lines.append("=" * 70)
return "\n".join(lines)
def interactive_mode():
"""Interactive device profiling."""
print("=" * 60)
print("Regulatory Pathway Analyzer - Interactive Mode")
print("=" * 60)
device = DeviceProfile(
device_name=input("\nDevice Name: ").strip(),
intended_use=input("Intended Use: ").strip(),
device_class=input("Device Class (I/IIa/IIb/III): ").strip(),
novel_technology=input("Novel technology? (y/n): ").strip().lower() == 'y',
predicate_available=input("Predicate device available? (y/n): ").strip().lower() == 'y',
implantable=input("Implantable? (y/n): ").strip().lower() == 'y',
life_sustaining=input("Life-sustaining? (y/n): ").strip().lower() == 'y',
software_component=input("Software component? (y/n): ").strip().lower() == 'y',
ai_ml_component=input("AI/ML component? (y/n): ").strip().lower() == 'y',
)
markets = input("Target markets (comma-separated, e.g., US-FDA,EU-MDR): ").strip()
if markets:
device.target_markets = [m.strip() for m in markets.split(",")]
analyzer = RegulatoryPathwayAnalyzer()
analysis = analyzer.analyze(device)
print("\n" + format_analysis_text(analysis))
def main():
parser = argparse.ArgumentParser(description="Regulatory Pathway Analyzer for Medical Devices")
parser.add_argument("--device-name", type=str, help="Device name")
parser.add_argument("--device-class", type=str, choices=["I", "IIa", "IIb", "III"], help="Device classification")
parser.add_argument("--predicate", type=str, choices=["yes", "no"], help="Predicate device available")
parser.add_argument("--novel", action="store_true", help="Novel technology")
parser.add_argument("--implantable", action="store_true", help="Implantable device")
parser.add_argument("--software", action="store_true", help="Software component")
parser.add_argument("--ai-ml", action="store_true", help="AI/ML component")
parser.add_argument("--market", type=str, default="all", help="Target market(s)")
parser.add_argument("--data", type=str, help="JSON file with device profile")
parser.add_argument("--output", choices=["text", "json"], default="text", help="Output format")
parser.add_argument("--interactive", action="store_true", help="Interactive mode")
args = parser.parse_args()
if args.interactive:
interactive_mode()
return
if args.data:
with open(args.data) as f:
data = json.load(f)
device = DeviceProfile(**data)
elif args.device_class:
device = DeviceProfile(
device_name=args.device_name or "Unnamed Device",
intended_use="Medical device",
device_class=args.device_class,
novel_technology=args.novel,
predicate_available=args.predicate == "yes" if args.predicate else True,
implantable=args.implantable,
software_component=args.software,
ai_ml_component=args.ai_ml,
)
if args.market != "all":
device.target_markets = [m.strip() for m in args.market.split(",")]
else:
# Demo mode
device = DeviceProfile(
device_name="SmartGlucose Monitor Pro",
intended_use="Continuous glucose monitoring for diabetes management",
device_class="II",
novel_technology=False,
predicate_available=True,
software_component=True,
ai_ml_component=True,
target_markets=["US-FDA", "EU-MDR"]
)
analyzer = RegulatoryPathwayAnalyzer()
analysis = analyzer.analyze(device)
if args.output == "json":
result = {
"device": asdict(analysis.device),
"pathways": [asdict(p) for p in analysis.recommended_pathways],
"optimal_sequence": analysis.optimal_sequence,
"total_timeline_months": list(analysis.total_timeline_months),
"total_estimated_cost": list(analysis.total_estimated_cost),
"critical_success_factors": analysis.critical_success_factors,
"warnings": analysis.warnings
}
print(json.dumps(result, indent=2))
else:
print(format_analysis_text(analysis))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,442 @@
#!/usr/bin/env python3
"""
FMEA Analyzer - Failure Mode and Effects Analysis for medical device risk management.
Supports Design FMEA (dFMEA) and Process FMEA (pFMEA) per ISO 14971 and IEC 60812.
Calculates Risk Priority Numbers (RPN), identifies critical items, and generates
risk reduction recommendations.
Usage:
python fmea_analyzer.py --data fmea_input.json
python fmea_analyzer.py --interactive
python fmea_analyzer.py --data fmea_input.json --output json
"""
import argparse
import json
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional, Tuple
from enum import Enum
from datetime import datetime
class FMEAType(Enum):
DESIGN = "Design FMEA"
PROCESS = "Process FMEA"
class Severity(Enum):
INCONSEQUENTIAL = 1
MINOR = 2
MODERATE = 3
SIGNIFICANT = 4
SERIOUS = 5
CRITICAL = 6
SERIOUS_HAZARD = 7
HAZARDOUS = 8
HAZARDOUS_NO_WARNING = 9
CATASTROPHIC = 10
class Occurrence(Enum):
REMOTE = 1
LOW = 2
LOW_MODERATE = 3
MODERATE = 4
MODERATE_HIGH = 5
HIGH = 6
VERY_HIGH = 7
EXTREMELY_HIGH = 8
ALMOST_CERTAIN = 9
INEVITABLE = 10
class Detection(Enum):
ALMOST_CERTAIN = 1
VERY_HIGH = 2
HIGH = 3
MODERATE_HIGH = 4
MODERATE = 5
LOW_MODERATE = 6
LOW = 7
VERY_LOW = 8
REMOTE = 9
ABSOLUTELY_UNCERTAIN = 10
@dataclass
class FMEAEntry:
"""Single FMEA line item."""
item_process: str
function: str
failure_mode: str
effect: str
severity: int
cause: str
occurrence: int
current_controls: str
detection: int
rpn: int = 0
criticality: str = ""
recommended_actions: List[str] = field(default_factory=list)
responsibility: str = ""
target_date: str = ""
actions_taken: str = ""
revised_severity: int = 0
revised_occurrence: int = 0
revised_detection: int = 0
revised_rpn: int = 0
def calculate_rpn(self):
self.rpn = self.severity * self.occurrence * self.detection
if self.severity >= 8:
self.criticality = "CRITICAL"
elif self.rpn >= 200:
self.criticality = "HIGH"
elif self.rpn >= 100:
self.criticality = "MEDIUM"
else:
self.criticality = "LOW"
def calculate_revised_rpn(self):
if self.revised_severity and self.revised_occurrence and self.revised_detection:
self.revised_rpn = self.revised_severity * self.revised_occurrence * self.revised_detection
@dataclass
class FMEAReport:
"""Complete FMEA analysis report."""
fmea_type: str
product_process: str
team: List[str]
date: str
entries: List[FMEAEntry]
summary: Dict
risk_reduction_actions: List[Dict]
class FMEAAnalyzer:
"""Analyzes FMEA data and generates risk assessments."""
# RPN thresholds
RPN_CRITICAL = 200
RPN_HIGH = 100
RPN_MEDIUM = 50
def __init__(self, fmea_type: FMEAType = FMEAType.DESIGN):
self.fmea_type = fmea_type
def analyze_entries(self, entries: List[FMEAEntry]) -> Dict:
"""Analyze all FMEA entries and generate summary."""
for entry in entries:
entry.calculate_rpn()
entry.calculate_revised_rpn()
rpns = [e.rpn for e in entries if e.rpn > 0]
revised_rpns = [e.revised_rpn for e in entries if e.revised_rpn > 0]
critical = [e for e in entries if e.criticality == "CRITICAL"]
high = [e for e in entries if e.criticality == "HIGH"]
medium = [e for e in entries if e.criticality == "MEDIUM"]
# Severity distribution
sev_dist = {}
for e in entries:
sev_range = "1-3 (Low)" if e.severity <= 3 else "4-6 (Medium)" if e.severity <= 6 else "7-10 (High)"
sev_dist[sev_range] = sev_dist.get(sev_range, 0) + 1
summary = {
"total_entries": len(entries),
"rpn_statistics": {
"min": min(rpns) if rpns else 0,
"max": max(rpns) if rpns else 0,
"average": round(sum(rpns) / len(rpns), 1) if rpns else 0,
"median": sorted(rpns)[len(rpns) // 2] if rpns else 0
},
"risk_distribution": {
"critical_severity": len(critical),
"high_rpn": len(high),
"medium_rpn": len(medium),
"low_rpn": len(entries) - len(critical) - len(high) - len(medium)
},
"severity_distribution": sev_dist,
"top_risks": [
{
"item": e.item_process,
"failure_mode": e.failure_mode,
"rpn": e.rpn,
"severity": e.severity
}
for e in sorted(entries, key=lambda x: x.rpn, reverse=True)[:5]
]
}
if revised_rpns:
summary["revised_rpn_statistics"] = {
"min": min(revised_rpns),
"max": max(revised_rpns),
"average": round(sum(revised_rpns) / len(revised_rpns), 1),
"improvement": round((sum(rpns) - sum(revised_rpns)) / sum(rpns) * 100, 1) if rpns else 0
}
return summary
def generate_risk_reduction_actions(self, entries: List[FMEAEntry]) -> List[Dict]:
"""Generate recommended risk reduction actions."""
actions = []
# Sort by RPN descending
sorted_entries = sorted(entries, key=lambda e: e.rpn, reverse=True)
for entry in sorted_entries[:10]: # Top 10 risks
if entry.rpn >= self.RPN_HIGH or entry.severity >= 8:
strategies = []
# Severity reduction strategies (highest priority for high severity)
if entry.severity >= 7:
strategies.append({
"type": "Severity Reduction",
"action": f"Redesign {entry.item_process} to eliminate failure mode: {entry.failure_mode}",
"priority": "Highest",
"expected_impact": "May reduce severity by 2-4 points"
})
# Occurrence reduction strategies
if entry.occurrence >= 5:
strategies.append({
"type": "Occurrence Reduction",
"action": f"Implement preventive controls for cause: {entry.cause}",
"priority": "High",
"expected_impact": f"Target occurrence reduction from {entry.occurrence} to {max(1, entry.occurrence - 3)}"
})
# Detection improvement strategies
if entry.detection >= 5:
strategies.append({
"type": "Detection Improvement",
"action": f"Enhance detection methods: {entry.current_controls}",
"priority": "Medium",
"expected_impact": f"Target detection improvement from {entry.detection} to {max(1, entry.detection - 3)}"
})
actions.append({
"item": entry.item_process,
"failure_mode": entry.failure_mode,
"current_rpn": entry.rpn,
"current_severity": entry.severity,
"strategies": strategies
})
return actions
def create_entry_from_dict(self, data: Dict) -> FMEAEntry:
"""Create FMEA entry from dictionary."""
entry = FMEAEntry(
item_process=data.get("item_process", ""),
function=data.get("function", ""),
failure_mode=data.get("failure_mode", ""),
effect=data.get("effect", ""),
severity=data.get("severity", 1),
cause=data.get("cause", ""),
occurrence=data.get("occurrence", 1),
current_controls=data.get("current_controls", ""),
detection=data.get("detection", 1),
recommended_actions=data.get("recommended_actions", []),
responsibility=data.get("responsibility", ""),
target_date=data.get("target_date", ""),
actions_taken=data.get("actions_taken", ""),
revised_severity=data.get("revised_severity", 0),
revised_occurrence=data.get("revised_occurrence", 0),
revised_detection=data.get("revised_detection", 0)
)
entry.calculate_rpn()
entry.calculate_revised_rpn()
return entry
def generate_report(self, product_process: str, team: List[str], entries_data: List[Dict]) -> FMEAReport:
"""Generate complete FMEA report."""
entries = [self.create_entry_from_dict(e) for e in entries_data]
summary = self.analyze_entries(entries)
actions = self.generate_risk_reduction_actions(entries)
return FMEAReport(
fmea_type=self.fmea_type.value,
product_process=product_process,
team=team,
date=datetime.now().strftime("%Y-%m-%d"),
entries=entries,
summary=summary,
risk_reduction_actions=actions
)
def format_fmea_text(report: FMEAReport) -> str:
"""Format FMEA report as text."""
lines = [
"=" * 80,
f"{report.fmea_type.upper()} REPORT",
"=" * 80,
f"Product/Process: {report.product_process}",
f"Date: {report.date}",
f"Team: {', '.join(report.team)}",
"",
"SUMMARY",
"-" * 60,
f"Total Failure Modes Analyzed: {report.summary['total_entries']}",
f"Critical Severity (≥8): {report.summary['risk_distribution']['critical_severity']}",
f"High RPN (≥100): {report.summary['risk_distribution']['high_rpn']}",
f"Medium RPN (50-99): {report.summary['risk_distribution']['medium_rpn']}",
"",
"RPN Statistics:",
f" Min: {report.summary['rpn_statistics']['min']}",
f" Max: {report.summary['rpn_statistics']['max']}",
f" Average: {report.summary['rpn_statistics']['average']}",
f" Median: {report.summary['rpn_statistics']['median']}",
]
if "revised_rpn_statistics" in report.summary:
lines.extend([
"",
"Revised RPN Statistics:",
f" Average: {report.summary['revised_rpn_statistics']['average']}",
f" Improvement: {report.summary['revised_rpn_statistics']['improvement']}%",
])
lines.extend([
"",
"TOP RISKS",
"-" * 60,
f"{'Item':<25} {'Failure Mode':<30} {'RPN':>5} {'Sev':>4}",
"-" * 66,
])
for risk in report.summary.get("top_risks", []):
lines.append(f"{risk['item'][:24]:<25} {risk['failure_mode'][:29]:<30} {risk['rpn']:>5} {risk['severity']:>4}")
lines.extend([
"",
"FMEA ENTRIES",
"-" * 60,
])
for i, entry in enumerate(report.entries, 1):
marker = "" if entry.criticality in ["CRITICAL", "HIGH"] else ""
lines.extend([
f"",
f"{marker} Entry {i}: {entry.item_process} - {entry.function}",
f" Failure Mode: {entry.failure_mode}",
f" Effect: {entry.effect}",
f" Cause: {entry.cause}",
f" S={entry.severity} × O={entry.occurrence} × D={entry.detection} = RPN {entry.rpn} [{entry.criticality}]",
f" Current Controls: {entry.current_controls}",
])
if entry.recommended_actions:
lines.append(f" Recommended Actions:")
for action in entry.recommended_actions:
lines.append(f"{action}")
if entry.revised_rpn > 0:
lines.append(f" Revised: S={entry.revised_severity} × O={entry.revised_occurrence} × D={entry.revised_detection} = RPN {entry.revised_rpn}")
if report.risk_reduction_actions:
lines.extend([
"",
"RISK REDUCTION RECOMMENDATIONS",
"-" * 60,
])
for action in report.risk_reduction_actions:
lines.extend([
f"",
f" {action['item']} - {action['failure_mode']}",
f" Current RPN: {action['current_rpn']} (Severity: {action['current_severity']})",
])
for strategy in action["strategies"]:
lines.append(f" [{strategy['priority']}] {strategy['type']}: {strategy['action']}")
lines.append(f" Expected: {strategy['expected_impact']}")
lines.append("=" * 80)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="FMEA Analyzer for Medical Device Risk Management")
parser.add_argument("--type", choices=["design", "process"], default="design", help="FMEA type")
parser.add_argument("--data", type=str, help="JSON file with FMEA data")
parser.add_argument("--output", choices=["text", "json"], default="text", help="Output format")
parser.add_argument("--interactive", action="store_true", help="Interactive mode")
args = parser.parse_args()
fmea_type = FMEAType.DESIGN if args.type == "design" else FMEAType.PROCESS
analyzer = FMEAAnalyzer(fmea_type)
if args.data:
with open(args.data) as f:
data = json.load(f)
report = analyzer.generate_report(
product_process=data.get("product_process", ""),
team=data.get("team", []),
entries_data=data.get("entries", [])
)
else:
# Demo data
demo_entries = [
{
"item_process": "Battery Module",
"function": "Provide power for 8 hours",
"failure_mode": "Premature battery drain",
"effect": "Device shuts down during procedure",
"severity": 8,
"cause": "Cell degradation due to temperature cycling",
"occurrence": 4,
"current_controls": "Incoming battery testing, temperature spec in IFU",
"detection": 5,
"recommended_actions": ["Add battery health monitoring algorithm", "Implement low-battery warning at 20%"]
},
{
"item_process": "Software Controller",
"function": "Control device operation",
"failure_mode": "Firmware crash",
"effect": "Device becomes unresponsive",
"severity": 7,
"cause": "Memory leak in logging module",
"occurrence": 3,
"current_controls": "Code review, unit testing, integration testing",
"detection": 4,
"recommended_actions": ["Add watchdog timer", "Implement memory usage monitoring"]
},
{
"item_process": "Sterile Packaging",
"function": "Maintain sterility until use",
"failure_mode": "Seal breach",
"effect": "Device contamination",
"severity": 9,
"cause": "Sealing jaw temperature variation",
"occurrence": 2,
"current_controls": "Seal integrity testing (dye penetration), SPC on sealing process",
"detection": 3,
"recommended_actions": ["Add real-time seal temperature monitoring", "Implement 100% seal integrity testing"]
}
]
report = analyzer.generate_report(
product_process="Insulin Pump Model X200",
team=["Quality Engineer", "R&D Lead", "Manufacturing Engineer", "Risk Manager"],
entries_data=demo_entries
)
if args.output == "json":
result = {
"fmea_type": report.fmea_type,
"product_process": report.product_process,
"date": report.date,
"team": report.team,
"entries": [asdict(e) for e in report.entries],
"summary": report.summary,
"risk_reduction_actions": report.risk_reduction_actions
}
print(json.dumps(result, indent=2))
else:
print(format_fmea_text(report))
if __name__ == "__main__":
main()