Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b0e631b9 |
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: ad-creative-video
|
||||
description: This skill should be used when the user asks to "make a video ad", "create an animated ad", "build a performance/UGC-style ad", "batch-produce ad creative variations", "generate ad variants for A/B testing", "swap headline/offer/CTA across many ad versions", or "export one ad in multiple aspect ratios for Meta/TikTok/Reels". Covers ad hook structures, hook→CTA message-match, data-driven variant generation (1 template × CSV = N ads), multi-aspect export, and platform specs.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Ad Creative Video
|
||||
|
||||
Build ONE motion-graphics ad template, then mass-produce variants from a data table — different hooks, offers, CTAs, and products — so a performance marketer can A/B test dozens of versions across placements and aspect ratios. The craft is making the template fully data-driven and message-matched, so 40 variants stay on-brand and only the tested variable changes.
|
||||
|
||||
## When to use
|
||||
|
||||
- Performance/UGC-style video ads where the first 1.5–3s must stop the scroll.
|
||||
- Batch variant generation: one template rendered against many rows (headline/offer/CTA/product swaps) for A/B testing.
|
||||
- Multi-aspect export of the same ad for Meta Feed, Reels/Stories, TikTok, YouTube.
|
||||
|
||||
Not for sale/discount countdown promos, and not for testimonial/review-driven ads — those are different structures. Stay on the batch-variation, test-everything angle.
|
||||
|
||||
## The two rules that make variants worth running
|
||||
|
||||
1. **Isolate one variable per variant.** A test only teaches something if exactly one thing changes. Hold layout, motion, colors, and timing constant; swap *only* the field under test (hook, or offer, or CTA). Mixing two changes makes the winner uninterpretable.
|
||||
2. **Message-match the hook to the CTA.** The promise made in the first 3 seconds must be the promise the button pays off. A "stop wasting 2 hours a day" hook ends on "Save 2 hours — try free," not a generic "Shop now." The hook and CTA are a matched pair in every row of the data table.
|
||||
|
||||
## Ad anatomy (15–30s)
|
||||
|
||||
| Beat | Job | Budget |
|
||||
|---|---|---|
|
||||
| Hook (0–3s) | Stop the scroll; state the problem or pattern-interrupt | 0–3s |
|
||||
| Context | Make the viewer feel the problem / agitate | 3–8s |
|
||||
| Payoff | Show the product as the solution, one clear benefit | 8–18s |
|
||||
| Proof | One concrete number, demo, or result | 18–25s |
|
||||
| CTA (last 3s) | Single action, message-matched to the hook | hold ≥2s |
|
||||
|
||||
Land the hook's emotional trigger before the 2-second mark — judgment forms in ~1.7s and scroll speed keeps rising. Test the hook FIRST; it moves CPA more than any other element.
|
||||
|
||||
## Hook taxonomy (the field you vary most)
|
||||
|
||||
| Hook type | Template | When it wins |
|
||||
|---|---|---|
|
||||
| Problem-first | "I didn't realize how much X was costing me until…" | pain is felt but unnamed |
|
||||
| Pattern interrupt | "This is going to sound controversial, but…" | crowded feed, generic category |
|
||||
| Curiosity gap | "Nobody talks about the one thing that…" | educated, skeptical audience |
|
||||
| Immediate benefit | "Cut your X in half in 7 days" | clear, quantifiable outcome |
|
||||
| Direct callout | "If you do X every morning, stop." | sharp audience segment |
|
||||
|
||||
Keep a column of 5–10 hook strings and let the batch render produce one ad per hook against the same body — that is the cleanest hook test possible.
|
||||
|
||||
## Data-driven template (everything is a prop)
|
||||
|
||||
Hardcode nothing the marketer might A/B test. The composition reads a single `variant` object; the renderer never edits the component. Make every animated value a pure function of `useCurrentFrame()` so each frame renders deterministically (no CSS transitions, no library timers — they desync the render).
|
||||
|
||||
```tsx
|
||||
import {useCurrentFrame, interpolate, AbsoluteFill, useVideoConfig} from "remotion";
|
||||
|
||||
type Variant = {hook: string; benefit: string; proof: string; cta: string; bg: string; accent: string};
|
||||
|
||||
export const AdTemplate: React.FC<{v: Variant}> = ({v}) => {
|
||||
const frame = useCurrentFrame(); const {fps} = useVideoConfig();
|
||||
const hookIn = interpolate(frame, [0, 8], [40, 0], {extrapolateRight: "clamp"});
|
||||
const ctaIn = interpolate(frame, [fps * 22, fps * 23], [0, 1], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
return (
|
||||
<AbsoluteFill style={{background: v.bg, fontFamily: "Inter, sans-serif"}}>
|
||||
<h1 style={{transform: `translateY(${hookIn}px)`, opacity: interpolate(frame,[0,8],[0,1])}}>{v.hook}</h1>
|
||||
{frame > fps * 8 && <p>{v.benefit}</p>}
|
||||
{frame > fps * 18 && <strong>{v.proof}</strong>}
|
||||
<button style={{opacity: ctaIn, background: v.accent}}>{v.cta}</button>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
See `references/batch-ad-pipeline.md` for the complete template, the CSV→props parser with validation, and the brand-lock theme object.
|
||||
|
||||
## 1 template × CSV = N variants
|
||||
|
||||
The payoff: author the ad once, then let a CSV drive the matrix. Each row is one fully-formed, message-matched variant. Render once per row.
|
||||
|
||||
```bash
|
||||
# variants.csv: id,hook,benefit,proof,cta,bg,accent → one MP4 per row
|
||||
node csv-to-props.js variants.csv ./props # writes props/<id>.json per row
|
||||
for f in props/*.json; do
|
||||
id=$(basename "$f" .json)
|
||||
npx remotion render AdTemplate "out/${id}.mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
To build a clean test matrix, hold every column constant and vary one: 5 hooks × 1 body = 5 ads (hook test); then take the winning hook × 3 CTAs (CTA test). `references/batch-ad-pipeline.md` has a matrix generator that expands variable lists into the CSV so combinatorial tests stay one-variable-at-a-time.
|
||||
|
||||
## Multi-aspect export
|
||||
|
||||
Each placement wants a native aspect. Compose against a center safe zone so one master reframes cleanly to all of them — don't letterbox, re-center.
|
||||
|
||||
| Aspect | Placements | Resolution | Keep-clear safe zone |
|
||||
|---|---|---|---|
|
||||
| 9:16 | Reels, Stories, TikTok In-Feed | 1080×1920 | top ~14%, bottom ~20–35% (UI/caption), sides ~6% |
|
||||
| 4:5 | Meta Feed (best feed real estate) | 1080×1350 | center 90%; CTA out of bottom 10% |
|
||||
| 1:1 | Feed, broad reach | 1080×1080 | center square survives every crop |
|
||||
| 16:9 | YouTube, in-stream | 1920×1080 | center 90% |
|
||||
|
||||
Design the hook text, product, and CTA inside the **1:1 center square** so they survive every crop. Drive aspect from a prop and render each per variant:
|
||||
|
||||
```bash
|
||||
for ar in 9x16 4x5 1x1; do
|
||||
npx remotion render AdTemplate "out/${id}-${ar}.mp4" --props="props/${id}.json" \
|
||||
--props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
```
|
||||
|
||||
Keep critical content out of the 9:16 bottom third — that is where the platform stacks captions, the CTA button, and the username. `references/platform-specs.md` has exact pixel safe zones and per-platform duration/format limits.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- One variable per variant; layout/motion/timing identical across a test.
|
||||
- Hook lands its trigger before 2s; hook and CTA are message-matched in every row.
|
||||
- Every animated value is a pure function of `useCurrentFrame()` — no CSS/library timers.
|
||||
- Dataset is a prop; nothing testable is hardcoded; one template renders the whole CSV.
|
||||
- Each variant exported 9:16 + 4:5 + 1:1 (+16:9 if needed), key content in the center square.
|
||||
- Brand stays locked across all variants via a single theme object.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
**Output contract:**
|
||||
- A Remotion ad template registered as `<Composition>` (+ zod `schema` + `defaultProps`), every animated value frame-driven (no CSS transitions / library timers / `Date.now()` / `Math.random()`).
|
||||
- Deliverable = the rendered `out/*.mp4` per variant per aspect (plus the project + CSV, so the marketer re-renders on new rows).
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Cheap PNGs first, full encode only once they're clean. Render with the **shipped** props (the real row), not just `defaultProps`.
|
||||
|
||||
```bash
|
||||
# Frame-exact stills across the hook→CTA arc, with a real variant's props
|
||||
npx remotion still AdTemplate out/f-hook.png --frame=12 --props=props/v1.json # hook readable < 2s
|
||||
npx remotion still AdTemplate out/f-mid.png --frame=300 --props=props/v1.json # benefit/proof
|
||||
npx remotion still AdTemplate out/f-cta.png --frame=689 --props=props/v1.json # CTA, message-matched
|
||||
# inspect each: fidelity (hook / offer / proof / CTA text exact, brand bg+accent right)
|
||||
# AND artifacts (text overflow, off-canvas, CTA inside the 9:16 bottom third, missing font, wrong row binding)
|
||||
```
|
||||
|
||||
**Multi-aspect / batch — verify one variant in EACH aspect before batch-rendering the matrix.** A layout bug repeats across every row × aspect; catch it once.
|
||||
|
||||
```bash
|
||||
for ar in 9x16 4x5 1x1; do # one representative variant, every target aspect
|
||||
npx remotion still AdTemplate "out/v1-${ar}.png" --frame=300 \
|
||||
--props=props/v1.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
# stills clean in all aspects? → then batch-render every row × aspect:
|
||||
for f in props/*.json; do id=$(basename "$f" .json); for ar in 9x16 4x5 1x1; do
|
||||
npx remotion render AdTemplate "out/${id}-${ar}.mp4" --props="$f" --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done; done
|
||||
npx remotion render AdTemplate out/demo.gif --codec=gif --props=props/v1.json # README demo
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. `npx remotion still` renders cleanly at hook / mid / CTA — no errors, no missing fonts/assets.
|
||||
2. Hook/offer/proof/CTA text exact and brand colors right; nothing in the 9:16 bottom third or outside the center safe zone.
|
||||
3. Frame-driven only — no CSS/library timers, `Date.now()`, or `Math.random()`.
|
||||
4. Shipped row's props render correctly (not just `defaultProps`); one variable per variant holds.
|
||||
5. One variant verified in 9:16 + 4:5 + 1:1 before the batch; MP4s play; (optional) GIF for the README.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/batch-ad-pipeline.md` — the full runnable Remotion ad template, the CSV→typed-props parser with validation, a one-variable test-matrix generator, the brand-lock theme object, and the complete batch + multi-aspect render script.
|
||||
- `references/platform-specs.md` — exact 2025/2026 pixel safe zones, durations, aspect ratios, and format limits for Meta Feed/Reels/Stories, TikTok In-Feed, and YouTube, plus a hook→CTA message-match worksheet.
|
||||
@@ -0,0 +1,289 @@
|
||||
# Batch Ad Pipeline — full template, parser, matrix, render
|
||||
|
||||
This is the complete, runnable implementation behind the SKILL.md overview: a data-driven Remotion ad template, a CSV→props parser with validation, a one-variable test-matrix generator, a brand-lock theme, and the batch + multi-aspect render scripts. Hardcode nothing a marketer might want to A/B test.
|
||||
|
||||
## 1. The brand-lock theme
|
||||
|
||||
Keep every brand-controlled value in one object so 40 variants stay consistent and only the *tested* fields change per row. The variant data supplies copy and per-campaign accents; the theme supplies the locked look.
|
||||
|
||||
```ts
|
||||
// theme.ts
|
||||
export const theme = {
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
ink: "#0B0B0F",
|
||||
paper: "#FFFFFF",
|
||||
radius: 20,
|
||||
hookSize: 76, // px, on the 1080-wide master
|
||||
bodySize: 44,
|
||||
proofSize: 52,
|
||||
ctaSize: 40,
|
||||
pad: 80, // outer padding; also the side safe margin
|
||||
durationInSeconds: 25,
|
||||
} as const;
|
||||
```
|
||||
|
||||
## 2. Aspect config
|
||||
|
||||
Drive layout from a single aspect prop so one composition reframes to every placement. Dimensions and the keep-clear bottom band differ per aspect.
|
||||
|
||||
```ts
|
||||
// aspects.ts
|
||||
export type Aspect = "9x16" | "4x5" | "1x1" | "16x9";
|
||||
export const ASPECTS: Record<Aspect, {w: number; h: number; bottomSafe: number}> = {
|
||||
"9x16": {w: 1080, h: 1920, bottomSafe: 540}, // captions + CTA + username stack here
|
||||
"4x5": {w: 1080, h: 1350, bottomSafe: 135},
|
||||
"1x1": {w: 1080, h: 1080, bottomSafe: 110},
|
||||
"16x9": {w: 1920, h: 1080, bottomSafe: 110},
|
||||
};
|
||||
```
|
||||
|
||||
## 3. The data-driven template
|
||||
|
||||
Every animated value is a pure function of `useCurrentFrame()` — no CSS transitions, no GSAP/library timers (they desync deterministic rendering and flicker in the export). The component reads one `variant` plus an `aspect`; the renderer never edits this file.
|
||||
|
||||
```tsx
|
||||
// AdTemplate.tsx
|
||||
import {
|
||||
AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring, Sequence,
|
||||
} from "remotion";
|
||||
import {theme} from "./theme";
|
||||
import {ASPECTS, Aspect} from "./aspects";
|
||||
|
||||
export type Variant = {
|
||||
id: string;
|
||||
hook: string; // 0–3s scroll-stopper
|
||||
benefit: string; // the single payoff claim
|
||||
proof: string; // one concrete number / result
|
||||
cta: string; // message-matched to the hook
|
||||
accent: string; // per-campaign accent color
|
||||
bg: string; // background color
|
||||
};
|
||||
|
||||
export const AdTemplate: React.FC<{v: Variant; aspect: Aspect}> = ({v, aspect}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
const {bottomSafe} = ASPECTS[aspect];
|
||||
|
||||
// Hook rises + fades in over the first 8 frames, holds, then eases up and out at 8s.
|
||||
const hookY = interpolate(frame, [0, 8], [48, 0], {extrapolateRight: "clamp"});
|
||||
const hookOpacity =
|
||||
interpolate(frame, [0, 8], [0, 1], {extrapolateRight: "clamp"}) *
|
||||
interpolate(frame, [fps * 7, fps * 8], [1, 0], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
|
||||
// CTA springs in for impact in the last 3s and holds.
|
||||
const ctaProgress = spring({frame: frame - fps * 22, fps, config: {damping: 14, stiffness: 120}});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: v.bg, fontFamily: theme.fontFamily, color: theme.ink}}>
|
||||
<AbsoluteFill style={{padding: theme.pad, paddingBottom: bottomSafe, justifyContent: "center"}}>
|
||||
|
||||
{/* HOOK 0–8s */}
|
||||
<Sequence durationInFrames={fps * 8}>
|
||||
<h1 style={{fontSize: theme.hookSize, fontWeight: 800, lineHeight: 1.05,
|
||||
transform: `translateY(${hookY}px)`, opacity: hookOpacity}}>
|
||||
{v.hook}
|
||||
</h1>
|
||||
</Sequence>
|
||||
|
||||
{/* BENEFIT 8–18s */}
|
||||
<Sequence from={fps * 8} durationInFrames={fps * 10}>
|
||||
<FadeUp delay={0}>
|
||||
<p style={{fontSize: theme.bodySize, fontWeight: 600}}>{v.benefit}</p>
|
||||
</FadeUp>
|
||||
</Sequence>
|
||||
|
||||
{/* PROOF 18–22s */}
|
||||
<Sequence from={fps * 18} durationInFrames={fps * 4}>
|
||||
<FadeUp delay={0}>
|
||||
<strong style={{fontSize: theme.proofSize, color: v.accent,
|
||||
fontVariantNumeric: "tabular-nums"}}>{v.proof}</strong>
|
||||
</FadeUp>
|
||||
</Sequence>
|
||||
|
||||
{/* CTA last 3s, message-matched to the hook */}
|
||||
<Sequence from={fps * 22}>
|
||||
<button style={{
|
||||
alignSelf: "flex-start", marginTop: 40, padding: "22px 44px",
|
||||
fontSize: theme.ctaSize, fontWeight: 700, border: "none",
|
||||
borderRadius: theme.radius, color: theme.paper, backgroundColor: v.accent,
|
||||
transform: `scale(${interpolate(ctaProgress, [0, 1], [0.85, 1])})`,
|
||||
opacity: ctaProgress,
|
||||
}}>
|
||||
{v.cta}
|
||||
</button>
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const FadeUp: React.FC<{children: React.ReactNode; delay: number}> = ({children, delay}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const o = interpolate(frame - delay, [0, 10], [0, 1], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
const y = interpolate(frame - delay, [0, 10], [24, 0], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
|
||||
return <div style={{opacity: o, transform: `translateY(${y}px)`}}>{children}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## 4. Register the composition (aspect + variant as props)
|
||||
|
||||
```tsx
|
||||
// Root.tsx
|
||||
import {Composition} from "remotion";
|
||||
import {AdTemplate} from "./AdTemplate";
|
||||
import {ASPECTS} from "./aspects";
|
||||
import {theme} from "./theme";
|
||||
|
||||
const defaultVariant = {
|
||||
id: "demo", hook: "Spending 2 hrs/day on reports?", benefit: "Auto-build them in one click.",
|
||||
proof: "Teams save 11 hrs a week.", cta: "Save 2 hours — try free", accent: "#4F46E5", bg: "#FFFFFF",
|
||||
};
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
const aspect = "9x16" as const; // overridden per-render via --props
|
||||
const {w, h} = ASPECTS[aspect];
|
||||
return (
|
||||
<Composition
|
||||
id="AdTemplate"
|
||||
component={AdTemplate}
|
||||
durationInFrames={theme.durationInSeconds * 30}
|
||||
fps={30}
|
||||
width={w}
|
||||
height={h}
|
||||
defaultProps={{v: defaultVariant, aspect}}
|
||||
// Resolve real dimensions from the incoming aspect prop at render time:
|
||||
calculateMetadata={({props}) => {
|
||||
const a = ASPECTS[(props as any).aspect ?? "9x16"];
|
||||
return {width: a.w, height: a.h};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
`calculateMetadata` lets a single composition output any aspect from the `aspect` prop — no separate composition per ratio.
|
||||
|
||||
## 5. CSV → typed props, with validation
|
||||
|
||||
The dataset is the input. Parse, validate every row (a bad hex or empty CTA must fail loudly, not render a broken ad), and write one props file per variant.
|
||||
|
||||
```js
|
||||
// csv-to-props.js usage: node csv-to-props.js variants.csv ./props [aspect]
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const [, , csvPath, outDir, aspect = "9x16"] = process.argv;
|
||||
const REQUIRED = ["id", "hook", "benefit", "proof", "cta", "accent", "bg"];
|
||||
const isHex = (s) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s);
|
||||
|
||||
const parseCSV = (text) => {
|
||||
const [head, ...lines] = text.trim().split(/\r?\n/);
|
||||
const cols = head.split(",").map((c) => c.trim());
|
||||
return lines.filter(Boolean).map((line) => {
|
||||
// simple split; wrap fields containing commas in double quotes in the CSV
|
||||
const cells = line.match(/("([^"]|"")*"|[^,]*)/g).filter((_, i, a) => i < a.length - 1);
|
||||
const row = {};
|
||||
cols.forEach((c, i) => (row[c] = (cells[i] ?? "").replace(/^"|"$/g, "").replace(/""/g, '"').trim()));
|
||||
return row;
|
||||
});
|
||||
};
|
||||
|
||||
const rows = parseCSV(fs.readFileSync(csvPath, "utf8"));
|
||||
fs.mkdirSync(outDir, {recursive: true});
|
||||
|
||||
const seen = new Set();
|
||||
let errors = 0;
|
||||
rows.forEach((row, n) => {
|
||||
const where = `row ${n + 2} (${row.id || "no-id"})`;
|
||||
REQUIRED.forEach((k) => { if (!row[k]) { console.error(`✗ ${where}: missing "${k}"`); errors++; } });
|
||||
if (seen.has(row.id)) { console.error(`✗ ${where}: duplicate id`); errors++; }
|
||||
seen.add(row.id);
|
||||
if (row.hook && row.hook.length > 60) console.warn(`⚠ ${where}: hook >60 chars may clip in 9:16`);
|
||||
["accent", "bg"].forEach((k) => { if (row[k] && !isHex(row[k])) { console.error(`✗ ${where}: "${k}" not a hex color`); errors++; } });
|
||||
if (errors) return;
|
||||
fs.writeFileSync(path.join(outDir, `${row.id}.json`), JSON.stringify({v: row, aspect}, null, 2));
|
||||
});
|
||||
|
||||
if (errors) { console.error(`\n${errors} error(s) — no broken ads written.`); process.exit(1); }
|
||||
console.log(`✓ ${rows.length} variant prop files written to ${outDir}`);
|
||||
```
|
||||
|
||||
Example `variants.csv` (one fully message-matched ad per row):
|
||||
|
||||
```csv
|
||||
id,hook,benefit,proof,cta,accent,bg
|
||||
v01_problem,Spending 2 hrs/day on reports?,Auto-build them in one click.,Teams save 11 hrs a week.,Save 2 hours — try free,#4F46E5,#FFFFFF
|
||||
v02_interrupt,Your reporting tool is lying to you.,See the real numbers instantly.,Teams save 11 hrs a week.,See the real numbers — free,#DC2626,#0B0B0F
|
||||
v03_curiosity,Nobody talks about this reporting trick.,One click builds the whole report.,Teams save 11 hrs a week.,Try the trick — free,#059669,#FFFFFF
|
||||
```
|
||||
|
||||
Note the CTA mirrors the hook in every row — that is the message-match discipline encoded into the data.
|
||||
|
||||
## 6. Test-matrix generator — one variable at a time
|
||||
|
||||
A test only teaches something if exactly one field changes. This expands a base variant plus a list of values for ONE field into a CSV, so a hook test or a CTA test stays clean and combinatorial mistakes are impossible.
|
||||
|
||||
```js
|
||||
// make-matrix.js usage: node make-matrix.js > variants.csv
|
||||
const base = {benefit: "Auto-build them in one click.", proof: "Teams save 11 hrs a week.",
|
||||
cta: "Save 2 hours — try free", accent: "#4F46E5", bg: "#FFFFFF"};
|
||||
|
||||
// Vary ONLY this field; everything else is held identical across the test.
|
||||
const FIELD = "hook";
|
||||
const values = [
|
||||
"Spending 2 hrs/day on reports?",
|
||||
"Your reporting tool is lying to you.",
|
||||
"Nobody talks about this reporting trick.",
|
||||
"Cut report time in half this week.",
|
||||
"If you build reports by hand, stop.",
|
||||
];
|
||||
|
||||
const esc = (s) => (/[",]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
|
||||
const cols = ["id", "hook", "benefit", "proof", "cta", "accent", "bg"];
|
||||
console.log(cols.join(","));
|
||||
values.forEach((val, i) => {
|
||||
const row = {...base, [FIELD]: val, id: `${FIELD}_${String(i + 1).padStart(2, "0")}`};
|
||||
console.log(cols.map((c) => esc(row[c])).join(","));
|
||||
});
|
||||
```
|
||||
|
||||
To run a CTA test next, set `FIELD = "cta"`, paste the *winning* hook into `base.hook`, and list CTA variants. Same one-variable rule, next stage of the funnel.
|
||||
|
||||
## 7. Batch + multi-aspect render
|
||||
|
||||
Render every variant in every aspect a campaign needs. Concurrency keeps large batches fast.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# render-all.sh one MP4 per (variant × aspect)
|
||||
set -euo pipefail
|
||||
|
||||
node make-matrix.js > variants.csv # 1) build the one-variable test matrix
|
||||
ASPECTS=("9x16" "4x5" "1x1") # placements this campaign needs
|
||||
|
||||
for ar in "${ASPECTS[@]}"; do
|
||||
node csv-to-props.js variants.csv "props/$ar" "$ar" # 2) typed, validated props per aspect
|
||||
for f in "props/$ar"/*.json; do
|
||||
id=$(basename "$f" .json)
|
||||
npx remotion render AdTemplate "out/${id}_${ar}.mp4" \
|
||||
--props="$f" --concurrency=4 --log=error # 3) render
|
||||
done
|
||||
done
|
||||
|
||||
echo "Done. $(ls out/*.mp4 | wc -l) ad variants in ./out"
|
||||
```
|
||||
|
||||
5 hooks × 3 aspects = 15 MP4s from one template and one command. Add a CTA stage and the same template produces the next test wave with zero new component code.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Two variables changed at once** — the winner is uninterpretable. The matrix generator prevents this; keep using it.
|
||||
- **Hook clips in 9:16** — long hooks overflow; the parser warns past 60 chars. Keep hooks short and high in the frame.
|
||||
- **CTA hidden behind platform UI** — anything in the 9:16 bottom third gets covered by captions and the CTA button. The `bottomSafe` padding reserves that band.
|
||||
- **Drift across variants** — accent/copy live in the data, everything else in `theme`. If a variant looks off-brand, the fix is the theme, not the row.
|
||||
- **Animation flicker in export** — caused by CSS transitions or library timers. Every value must derive from `useCurrentFrame()`.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can mass-produce ad-creative variants from one template × a data table — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=ad-video-skills&utm_content=ref_footer&utm_term=ad-creative-video)
|
||||
@@ -0,0 +1,72 @@
|
||||
# Platform Specs & Message-Match Worksheet
|
||||
|
||||
Exact pixel safe zones, durations, aspect ratios, and format limits per placement (2025/2026), plus a worksheet for building message-matched hook→CTA pairs before they go into the CSV. Verify against each platform's current Ads Manager docs before a big spend — placements and safe zones shift.
|
||||
|
||||
## Aspect ratio → placement map
|
||||
|
||||
| Aspect | Resolution | Native placements |
|
||||
|---|---|---|
|
||||
| 9:16 | 1080×1920 | TikTok In-Feed, Instagram/Facebook Reels, Stories, YouTube Shorts |
|
||||
| 4:5 | 1080×1350 | Meta Feed (largest feed real estate, recommended for Feed video) |
|
||||
| 1:1 | 1080×1080 | Meta Feed, broad/automatic placements, X |
|
||||
| 16:9 | 1920×1080 | YouTube in-stream, landscape feed, in-article |
|
||||
|
||||
Master rule: design the hook, product, and CTA inside the **1:1 center square** so a single composition reframes to every aspect by re-centering, not letterboxing. Black bars on a vertical feed read as "ad" and underperform.
|
||||
|
||||
## Safe zones (keep-clear bands)
|
||||
|
||||
Platform UI — captions, CTA chips, usernames, profile icons, progress bars — overlaps the frame. Keep all critical content out of these bands.
|
||||
|
||||
| Aspect / placement | Top keep-clear | Bottom keep-clear | Sides |
|
||||
|---|---|---|---|
|
||||
| 9:16 Reels/Stories (Meta) | ~14% (~270px on 1920h) | ~20–35% (~384–672px) | ~6% (~65px) |
|
||||
| 9:16 TikTok In-Feed | ~130px | ~484px (right-side icon rail + caption) | right ~140px |
|
||||
| 4:5 Meta Feed | ~5% | ~10% (keep CTA above it) | ~5% |
|
||||
| 1:1 / 16:9 | center 90% safe | center 90% safe | center 90% safe |
|
||||
|
||||
The 9:16 bottom third is the single most violated zone — captions, the CTA button, and the handle stack there. The `bottomSafe` padding in `batch-ad-pipeline.md` reserves it.
|
||||
|
||||
## Duration & format limits
|
||||
|
||||
| Placement | Sweet spot | Hard limits | Format |
|
||||
|---|---|---|---|
|
||||
| Meta Feed (4:5/1:1) | 6–15s | up to 240 min, ≤4GB | MP4/MOV, H.264 |
|
||||
| Meta Reels/Stories (9:16) | 8–15s | Reels ≤90s, Stories ≤2 min | MP4/MOV |
|
||||
| TikTok In-Feed (9:16) | 15–30s (TikTok cites 21–34s) | up to 10 min (non-Spark) | MP4/MOV, ≥540×960 |
|
||||
| YouTube in-stream (16:9) | 15–30s | skippable after 5s | MP4 |
|
||||
|
||||
Practical default for performance video: **15–30s, with the hook trigger landing before 2s.** Most ad-account data shows attention falls off a cliff after ~15s, so front-load everything that matters.
|
||||
|
||||
## Hook → CTA message-match worksheet
|
||||
|
||||
Fill one row per ad *before* writing the CSV. The CTA must pay off the exact promise the hook made; if it doesn't, the click is wasted and the test is noisy. Each completed row becomes one record in `variants.csv`.
|
||||
|
||||
| Hook (0–3s promise) | Hook type | Single benefit | Proof point | CTA (pays off the hook) |
|
||||
|---|---|---|---|---|
|
||||
| "Spending 2 hrs/day on reports?" | problem-first | one-click report build | "save 11 hrs/week" | "Save 2 hours — try free" |
|
||||
| "Your reporting tool is lying to you." | pattern interrupt | shows real numbers instantly | "save 11 hrs/week" | "See the real numbers — free" |
|
||||
| "Nobody talks about this reporting trick." | curiosity gap | one click builds it | "save 11 hrs/week" | "Try the trick — free" |
|
||||
| "Cut report time in half this week." | immediate benefit | auto-built reports | "save 11 hrs/week" | "Cut it in half — free" |
|
||||
| "If you build reports by hand, stop." | direct callout | automate it now | "save 11 hrs/week" | "Automate yours — free" |
|
||||
|
||||
Bad pairing to avoid: a "stop wasting 2 hours" hook ending on a generic "Shop now" — the promise and the payoff don't connect, so even a great hook converts poorly.
|
||||
|
||||
## Testing order (which field to vary first)
|
||||
|
||||
Run tests in funnel order; isolate one field per wave using the matrix generator:
|
||||
|
||||
1. **Hook** — biggest lever on CPA; test 5–8 hooks against one identical body.
|
||||
2. **Offer / benefit** — take the winning hook, vary the core promise.
|
||||
3. **CTA** — winning hook + offer, vary the closing action.
|
||||
4. **Format / aspect** — same winning ad, compare placements.
|
||||
|
||||
Budget guide many teams use: ~60% to proven winners, ~30% to variations of winners, ~10% to fresh concepts. Run each test long enough for a real sample (commonly several hundred impressions per variant, ~1 week to smooth daily swings) before declaring a winner.
|
||||
|
||||
## Pre-flight checklist per variant
|
||||
|
||||
- Hook trigger lands before 2s and fits the frame (≤~60 chars in 9:16).
|
||||
- CTA visibly pays off the hook's specific promise.
|
||||
- Critical content inside the center square and outside every keep-clear band.
|
||||
- Duration within the placement's sweet spot.
|
||||
- Exported in every aspect the campaign's placements require.
|
||||
- Exactly one field differs from the other variants in this test wave.
|
||||
Whitespace-only changes.
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: launch-video
|
||||
description: This skill should be used when the user asks to "make a launch video", "create a product hype/sizzle film", "build a teaser/trailer for a drop", "do a reveal montage with an end-card CTA", "cut a hero video to the beat", or "export a launch film in 16:9/9:16/1:1". Covers the hook→tease→reveal→feature-montage→end-card arc and multi-aspect export.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Launch Video
|
||||
|
||||
Make a premium, high-energy 15–60s launch film that stops the scroll and converts. Build the classic arc — hook → tease → reveal → feature montage → end-card CTA — cut to the music, and export for every platform aspect.
|
||||
|
||||
## When to use
|
||||
|
||||
- Product drops, feature announcements, trailers, hype reels (15–60s).
|
||||
- "Sizzle" hero videos for a landing page.
|
||||
- Beat-synced reveals where the brand moment lands on the drop.
|
||||
|
||||
## The arc
|
||||
|
||||
| Beat | Job | Typical share (of 30s) |
|
||||
|---|---|---|
|
||||
| Hook (0–3s) | A striking frame/motion that stops the scroll | 0–3s |
|
||||
| Tease | Hint the product; build curiosity, beat-synced | 3–9s |
|
||||
| Reveal | Product/logo hits on the music drop | 9–13s |
|
||||
| Feature montage | Fast, kinetic, one benefit per shot | 13–25s |
|
||||
| End card | Logo + tagline + CTA, clean hold | 25–30s |
|
||||
|
||||
Scale the same proportions for 15s (tighter) or 60s (longer montage, never a longer hook).
|
||||
|
||||
## Two non-negotiable rules
|
||||
|
||||
1. **Sound design leads picture.** Lock the track first, mark the beats and the drop, then cut visuals to those marks. Never score a finished cut — the reveal lands on the drop, not near it.
|
||||
2. **Quality over quantity.** A few flawless shots beat many mediocre ones. Cut any shot that isn't premium.
|
||||
|
||||
## Beat-synced reveal on the drop
|
||||
|
||||
Find the drop's exact timecode in the audio, then time the reveal so the brand mark snaps to full at that frame, with a micro-overshoot for impact.
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
const DROP = 9.0; // seconds, measured from the track
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
// tease builds tension up to the drop
|
||||
tl.to(".tease", { opacity: 1, scale: 1.05, duration: DROP, ease: "power1.in" })
|
||||
// reveal SNAPS on the drop frame
|
||||
.set(".logo", { opacity: 1 }, DROP)
|
||||
.fromTo(".logo", { scale: 1.18 }, { scale: 1.0, duration: .18, ease: "power3.out" }, DROP)
|
||||
.fromTo(".flash", { opacity: .9 }, { opacity: 0, duration: .25 }, DROP); // white hit
|
||||
audio.addEventListener("play", () => tl.play());
|
||||
```
|
||||
|
||||
Cut on transients, not on a fixed grid: the hardest cuts go on kick/snare hits; ramp speed (time-remap) into the drop, then hard-cut out of it.
|
||||
|
||||
## Kinetic feature montage
|
||||
|
||||
One benefit per shot, ~0.6–1.0s each, hard cuts on the beat. Each shot = a bold keyword + a single supporting visual.
|
||||
|
||||
```css
|
||||
.feature { opacity:0; }
|
||||
.feature.in .kw { animation: pop .35s cubic-bezier(.22,1,.36,1) forwards; }
|
||||
.feature.in .word { display:inline-block; }
|
||||
@keyframes pop { from { opacity:0; transform:translateY(24px) } to { opacity:1; transform:none } }
|
||||
```
|
||||
|
||||
```js
|
||||
// drive shots off beat marks measured from the track
|
||||
const beats = [13.0, 13.8, 14.6, 15.4, 16.2]; // seconds
|
||||
beats.forEach((t, i) => scheduleAt(t, () => showFeature(i)));
|
||||
```
|
||||
|
||||
Keep one motion language across the montage (same enter curve, same exit) so speed reads as confidence, not chaos.
|
||||
|
||||
## End card
|
||||
|
||||
Hold the logo + tagline + CTA still and clean for ≥2s. No busy motion competing with the CTA; let the brand settle. Land the brand moment on the strongest remaining beat, then a crisp button/URL.
|
||||
|
||||
## Multi-aspect export
|
||||
|
||||
Compose with a center safe zone so one master crops cleanly to all aspects.
|
||||
|
||||
| Aspect | Use | Resolution | Safe zone |
|
||||
|---|---|---|---|
|
||||
| 16:9 | YouTube, landing hero, X | 1920×1080 | keep key content within center 90% |
|
||||
| 9:16 | Reels, TikTok, Shorts, Stories | 1080×1920 | text within center 80% h; avoid top 12% / bottom 18% (UI) |
|
||||
| 1:1 | Feed posts | 1080×1080 | center square of the 16:9 frame |
|
||||
|
||||
Design the hero/logo/CTA inside the **1:1 center square** so it survives every crop. Render the 16:9 master, then reframe (don't just letterbox) 9:16 and 1:1 from the same project.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Track locked first; reveal lands exactly on the drop.
|
||||
- Hook earns the first 3 seconds.
|
||||
- Montage: one benefit per shot, hard cuts on beats, consistent motion language.
|
||||
- End card holds ≥2s with one clear CTA.
|
||||
- 16:9 + 9:16 + 1:1 exports, key content in the center safe zone.
|
||||
- Premium-only shots — nothing mediocre survives the cut.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
If the launch film is built/rendered in Remotion, treat it as the heavy tier: register a `<Composition>` (+ zod `schema` + `defaultProps`), drive every value off `useCurrentFrame()` — no GSAP timelines / audio-event callbacks / `Date.now()` / `Math.random()` at render time (bake beat + drop timecodes into props). Deliverable = the rendered `out/*.mp4` per aspect (plus the project).
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Sample the arc, with the **shipped** props (real headline/tagline/CTA), not just `defaultProps`.
|
||||
|
||||
```bash
|
||||
# Frame-exact stills across hook → reveal → end-card
|
||||
npx remotion still Launch out/f-hook.png --frame=24 --props=props/launch.json # hook earns first 3s
|
||||
npx remotion still Launch out/f-reveal.png --frame=270 --props=props/launch.json # logo SNAPS on the drop frame
|
||||
npx remotion still Launch out/f-end.png --frame=899 --props=props/launch.json # end card: logo+tagline+CTA, clean hold
|
||||
# inspect each: fidelity (product/logo, tagline, CTA text exact, brand colors right, reveal at the drop frame)
|
||||
# AND artifacts (text overflow, off-canvas, CTA in the 9:16 bottom third, missing font, flash frame stuck on)
|
||||
```
|
||||
|
||||
**Multi-aspect — verify the master in EACH aspect before encoding all of them.** A reframe bug (hero pushed off the safe zone) repeats across every aspect; catch it once.
|
||||
|
||||
```bash
|
||||
for ar in 16x9 9x16 1x1; do # check reveal + end card per aspect
|
||||
npx remotion still Launch "out/reveal-${ar}.png" --frame=270 \
|
||||
--props=props/launch.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
# stills clean in all aspects? → then encode each:
|
||||
for ar in 16x9 9x16 1x1; do
|
||||
npx remotion render Launch "out/launch-${ar}.mp4" --props=props/launch.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
npx remotion render Launch out/demo.gif --codec=gif --props=props/launch.json # README demo
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. `npx remotion still` renders cleanly at hook / reveal / end card — no errors, no missing fonts/assets.
|
||||
2. Logo/tagline/CTA text exact, brand colors right; reveal lands on the drop frame; key content inside the center safe zone, clear of the 9:16 bottom third.
|
||||
3. Frame-driven only — no GSAP/audio-callback timing, `Date.now()`, or `Math.random()` (beats/drop baked into props).
|
||||
4. Shipped props render correctly (not just `defaultProps`); the end card holds ≥2s with one CTA.
|
||||
5. Master verified in 16:9 + 9:16 + 1:1 before encoding; MP4s play; (optional) GIF for the README.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/launch-structure.md` — a full shot-by-shot template with timecodes for a 30s film (scalable to 15–60s), beat-synced reveal timing, kinetic-montage shot recipes, the sound-design-leads-picture workflow, and detailed multi-aspect safe-area maps for 16:9 / 9:16 / 1:1.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Launch Video Structure — Cookbook
|
||||
|
||||
Shot-by-shot template, beat-sync timing, montage recipes, and multi-aspect maps for a premium 15–60s launch film.
|
||||
|
||||
## Sound-design-leads-picture workflow
|
||||
|
||||
1. **Pick/commission the track first.** The track defines the film's length and energy.
|
||||
2. **Mark the structure in the audio**: intro, build, the **drop** (the single biggest moment), and the kick/snare transients through the montage. Note exact timecodes.
|
||||
3. **Map beats → cuts.** Hard cuts land on transients; the reveal lands on the drop frame.
|
||||
4. **Build picture to the marks.** Never finish a cut and then look for music. Score-first guarantees the reveal hits.
|
||||
5. **Mix last**: add risers into the drop, an impact/whoosh on the reveal, and let the end-card breathe (drop music low, or a clean tag).
|
||||
|
||||
## Shot-by-shot template — 30s film (scalable)
|
||||
|
||||
```
|
||||
00:00–00:03 HOOK
|
||||
Visual : single striking frame — extreme close detail / impossible motion
|
||||
Motion : slow push-in or a fast whip that resolves; NO logo yet
|
||||
Audio : sub-bass swell begins, sparse
|
||||
Cut : on first audible beat at ~03s
|
||||
|
||||
00:03–00:09 TEASE
|
||||
Visual : fragments/silhouettes of the product, never the full thing
|
||||
Motion : light leaks, partial reveals, parallax; tension rising
|
||||
Audio : riser building toward the drop
|
||||
Cut : quick cuts accelerating into the drop
|
||||
|
||||
00:09–00:13 REVEAL ← THE DROP at 00:09
|
||||
Visual : product/logo SNAPS to full on the drop frame
|
||||
Motion : scale 1.18→1.0 over 0.18s + white flash-out; settle clean
|
||||
Audio : drop hits + impact stinger on the exact frame
|
||||
Hold : let it sit ~1.2s before moving
|
||||
|
||||
00:13–00:25 FEATURE MONTAGE
|
||||
Visual : one benefit per shot, ~0.6–1.0s each (≈8–10 shots)
|
||||
Motion : bold keyword pop + one supporting visual; consistent enter/exit
|
||||
Audio : hard cuts on kick/snare; energy sustained
|
||||
Pattern: KEYWORD + UI/product moment, repeat with rhythm
|
||||
|
||||
00:25–00:30 END CARD
|
||||
Visual : logo + tagline + CTA, centered, still
|
||||
Motion : minimal — a soft settle, then hold ≥2s
|
||||
Audio : music resolves; clean sonic tag
|
||||
CTA : one action (URL or button), nothing competing
|
||||
```
|
||||
|
||||
### Re-proportioning
|
||||
|
||||
- **15s**: Hook 0–2s · Tease 2–5s · Reveal 5–7s · Montage 7–12s (4–5 shots) · End card 12–15s.
|
||||
- **60s**: same hook (still ≤3s) · longer tease and a richer montage (14–16 shots, grouped in waves) · end card ≥3s.
|
||||
|
||||
The hook never gets longer — only the montage scales.
|
||||
|
||||
## Beat-synced reveal (timing detail)
|
||||
|
||||
```js
|
||||
import gsap from "gsap";
|
||||
const DROP = 9.0; // measured drop timecode (s)
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// build tension toward the drop
|
||||
tl.fromTo(".tease", { scale: 1.0, opacity: .6 },
|
||||
{ scale: 1.08, opacity: 1, duration: DROP, ease: "power1.in" });
|
||||
|
||||
// REVEAL exactly on the drop
|
||||
tl.set(".logo", { opacity: 1 }, DROP);
|
||||
tl.fromTo(".logo", { scale: 1.18 }, { scale: 1.0, duration: .18, ease: "power3.out" }, DROP);
|
||||
tl.fromTo(".flash", { opacity: .9 }, { opacity: 0, duration: .25, ease: "power2.out" }, DROP);
|
||||
tl.to(".tease", { opacity: 0, duration: .12 }, DROP);
|
||||
|
||||
// hold the reveal
|
||||
tl.to(".logo", { scale: 1.0, duration: 1.2 }); // dwell
|
||||
|
||||
audio.addEventListener("play", () => tl.play());
|
||||
```
|
||||
|
||||
Speed-ramp into the drop (Remotion time-remap idea): play the last ~0.5s of the tease faster, then hard-cut to the reveal on the drop frame for a snap.
|
||||
|
||||
## Kinetic montage shot recipe
|
||||
|
||||
Each shot: a bold keyword + one product/UI visual, ~0.6–1.0s, hard cut on the beat.
|
||||
|
||||
```css
|
||||
.feature { position:absolute; inset:0; opacity:0; }
|
||||
.feature.in { opacity:1; }
|
||||
.feature .word { display:inline-block; opacity:0; transform:translateY(26px); }
|
||||
.feature.in .word { animation: pop .32s cubic-bezier(.22,1,.36,1) forwards; }
|
||||
.feature.in .word:nth-child(2){ animation-delay:.04s }
|
||||
.feature.in .word:nth-child(3){ animation-delay:.08s }
|
||||
@keyframes pop { to { opacity:1; transform:none; } }
|
||||
```
|
||||
|
||||
```js
|
||||
// schedule shots on measured beat marks
|
||||
const beats = [13.0, 13.8, 14.6, 15.4, 16.2, 17.0, 17.8, 18.6]; // seconds
|
||||
let i = 0;
|
||||
function loop(now){
|
||||
const t = (now - start) / 1000;
|
||||
if (i < beats.length && t >= beats[i]) { showFeature(i); i++; }
|
||||
if (i < beats.length) requestAnimationFrame(loop);
|
||||
}
|
||||
```
|
||||
|
||||
Montage discipline: keep ONE enter curve and ONE exit across all shots; vary content, never grammar. Alternate visual rhythm (full-frame ↔ detail) but keep cut cadence locked to the beat.
|
||||
|
||||
## Multi-aspect safe-area maps
|
||||
|
||||
Compose so a single master reframes to every aspect. Place hero/logo/CTA inside the smallest common safe zone — the 1:1 center square.
|
||||
|
||||
```
|
||||
16:9 1920×1080
|
||||
Safe: center 90% (96px H / 54px V margins)
|
||||
Use : YouTube, landing hero, X timeline
|
||||
|
||||
9:16 1080×1920
|
||||
Safe: text within center 80% height
|
||||
Avoid: top 12% (status/clock area), bottom 18% (caption + UI buttons)
|
||||
Use : Reels, TikTok, Shorts, Stories
|
||||
|
||||
1:1 1080×1080
|
||||
Safe: this square == the design anchor; put logo/CTA here
|
||||
Use : feed posts
|
||||
```
|
||||
|
||||
Export workflow:
|
||||
|
||||
1. Build the 16:9 master with all key content inside the 1:1 center square.
|
||||
2. For 9:16, **reframe** (pan/scale per shot to keep the subject centered) — do not letterbox.
|
||||
3. For 1:1, crop the center square; verify text isn't clipped.
|
||||
4. Re-check captions/CTA against each platform's UI overlay zones before export.
|
||||
|
||||
## Final QA checklist
|
||||
|
||||
- [ ] Reveal lands on the exact drop frame, with overshoot + flash.
|
||||
- [ ] Hook holds attention in the first 3s without showing the product.
|
||||
- [ ] Every montage shot is one benefit, premium quality, on a beat.
|
||||
- [ ] End card holds ≥2s, single CTA, nothing competing.
|
||||
- [ ] 16:9 / 9:16 / 1:1 each pass safe-area and caption checks.
|
||||
- [ ] Audio mixed: riser → impact on drop → clean tag on end card.
|
||||
|
||||
---
|
||||
Nail the reveal beat and the sound-leads-picture cut, and a launch film lands. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=ad-video-skills&utm_content=skill_footer&utm_term=launch-video)** — the AI motion agent for editable, on-brand motion graphics.
|
||||
Whitespace-only changes.
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: testimonial-video
|
||||
description: This skill should be used when the user asks to "make an animated testimonial", "create a quote video", "build a review video", "turn a customer testimonial into a graphic", "animate a customer review", "make a social proof video", "add an animated star rating", or "turn a list of reviews into videos". Covers quote typography, staggered line reveals, kinetic emphasis on key phrases, animated star ratings, the author block (name/role/avatar/company), and templating one card across many quotes.
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
# Testimonial Video
|
||||
|
||||
Animate a written quote, review, or testimonial into a short, trustworthy social-proof clip. The craft is typographic, not flashy: a clean quote card where the words land in reading order, the key phrase gets emphasis exactly when it should, the stars fill to the real score, and the author signs off. One card design, driven by props, becomes a video for every review on the list.
|
||||
|
||||
## When to use
|
||||
|
||||
- Customer review → a 6–12s vertical/feed clip (the headline use case).
|
||||
- A pull-quote or testimonial graphic with author name, role, avatar, company.
|
||||
- An animated star rating (including half/fractional scores like 4.5).
|
||||
- A batch: a list/CSV of reviews → one video each, same template.
|
||||
|
||||
Do **not** reach here for sale/discount promos or generic ad creative — this skill is strictly the quote / social-proof angle.
|
||||
|
||||
## The one rule: trust before motion
|
||||
|
||||
A testimonial earns its job by feeling *real*, not produced. Restraint reads as credibility; over-animation reads as an ad people skip.
|
||||
|
||||
1. **Reveal in reading order.** The eye must finish a line before the next arrives. Stagger lines (or clauses), never all words at once, and never reorder for visual flair.
|
||||
2. **Quote the words exactly.** Do not paraphrase, trim mid-sentence, or invent a rating. If you emphasize a phrase, it must be the customer's phrase.
|
||||
3. **The author is the proof.** A quote with no attributable human is just a slogan. Name + role + company (and avatar if real) is non-negotiable.
|
||||
|
||||
## The anatomy of a quote card
|
||||
|
||||
| Element | Job | Timing |
|
||||
|---|---|---|
|
||||
| Quote mark motif | Signals "this is a quote" before words read | Frame 0, scales/fades in first |
|
||||
| Quote body | The testimonial, revealed line-by-line | Staggered, ~6–10 frames apart |
|
||||
| Emphasis span | The one phrase that sells | Lands *after* its line settles |
|
||||
| Star rating | The score, filled to the real value | Fills left→right after the quote |
|
||||
| Author block | Name / role / company / avatar | Signs off last, slides up |
|
||||
|
||||
Build in that order; reveal in that order. The viewer reads the quote, believes it, sees the score, then learns who said it.
|
||||
|
||||
## Staggered line reveal
|
||||
|
||||
Split the quote into lines (or clauses), then offset each one's entrance. In Remotion, drive every line off `useCurrentFrame()` so it renders deterministically — never a CSS transition or a JS timer, which desync from the frame renderer.
|
||||
|
||||
```jsx
|
||||
import { useCurrentFrame, spring, useVideoConfig } from "remotion";
|
||||
|
||||
const QuoteBody = ({ lines, startAt = 8, stagger = 6 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
return (
|
||||
<blockquote>
|
||||
{lines.map((line, i) => {
|
||||
const enter = spring({ frame: frame - startAt - i * stagger, fps,
|
||||
config: { damping: 200 } }); // 0→1, no overshoot
|
||||
return (
|
||||
<span key={i} style={{ display: "block",
|
||||
opacity: enter,
|
||||
transform: `translateY(${(1 - enter) * 14}px)` }}>
|
||||
{line}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</blockquote>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Keep one motion language: same easing, same 14px rise, every line. Speed reads as confidence only when it's consistent. See `references/quote-card.md` for the full composition.
|
||||
|
||||
## Kinetic emphasis on the key phrase
|
||||
|
||||
Pick the single phrase that carries the testimonial ("saved us 10 hours a week") and let it arrive *after* its line has settled — a highlight sweep, a weight shift, or a color pop. Emphasis competing with the reveal cancels both.
|
||||
|
||||
```jsx
|
||||
const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
// a highlighter wiping in behind the phrase, left→right
|
||||
<mark style={{ background:
|
||||
`linear-gradient(90deg, var(--accent) ${sweep}%, transparent ${sweep}%)`,
|
||||
color: "inherit", padding: "0 .08em" }}>{phrase}</mark>
|
||||
```
|
||||
|
||||
Emphasize at most one span per quote. Two highlights is no highlight.
|
||||
|
||||
## Animated star rating (fractional)
|
||||
|
||||
Render the real score, including halves. Use one full row of star glyphs as a mask, then wipe a filled layer to `rating / max` width — this handles 4.5 as cleanly as 5.0, with no half-star sprite.
|
||||
|
||||
```jsx
|
||||
const StarRating = ({ rating, max = 5, fillStart = 30, fillDur = 14 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const target = (rating / max) * 100;
|
||||
const w = interpolate(frame, [fillStart, fillStart + fillDur], [0, target],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
||||
return (
|
||||
<div className="stars" role="img" aria-label={`${rating} out of ${max} stars`}>
|
||||
<div className="track">★★★★★</div>
|
||||
<div className="fill" style={{ width: `${w}%` }}>★★★★★</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
```css
|
||||
.stars { position: relative; display: inline-block; font-size: 40px; letter-spacing: 4px; }
|
||||
.track { color: rgba(255,255,255,.22); }
|
||||
.fill { position: absolute; inset: 0; overflow: hidden; white-space: nowrap;
|
||||
color: var(--accent); } /* clipped by width → fractional fill */
|
||||
```
|
||||
|
||||
Fill left→right after the quote reads. Never animate stars before the words — the score must feel earned, not pre-loaded. Always set `aria-label` to the real value.
|
||||
|
||||
## The author block
|
||||
|
||||
Sign off last. The block slides up under the stars and holds still — stillness is what makes attribution read as fact.
|
||||
|
||||
```jsx
|
||||
<figcaption className="author">
|
||||
{avatar && <img src={avatar} alt="" className="avatar" />}
|
||||
<div>
|
||||
<span className="name">{name}</span>
|
||||
<span className="role">{role}{company && `, ${company}`}</span>
|
||||
</div>
|
||||
</figcaption>
|
||||
```
|
||||
|
||||
Use a real avatar or none — a generic placeholder face actively destroys trust. Name is the loudest line in the block; role/company is secondary weight.
|
||||
|
||||
## Template × reviews — one video per quote
|
||||
|
||||
The payoff: make every field a prop, hardcode nothing, then render once per review.
|
||||
|
||||
```jsx
|
||||
export const Testimonial = ({ quote, highlight, rating, name, role, company, avatar }) => { /* … */ };
|
||||
```
|
||||
```bash
|
||||
for f in reviews/*.json; do
|
||||
npx remotion render Testimonial "out/$(basename "$f" .json).mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
Keep colors/fonts/quote-mark/layout in one theme object so 50 testimonials stay brand-locked and only the words and score change. See `references/batch-and-typography.md` for the review schema, CSV→props parsing, quote-typography craft (smart quotes, hanging punctuation, line-splitting, orphans), and the batch script.
|
||||
|
||||
## Output checklist
|
||||
|
||||
- Lines reveal in reading order; one consistent enter motion, ~6–10 frame stagger.
|
||||
- The quote is verbatim; exactly one emphasized span, landing after its line settles.
|
||||
- Stars fill to the *real* score (halves supported) after the quote, with a correct `aria-label`.
|
||||
- Author block (name + role + company, real avatar or none) signs off last and holds still.
|
||||
- Every animated value derives from `useCurrentFrame()` — no CSS/JS timers.
|
||||
- Every field is a prop; one template renders every review in the folder.
|
||||
|
||||
## Deliver & verify (rendered stills → MP4)
|
||||
|
||||
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
|
||||
|
||||
**Output contract:**
|
||||
- A Remotion `Testimonial` registered as `<Composition>` (+ zod `schema` + `defaultProps`), every value frame-driven (no CSS transitions / JS timers / `Date.now()` / `Math.random()`).
|
||||
- Deliverable = the rendered `out/*.mp4` per review per aspect (plus the project + review data, so the user re-renders on new quotes).
|
||||
|
||||
**Verify loop — render stills → inspect → encode.** Cheap PNGs first; render with the **shipped** review's props, not just `defaultProps`.
|
||||
|
||||
```bash
|
||||
# Frame-exact stills across the reveal order: quote → stars → author
|
||||
npx remotion still Testimonial out/f-quote.png --frame=30 --props=reviews/r1.json # lines in reading order, emphasis span
|
||||
npx remotion still Testimonial out/f-stars.png --frame=48 --props=reviews/r1.json # stars filled to the REAL score (e.g. 4.5)
|
||||
npx remotion still Testimonial out/f-author.png --frame=215 --props=reviews/r1.json # author block signed off, holding still
|
||||
# inspect each: fidelity (quote VERBATIM, exactly one emphasized span, star fill matches rating, name/role/company exact)
|
||||
# AND artifacts (text overflow/orphans, off-canvas, author in the 9:16 bottom third, missing font, broken avatar)
|
||||
```
|
||||
|
||||
**Multi-aspect / batch — verify one review in EACH target aspect before batch-rendering the list.** A layout bug (overflowing quote, clipped author) repeats across every review × aspect; catch it once.
|
||||
|
||||
```bash
|
||||
for ar in 9x16 1x1 16x9; do # one representative review, every aspect
|
||||
npx remotion still Testimonial "out/r1-${ar}.png" --frame=215 \
|
||||
--props=reviews/r1.json --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done
|
||||
# stills clean in all aspects? → then batch-render the list:
|
||||
for f in reviews/*.json; do id=$(basename "$f" .json); for ar in 9x16 1x1 16x9; do
|
||||
npx remotion render Testimonial "out/${id}-${ar}.mp4" --props="$f" --props-merge="{\"aspect\":\"${ar}\"}"
|
||||
done; done
|
||||
npx remotion render Testimonial out/demo.gif --codec=gif --props=reviews/r1.json # README demo
|
||||
```
|
||||
|
||||
**Before you finish:**
|
||||
1. `npx remotion still` renders cleanly at quote / stars / author — no errors, no missing fonts/avatar.
|
||||
2. Quote is verbatim with one emphasized span; stars fill to the real score; name/role/company exact and inside the safe zone (clear of the 9:16 bottom third).
|
||||
3. Frame-driven only — no CSS/JS timers, `Date.now()`, or `Math.random()`.
|
||||
4. Shipped review's props render correctly (not just `defaultProps`); `aria-label` carries the real rating.
|
||||
5. One review verified in 9:16 + 1:1 + 16:9 before the batch; MP4s play; (optional) GIF for the README.
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/quote-card.md` — a complete runnable Remotion `Testimonial` composition: staggered line reveal, key-phrase highlight sweep, fractional star fill, author block, theme object, and the timing map for a 9:16 card. Plus a dependency-free HTML/CSS variant.
|
||||
- `references/batch-and-typography.md` — the review prop schema, CSV/JSON→props parsing and validation, quote typography craft (smart quotes, hanging quotation marks, line/clause splitting, orphan control, legibility in motion), star-rating accessibility, and the template×reviews batch render script for N videos.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Batch & Typography
|
||||
|
||||
How to feed many reviews into one card design, plus the quote-typography craft that separates a credible testimonial from a sloppy one.
|
||||
|
||||
## Review prop schema
|
||||
|
||||
One object per testimonial. Keep it flat so a CSV row maps to it directly.
|
||||
|
||||
```ts
|
||||
type Review = {
|
||||
quote: string; // verbatim — never paraphrased or trimmed mid-sentence
|
||||
highlight?: string; // EXACT substring of `quote`; one phrase only
|
||||
rating: number; // 0–max, halves allowed (4.5)
|
||||
max?: number; // default 5
|
||||
name: string; // required — a quote with no name is not social proof
|
||||
role?: string;
|
||||
company?: string;
|
||||
avatar?: string; // real photo URL, or omit (never a stock face)
|
||||
};
|
||||
```
|
||||
|
||||
### Validation (fail loud, before rendering 50 videos)
|
||||
|
||||
```ts
|
||||
function validate(r: Review): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!r.quote?.trim()) errs.push("quote is empty");
|
||||
if (!r.name?.trim()) errs.push("name is required (no anonymous testimonials)");
|
||||
const max = r.max ?? 5;
|
||||
if (r.rating < 0 || r.rating > max) errs.push(`rating ${r.rating} out of 0–${max}`);
|
||||
if (r.highlight && !r.quote.includes(r.highlight))
|
||||
errs.push(`highlight "${r.highlight}" is not a substring of the quote`);
|
||||
return errs;
|
||||
}
|
||||
```
|
||||
|
||||
The `highlight ⊂ quote` check is the important one: it guarantees you only ever emphasize words the customer actually wrote. If the highlight isn't an exact substring, the emphasis silently disappears in the card — so block the render instead.
|
||||
|
||||
## CSV → props
|
||||
|
||||
Reviews usually arrive as a spreadsheet export. Map columns to the schema and coerce the rating.
|
||||
|
||||
```js
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
|
||||
// CSV columns: quote,highlight,rating,name,role,company,avatar
|
||||
const rows = parse(readFileSync("reviews.csv"), { columns: true, trim: true });
|
||||
mkdirSync("reviews", { recursive: true });
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
const r = {
|
||||
quote: row.quote,
|
||||
highlight: row.highlight || undefined,
|
||||
rating: Number(row.rating),
|
||||
name: row.name,
|
||||
role: row.role || undefined,
|
||||
company: row.company || undefined,
|
||||
avatar: row.avatar || undefined,
|
||||
};
|
||||
const errs = validate(r);
|
||||
if (errs.length) { console.error(`Row ${i + 1} (${r.name}):`, errs); process.exit(1); }
|
||||
writeFileSync(`reviews/${String(i).padStart(3, "0")}-${slug(r.name)}.json`,
|
||||
JSON.stringify(r, null, 2));
|
||||
});
|
||||
|
||||
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
```
|
||||
|
||||
## Batch render — one video per review
|
||||
|
||||
```bash
|
||||
mkdir -p out
|
||||
for f in reviews/*.json; do
|
||||
name=$(basename "$f" .json)
|
||||
npx remotion render Testimonial "out/$name.mp4" --props="$f"
|
||||
done
|
||||
```
|
||||
|
||||
Everything visual lives in the single `theme` object in the composition (colors, fonts, quote-mark glyph, spacing). Because only the props change per render, 50 testimonials stay pixel-for-pixel brand-locked while the words and score vary. To re-theme the whole set, edit one object and re-run the loop.
|
||||
|
||||
For multi-aspect (9:16 feed + 1:1 post + 16:9 site), register the composition at each size with a shared center-safe layout and loop over both `reviews/*.json` and the aspect list.
|
||||
|
||||
## Quote typography craft
|
||||
|
||||
Motion is the easy part; legible, well-set type is what makes the quote trustworthy.
|
||||
|
||||
### Smart quotes, always
|
||||
|
||||
Straight quotes (`"`) read as un-proofed. Convert to typographic quotes before rendering.
|
||||
|
||||
```js
|
||||
const smarten = (s) => s
|
||||
.replace(/(^|[\s([{])"/g, "$1“").replace(/"/g, "”") // “ ”
|
||||
.replace(/(^|[\s([{])'/g, "$1‘").replace(/'/g, "’") // ‘ ’
|
||||
.replace(/--/g, "—"); // em dash
|
||||
```
|
||||
|
||||
### Hanging quotation marks
|
||||
|
||||
A leading quote glyph indents the first line and breaks the flush-left edge. Hang it into the margin so the text block stays optically aligned — the gold standard for set quotes.
|
||||
|
||||
```css
|
||||
blockquote { hanging-punctuation: first; } /* Safari/WebKit */
|
||||
/* Cross-engine fallback: pull the opening mark left by its own width */
|
||||
blockquote::before { content: "\201C"; margin-left: -0.55em; color: var(--muted); }
|
||||
```
|
||||
|
||||
When the card uses a large decorative quote mark (as in `quote-card.md`), drop the inline opening glyph so the mark isn't doubled.
|
||||
|
||||
### Splitting into lines / clauses
|
||||
|
||||
Reveal in reading order means splitting on meaning, not on a fixed character count. Prefer clause boundaries (`. ! ? , ; —`), then pack to a comfortable measure (~32–40 chars at card size). The `toLines` helper in `quote-card.md` does exactly this. Never split a short quote into one-word-per-line — that reads as a ransom note, not a testimonial.
|
||||
|
||||
### Orphans & widows
|
||||
|
||||
A single dangling word on the last line looks broken. Bind the last two words with a non-breaking space so they wrap together.
|
||||
|
||||
```js
|
||||
const noOrphan = (s) => s.replace(/ ([^ ]+)\s*$/, " $1");
|
||||
```
|
||||
|
||||
### Legibility in motion
|
||||
|
||||
- Use a font that stays readable while moving — avoid thin weights and tight tracking; a serif or a humanist sans at 500–600 weight holds up best on a rising line.
|
||||
- Animate **opacity + a small translate** only. Blur, rotation, and scale on body text hurt readability mid-reveal.
|
||||
- Contrast: body text ≥ 4.5:1 against the card; the highlight accent should pass against both the card and the ink it sits behind.
|
||||
- One emphasized span per quote. A second highlight halves the weight of the first.
|
||||
|
||||
## Star-rating accessibility & correctness
|
||||
|
||||
- The visual stars are decorative; expose the value to assistive tech with `role="img"` and `aria-label="4.5 out of 5 stars"` on the container (already in `quote-card.md`).
|
||||
- For fractional scores, the width-clip method (`rating / max * 100%`) is exact for any decimal — no rounding to the nearest half-star, so 4.3 renders as 4.3.
|
||||
- Never animate the stars before the quote is read, and never show more filled stars than the data supports. A faked rating is the fastest way to lose the trust the testimonial was meant to build.
|
||||
- If a review has no rating, omit the star row entirely rather than defaulting to 5 — an invented score is worse than none.
|
||||
|
||||
## Pacing notes
|
||||
|
||||
- Hold the finished card still for the back half of the clip (≥3s) so it's screenshot-able and re-shareable — shares are the whole point of social proof.
|
||||
- For a sequence of testimonials in one video, give each card a full read (~3–4s) and hard-cut between them; cross-dissolving quotes makes both unreadable.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Quote Card — complete runnable component
|
||||
|
||||
A full testimonial card you can drop into a Remotion project, plus a zero-dependency HTML/CSS variant for non-Remotion pipelines. Both reveal in reading order, emphasize one phrase, fill stars to the real score, and sign off with the author.
|
||||
|
||||
## Timing map (9:16, 30fps, ~10s)
|
||||
|
||||
| Frame | Beat |
|
||||
|---|---|
|
||||
| 0–8 | Quote mark scales in; card settles |
|
||||
| 8–48 | Quote body, line-by-line (8 + i×6) |
|
||||
| 44–54 | Key-phrase highlight sweep (after its line settles) |
|
||||
| 56–70 | Stars fill left→right to `rating/max` |
|
||||
| 72–88 | Author block slides up, holds |
|
||||
| 88–300 | Everything holds still (screenshot-able) |
|
||||
|
||||
The card holds motionless for the back half — stillness is what makes a testimonial read as a fact rather than an ad.
|
||||
|
||||
## Remotion composition
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img,
|
||||
} from "remotion";
|
||||
|
||||
export type Review = {
|
||||
quote: string; // verbatim testimonial
|
||||
highlight?: string; // exact substring of `quote` to emphasize
|
||||
rating: number; // e.g. 4.5
|
||||
max?: number; // default 5
|
||||
name: string;
|
||||
role?: string;
|
||||
company?: string;
|
||||
avatar?: string; // real photo URL, or omit
|
||||
};
|
||||
|
||||
const theme = {
|
||||
bg: "#0E1116",
|
||||
ink: "#F5F7FA",
|
||||
muted: "rgba(245,247,250,.55)",
|
||||
accent: "#FFC24B",
|
||||
font: "Inter, system-ui, sans-serif",
|
||||
quoteFont: "'Newsreader', Georgia, serif",
|
||||
quoteMark: "“", // left double quotation mark
|
||||
};
|
||||
|
||||
// Split a quote into clauses for reading-order reveal.
|
||||
// Prefer sentence/clause boundaries; fall back to ~32-char wraps.
|
||||
const toLines = (q: string): string[] => {
|
||||
const parts = q.match(/[^.!?,;]+[.!?,;]?\s*/g) ?? [q];
|
||||
const lines: string[] = [];
|
||||
let buf = "";
|
||||
for (const p of parts) {
|
||||
if ((buf + p).length > 34 && buf) { lines.push(buf.trim()); buf = p; }
|
||||
else buf += p;
|
||||
}
|
||||
if (buf.trim()) lines.push(buf.trim());
|
||||
return lines;
|
||||
};
|
||||
|
||||
// Render a line, wrapping the highlight substring in <mark> if present.
|
||||
const Line: React.FC<{ text: string; highlight?: string; sweep: number }> = ({
|
||||
text, highlight, sweep,
|
||||
}) => {
|
||||
if (!highlight || !text.includes(highlight)) return <>{text}</>;
|
||||
const [before, after] = text.split(highlight);
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<mark
|
||||
style={{
|
||||
background: `linear-gradient(90deg, ${theme.accent} ${sweep}%, transparent ${sweep}%)`,
|
||||
color: "inherit",
|
||||
padding: "0 .08em",
|
||||
borderRadius: 3,
|
||||
WebkitBoxDecorationBreak: "clone",
|
||||
}}
|
||||
>
|
||||
{highlight}
|
||||
</mark>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Testimonial: React.FC<Review> = ({
|
||||
quote, highlight, rating, max = 5, name, role, company, avatar,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const lines = toLines(quote);
|
||||
|
||||
// Quote mark
|
||||
const markIn = spring({ frame, fps, config: { damping: 14 } });
|
||||
|
||||
// Highlight sweep, after the body has revealed
|
||||
const hlStart = 8 + lines.length * 6 + 4;
|
||||
const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100], {
|
||||
extrapolateLeft: "clamp", extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Stars fill, after the quote
|
||||
const fillStart = hlStart + 14;
|
||||
const target = (rating / max) * 100;
|
||||
const starW = interpolate(frame, [fillStart, fillStart + 14], [0, target], {
|
||||
extrapolateLeft: "clamp", extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Author block slides up last
|
||||
const authStart = fillStart + 16;
|
||||
const auth = spring({ frame: frame - authStart, fps, config: { damping: 200 } });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: theme.bg, fontFamily: theme.font,
|
||||
padding: 120, justifyContent: "center", color: theme.ink }}>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{ fontFamily: theme.quoteFont, fontSize: 200, lineHeight: .6,
|
||||
color: theme.accent, opacity: markIn, transform: `scale(${markIn})`,
|
||||
transformOrigin: "left top" }}
|
||||
>
|
||||
{theme.quoteMark}
|
||||
</div>
|
||||
|
||||
<blockquote style={{ fontFamily: theme.quoteFont, fontSize: 64,
|
||||
lineHeight: 1.25, margin: "24px 0 48px", fontWeight: 500 }}>
|
||||
{lines.map((line, i) => {
|
||||
const enter = spring({ frame: frame - (8 + i * 6), fps,
|
||||
config: { damping: 200 } });
|
||||
return (
|
||||
<span key={i} style={{ display: "block", opacity: enter,
|
||||
transform: `translateY(${(1 - enter) * 14}px)` }}>
|
||||
<Line text={line} highlight={highlight} sweep={sweep} />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</blockquote>
|
||||
|
||||
{/* Stars */}
|
||||
<div role="img" aria-label={`${rating} out of ${max} stars`}
|
||||
style={{ position: "relative", display: "inline-block", fontSize: 52,
|
||||
letterSpacing: 6, marginBottom: 56 }}>
|
||||
<div style={{ color: "rgba(245,247,250,.20)" }}>{"★".repeat(max)}</div>
|
||||
<div style={{ position: "absolute", inset: 0, overflow: "hidden",
|
||||
whiteSpace: "nowrap", width: `${starW}%`, color: theme.accent }}>
|
||||
{"★".repeat(max)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Author */}
|
||||
<figcaption style={{ display: "flex", alignItems: "center", gap: 20,
|
||||
opacity: auth, transform: `translateY(${(1 - auth) * 16}px)` }}>
|
||||
{avatar && (
|
||||
<Img src={avatar} alt=""
|
||||
style={{ width: 88, height: 88, borderRadius: "50%", objectFit: "cover" }} />
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<span style={{ fontSize: 38, fontWeight: 700 }}>{name}</span>
|
||||
<span style={{ fontSize: 30, color: theme.muted }}>
|
||||
{[role, company].filter(Boolean).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
</figcaption>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it with a duration long enough for the back-half hold:
|
||||
|
||||
```tsx
|
||||
<Composition id="Testimonial" component={Testimonial}
|
||||
durationInFrames={300} fps={30} width={1080} height={1920}
|
||||
defaultProps={{
|
||||
quote: "We shipped in days, not months. Onboarding saved us 10 hours a week.",
|
||||
highlight: "saved us 10 hours a week",
|
||||
rating: 4.5, name: "Dana Reyes", role: "Head of Ops", company: "Northwind",
|
||||
}} />
|
||||
```
|
||||
|
||||
## Why these choices
|
||||
|
||||
- **`spring` with `damping: 200`** gives a clean rise with no overshoot — testimonials should feel composed, not bouncy. The quote mark uses lighter damping (14) for a touch of life on the one decorative element.
|
||||
- **Highlight as a gradient `<mark>`** wipes a real highlighter behind the phrase without a second element or a mask, and survives line wraps via `box-decoration-break: clone`.
|
||||
- **Stars as two stacked glyph rows clipped by width** render any fractional score (4.5, 4.3) with no half-star asset, and the filled row inherits the brand accent.
|
||||
- **Author after stars** matches reading psychology: claim → proof → who. Reordering weakens trust.
|
||||
|
||||
## Zero-dependency HTML/CSS variant
|
||||
|
||||
For pipelines that capture a headless-browser timeline (or just a web embed). Animations are CSS `@keyframes` with per-line `animation-delay`; if rendering frame-exact video, prefer the Remotion version above so frames stay deterministic.
|
||||
|
||||
```html
|
||||
<figure class="card" style="--accent:#FFC24B; --rating:4.5; --max:5">
|
||||
<div class="qmark" aria-hidden="true">“</div>
|
||||
<blockquote>
|
||||
<span style="--i:0">We shipped in days, not months.</span>
|
||||
<span style="--i:1">Onboarding <mark>saved us 10 hours a week.</mark></span>
|
||||
</blockquote>
|
||||
<div class="stars" role="img" aria-label="4.5 out of 5 stars">
|
||||
<div class="track">★★★★★</div><div class="fill">★★★★★</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<img src="dana.jpg" alt="" class="avatar">
|
||||
<div><b>Dana Reyes</b><small>Head of Ops, Northwind</small></div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
```
|
||||
|
||||
```css
|
||||
.card { --bg:#0E1116; --ink:#F5F7FA; background:var(--bg); color:var(--ink);
|
||||
font-family:Inter,system-ui,sans-serif; padding:120px; max-width:1080px; }
|
||||
.qmark { font:200px/.6 'Newsreader',Georgia,serif; color:var(--accent);
|
||||
animation:pop .4s cubic-bezier(.22,1,.36,1) both; }
|
||||
blockquote { font:500 64px/1.25 'Newsreader',Georgia,serif; margin:24px 0 48px; }
|
||||
blockquote span { display:block; opacity:0;
|
||||
animation:rise .5s cubic-bezier(.22,1,.36,1) both;
|
||||
animation-delay:calc(.27s + var(--i) * .2s); }
|
||||
mark { background:linear-gradient(90deg,var(--accent) 100%,transparent 100%);
|
||||
color:inherit; padding:0 .08em; border-radius:3px;
|
||||
-webkit-box-decoration-break:clone; box-decoration-break:clone;
|
||||
animation:sweep .35s ease-out both; animation-delay:1s; }
|
||||
@keyframes sweep { from { background-size:0 100% } to { background-size:100% 100% } }
|
||||
.stars { position:relative; display:inline-block; font-size:52px; letter-spacing:6px; }
|
||||
.track { color:rgba(245,247,250,.20); }
|
||||
.fill { position:absolute; inset:0; overflow:hidden; white-space:nowrap;
|
||||
color:var(--accent); width:0; animation:fill .5s ease-out 1.3s forwards; }
|
||||
@keyframes fill { to { width:calc(var(--rating) / var(--max) * 100%) } }
|
||||
figcaption { display:flex; align-items:center; gap:20px; margin-top:56px;
|
||||
opacity:0; animation:rise .5s ease-out 1.8s both; }
|
||||
.avatar { width:88px; height:88px; border-radius:50%; object-fit:cover; }
|
||||
figcaption b { font-size:38px; } figcaption small { display:block; font-size:30px;
|
||||
color:rgba(245,247,250,.55); }
|
||||
@keyframes pop { from { opacity:0; transform:scale(.7) } to { opacity:1; transform:none } }
|
||||
@keyframes rise { from { opacity:0; transform:translateY(14px) } to { opacity:1; transform:none } }
|
||||
```
|
||||
|
||||
The CSS `mark` sweep uses `background-size` (animatable) rather than a gradient color-stop, since color-stop percentages don't animate in CSS. The star `--rating/--max` math is the same fractional fill as the Remotion version.
|
||||
|
||||
---
|
||||
## Built by the team behind iart.ai
|
||||
|
||||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can turn a list of reviews/quotes into testimonial videos, swap text, and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=ad-video-skills&utm_content=ref_footer&utm_term=testimonial-video)
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/ad-creative-video
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/launch-video
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/testimonial-video
|
||||
@@ -1,45 +0,0 @@
|
||||
/* CycleWise AU - scroll animations (Step 1).
|
||||
Purely cosmetic: if GSAP fails to load or the visitor prefers reduced
|
||||
motion, this file does nothing and the page looks exactly as before. */
|
||||
(function(){
|
||||
"use strict";
|
||||
if (!window.gsap || !window.ScrollTrigger) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
var done = new WeakSet();
|
||||
|
||||
function reveal(panel){
|
||||
if (!panel || done.has(panel)) return;
|
||||
done.add(panel);
|
||||
|
||||
// Top of the tab: hero text and picture fade up one after another.
|
||||
gsap.from(panel.querySelectorAll(".hero > *"), {
|
||||
opacity: 0, y: 24, duration: 0.7, stagger: 0.12, ease: "power2.out",
|
||||
clearProps: "all"
|
||||
});
|
||||
|
||||
// Everything below: fade up when it scrolls into view.
|
||||
panel.querySelectorAll(
|
||||
".ref-heading, .ref-grid, .timeline, .week-panel, .filmstrip, .cal-card, .detail-card"
|
||||
).forEach(function(el){
|
||||
gsap.from(el, {
|
||||
opacity: 0, y: 32, duration: 0.7, ease: "power2.out",
|
||||
clearProps: "all",
|
||||
scrollTrigger: { trigger: el, start: "top 88%", once: true }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function activePanel(){ return document.querySelector(".mode-panel.active"); }
|
||||
|
||||
reveal(activePanel());
|
||||
|
||||
document.querySelectorAll(".mode-btn").forEach(function(btn){
|
||||
btn.addEventListener("click", function(){
|
||||
// Wait one tick so the page's own code switches the tab first.
|
||||
setTimeout(function(){ reveal(activePanel()); ScrollTrigger.refresh(); }, 0);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -1,539 +0,0 @@
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
/* ---------------- date helpers ---------------- */
|
||||
function toDate(y,m,d){ return new Date(y,m,d); }
|
||||
function fromInput(str){
|
||||
if(!str) return null;
|
||||
var p = str.split("-");
|
||||
return new Date(+p[0], +p[1]-1, +p[2]);
|
||||
}
|
||||
function toInputVal(d){
|
||||
return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");
|
||||
}
|
||||
function addDays(d,n){ var r=new Date(d); r.setDate(r.getDate()+n); return r; }
|
||||
function daysBetween(a,b){ // b - a, in whole days, ignoring time
|
||||
var A = new Date(a.getFullYear(),a.getMonth(),a.getDate());
|
||||
var B = new Date(b.getFullYear(),b.getMonth(),b.getDate());
|
||||
return Math.round((B-A)/86400000);
|
||||
}
|
||||
function sameDay(a,b){ return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); }
|
||||
function mod(n,m){ return ((n % m) + m) % m; }
|
||||
var MONTH_NAMES = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||
var today = new Date(); today.setHours(0,0,0,0);
|
||||
|
||||
function loadLS(key, fallback){
|
||||
try{ var v = localStorage.getItem(key); return v===null ? fallback : v; }catch(e){ return fallback; }
|
||||
}
|
||||
function saveLS(key, val){ try{ localStorage.setItem(key,val); }catch(e){} }
|
||||
|
||||
/* ---------------- mode switching ---------------- */
|
||||
var modeBtns = document.querySelectorAll(".mode-btn");
|
||||
var panels = { period: document.getElementById("mode-period"), ovulation: document.getElementById("mode-ovulation"), pregnancy: document.getElementById("mode-pregnancy") };
|
||||
modeBtns.forEach(function(btn){
|
||||
btn.addEventListener("click", function(){
|
||||
modeBtns.forEach(function(b){ b.classList.remove("active"); b.setAttribute("aria-selected","false"); });
|
||||
btn.classList.add("active"); btn.setAttribute("aria-selected","true");
|
||||
Object.keys(panels).forEach(function(k){ panels[k].classList.remove("active"); });
|
||||
panels[btn.dataset.mode].classList.add("active");
|
||||
saveLS("ca-mode", btn.dataset.mode);
|
||||
});
|
||||
});
|
||||
var savedMode = loadLS("ca-mode", null);
|
||||
if(savedMode && panels[savedMode]){
|
||||
modeBtns.forEach(function(b){ b.classList.toggle("active", b.dataset.mode===savedMode); b.setAttribute("aria-selected", b.dataset.mode===savedMode ? "true":"false"); });
|
||||
Object.keys(panels).forEach(function(k){ panels[k].classList.toggle("active", k===savedMode); });
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
PHASE + OVULATION REFERENCE DATA
|
||||
================================================================ */
|
||||
var PHASES = {
|
||||
menstrual: {
|
||||
title:"Menstrual", color:"var(--period)", soft:"var(--period-soft)",
|
||||
window:"Cycle day 1 through the end of bleeding",
|
||||
desc:"The uterine lining sheds because no pregnancy occurred last cycle. Estrogen and progesterone are both at their lowest point of the month.",
|
||||
hormone:"Estrogen & progesterone: low", energy:"Energy: lowest",
|
||||
symptoms:"Cramping, fatigue, lower back ache, mood dips",
|
||||
tip:"Iron-rich food, gentle movement, and heat on the lower abdomen ease cramping better than rest alone."
|
||||
},
|
||||
follicular: {
|
||||
title:"Follicular", color:"var(--ovulation)", soft:"var(--ovulation-soft)",
|
||||
window:"Cycle day 1 through ovulation (overlaps menstrual)",
|
||||
desc:"The pituitary releases FSH, prompting several ovarian follicles to mature. Estrogen climbs steadily as one dominant follicle takes the lead.",
|
||||
hormone:"Estrogen: rising", energy:"Energy: climbing",
|
||||
symptoms:"Clearer skin, sharper focus, rising motivation",
|
||||
tip:"This is the physiologically best window for starting new training blocks or demanding projects."
|
||||
},
|
||||
ovulatory: {
|
||||
title:"Ovulatory", color:"var(--ovulation-strong)", soft:"var(--ovulation-soft)",
|
||||
window:"Roughly one day, mid-cycle",
|
||||
desc:"A surge in luteinizing hormone (LH) triggers the dominant follicle to release its egg. This is the single most fertile day of the cycle.",
|
||||
hormone:"LH: sharp surge", energy:"Energy & libido: peak",
|
||||
symptoms:"Egg-white cervical mucus, one-sided pelvic twinge (mittelschmerz)",
|
||||
tip:"Ovulation predictor kits detect the LH surge roughly 24–36 hours before the egg is released."
|
||||
},
|
||||
luteal: {
|
||||
title:"Luteal", color:"var(--pregnancy)", soft:"var(--pregnancy-soft)",
|
||||
window:"Ovulation through the day before your next period",
|
||||
desc:"The ruptured follicle becomes the corpus luteum and secretes progesterone to prepare the uterine lining. If no implantation occurs, hormone levels fall and the cycle restarts.",
|
||||
hormone:"Progesterone: rises then falls", energy:"Energy: gradually declining",
|
||||
symptoms:"Bloating, breast tenderness, irritability, food cravings (PMS)",
|
||||
tip:"Basal body temperature stays elevated ~0.3–0.5°C through this phase — a useful way to confirm ovulation already happened."
|
||||
}
|
||||
};
|
||||
|
||||
// offset relative to ovulation day (0 = ovulation)
|
||||
var OVULATION_DAYS = {
|
||||
"-5": {fert:"Low", fertClass:"fert-low", bbt:"Baseline (pre-shift)", mucus:"Dry to sticky", note:"Sperm can survive up to 5 days, so conception is technically possible starting here."},
|
||||
"-4": {fert:"Low–moderate", fertClass:"fert-low", bbt:"Baseline", mucus:"Sticky, cloudy", note:"Fertility begins climbing as estrogen rises toward its pre-ovulation peak."},
|
||||
"-3": {fert:"Moderate", fertClass:"fert-mid", bbt:"Baseline", mucus:"Creamy, increasing in volume", note:"Cervical mucus is becoming more hospitable to sperm."},
|
||||
"-2": {fert:"High", fertClass:"fert-high", bbt:"Baseline", mucus:"Thin, stretchy, egg-white-like", note:"One of the two most fertile days of the entire cycle."},
|
||||
"-1": {fert:"Peak", fertClass:"fert-peak", bbt:"Baseline (often the day of the LH surge)", mucus:"Clear, slippery, maximum stretch", note:"Statistically the single highest-probability day for conception."},
|
||||
"0": {fert:"Peak", fertClass:"fert-peak", bbt:"Still baseline — the rise hasn't happened yet", mucus:"Egg-white, abundant", note:"The egg is released and is viable for roughly 12–24 hours. Some feel a one-sided twinge (mittelschmerz)."},
|
||||
"1": {fert:"Dropping fast", fertClass:"fert-mid", bbt:"Beginning to rise ~0.3–0.5°C", mucus:"Becoming tacky again", note:"The egg's fertile window is closing; BBT starts its post-ovulation climb."},
|
||||
"2": {fert:"Very low", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry or minimal", note:"Progesterone from the corpus luteum keeps temperature elevated through the luteal phase."},
|
||||
"3": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"If fertilization happened, the embryo is traveling toward the uterus."},
|
||||
"4": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Too late in the cycle for a new ovulation; luteal phase symptoms may start appearing."},
|
||||
"5": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Implantation, if it happens this cycle, typically occurs around days 6–10 after ovulation."}
|
||||
};
|
||||
var FERT_COLOR = {"fert-low":"var(--ink-soft)","fert-mid":"var(--fertile)","fert-high":"var(--fertile-strong)","fert-peak":"var(--ovulation-strong)"};
|
||||
|
||||
function phaseForDayInCycle(dic, periodLen, ovulationDic){
|
||||
if(dic < periodLen) return "menstrual";
|
||||
if(dic < ovulationDic) return "follicular";
|
||||
if(dic === ovulationDic) return "ovulatory";
|
||||
return "luteal";
|
||||
}
|
||||
function isFertile(dic, ovulationDic){
|
||||
return dic >= ovulationDic - 5 && dic <= ovulationDic;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
PREGNANCY WEEK DATA (1–40)
|
||||
================================================================ */
|
||||
var WEEKS = [
|
||||
{c:"a poppy seed (not yet fertilized)", m:"n/a", dev:"Pregnancy is dated from the first day of this period, before conception happens — your body is shedding last cycle's lining and preparing a fresh egg.", body:"This week looks and feels like an ordinary period.", tip:"If you're trying to conceive, this is a good week to start a prenatal vitamin with folic acid."},
|
||||
{c:"an egg cell", m:"~0.1 mm", dev:"Rising estrogen prompts an egg to mature; ovulation typically occurs near the end of this week, roughly two weeks before a missed period.", body:"Cervical mucus becomes clear and stretchy around ovulation — a natural fertility sign.", tip:"This is the fertile window if you're trying to conceive — see the Ovulation tab for day-by-day detail."},
|
||||
{c:"a grain of sand", m:"~0.1–0.2 mm", dev:"If sperm meets egg this week, fertilization occurs in the fallopian tube and the resulting cell begins dividing as it travels toward the uterus.", body:"No symptoms are detectable yet — even a sensitive test won't turn positive for another week or two.", tip:"Implantation, if it happens, usually occurs 6–10 days after ovulation."},
|
||||
{c:"a poppy seed", m:"~0.1 mm", dev:"The fertilized egg implants in the uterine lining and begins producing hCG, the hormone home pregnancy tests detect.", body:"Most people notice no symptoms yet; a sensitive test may already turn positive near the end of this week.", tip:"A missed period is usually the first sign — testing a few days after that gives the most reliable result."},
|
||||
{c:"a sesame seed", m:"~2 mm", dev:"The neural tube — the earliest structure of the brain and spinal cord — begins to form, along with the first blood vessels.", body:"A missed period is usually the first clear sign; fatigue and tender breasts may begin.", tip:"Avoid alcohol and confirm any medications with a clinician — this window is critical for early development."},
|
||||
{c:"a lentil", m:"~4 mm", dev:"A primitive heart tube starts beating, and the beginnings of the face, arms, and legs appear as small buds.", body:"Morning sickness and smell sensitivity often begin here, though timing varies widely.", tip:"Small, frequent, bland meals tend to sit better than three large ones if nausea has started."},
|
||||
{c:"a blueberry", m:"~1.3 cm", dev:"The brain is dividing into distinct sections, and the arm and leg buds are elongating into recognizable limbs.", body:"Bloating and mild cramping are common as the uterus makes room to grow.", tip:"This is when most people schedule their first prenatal appointment — dating scans are most accurate now."},
|
||||
{c:"a raspberry", m:"~1.6 cm", dev:"Fingers and toes are forming, still webbed; the heart now has four distinct chambers.", body:"Fatigue often peaks around this point as progesterone rises sharply.", tip:"Rest when you can — first-trimester exhaustion is real and temporary, not a sign anything is wrong."},
|
||||
{c:"a grape", m:"~2.3 cm · 4 g", dev:"The embryo is officially called a fetus starting around now; essential organs are all present in early form and the tail has disappeared.", body:"Nausea, food aversions, and a heightened sense of smell are often at their strongest.", tip:"Ginger tea, vitamin B6, and staying hydrated are commonly recommended first-line options for nausea."},
|
||||
{c:"a kumquat", m:"~3 cm · 7 g", dev:"Vital organs — kidneys, liver, brain — are functioning in a basic form, and tiny fingernail beds are forming.", body:"A visible baby bump is still unlikely, though clothes may feel tighter.", tip:"Ask your provider about first-trimester screening options available in this window."},
|
||||
{c:"a fig", m:"~4.1 cm · 10 g", dev:"The fetus can now bend its arms and legs, and tooth buds are forming beneath the gums.", body:"Round ligament twinges may start as the uterus stretches.", tip:"Gentle stretching and changing positions slowly can ease early ligament discomfort."},
|
||||
{c:"a lime", m:"~5.4 cm · 14 g", dev:"Reflexes are developing — fingers can curl, and toes can wiggle, though it can't be felt yet.", body:"The end of the first trimester is near, and miscarriage risk drops meaningfully from here.", tip:"Many people choose to share their pregnancy news around now, though the choice is entirely personal."},
|
||||
{c:"a plum", m:"~6.7 cm · 23 g", dev:"Vocal cords are forming, and unique fingerprints are being etched onto tiny fingertips.", body:"Some early nausea may start easing as the placenta takes over hormone production.", tip:"The nuchal translucency scan, if chosen, is typically done between now and week 13."},
|
||||
{c:"a passion fruit", m:"~10 cm · 45 g", dev:"Baby can open and close fists and may even suck a thumb; genitals are differentiating, though not yet visible on ultrasound.", body:"For many, first-trimester nausea and fatigue begin to lift.", tip:"Second-trimester energy often supports resuming or starting light-to-moderate exercise — check in with your provider first."},
|
||||
{c:"a peach", m:"~11.6 cm · 58 g", dev:"Vocal cords are complete and the intestines, which developed in the umbilical cord, move into the abdomen.", body:"Welcome to the second trimester — often called the most comfortable stretch of pregnancy.", tip:"An anatomy scan is usually scheduled in the coming weeks; ask your clinic when it's booked."},
|
||||
{c:"a lemon", m:"~13 cm · 70 g", dev:"Fine, downy hair called lanugo begins covering the body, and the kidneys are producing urine into the amniotic fluid.", body:"A small bump may finally become visible; appetite often improves noticeably.", tip:"Iron needs rise sharply now — leafy greens, legumes, and lean meat help meet increased demand."},
|
||||
{c:"an apple", m:"~14.2 cm · 95 g", dev:"The skeleton continues hardening and baby can sense light through closed eyelids.", body:"Skin changes like a faint linea nigra or darkened areolas may appear as pigment increases.", tip:"Daily SPF helps limit pregnancy-related skin pigmentation (melasma) from sun exposure."},
|
||||
{c:"an avocado", m:"~16 cm · 135 g", dev:"The placenta is now fully formed and functioning as baby's life-support system.", body:"First flutters of movement ('quickening') may be felt any time from now through week 20.", tip:"Movement timing varies a lot — first pregnancies and anterior placentas often mean feeling it later."},
|
||||
{c:"a pomegranate", m:"~17.7 cm · 160 g", dev:"The skeleton is converting from cartilage to true bone, and fat stores are just beginning to accumulate.", body:"The uterus is now roughly at the level of the belly button.", tip:"A pregnancy support pillow can meaningfully improve sleep as side-sleeping becomes more comfortable."},
|
||||
{c:"a bell pepper", m:"~19 cm · 190 g", dev:"Ears have reached their final position and baby may begin startling at loud external sounds.", body:"Quickening is commonly felt by now — often described as bubbles or light taps.", tip:"Try narrating your day out loud — hearing is developing and familiar voices become recognizable over time."},
|
||||
{c:"a mango", m:"~20.3 cm · 240 g", dev:"A waxy coating called vernix caseosa begins protecting the skin, and taste buds are forming.", body:"Backaches can start as posture shifts to balance the growing bump.", tip:"A supportive, flatter shoe and a pillow between the knees at night both help low back strain."},
|
||||
{c:"a banana", m:"~25.6 cm · 300 g", dev:"The detailed anatomy ultrasound is often done around now if it hasn't happened already, and sex can typically be identified on it.", body:"Movements are stronger and more frequent; the uterus is now level with the belly button.", tip:"If you want to know the sex and haven't had the anatomy scan yet, ask your clinic to schedule it soon — it's usually done weeks 18–22."},
|
||||
{c:"a carrot", m:"~27 cm · 360 g", dev:"Baby is swallowing small amounts of amniotic fluid, giving the digestive system practice.", body:"Stretch marks may start appearing on the belly, hips, or chest as skin stretches.", tip:"Moisturizing won't prevent stretch marks, but it can ease the itchiness that comes with stretching skin."},
|
||||
{c:"a papaya", m:"~28.9 cm · 430 g", dev:"Eyebrows and eyelids are fully formed, and the sense of touch is developing rapidly.", body:"Braxton Hicks — brief, painless practice contractions — may be felt occasionally from here on.", tip:"Braxton Hicks usually ease with a change of position or a glass of water; true labor contractions don't."},
|
||||
{c:"an ear of corn", m:"~32.2 cm · 600 g", dev:"The lungs begin producing surfactant, a substance essential for breathing air after birth — a key viability milestone.", body:"The linea nigra (a dark vertical line on the belly) may become more visible.", tip:"This week is often cited as the threshold of viability with intensive neonatal care, should birth happen very early."},
|
||||
{c:"a cauliflower head", m:"~33.7 cm · 660 g", dev:"Baby can now respond to your voice, and hand grip strength is developing.", body:"The glucose screening test for gestational diabetes is typically scheduled around now.", tip:"If asked to fast beforehand, follow your clinic's instructions closely for an accurate glucose result."},
|
||||
{c:"a head of lettuce", m:"~35.6 cm · 760 g", dev:"Eyes begin to open after being fused shut since early development, and hair may be visibly growing.", body:"Round ligament pain and rib discomfort can increase as space gets tighter.", tip:"A maternity support band can ease rib and ligament pressure during long periods of standing or walking."},
|
||||
{c:"a bunch of scallions", m:"~36.6 cm · 875 g", dev:"Brain tissue is developing rapidly, and baby is developing regular hiccups you may be able to feel.", body:"Third trimester begins soon — appointments typically become more frequent from here.", tip:"Hiccup-like twitches are completely normal and are the diaphragm practicing its rhythm."},
|
||||
{c:"an eggplant", m:"~37.6 cm · 1.1 kg", dev:"Eyes can blink now, and baby may cycle through active REM sleep.", body:"Welcome to the third trimester — the home stretch begins.", tip:"This is a common time to start planning a hospital bag and reviewing birth preferences."},
|
||||
{c:"a butternut squash", m:"~38.6 cm · 1.2 kg", dev:"Bones are fully formed but still soft and flexible, ready to move through the birth canal.", body:"Frequent urination often returns as the head engages lower in the pelvis.", tip:"Kick counts become a useful daily check-in — note baby's typical pattern of movement."},
|
||||
{c:"a large cabbage", m:"~39.9 cm · 1.3 kg", dev:"The brain is developing at its fastest rate yet, adding folds and surface area.", body:"Sleep can become harder to find as the bump limits comfortable positions.", tip:"A full-body pregnancy pillow supports the belly, back, and knees simultaneously."},
|
||||
{c:"a coconut", m:"~41.1 cm · 1.5 kg", dev:"All five senses are now functioning, and baby is gaining roughly half a pound a week.", body:"Colostrum, the first form of breast milk, may begin to leak slightly.", tip:"A few nursing pads in your bag can help if colostrum leaking becomes noticeable."},
|
||||
{c:"a jicama", m:"~42.4 cm · 1.7 kg", dev:"Baby is practicing breathing movements against a closed airway, strengthening the lungs.", body:"Swelling and mild carpal tunnel tingling in the hands can appear from fluid retention.", tip:"Report sudden or severe swelling to your provider promptly — it can occasionally signal preeclampsia."},
|
||||
{c:"a pineapple", m:"~43.7 cm · 1.9 kg", dev:"Bones are hardening overall, though the skull stays deliberately soft and flexible for delivery.", body:"Pelvic pressure increases as baby continues descending.", tip:"Prenatal massage or a warm bath can ease the deep pelvic ache many feel this week."},
|
||||
{c:"a cantaloupe", m:"~45 cm · 2.1 kg", dev:"The central nervous system is maturing quickly, and baby's grip is now quite strong.", body:"Braxton Hicks contractions often become more noticeable and frequent.", tip:"Time any contractions that feel different — regular, intensifying, closer together warrants a call to your provider."},
|
||||
{c:"a honeydew melon", m:"~46.2 cm · 2.4 kg", dev:"The kidneys are fully developed, and the liver is processing waste products on its own.", body:"You may feel baby 'drop' lower as it settles into position for birth.", tip:"Lightening (the baby dropping) can ease heartburn but often increases bathroom trips."},
|
||||
{c:"a head of romaine", m:"~47.4 cm · 2.6 kg", dev:"Most physical development is complete; the focus now is steady weight gain.", body:"You've just reached 'early term' — babies born from here on tend to do very well.", tip:"Pack your hospital bag if you haven't yet — early-term babies can arrive with little warning."},
|
||||
{c:"a leek", m:"~49.8 cm · 3.1 kg", dev:"Baby continues laying down fat for temperature regulation after birth, and the brain is still rapidly developing.", body:"Nesting instinct and a burst of energy are common right before labor.", tip:"Balance nesting energy with rest — labor's timing is unpredictable."},
|
||||
{c:"a mini watermelon", m:"~50.7 cm · 3.3 kg", dev:"Full term. The lungs are completing final maturation in preparation for that first breath.", body:"Increased pelvic pressure and more frequent, intense Braxton Hicks are common.", tip:"Watch for your labor signs: regular contractions, water breaking, or a bloody show."},
|
||||
{c:"a small pumpkin", m:"~51.2 cm · 3.5 kg", dev:"Baby is fully developed and simply continuing to grow, ready to meet you.", body:"If labor hasn't started, your provider will discuss monitoring or induction options.", tip:"Most babies arrive within two weeks of their due date in either direction — both are entirely normal."}
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
SVG helpers
|
||||
================================================================ */
|
||||
function polarToXY(cx,cy,r,angleDeg){
|
||||
var rad = (angleDeg-90) * Math.PI/180;
|
||||
return [cx + r*Math.cos(rad), cy + r*Math.sin(rad)];
|
||||
}
|
||||
function arcPath(cx,cy,r,startDeg,endDeg){
|
||||
if(endDeg <= startDeg) endDeg = startDeg + 0.01;
|
||||
var s = polarToXY(cx,cy,r,startDeg), e = polarToXY(cx,cy,r,endDeg);
|
||||
var large = (endDeg-startDeg) > 180 ? 1 : 0;
|
||||
return "M "+s[0].toFixed(2)+" "+s[1].toFixed(2)+" A "+r+" "+r+" 0 "+large+" 1 "+e[0].toFixed(2)+" "+e[1].toFixed(2);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
SHARED STATE: period start / cycle length / period length
|
||||
================================================================ */
|
||||
var exampleStart = addDays(today, -10);
|
||||
var startInput = document.getElementById("p-start");
|
||||
var cycleLenInput = document.getElementById("p-cyclelen");
|
||||
var periodLenInput = document.getElementById("p-periodlen");
|
||||
|
||||
var savedStart = loadLS("ca-start", null);
|
||||
var savedCycleLen = loadLS("ca-cyclelen", null);
|
||||
var savedPeriodLen = loadLS("ca-periodlen", null);
|
||||
var usingExample = !savedStart;
|
||||
|
||||
startInput.value = savedStart || toInputVal(exampleStart);
|
||||
cycleLenInput.value = savedCycleLen || 28;
|
||||
periodLenInput.value = savedPeriodLen || 5;
|
||||
|
||||
function getCycleState(){
|
||||
return {
|
||||
start: fromInput(startInput.value) || exampleStart,
|
||||
cycleLen: Math.max(21, Math.min(35, parseInt(cycleLenInput.value,10) || 28)),
|
||||
periodLen: Math.max(2, Math.min(10, parseInt(periodLenInput.value,10) || 5))
|
||||
};
|
||||
}
|
||||
|
||||
var pCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
var oCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
var pSelectedDate = new Date(today);
|
||||
var oSelectedDate = new Date(today);
|
||||
|
||||
[startInput,cycleLenInput,periodLenInput].forEach(function(inp){
|
||||
inp.addEventListener("input", function(){
|
||||
saveLS("ca-start", startInput.value);
|
||||
saveLS("ca-cyclelen", cycleLenInput.value);
|
||||
saveLS("ca-periodlen", periodLenInput.value);
|
||||
usingExample = false;
|
||||
document.getElementById("p-example-note").style.display = "none";
|
||||
document.getElementById("pr-example-note").style.display = "none";
|
||||
renderAll();
|
||||
});
|
||||
});
|
||||
if(!usingExample) document.getElementById("p-example-note").style.display = "none";
|
||||
|
||||
/* ================================================================
|
||||
CALENDAR RENDERING (shared builder)
|
||||
================================================================ */
|
||||
function buildCalendar(gridEl, weekdayHeaderIgnored, monthDate, dayClassifier, onSelect, selectedDate){
|
||||
gridEl.innerHTML = "";
|
||||
var year = monthDate.getFullYear(), month = monthDate.getMonth();
|
||||
var firstDow = new Date(year,month,1).getDay();
|
||||
var daysInMonth = new Date(year,month+1,0).getDate();
|
||||
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
|
||||
for(var i=0;i<totalCells;i++){
|
||||
var dayNum = i - firstDow + 1;
|
||||
var cell = document.createElement("button");
|
||||
cell.className = "day-cell";
|
||||
if(dayNum < 1 || dayNum > daysInMonth){
|
||||
cell.classList.add("pad");
|
||||
gridEl.appendChild(cell);
|
||||
continue;
|
||||
}
|
||||
var d = new Date(year,month,dayNum);
|
||||
var info = dayClassifier(d);
|
||||
cell.innerHTML = "<span>"+dayNum+"</span>";
|
||||
if(info.period) cell.classList.add("is-period");
|
||||
if(info.fertile) cell.classList.add("is-fertile");
|
||||
if(info.ovulation) cell.classList.add("is-ovulation");
|
||||
if(sameDay(d,today)) cell.classList.add("today");
|
||||
if(selectedDate && sameDay(d,selectedDate)) cell.classList.add("selected");
|
||||
cell.setAttribute("aria-label", d.toDateString());
|
||||
cell.addEventListener("click", function(dd){ return function(){ onSelect(dd); }; }(d));
|
||||
gridEl.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyForCycle(d, state){
|
||||
var dic = mod(daysBetween(state.start, d), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
return {
|
||||
period: dic < state.periodLen,
|
||||
fertile: isFertile(dic, ovDic),
|
||||
ovulation: dic === ovDic,
|
||||
dic: dic, ovDic: ovDic
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- PERIOD tab render ---------------- */
|
||||
function renderPeriodCalendar(){
|
||||
var state = getCycleState();
|
||||
document.getElementById("p-cal-title").textContent = MONTH_NAMES[pCalMonth.getMonth()] + " " + pCalMonth.getFullYear();
|
||||
buildCalendar(document.getElementById("p-daygrid"), null, pCalMonth,
|
||||
function(d){ return classifyForCycle(d, state); },
|
||||
function(d){ pSelectedDate = d; renderPeriodDetail(); renderPeriodCalendar(); },
|
||||
pSelectedDate
|
||||
);
|
||||
}
|
||||
|
||||
function renderPeriodDetail(){
|
||||
var state = getCycleState();
|
||||
var info = classifyForCycle(pSelectedDate, state);
|
||||
var phaseKey = phaseForDayInCycle(info.dic, state.periodLen, info.ovDic);
|
||||
var phase = PHASES[phaseKey];
|
||||
var el = document.getElementById("p-detail");
|
||||
el.innerHTML =
|
||||
'<span class="d-date">'+pSelectedDate.toDateString()+'</span>'+
|
||||
'<span class="pill" style="background:'+phase.soft+';color:'+phase.color+'">'+phase.title+' phase'+(info.fertile?' · fertile window':'')+'</span>'+
|
||||
'<div class="d-phase">Cycle day '+(info.dic+1)+'</div>'+
|
||||
'<p class="d-desc">'+phase.desc+'</p>'+
|
||||
'<div class="d-grid">'+
|
||||
'<div class="d-mini"><div class="k">Hormones</div><div class="v">'+phase.hormone+'</div></div>'+
|
||||
'<div class="d-mini"><div class="k">Typical symptoms</div><div class="v" style="font-weight:500;font-size:0.82rem;">'+phase.symptoms+'</div></div>'+
|
||||
'</div>'+
|
||||
'<p class="d-desc"><strong style="color:var(--ink)">Care note — </strong>'+phase.tip+'</p>';
|
||||
}
|
||||
|
||||
function renderPeriodStats(){
|
||||
var state = getCycleState();
|
||||
var dic = mod(daysBetween(state.start, today), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
var daysToNextPeriod = mod(state.cycleLen - dic, state.cycleLen);
|
||||
if(daysToNextPeriod === 0 && dic !== 0) daysToNextPeriod = state.cycleLen;
|
||||
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
|
||||
var phaseKey = phaseForDayInCycle(dic, state.periodLen, ovDic);
|
||||
document.getElementById("period-stats").innerHTML =
|
||||
statTile(dic+1, "Cycle day today") +
|
||||
statTile(PHASES[phaseKey].title, "Current phase") +
|
||||
statTile(dic===0 ? "Today" : (daysToNextPeriod+"d"), "Until next period") +
|
||||
statTile(dic===ovDic ? "Today" : (daysToOvulation+"d"), "Until ovulation");
|
||||
|
||||
// hero ring
|
||||
var cx=100, cy=100, r=88;
|
||||
var perStart=0, perEnd = state.periodLen/state.cycleLen*360;
|
||||
var follEnd = ovDic/state.cycleLen*360;
|
||||
var fertStart = Math.max(0,(ovDic-5))/state.cycleLen*360;
|
||||
var lutStart = (ovDic+1)/state.cycleLen*360;
|
||||
document.getElementById("phase-arc-menstrual").setAttribute("d", arcPath(cx,cy,r,perStart,perEnd));
|
||||
document.getElementById("phase-arc-follicular").setAttribute("d", arcPath(cx,cy,r,perEnd,fertStart));
|
||||
document.getElementById("phase-arc-fertile").setAttribute("d", arcPath(cx,cy,r,fertStart,follEnd+ (360/state.cycleLen)));
|
||||
document.getElementById("phase-arc-luteal").setAttribute("d", arcPath(cx,cy,r,lutStart,360));
|
||||
var todayAngle = dic/state.cycleLen*360;
|
||||
var tp = polarToXY(cx,cy,r,todayAngle);
|
||||
document.getElementById("today-marker").setAttribute("cx", tp[0]);
|
||||
document.getElementById("today-marker").setAttribute("cy", tp[1]);
|
||||
document.getElementById("cycle-day-big").textContent = dic+1;
|
||||
// hand the same numbers to the optional 3D ring (ring3d.js)
|
||||
window.cycleRingData = {perEnd:perEnd, fertStart:fertStart, fertEnd:follEnd+(360/state.cycleLen),
|
||||
lutStart:lutStart, todayAngle:todayAngle};
|
||||
window.dispatchEvent(new CustomEvent("cycle:ring", {detail: window.cycleRingData}));
|
||||
}
|
||||
|
||||
function statTile(num,label){
|
||||
return '<div class="stat-tile"><div class="num">'+num+'</div><div class="lbl">'+label+'</div></div>';
|
||||
}
|
||||
|
||||
document.getElementById("p-prev").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()-1); renderPeriodCalendar(); });
|
||||
document.getElementById("p-next").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()+1); renderPeriodCalendar(); });
|
||||
|
||||
function renderPhaseRefGrid(){
|
||||
var order = ["menstrual","follicular","ovulatory","luteal"];
|
||||
document.getElementById("phase-ref-grid").innerHTML = order.map(function(key,i){
|
||||
var p = PHASES[key];
|
||||
return '<div class="ref-card"><span class="r-num">0'+(i+1)+'</span><h4 style="color:'+p.color+'">'+p.title+'</h4>'+
|
||||
'<p>'+p.desc+'</p>'+
|
||||
'<div class="tag-row"><span class="tagchip">'+p.window+'</span></div></div>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/* ---------------- OVULATION tab render ---------------- */
|
||||
var ovSelectedOffset = 0;
|
||||
function renderOvTimeline(){
|
||||
var wrap = document.getElementById("ov-timeline");
|
||||
wrap.innerHTML = "";
|
||||
for(var off=-5; off<=5; off++){
|
||||
var d = OVULATION_DAYS[String(off)];
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "tl-btn" + (off===ovSelectedOffset ? " active":"");
|
||||
btn.innerHTML = '<div class="tl-day">'+(off===0?"OVUL.":(off>0?"+":"")+off)+'</div>'+
|
||||
'<div class="tl-fert" style="background:'+FERT_COLOR[d.fertClass]+'"></div>';
|
||||
btn.addEventListener("click", function(o){ return function(){ ovSelectedOffset=o; renderOvTimeline(); renderOvDetailFromOffset(); }; }(off));
|
||||
wrap.appendChild(btn);
|
||||
}
|
||||
}
|
||||
function renderOvDetailFromOffset(){
|
||||
var d = OVULATION_DAYS[String(ovSelectedOffset)];
|
||||
var el = document.getElementById("o-detail");
|
||||
var label = ovSelectedOffset===0 ? "Ovulation day" : (ovSelectedOffset>0 ? ovSelectedOffset+" day"+(ovSelectedOffset>1?"s":"")+" after ovulation" : Math.abs(ovSelectedOffset)+" day"+(ovSelectedOffset<-1?"s":"")+" before ovulation");
|
||||
el.innerHTML =
|
||||
'<span class="d-date">'+label+'</span>'+
|
||||
'<span class="pill" style="background:var(--ovulation-soft);color:'+FERT_COLOR[d.fertClass]+'">Fertility: '+d.fert+'</span>'+
|
||||
'<div class="d-phase">'+(ovSelectedOffset===0?"Egg release":(ovSelectedOffset<0?"Building fertility":"Fertility fading"))+'</div>'+
|
||||
'<div class="d-grid">'+
|
||||
'<div class="d-mini"><div class="k">Basal body temp</div><div class="v" style="font-size:0.82rem;">'+d.bbt+'</div></div>'+
|
||||
'<div class="d-mini"><div class="k">Cervical mucus</div><div class="v" style="font-size:0.82rem;">'+d.mucus+'</div></div>'+
|
||||
'</div>'+
|
||||
'<p class="d-desc">'+d.note+'</p>';
|
||||
}
|
||||
|
||||
function renderOvulationCalendar(){
|
||||
var state = getCycleState();
|
||||
document.getElementById("o-cal-title").textContent = MONTH_NAMES[oCalMonth.getMonth()] + " " + oCalMonth.getFullYear();
|
||||
buildCalendar(document.getElementById("o-daygrid"), null, oCalMonth,
|
||||
function(d){ return classifyForCycle(d, state); },
|
||||
function(d){
|
||||
oSelectedDate = d;
|
||||
var info = classifyForCycle(d, state);
|
||||
var offset = info.dic - info.ovDic;
|
||||
if(offset < -5) offset = -5; if(offset > 5) offset = 5;
|
||||
ovSelectedOffset = offset;
|
||||
renderOvTimeline(); renderOvDetailFromOffset(); renderOvulationCalendar();
|
||||
},
|
||||
oSelectedDate
|
||||
);
|
||||
}
|
||||
|
||||
function renderOvulationStats(){
|
||||
var state = getCycleState();
|
||||
var dic = mod(daysBetween(state.start, today), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
|
||||
var fertileStartDic = mod(ovDic-5, state.cycleLen);
|
||||
var inFertile = isFertile(dic, ovDic);
|
||||
var daysToFertile = inFertile ? 0 : mod(fertileStartDic - dic, state.cycleLen);
|
||||
document.getElementById("ovulation-stats").innerHTML =
|
||||
statTile(inFertile ? "Yes" : "No", "In fertile window") +
|
||||
statTile(dic===ovDic ? "Today" : daysToOvulation+"d", "Until ovulation") +
|
||||
statTile(inFertile ? "Now" : daysToFertile+"d", "Fertile window") +
|
||||
statTile("6 days", "Window length");
|
||||
}
|
||||
|
||||
document.getElementById("o-prev").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()-1); renderOvulationCalendar(); });
|
||||
document.getElementById("o-next").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()+1); renderOvulationCalendar(); });
|
||||
|
||||
/* ================================================================
|
||||
PREGNANCY tab
|
||||
================================================================ */
|
||||
var lmpInput = document.getElementById("pr-lmp");
|
||||
var exampleLMP = addDays(today, -20*7 - 3);
|
||||
var savedLMP = loadLS("ca-lmp", null);
|
||||
lmpInput.value = savedLMP || toInputVal(exampleLMP);
|
||||
if(savedLMP) document.getElementById("pr-example-note").style.display = "none";
|
||||
|
||||
var slider = document.getElementById("week-slider");
|
||||
var prCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
|
||||
lmpInput.addEventListener("input", function(){
|
||||
saveLS("ca-lmp", lmpInput.value);
|
||||
document.getElementById("pr-example-note").style.display = "none";
|
||||
document.getElementById("p-example-note").style.display = "none";
|
||||
syncSliderToLMP();
|
||||
renderPregnancy();
|
||||
});
|
||||
|
||||
function getLMP(){ return fromInput(lmpInput.value) || exampleLMP; }
|
||||
function currentGestWeek(){
|
||||
var lmp = getLMP();
|
||||
var w = Math.floor(daysBetween(lmp,today)/7) + 1;
|
||||
return Math.max(1, Math.min(40, w));
|
||||
}
|
||||
function syncSliderToLMP(){ slider.value = currentGestWeek(); }
|
||||
syncSliderToLMP();
|
||||
|
||||
function renderPregnancyStats(){
|
||||
var lmp = getLMP();
|
||||
var due = addDays(lmp, 280);
|
||||
var week = currentGestWeek();
|
||||
var daysLeft = Math.max(0, daysBetween(today, due));
|
||||
document.getElementById("pregnancy-stats").innerHTML =
|
||||
statTile(week, "Weeks along") +
|
||||
statTile(Math.floor((week-1)/13)+1 <= 3 ? ["First","Second","Third"][Math.min(2,Math.floor((week-1)/13))] : "Third", "Trimester") +
|
||||
statTile(daysLeft, "Days to due date") +
|
||||
statTile(due.toLocaleDateString(undefined,{month:"short",day:"numeric"}), "Estimated due date");
|
||||
|
||||
var pct = (week-1)/39;
|
||||
var pathLen = 230;
|
||||
var offset = pathLen - pct*pathLen;
|
||||
var path = document.getElementById("pregnancy-progress-path");
|
||||
path.setAttribute("stroke-dashoffset", offset.toFixed(1));
|
||||
try{
|
||||
var totalLen = path.getTotalLength ? path.getTotalLength() : pathLen;
|
||||
var pt = path.getPointAtLength ? path.getPointAtLength(pct*totalLen) : null;
|
||||
if(pt) { document.getElementById("pregnancy-progress-dot").setAttribute("cx",pt.x); document.getElementById("pregnancy-progress-dot").setAttribute("cy",pt.y); }
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function renderWeekPanel(week){
|
||||
var w = WEEKS[week-1];
|
||||
document.getElementById("wk-badge").textContent = "WEEK " + week;
|
||||
document.getElementById("wk-compare").textContent = "size of " + w.c;
|
||||
document.getElementById("wk-metric").textContent = w.m === "n/a" ? "not yet fertilized" : "≈ " + w.m;
|
||||
document.getElementById("wk-dev").textContent = w.dev;
|
||||
document.getElementById("wk-body").textContent = w.body;
|
||||
document.getElementById("wk-tip").textContent = w.tip;
|
||||
var tri = week<=13?1:(week<=27?2:3);
|
||||
var triColor = "var(--pregnancy)";
|
||||
document.getElementById("wk-blob").innerHTML =
|
||||
'<svg width="96" height="96" viewBox="0 0 96 96"><circle cx="48" cy="48" r="'+(14+week*0.9)+'" fill="'+ (tri===1?"var(--pregnancy-soft)":tri===2?"var(--pregnancy)":"var(--pregnancy-strong)") +'" opacity="0.85"/></svg>';
|
||||
document.querySelectorAll(".film-item").forEach(function(f){ f.classList.toggle("active", +f.dataset.week===week); });
|
||||
var active = document.querySelector('.film-item[data-week="'+week+'"]');
|
||||
if(active) active.scrollIntoView({inline:"center", block:"nearest", behavior:"smooth"});
|
||||
}
|
||||
|
||||
slider.addEventListener("input", function(){ renderWeekPanel(+slider.value); });
|
||||
|
||||
function buildFilmstrip(){
|
||||
var wrap = document.getElementById("week-filmstrip");
|
||||
wrap.innerHTML = "";
|
||||
for(var w=1; w<=40; w++){
|
||||
var item = document.createElement("button");
|
||||
item.className = "film-item";
|
||||
item.dataset.week = w;
|
||||
item.innerHTML = '<div class="fw">WK</div><div class="fn">'+w+'</div>';
|
||||
item.addEventListener("click", function(ww){ return function(){ slider.value=ww; renderWeekPanel(ww); }; }(w));
|
||||
wrap.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPregnancyCalendar(){
|
||||
var lmp = getLMP();
|
||||
document.getElementById("pr-cal-title").textContent = MONTH_NAMES[prCalMonth.getMonth()] + " " + prCalMonth.getFullYear();
|
||||
var grid = document.getElementById("pr-daygrid");
|
||||
grid.innerHTML = "";
|
||||
var year = prCalMonth.getFullYear(), month = prCalMonth.getMonth();
|
||||
var firstDow = new Date(year,month,1).getDay();
|
||||
var daysInMonth = new Date(year,month+1,0).getDate();
|
||||
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
|
||||
for(var i=0;i<totalCells;i++){
|
||||
var dayNum = i - firstDow + 1;
|
||||
var cell = document.createElement("button");
|
||||
cell.className = "day-cell";
|
||||
if(dayNum<1 || dayNum>daysInMonth){ cell.classList.add("pad"); grid.appendChild(cell); continue; }
|
||||
var d = new Date(year,month,dayNum);
|
||||
var wk = Math.floor(daysBetween(lmp,d)/7)+1;
|
||||
var inRange = wk>=1 && wk<=42;
|
||||
cell.innerHTML = "<span>"+dayNum+"</span>" + (inRange ? '<span class="dot-row" style="position:static;margin-top:2px;font-size:0.55rem;color:var(--pregnancy-strong)">W'+wk+'</span>' : "");
|
||||
if(inRange && wk<=40) cell.classList.add("is-fertile");
|
||||
if(sameDay(d,today)) cell.classList.add("today");
|
||||
cell.addEventListener("click", function(ww){ return function(){ if(ww>=1&&ww<=40){ slider.value=ww; renderWeekPanel(ww); } }; }(wk));
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
document.getElementById("pr-prev").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()-1); renderPregnancyCalendar(); });
|
||||
document.getElementById("pr-next").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()+1); renderPregnancyCalendar(); });
|
||||
|
||||
function renderPregnancy(){
|
||||
renderPregnancyStats();
|
||||
renderWeekPanel(currentGestWeek());
|
||||
renderPregnancyCalendar();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
INIT
|
||||
================================================================ */
|
||||
function renderAll(){
|
||||
renderPeriodStats();
|
||||
renderPeriodCalendar();
|
||||
renderPeriodDetail();
|
||||
renderOvulationStats();
|
||||
renderOvTimeline();
|
||||
renderOvDetailFromOffset();
|
||||
renderOvulationCalendar();
|
||||
renderPregnancy();
|
||||
}
|
||||
|
||||
renderPhaseRefGrid();
|
||||
buildFilmstrip();
|
||||
slider.value = currentGestWeek();
|
||||
renderAll();
|
||||
})();
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright 2020 The Fraunces Project Authors (github.com/undercasetype/Fraunces) Fraunces-Italic[SOFT,WONK,opsz,wght].ttf: Copyright 2020 The Fraunces Project Authors (github.com/undercasetype/Fraunces)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans) WorkSans-Italic[wght].ttf: Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,15 +0,0 @@
|
||||
/* Self-hosted fonts (Latin subset), SIL Open Font License 1.1 - see OFL-*.txt */
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:400;font-display:swap;src:url("fraunces-latin-400-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:500;font-display:swap;src:url("fraunces-latin-500-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:600;font-display:swap;src:url("fraunces-latin-600-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:700;font-display:swap;src:url("fraunces-latin-700-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:900;font-display:swap;src:url("fraunces-latin-900-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:italic;font-weight:500;font-display:swap;src:url("fraunces-latin-500-italic.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:italic;font-weight:600;font-display:swap;src:url("fraunces-latin-600-italic.woff2") format("woff2");}
|
||||
@font-face{font-family:"Work Sans";font-style:normal;font-weight:400;font-display:swap;src:url("work-sans-latin-400-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Work Sans";font-style:normal;font-weight:500;font-display:swap;src:url("work-sans-latin-500-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Work Sans";font-style:normal;font-weight:600;font-display:swap;src:url("work-sans-latin-600-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Work Sans";font-style:normal;font-weight:700;font-display:swap;src:url("work-sans-latin-700-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:400;font-display:swap;src:url("ibm-plex-mono-latin-400-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:500;font-display:swap;src:url("ibm-plex-mono-latin-500-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:600;font-display:swap;src:url("ibm-plex-mono-latin-600-normal.woff2") format("woff2");}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+550
-32
@@ -1,11 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en-AU">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>CycleWise AU</title>
|
||||
<link rel="stylesheet" href="fonts/fonts.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,400;0,500;0,600;0,700;0,900;1,500;1,600&family=Work+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--paper:#FBF7F2;
|
||||
@@ -135,7 +131,6 @@ button{font-family:inherit;}
|
||||
.mode-panel.active{display:block;animation:rise .4s ease both;}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
|
||||
@media (prefers-reduced-motion: reduce){.mode-panel.active{animation:none;}}
|
||||
@media (prefers-color-scheme: dark){.mode-btn[data-mode="pregnancy"].active{color:#1B1417;}}
|
||||
|
||||
.hero{
|
||||
display:grid;grid-template-columns:1.3fr 1fr;gap:40px;
|
||||
@@ -149,11 +144,7 @@ button{font-family:inherit;}
|
||||
}
|
||||
.hero h1{font-size:clamp(1.9rem,3.4vw,2.7rem);font-weight:600;line-height:1.08;}
|
||||
.hero p.lede{color:var(--ink-soft);font-size:1.02rem;line-height:1.6;max-width:52ch;margin-top:14px;}
|
||||
.hero-art{display:flex;justify-content:center;position:relative;}
|
||||
.hero-art.has-3d{align-items:center;}
|
||||
.hero-art.has-3d .ring3d{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);}
|
||||
.hero-art.has-3d svg{position:relative;max-width:300px;}
|
||||
.hero-art.has-3d svg path,.hero-art.has-3d svg circle{display:none;}
|
||||
.hero-art{display:flex;justify-content:center;}
|
||||
.hero-art svg{width:100%;max-width:220px;height:auto;}
|
||||
|
||||
.stat-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}
|
||||
@@ -271,7 +262,7 @@ button{font-family:inherit;}
|
||||
text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;font-weight:600;}
|
||||
input[type="range"].week-slider{
|
||||
width:100%;-webkit-appearance:none;appearance:none;height:8px;border-radius:999px;
|
||||
background:linear-gradient(to right, var(--pregnancy) 0, var(--pregnancy) calc(11px + (100% - 22px) * var(--f,0)), var(--pregnancy-soft) calc(11px + (100% - 22px) * var(--f,0)), var(--pregnancy-soft) 100%);
|
||||
background:linear-gradient(to right, var(--pregnancy-soft) 0%, var(--pregnancy-soft) 100%);
|
||||
outline:none;cursor:pointer;
|
||||
}
|
||||
input[type="range"].week-slider::-webkit-slider-thumb{
|
||||
@@ -306,7 +297,6 @@ input[type="range"].week-slider::-moz-range-thumb{
|
||||
|
||||
.filmstrip{display:flex;gap:8px;overflow-x:auto;padding:4px 2px 20px;margin-top:8px;}
|
||||
.film-item{
|
||||
color:var(--ink);
|
||||
flex:none;width:64px;height:64px;border-radius:12px;border:1px solid var(--line);
|
||||
background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
cursor:pointer;font-family:"IBM Plex Mono",monospace;
|
||||
@@ -320,8 +310,7 @@ footer.note{
|
||||
font-size:0.78rem;color:var(--ink-soft);line-height:1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="wordmark">
|
||||
@@ -335,15 +324,15 @@ footer.note{
|
||||
<span class="tag">Menstrual & Pregnancy Charting</span>
|
||||
</div>
|
||||
<nav class="mode-switch" role="tablist" aria-label="Tracking mode">
|
||||
<button class="mode-btn active" id="tab-period" aria-controls="mode-period" data-mode="period" role="tab" aria-selected="true">Period</button>
|
||||
<button class="mode-btn" id="tab-ovulation" aria-controls="mode-ovulation" data-mode="ovulation" role="tab" aria-selected="false">Ovulation</button>
|
||||
<button class="mode-btn" id="tab-pregnancy" aria-controls="mode-pregnancy" data-mode="pregnancy" role="tab" aria-selected="false">Pregnancy</button>
|
||||
<button class="mode-btn active" data-mode="period" role="tab" aria-selected="true">Period</button>
|
||||
<button class="mode-btn" data-mode="ovulation" role="tab" aria-selected="false">Ovulation</button>
|
||||
<button class="mode-btn" data-mode="pregnancy" role="tab" aria-selected="false">Pregnancy</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ================= PERIOD ================= -->
|
||||
<section id="mode-period" class="mode-panel active" role="tabpanel" aria-labelledby="tab-period">
|
||||
<section id="mode-period" class="mode-panel active">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--period)">01 — Menstrual Cycle</span>
|
||||
@@ -409,7 +398,7 @@ footer.note{
|
||||
</section>
|
||||
|
||||
<!-- ================= OVULATION ================= -->
|
||||
<section id="mode-ovulation" class="mode-panel" role="tabpanel" aria-labelledby="tab-ovulation">
|
||||
<section id="mode-ovulation" class="mode-panel">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--ovulation)">02 — Fertile Window</span>
|
||||
@@ -457,7 +446,7 @@ footer.note{
|
||||
</section>
|
||||
|
||||
<!-- ================= PREGNANCY ================= -->
|
||||
<section id="mode-pregnancy" class="mode-panel" role="tabpanel" aria-labelledby="tab-pregnancy">
|
||||
<section id="mode-pregnancy" class="mode-panel">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--pregnancy)">03 — Forty Weeks</span>
|
||||
@@ -487,7 +476,7 @@ footer.note{
|
||||
<span style="color:var(--pregnancy)">Trimester 2 · Weeks 14–27</span>
|
||||
<span style="color:var(--pregnancy)">Trimester 3 · Weeks 28–40</span>
|
||||
</div>
|
||||
<input type="range" class="week-slider" id="week-slider" aria-label="Pregnancy week, 1 to 40" min="1" max="40" value="20">
|
||||
<input type="range" class="week-slider" id="week-slider" min="1" max="40" value="20">
|
||||
<div class="week-ticks"><span>Wk 1</span><span>Wk 13</span><span>Wk 27</span><span>Wk 40</span></div>
|
||||
</div>
|
||||
|
||||
@@ -535,11 +524,540 @@ footer.note{
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="vendor/gsap.min.js"></script>
|
||||
<script src="vendor/ScrollTrigger.min.js"></script>
|
||||
<script src="ring3d.js"></script>
|
||||
<script src="animations.js"></script>
|
||||
<script src="timeline.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
/* ---------------- date helpers ---------------- */
|
||||
function toDate(y,m,d){ return new Date(y,m,d); }
|
||||
function fromInput(str){
|
||||
if(!str) return null;
|
||||
var p = str.split("-");
|
||||
return new Date(+p[0], +p[1]-1, +p[2]);
|
||||
}
|
||||
function toInputVal(d){
|
||||
return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");
|
||||
}
|
||||
function addDays(d,n){ var r=new Date(d); r.setDate(r.getDate()+n); return r; }
|
||||
function daysBetween(a,b){ // b - a, in whole days, ignoring time
|
||||
var A = new Date(a.getFullYear(),a.getMonth(),a.getDate());
|
||||
var B = new Date(b.getFullYear(),b.getMonth(),b.getDate());
|
||||
return Math.round((B-A)/86400000);
|
||||
}
|
||||
function sameDay(a,b){ return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); }
|
||||
function mod(n,m){ return ((n % m) + m) % m; }
|
||||
var MONTH_NAMES = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||
var today = new Date(); today.setHours(0,0,0,0);
|
||||
|
||||
function loadLS(key, fallback){
|
||||
try{ var v = localStorage.getItem(key); return v===null ? fallback : v; }catch(e){ return fallback; }
|
||||
}
|
||||
function saveLS(key, val){ try{ localStorage.setItem(key,val); }catch(e){} }
|
||||
|
||||
/* ---------------- mode switching ---------------- */
|
||||
var modeBtns = document.querySelectorAll(".mode-btn");
|
||||
var panels = { period: document.getElementById("mode-period"), ovulation: document.getElementById("mode-ovulation"), pregnancy: document.getElementById("mode-pregnancy") };
|
||||
modeBtns.forEach(function(btn){
|
||||
btn.addEventListener("click", function(){
|
||||
modeBtns.forEach(function(b){ b.classList.remove("active"); b.setAttribute("aria-selected","false"); });
|
||||
btn.classList.add("active"); btn.setAttribute("aria-selected","true");
|
||||
Object.keys(panels).forEach(function(k){ panels[k].classList.remove("active"); });
|
||||
panels[btn.dataset.mode].classList.add("active");
|
||||
saveLS("ca-mode", btn.dataset.mode);
|
||||
});
|
||||
});
|
||||
var savedMode = loadLS("ca-mode", null);
|
||||
if(savedMode && panels[savedMode]){
|
||||
modeBtns.forEach(function(b){ b.classList.toggle("active", b.dataset.mode===savedMode); b.setAttribute("aria-selected", b.dataset.mode===savedMode ? "true":"false"); });
|
||||
Object.keys(panels).forEach(function(k){ panels[k].classList.toggle("active", k===savedMode); });
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
PHASE + OVULATION REFERENCE DATA
|
||||
================================================================ */
|
||||
var PHASES = {
|
||||
menstrual: {
|
||||
title:"Menstrual", color:"var(--period)", soft:"var(--period-soft)",
|
||||
window:"Cycle day 1 through the end of bleeding",
|
||||
desc:"The uterine lining sheds because no pregnancy occurred last cycle. Estrogen and progesterone are both at their lowest point of the month.",
|
||||
hormone:"Estrogen & progesterone: low", energy:"Energy: lowest",
|
||||
symptoms:"Cramping, fatigue, lower back ache, mood dips",
|
||||
tip:"Iron-rich food, gentle movement, and heat on the lower abdomen ease cramping better than rest alone."
|
||||
},
|
||||
follicular: {
|
||||
title:"Follicular", color:"var(--ovulation)", soft:"var(--ovulation-soft)",
|
||||
window:"Cycle day 1 through ovulation (overlaps menstrual)",
|
||||
desc:"The pituitary releases FSH, prompting several ovarian follicles to mature. Estrogen climbs steadily as one dominant follicle takes the lead.",
|
||||
hormone:"Estrogen: rising", energy:"Energy: climbing",
|
||||
symptoms:"Clearer skin, sharper focus, rising motivation",
|
||||
tip:"This is the physiologically best window for starting new training blocks or demanding projects."
|
||||
},
|
||||
ovulatory: {
|
||||
title:"Ovulatory", color:"var(--ovulation-strong)", soft:"var(--ovulation-soft)",
|
||||
window:"Roughly one day, mid-cycle",
|
||||
desc:"A surge in luteinizing hormone (LH) triggers the dominant follicle to release its egg. This is the single most fertile day of the cycle.",
|
||||
hormone:"LH: sharp surge", energy:"Energy & libido: peak",
|
||||
symptoms:"Egg-white cervical mucus, one-sided pelvic twinge (mittelschmerz)",
|
||||
tip:"Ovulation predictor kits detect the LH surge roughly 24–36 hours before the egg is released."
|
||||
},
|
||||
luteal: {
|
||||
title:"Luteal", color:"var(--pregnancy)", soft:"var(--pregnancy-soft)",
|
||||
window:"Ovulation through the day before your next period",
|
||||
desc:"The ruptured follicle becomes the corpus luteum and secretes progesterone to prepare the uterine lining. If no implantation occurs, hormone levels fall and the cycle restarts.",
|
||||
hormone:"Progesterone: rises then falls", energy:"Energy: gradually declining",
|
||||
symptoms:"Bloating, breast tenderness, irritability, food cravings (PMS)",
|
||||
tip:"Basal body temperature stays elevated ~0.3–0.5°C through this phase — a useful way to confirm ovulation already happened."
|
||||
}
|
||||
};
|
||||
|
||||
// offset relative to ovulation day (0 = ovulation)
|
||||
var OVULATION_DAYS = {
|
||||
"-5": {fert:"Low", fertClass:"fert-low", bbt:"Baseline (pre-shift)", mucus:"Dry to sticky", note:"Sperm can survive up to 5 days, so conception is technically possible starting here."},
|
||||
"-4": {fert:"Low–moderate", fertClass:"fert-low", bbt:"Baseline", mucus:"Sticky, cloudy", note:"Fertility begins climbing as estrogen rises toward its pre-ovulation peak."},
|
||||
"-3": {fert:"Moderate", fertClass:"fert-mid", bbt:"Baseline", mucus:"Creamy, increasing in volume", note:"Cervical mucus is becoming more hospitable to sperm."},
|
||||
"-2": {fert:"High", fertClass:"fert-high", bbt:"Baseline", mucus:"Thin, stretchy, egg-white-like", note:"One of the two most fertile days of the entire cycle."},
|
||||
"-1": {fert:"Peak", fertClass:"fert-peak", bbt:"Baseline (often the day of the LH surge)", mucus:"Clear, slippery, maximum stretch", note:"Statistically the single highest-probability day for conception."},
|
||||
"0": {fert:"Peak", fertClass:"fert-peak", bbt:"Still baseline — the rise hasn't happened yet", mucus:"Egg-white, abundant", note:"The egg is released and is viable for roughly 12–24 hours. Some feel a one-sided twinge (mittelschmerz)."},
|
||||
"1": {fert:"Dropping fast", fertClass:"fert-mid", bbt:"Beginning to rise ~0.3–0.5°C", mucus:"Becoming tacky again", note:"The egg's fertile window is closing; BBT starts its post-ovulation climb."},
|
||||
"2": {fert:"Very low", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry or minimal", note:"Progesterone from the corpus luteum keeps temperature elevated through the luteal phase."},
|
||||
"3": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"If fertilization happened, the embryo is traveling toward the uterus."},
|
||||
"4": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Too late in the cycle for a new ovulation; luteal phase symptoms may start appearing."},
|
||||
"5": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Implantation, if it happens this cycle, typically occurs around days 6–10 after ovulation."}
|
||||
};
|
||||
var FERT_COLOR = {"fert-low":"var(--ink-soft)","fert-mid":"var(--fertile)","fert-high":"var(--fertile-strong)","fert-peak":"var(--ovulation-strong)"};
|
||||
|
||||
function phaseForDayInCycle(dic, periodLen, ovulationDic){
|
||||
if(dic < periodLen) return "menstrual";
|
||||
if(dic < ovulationDic) return "follicular";
|
||||
if(dic === ovulationDic) return "ovulatory";
|
||||
return "luteal";
|
||||
}
|
||||
function isFertile(dic, ovulationDic){
|
||||
return dic >= ovulationDic - 5 && dic <= ovulationDic;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
PREGNANCY WEEK DATA (1–40)
|
||||
================================================================ */
|
||||
var WEEKS = [
|
||||
{c:"a poppy seed (not yet fertilized)", m:"n/a", dev:"Pregnancy is dated from the first day of this period, before conception happens — your body is shedding last cycle's lining and preparing a fresh egg.", body:"This week looks and feels like an ordinary period.", tip:"If you're trying to conceive, this is a good week to start a prenatal vitamin with folic acid."},
|
||||
{c:"an egg cell", m:"~0.1 mm", dev:"Rising estrogen prompts an egg to mature; ovulation typically occurs near the end of this week, roughly two weeks before a missed period.", body:"Cervical mucus becomes clear and stretchy around ovulation — a natural fertility sign.", tip:"This is the fertile window if you're trying to conceive — see the Ovulation tab for day-by-day detail."},
|
||||
{c:"a grain of sand", m:"~0.1–0.2 mm", dev:"If sperm meets egg this week, fertilization occurs in the fallopian tube and the resulting cell begins dividing as it travels toward the uterus.", body:"No symptoms are detectable yet — even a sensitive test won't turn positive for another week or two.", tip:"Implantation, if it happens, usually occurs 6–10 days after ovulation."},
|
||||
{c:"a poppy seed", m:"~0.1 mm", dev:"The fertilized egg implants in the uterine lining and begins producing hCG, the hormone home pregnancy tests detect.", body:"Most people notice no symptoms yet; a sensitive test may already turn positive near the end of this week.", tip:"A missed period is usually the first sign — testing a few days after that gives the most reliable result."},
|
||||
{c:"a sesame seed", m:"~2 mm", dev:"The neural tube — the earliest structure of the brain and spinal cord — begins to form, along with the first blood vessels.", body:"A missed period is usually the first clear sign; fatigue and tender breasts may begin.", tip:"Avoid alcohol and confirm any medications with a clinician — this window is critical for early development."},
|
||||
{c:"a lentil", m:"~4 mm", dev:"A primitive heart tube starts beating, and the beginnings of the face, arms, and legs appear as small buds.", body:"Morning sickness and smell sensitivity often begin here, though timing varies widely.", tip:"Small, frequent, bland meals tend to sit better than three large ones if nausea has started."},
|
||||
{c:"a blueberry", m:"~1.3 cm", dev:"The brain is dividing into distinct sections, and the arm and leg buds are elongating into recognizable limbs.", body:"Bloating and mild cramping are common as the uterus makes room to grow.", tip:"This is when most people schedule their first prenatal appointment — dating scans are most accurate now."},
|
||||
{c:"a raspberry", m:"~1.6 cm", dev:"Fingers and toes are forming, still webbed; the heart now has four distinct chambers.", body:"Fatigue often peaks around this point as progesterone rises sharply.", tip:"Rest when you can — first-trimester exhaustion is real and temporary, not a sign anything is wrong."},
|
||||
{c:"a grape", m:"~2.3 cm · 4 g", dev:"The embryo is officially called a fetus starting around now; essential organs are all present in early form and the tail has disappeared.", body:"Nausea, food aversions, and a heightened sense of smell are often at their strongest.", tip:"Ginger tea, vitamin B6, and staying hydrated are commonly recommended first-line options for nausea."},
|
||||
{c:"a kumquat", m:"~3 cm · 7 g", dev:"Vital organs — kidneys, liver, brain — are functioning in a basic form, and tiny fingernail beds are forming.", body:"A visible baby bump is still unlikely, though clothes may feel tighter.", tip:"Ask your provider about first-trimester screening options available in this window."},
|
||||
{c:"a fig", m:"~4.1 cm · 10 g", dev:"The fetus can now bend its arms and legs, and tooth buds are forming beneath the gums.", body:"Round ligament twinges may start as the uterus stretches.", tip:"Gentle stretching and changing positions slowly can ease early ligament discomfort."},
|
||||
{c:"a lime", m:"~5.4 cm · 14 g", dev:"Reflexes are developing — fingers can curl, and toes can wiggle, though it can't be felt yet.", body:"The end of the first trimester is near, and miscarriage risk drops meaningfully from here.", tip:"Many people choose to share their pregnancy news around now, though the choice is entirely personal."},
|
||||
{c:"a plum", m:"~6.7 cm · 23 g", dev:"Vocal cords are forming, and unique fingerprints are being etched onto tiny fingertips.", body:"Some early nausea may start easing as the placenta takes over hormone production.", tip:"The nuchal translucency scan, if chosen, is typically done between now and week 13."},
|
||||
{c:"a passion fruit", m:"~10 cm · 45 g", dev:"Baby can open and close fists and may even suck a thumb; genitals are differentiating, though not yet visible on ultrasound.", body:"For many, first-trimester nausea and fatigue begin to lift.", tip:"Second-trimester energy often supports resuming or starting light-to-moderate exercise — check in with your provider first."},
|
||||
{c:"a peach", m:"~11.6 cm · 58 g", dev:"Vocal cords are complete and the intestines, which developed in the umbilical cord, move into the abdomen.", body:"Welcome to the second trimester — often called the most comfortable stretch of pregnancy.", tip:"An anatomy scan is usually scheduled in the coming weeks; ask your clinic when it's booked."},
|
||||
{c:"a lemon", m:"~13 cm · 70 g", dev:"Fine, downy hair called lanugo begins covering the body, and the kidneys are producing urine into the amniotic fluid.", body:"A small bump may finally become visible; appetite often improves noticeably.", tip:"Iron needs rise sharply now — leafy greens, legumes, and lean meat help meet increased demand."},
|
||||
{c:"an apple", m:"~14.2 cm · 95 g", dev:"The skeleton continues hardening and baby can sense light through closed eyelids.", body:"Skin changes like a faint linea nigra or darkened areolas may appear as pigment increases.", tip:"Daily SPF helps limit pregnancy-related skin pigmentation (melasma) from sun exposure."},
|
||||
{c:"an avocado", m:"~16 cm · 135 g", dev:"The placenta is now fully formed and functioning as baby's life-support system.", body:"First flutters of movement ('quickening') may be felt any time from now through week 20.", tip:"Movement timing varies a lot — first pregnancies and anterior placentas often mean feeling it later."},
|
||||
{c:"a pomegranate", m:"~17.7 cm · 160 g", dev:"The skeleton is converting from cartilage to true bone, and fat stores are just beginning to accumulate.", body:"The uterus is now roughly at the level of the belly button.", tip:"A pregnancy support pillow can meaningfully improve sleep as side-sleeping becomes more comfortable."},
|
||||
{c:"a bell pepper", m:"~19 cm · 190 g", dev:"Ears have reached their final position and baby may begin startling at loud external sounds.", body:"Quickening is commonly felt by now — often described as bubbles or light taps.", tip:"Try narrating your day out loud — hearing is developing and familiar voices become recognizable over time."},
|
||||
{c:"a mango", m:"~20.3 cm · 240 g", dev:"A waxy coating called vernix caseosa begins protecting the skin, and taste buds are forming.", body:"Backaches can start as posture shifts to balance the growing bump.", tip:"A supportive, flatter shoe and a pillow between the knees at night both help low back strain."},
|
||||
{c:"a banana", m:"~25.6 cm · 300 g", dev:"The detailed anatomy ultrasound is often done around now if it hasn't happened already, and sex can typically be identified on it.", body:"Movements are stronger and more frequent; the uterus is now level with the belly button.", tip:"If you want to know the sex and haven't had the anatomy scan yet, ask your clinic to schedule it soon — it's usually done weeks 18–22."},
|
||||
{c:"a carrot", m:"~27 cm · 360 g", dev:"Baby is swallowing small amounts of amniotic fluid, giving the digestive system practice.", body:"Stretch marks may start appearing on the belly, hips, or chest as skin stretches.", tip:"Moisturizing won't prevent stretch marks, but it can ease the itchiness that comes with stretching skin."},
|
||||
{c:"a papaya", m:"~28.9 cm · 430 g", dev:"Eyebrows and eyelids are fully formed, and the sense of touch is developing rapidly.", body:"Braxton Hicks — brief, painless practice contractions — may be felt occasionally from here on.", tip:"Braxton Hicks usually ease with a change of position or a glass of water; true labor contractions don't."},
|
||||
{c:"an ear of corn", m:"~32.2 cm · 600 g", dev:"The lungs begin producing surfactant, a substance essential for breathing air after birth — a key viability milestone.", body:"The linea nigra (a dark vertical line on the belly) may become more visible.", tip:"This week is often cited as the threshold of viability with intensive neonatal care, should birth happen very early."},
|
||||
{c:"a cauliflower head", m:"~33.7 cm · 660 g", dev:"Baby can now respond to your voice, and hand grip strength is developing.", body:"The glucose screening test for gestational diabetes is typically scheduled around now.", tip:"If asked to fast beforehand, follow your clinic's instructions closely for an accurate glucose result."},
|
||||
{c:"a head of lettuce", m:"~35.6 cm · 760 g", dev:"Eyes begin to open after being fused shut since early development, and hair may be visibly growing.", body:"Round ligament pain and rib discomfort can increase as space gets tighter.", tip:"A maternity support band can ease rib and ligament pressure during long periods of standing or walking."},
|
||||
{c:"a bunch of scallions", m:"~36.6 cm · 875 g", dev:"Brain tissue is developing rapidly, and baby is developing regular hiccups you may be able to feel.", body:"Third trimester begins soon — appointments typically become more frequent from here.", tip:"Hiccup-like twitches are completely normal and are the diaphragm practicing its rhythm."},
|
||||
{c:"an eggplant", m:"~37.6 cm · 1.1 kg", dev:"Eyes can blink now, and baby may cycle through active REM sleep.", body:"Welcome to the third trimester — the home stretch begins.", tip:"This is a common time to start planning a hospital bag and reviewing birth preferences."},
|
||||
{c:"a butternut squash", m:"~38.6 cm · 1.2 kg", dev:"Bones are fully formed but still soft and flexible, ready to move through the birth canal.", body:"Frequent urination often returns as the head engages lower in the pelvis.", tip:"Kick counts become a useful daily check-in — note baby's typical pattern of movement."},
|
||||
{c:"a large cabbage", m:"~39.9 cm · 1.3 kg", dev:"The brain is developing at its fastest rate yet, adding folds and surface area.", body:"Sleep can become harder to find as the bump limits comfortable positions.", tip:"A full-body pregnancy pillow supports the belly, back, and knees simultaneously."},
|
||||
{c:"a coconut", m:"~41.1 cm · 1.5 kg", dev:"All five senses are now functioning, and baby is gaining roughly half a pound a week.", body:"Colostrum, the first form of breast milk, may begin to leak slightly.", tip:"A few nursing pads in your bag can help if colostrum leaking becomes noticeable."},
|
||||
{c:"a jicama", m:"~42.4 cm · 1.7 kg", dev:"Baby is practicing breathing movements against a closed airway, strengthening the lungs.", body:"Swelling and mild carpal tunnel tingling in the hands can appear from fluid retention.", tip:"Report sudden or severe swelling to your provider promptly — it can occasionally signal preeclampsia."},
|
||||
{c:"a pineapple", m:"~43.7 cm · 1.9 kg", dev:"Bones are hardening overall, though the skull stays deliberately soft and flexible for delivery.", body:"Pelvic pressure increases as baby continues descending.", tip:"Prenatal massage or a warm bath can ease the deep pelvic ache many feel this week."},
|
||||
{c:"a cantaloupe", m:"~45 cm · 2.1 kg", dev:"The central nervous system is maturing quickly, and baby's grip is now quite strong.", body:"Braxton Hicks contractions often become more noticeable and frequent.", tip:"Time any contractions that feel different — regular, intensifying, closer together warrants a call to your provider."},
|
||||
{c:"a honeydew melon", m:"~46.2 cm · 2.4 kg", dev:"The kidneys are fully developed, and the liver is processing waste products on its own.", body:"You may feel baby 'drop' lower as it settles into position for birth.", tip:"Lightening (the baby dropping) can ease heartburn but often increases bathroom trips."},
|
||||
{c:"a head of romaine", m:"~47.4 cm · 2.6 kg", dev:"Most physical development is complete; the focus now is steady weight gain.", body:"You've just reached 'early term' — babies born from here on tend to do very well.", tip:"Pack your hospital bag if you haven't yet — early-term babies can arrive with little warning."},
|
||||
{c:"a leek", m:"~49.8 cm · 3.1 kg", dev:"Baby continues laying down fat for temperature regulation after birth, and the brain is still rapidly developing.", body:"Nesting instinct and a burst of energy are common right before labor.", tip:"Balance nesting energy with rest — labor's timing is unpredictable."},
|
||||
{c:"a mini watermelon", m:"~50.7 cm · 3.3 kg", dev:"Full term. The lungs are completing final maturation in preparation for that first breath.", body:"Increased pelvic pressure and more frequent, intense Braxton Hicks are common.", tip:"Watch for your labor signs: regular contractions, water breaking, or a bloody show."},
|
||||
{c:"a small pumpkin", m:"~51.2 cm · 3.5 kg", dev:"Baby is fully developed and simply continuing to grow, ready to meet you.", body:"If labor hasn't started, your provider will discuss monitoring or induction options.", tip:"Most babies arrive within two weeks of their due date in either direction — both are entirely normal."}
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
SVG helpers
|
||||
================================================================ */
|
||||
function polarToXY(cx,cy,r,angleDeg){
|
||||
var rad = (angleDeg-90) * Math.PI/180;
|
||||
return [cx + r*Math.cos(rad), cy + r*Math.sin(rad)];
|
||||
}
|
||||
function arcPath(cx,cy,r,startDeg,endDeg){
|
||||
if(endDeg <= startDeg) endDeg = startDeg + 0.01;
|
||||
var s = polarToXY(cx,cy,r,startDeg), e = polarToXY(cx,cy,r,endDeg);
|
||||
var large = (endDeg-startDeg) > 180 ? 1 : 0;
|
||||
return "M "+s[0].toFixed(2)+" "+s[1].toFixed(2)+" A "+r+" "+r+" 0 "+large+" 1 "+e[0].toFixed(2)+" "+e[1].toFixed(2);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
SHARED STATE: period start / cycle length / period length
|
||||
================================================================ */
|
||||
var exampleStart = addDays(today, -10);
|
||||
var startInput = document.getElementById("p-start");
|
||||
var cycleLenInput = document.getElementById("p-cyclelen");
|
||||
var periodLenInput = document.getElementById("p-periodlen");
|
||||
|
||||
var savedStart = loadLS("ca-start", null);
|
||||
var savedCycleLen = loadLS("ca-cyclelen", null);
|
||||
var savedPeriodLen = loadLS("ca-periodlen", null);
|
||||
var usingExample = !savedStart;
|
||||
|
||||
startInput.value = savedStart || toInputVal(exampleStart);
|
||||
cycleLenInput.value = savedCycleLen || 28;
|
||||
periodLenInput.value = savedPeriodLen || 5;
|
||||
|
||||
function getCycleState(){
|
||||
return {
|
||||
start: fromInput(startInput.value) || exampleStart,
|
||||
cycleLen: Math.max(21, Math.min(35, parseInt(cycleLenInput.value,10) || 28)),
|
||||
periodLen: Math.max(2, Math.min(10, parseInt(periodLenInput.value,10) || 5))
|
||||
};
|
||||
}
|
||||
|
||||
var pCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
var oCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
var pSelectedDate = new Date(today);
|
||||
var oSelectedDate = new Date(today);
|
||||
|
||||
[startInput,cycleLenInput,periodLenInput].forEach(function(inp){
|
||||
inp.addEventListener("input", function(){
|
||||
saveLS("ca-start", startInput.value);
|
||||
saveLS("ca-cyclelen", cycleLenInput.value);
|
||||
saveLS("ca-periodlen", periodLenInput.value);
|
||||
usingExample = false;
|
||||
document.getElementById("p-example-note").style.display = "none";
|
||||
document.getElementById("pr-example-note").style.display = "none";
|
||||
renderAll();
|
||||
});
|
||||
});
|
||||
if(!usingExample) document.getElementById("p-example-note").style.display = "none";
|
||||
|
||||
/* ================================================================
|
||||
CALENDAR RENDERING (shared builder)
|
||||
================================================================ */
|
||||
function buildCalendar(gridEl, weekdayHeaderIgnored, monthDate, dayClassifier, onSelect, selectedDate){
|
||||
gridEl.innerHTML = "";
|
||||
var year = monthDate.getFullYear(), month = monthDate.getMonth();
|
||||
var firstDow = new Date(year,month,1).getDay();
|
||||
var daysInMonth = new Date(year,month+1,0).getDate();
|
||||
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
|
||||
for(var i=0;i<totalCells;i++){
|
||||
var dayNum = i - firstDow + 1;
|
||||
var cell = document.createElement("button");
|
||||
cell.className = "day-cell";
|
||||
if(dayNum < 1 || dayNum > daysInMonth){
|
||||
cell.classList.add("pad");
|
||||
gridEl.appendChild(cell);
|
||||
continue;
|
||||
}
|
||||
var d = new Date(year,month,dayNum);
|
||||
var info = dayClassifier(d);
|
||||
cell.innerHTML = "<span>"+dayNum+"</span>";
|
||||
if(info.period) cell.classList.add("is-period");
|
||||
if(info.fertile) cell.classList.add("is-fertile");
|
||||
if(info.ovulation) cell.classList.add("is-ovulation");
|
||||
if(sameDay(d,today)) cell.classList.add("today");
|
||||
if(selectedDate && sameDay(d,selectedDate)) cell.classList.add("selected");
|
||||
cell.setAttribute("aria-label", d.toDateString());
|
||||
cell.addEventListener("click", function(dd){ return function(){ onSelect(dd); }; }(d));
|
||||
gridEl.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyForCycle(d, state){
|
||||
var dic = mod(daysBetween(state.start, d), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
return {
|
||||
period: dic < state.periodLen,
|
||||
fertile: isFertile(dic, ovDic),
|
||||
ovulation: dic === ovDic,
|
||||
dic: dic, ovDic: ovDic
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- PERIOD tab render ---------------- */
|
||||
function renderPeriodCalendar(){
|
||||
var state = getCycleState();
|
||||
document.getElementById("p-cal-title").textContent = MONTH_NAMES[pCalMonth.getMonth()] + " " + pCalMonth.getFullYear();
|
||||
buildCalendar(document.getElementById("p-daygrid"), null, pCalMonth,
|
||||
function(d){ return classifyForCycle(d, state); },
|
||||
function(d){ pSelectedDate = d; renderPeriodDetail(); renderPeriodCalendar(); },
|
||||
pSelectedDate
|
||||
);
|
||||
}
|
||||
|
||||
function renderPeriodDetail(){
|
||||
var state = getCycleState();
|
||||
var info = classifyForCycle(pSelectedDate, state);
|
||||
var phaseKey = phaseForDayInCycle(info.dic, state.periodLen, info.ovDic);
|
||||
var phase = PHASES[phaseKey];
|
||||
var el = document.getElementById("p-detail");
|
||||
el.innerHTML =
|
||||
'<span class="d-date">'+pSelectedDate.toDateString()+'</span>'+
|
||||
'<span class="pill" style="background:'+phase.soft+';color:'+phase.color+'">'+phase.title+' phase'+(info.fertile?' · fertile window':'')+'</span>'+
|
||||
'<div class="d-phase">Cycle day '+(info.dic+1)+'</div>'+
|
||||
'<p class="d-desc">'+phase.desc+'</p>'+
|
||||
'<div class="d-grid">'+
|
||||
'<div class="d-mini"><div class="k">Hormones</div><div class="v">'+phase.hormone+'</div></div>'+
|
||||
'<div class="d-mini"><div class="k">Typical symptoms</div><div class="v" style="font-weight:500;font-size:0.82rem;">'+phase.symptoms+'</div></div>'+
|
||||
'</div>'+
|
||||
'<p class="d-desc"><strong style="color:var(--ink)">Care note — </strong>'+phase.tip+'</p>';
|
||||
}
|
||||
|
||||
function renderPeriodStats(){
|
||||
var state = getCycleState();
|
||||
var dic = mod(daysBetween(state.start, today), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
var daysToNextPeriod = mod(state.cycleLen - dic, state.cycleLen);
|
||||
if(daysToNextPeriod === 0 && dic !== 0) daysToNextPeriod = state.cycleLen;
|
||||
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
|
||||
var phaseKey = phaseForDayInCycle(dic, state.periodLen, ovDic);
|
||||
document.getElementById("period-stats").innerHTML =
|
||||
statTile(dic+1, "Cycle day today") +
|
||||
statTile(PHASES[phaseKey].title, "Current phase") +
|
||||
statTile(dic===0 ? "Today" : (daysToNextPeriod+"d"), "Until next period") +
|
||||
statTile(dic===ovDic ? "Today" : (daysToOvulation+"d"), "Until ovulation");
|
||||
|
||||
// hero ring
|
||||
var cx=100, cy=100, r=88;
|
||||
var perStart=0, perEnd = state.periodLen/state.cycleLen*360;
|
||||
var follEnd = ovDic/state.cycleLen*360;
|
||||
var fertStart = Math.max(0,(ovDic-5))/state.cycleLen*360;
|
||||
var lutStart = (ovDic+1)/state.cycleLen*360;
|
||||
document.getElementById("phase-arc-menstrual").setAttribute("d", arcPath(cx,cy,r,perStart,perEnd));
|
||||
document.getElementById("phase-arc-follicular").setAttribute("d", arcPath(cx,cy,r,perEnd,fertStart));
|
||||
document.getElementById("phase-arc-fertile").setAttribute("d", arcPath(cx,cy,r,fertStart,follEnd+ (360/state.cycleLen)));
|
||||
document.getElementById("phase-arc-luteal").setAttribute("d", arcPath(cx,cy,r,lutStart,360));
|
||||
var todayAngle = dic/state.cycleLen*360;
|
||||
var tp = polarToXY(cx,cy,r,todayAngle);
|
||||
document.getElementById("today-marker").setAttribute("cx", tp[0]);
|
||||
document.getElementById("today-marker").setAttribute("cy", tp[1]);
|
||||
document.getElementById("cycle-day-big").textContent = dic+1;
|
||||
}
|
||||
|
||||
function statTile(num,label){
|
||||
return '<div class="stat-tile"><div class="num">'+num+'</div><div class="lbl">'+label+'</div></div>';
|
||||
}
|
||||
|
||||
document.getElementById("p-prev").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()-1); renderPeriodCalendar(); });
|
||||
document.getElementById("p-next").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()+1); renderPeriodCalendar(); });
|
||||
|
||||
function renderPhaseRefGrid(){
|
||||
var order = ["menstrual","follicular","ovulatory","luteal"];
|
||||
document.getElementById("phase-ref-grid").innerHTML = order.map(function(key,i){
|
||||
var p = PHASES[key];
|
||||
return '<div class="ref-card"><span class="r-num">0'+(i+1)+'</span><h4 style="color:'+p.color+'">'+p.title+'</h4>'+
|
||||
'<p>'+p.desc+'</p>'+
|
||||
'<div class="tag-row"><span class="tagchip">'+p.window+'</span></div></div>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/* ---------------- OVULATION tab render ---------------- */
|
||||
var ovSelectedOffset = 0;
|
||||
function renderOvTimeline(){
|
||||
var wrap = document.getElementById("ov-timeline");
|
||||
wrap.innerHTML = "";
|
||||
for(var off=-5; off<=5; off++){
|
||||
var d = OVULATION_DAYS[String(off)];
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "tl-btn" + (off===ovSelectedOffset ? " active":"");
|
||||
btn.innerHTML = '<div class="tl-day">'+(off===0?"OVUL.":(off>0?"+":"")+off)+'</div>'+
|
||||
'<div class="tl-fert" style="background:'+FERT_COLOR[d.fertClass]+'"></div>';
|
||||
btn.addEventListener("click", function(o){ return function(){ ovSelectedOffset=o; renderOvTimeline(); renderOvDetailFromOffset(); }; }(off));
|
||||
wrap.appendChild(btn);
|
||||
}
|
||||
}
|
||||
function renderOvDetailFromOffset(){
|
||||
var d = OVULATION_DAYS[String(ovSelectedOffset)];
|
||||
var el = document.getElementById("o-detail");
|
||||
var label = ovSelectedOffset===0 ? "Ovulation day" : (ovSelectedOffset>0 ? ovSelectedOffset+" day"+(ovSelectedOffset>1?"s":"")+" after ovulation" : Math.abs(ovSelectedOffset)+" day"+(ovSelectedOffset<-1?"s":"")+" before ovulation");
|
||||
el.innerHTML =
|
||||
'<span class="d-date">'+label+'</span>'+
|
||||
'<span class="pill" style="background:var(--ovulation-soft);color:'+FERT_COLOR[d.fertClass]+'">Fertility: '+d.fert+'</span>'+
|
||||
'<div class="d-phase">'+(ovSelectedOffset===0?"Egg release":(ovSelectedOffset<0?"Building fertility":"Fertility fading"))+'</div>'+
|
||||
'<div class="d-grid">'+
|
||||
'<div class="d-mini"><div class="k">Basal body temp</div><div class="v" style="font-size:0.82rem;">'+d.bbt+'</div></div>'+
|
||||
'<div class="d-mini"><div class="k">Cervical mucus</div><div class="v" style="font-size:0.82rem;">'+d.mucus+'</div></div>'+
|
||||
'</div>'+
|
||||
'<p class="d-desc">'+d.note+'</p>';
|
||||
}
|
||||
|
||||
function renderOvulationCalendar(){
|
||||
var state = getCycleState();
|
||||
document.getElementById("o-cal-title").textContent = MONTH_NAMES[oCalMonth.getMonth()] + " " + oCalMonth.getFullYear();
|
||||
buildCalendar(document.getElementById("o-daygrid"), null, oCalMonth,
|
||||
function(d){ return classifyForCycle(d, state); },
|
||||
function(d){
|
||||
oSelectedDate = d;
|
||||
var info = classifyForCycle(d, state);
|
||||
var offset = info.dic - info.ovDic;
|
||||
if(offset < -5) offset = -5; if(offset > 5) offset = 5;
|
||||
ovSelectedOffset = offset;
|
||||
renderOvTimeline(); renderOvDetailFromOffset(); renderOvulationCalendar();
|
||||
},
|
||||
oSelectedDate
|
||||
);
|
||||
}
|
||||
|
||||
function renderOvulationStats(){
|
||||
var state = getCycleState();
|
||||
var dic = mod(daysBetween(state.start, today), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
|
||||
var fertileStartDic = mod(ovDic-5, state.cycleLen);
|
||||
var inFertile = isFertile(dic, ovDic);
|
||||
var daysToFertile = inFertile ? 0 : mod(fertileStartDic - dic, state.cycleLen);
|
||||
document.getElementById("ovulation-stats").innerHTML =
|
||||
statTile(inFertile ? "Yes" : "No", "In fertile window") +
|
||||
statTile(dic===ovDic ? "Today" : daysToOvulation+"d", "Until ovulation") +
|
||||
statTile(inFertile ? "Now" : daysToFertile+"d", "Fertile window") +
|
||||
statTile("6 days", "Window length");
|
||||
}
|
||||
|
||||
document.getElementById("o-prev").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()-1); renderOvulationCalendar(); });
|
||||
document.getElementById("o-next").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()+1); renderOvulationCalendar(); });
|
||||
|
||||
/* ================================================================
|
||||
PREGNANCY tab
|
||||
================================================================ */
|
||||
var lmpInput = document.getElementById("pr-lmp");
|
||||
var exampleLMP = addDays(today, -20*7 - 3);
|
||||
var savedLMP = loadLS("ca-lmp", null);
|
||||
lmpInput.value = savedLMP || toInputVal(exampleLMP);
|
||||
if(savedLMP) document.getElementById("pr-example-note").style.display = "none";
|
||||
|
||||
var slider = document.getElementById("week-slider");
|
||||
var prCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
|
||||
lmpInput.addEventListener("input", function(){
|
||||
saveLS("ca-lmp", lmpInput.value);
|
||||
document.getElementById("pr-example-note").style.display = "none";
|
||||
document.getElementById("p-example-note").style.display = "none";
|
||||
syncSliderToLMP();
|
||||
renderPregnancy();
|
||||
});
|
||||
|
||||
function getLMP(){ return fromInput(lmpInput.value) || exampleLMP; }
|
||||
function currentGestWeek(){
|
||||
var lmp = getLMP();
|
||||
var w = Math.floor(daysBetween(lmp,today)/7) + 1;
|
||||
return Math.max(1, Math.min(40, w));
|
||||
}
|
||||
function syncSliderToLMP(){ slider.value = currentGestWeek(); }
|
||||
syncSliderToLMP();
|
||||
|
||||
function renderPregnancyStats(){
|
||||
var lmp = getLMP();
|
||||
var due = addDays(lmp, 280);
|
||||
var week = currentGestWeek();
|
||||
var daysLeft = Math.max(0, daysBetween(today, due));
|
||||
document.getElementById("pregnancy-stats").innerHTML =
|
||||
statTile(week, "Weeks along") +
|
||||
statTile(Math.floor((week-1)/13)+1 <= 3 ? ["First","Second","Third"][Math.min(2,Math.floor((week-1)/13))] : "Third", "Trimester") +
|
||||
statTile(daysLeft, "Days to due date") +
|
||||
statTile(due.toLocaleDateString(undefined,{month:"short",day:"numeric"}), "Estimated due date");
|
||||
|
||||
var pct = (week-1)/39;
|
||||
var pathLen = 230;
|
||||
var offset = pathLen - pct*pathLen;
|
||||
var path = document.getElementById("pregnancy-progress-path");
|
||||
path.setAttribute("stroke-dashoffset", offset.toFixed(1));
|
||||
try{
|
||||
var totalLen = path.getTotalLength ? path.getTotalLength() : pathLen;
|
||||
var pt = path.getPointAtLength ? path.getPointAtLength(pct*totalLen) : null;
|
||||
if(pt) { document.getElementById("pregnancy-progress-dot").setAttribute("cx",pt.x); document.getElementById("pregnancy-progress-dot").setAttribute("cy",pt.y); }
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function renderWeekPanel(week){
|
||||
var w = WEEKS[week-1];
|
||||
document.getElementById("wk-badge").textContent = "WEEK " + week;
|
||||
document.getElementById("wk-compare").textContent = "size of " + w.c;
|
||||
document.getElementById("wk-metric").textContent = w.m === "n/a" ? "not yet fertilized" : "≈ " + w.m;
|
||||
document.getElementById("wk-dev").textContent = w.dev;
|
||||
document.getElementById("wk-body").textContent = w.body;
|
||||
document.getElementById("wk-tip").textContent = w.tip;
|
||||
var tri = week<=13?1:(week<=27?2:3);
|
||||
var triColor = "var(--pregnancy)";
|
||||
document.getElementById("wk-blob").innerHTML =
|
||||
'<svg width="96" height="96" viewBox="0 0 96 96"><circle cx="48" cy="48" r="'+(14+week*0.9)+'" fill="'+ (tri===1?"var(--pregnancy-soft)":tri===2?"var(--pregnancy)":"var(--pregnancy-strong)") +'" opacity="0.85"/></svg>';
|
||||
document.querySelectorAll(".film-item").forEach(function(f){ f.classList.toggle("active", +f.dataset.week===week); });
|
||||
var active = document.querySelector('.film-item[data-week="'+week+'"]');
|
||||
if(active) active.scrollIntoView({inline:"center", block:"nearest", behavior:"smooth"});
|
||||
}
|
||||
|
||||
slider.addEventListener("input", function(){ renderWeekPanel(+slider.value); });
|
||||
|
||||
function buildFilmstrip(){
|
||||
var wrap = document.getElementById("week-filmstrip");
|
||||
wrap.innerHTML = "";
|
||||
for(var w=1; w<=40; w++){
|
||||
var item = document.createElement("button");
|
||||
item.className = "film-item";
|
||||
item.dataset.week = w;
|
||||
item.innerHTML = '<div class="fw">WK</div><div class="fn">'+w+'</div>';
|
||||
item.addEventListener("click", function(ww){ return function(){ slider.value=ww; renderWeekPanel(ww); }; }(w));
|
||||
wrap.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPregnancyCalendar(){
|
||||
var lmp = getLMP();
|
||||
document.getElementById("pr-cal-title").textContent = MONTH_NAMES[prCalMonth.getMonth()] + " " + prCalMonth.getFullYear();
|
||||
var grid = document.getElementById("pr-daygrid");
|
||||
grid.innerHTML = "";
|
||||
var year = prCalMonth.getFullYear(), month = prCalMonth.getMonth();
|
||||
var firstDow = new Date(year,month,1).getDay();
|
||||
var daysInMonth = new Date(year,month+1,0).getDate();
|
||||
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
|
||||
for(var i=0;i<totalCells;i++){
|
||||
var dayNum = i - firstDow + 1;
|
||||
var cell = document.createElement("button");
|
||||
cell.className = "day-cell";
|
||||
if(dayNum<1 || dayNum>daysInMonth){ cell.classList.add("pad"); grid.appendChild(cell); continue; }
|
||||
var d = new Date(year,month,dayNum);
|
||||
var wk = Math.floor(daysBetween(lmp,d)/7)+1;
|
||||
var inRange = wk>=1 && wk<=42;
|
||||
cell.innerHTML = "<span>"+dayNum+"</span>" + (inRange ? '<span class="dot-row" style="position:static;margin-top:2px;font-size:0.55rem;color:var(--pregnancy-strong)">W'+wk+'</span>' : "");
|
||||
if(inRange && wk<=40) cell.classList.add("is-fertile");
|
||||
if(sameDay(d,today)) cell.classList.add("today");
|
||||
cell.addEventListener("click", function(ww){ return function(){ if(ww>=1&&ww<=40){ slider.value=ww; renderWeekPanel(ww); } }; }(wk));
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
document.getElementById("pr-prev").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()-1); renderPregnancyCalendar(); });
|
||||
document.getElementById("pr-next").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()+1); renderPregnancyCalendar(); });
|
||||
|
||||
function renderPregnancy(){
|
||||
renderPregnancyStats();
|
||||
renderWeekPanel(currentGestWeek());
|
||||
renderPregnancyCalendar();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
INIT
|
||||
================================================================ */
|
||||
function renderAll(){
|
||||
renderPeriodStats();
|
||||
renderPeriodCalendar();
|
||||
renderPeriodDetail();
|
||||
renderOvulationStats();
|
||||
renderOvTimeline();
|
||||
renderOvDetailFromOffset();
|
||||
renderOvulationCalendar();
|
||||
renderPregnancy();
|
||||
}
|
||||
|
||||
renderPhaseRefGrid();
|
||||
buildFilmstrip();
|
||||
slider.value = currentGestWeek();
|
||||
renderAll();
|
||||
})();
|
||||
</script>
|
||||
@@ -1,151 +0,0 @@
|
||||
/* CycleWise AU - 3D cycle ring (Step 2).
|
||||
Draws the same four phase arcs as the flat SVG ring, in 3D.
|
||||
The page code sends the numbers via the "cycle:ring" event.
|
||||
If WebGL is missing or anything fails, the flat ring stays visible. */
|
||||
(function(){
|
||||
"use strict";
|
||||
function start(){
|
||||
try {
|
||||
if (!window.THREE) return;
|
||||
var art = document.getElementById("phase-arc-menstrual");
|
||||
art = art && art.closest(".hero-art");
|
||||
if (!art) return;
|
||||
|
||||
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
var svg = art.querySelector("svg");
|
||||
var canvas = document.createElement("canvas");
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
canvas.className = "ring3d";
|
||||
|
||||
var renderer;
|
||||
try { renderer = new THREE.WebGLRenderer({canvas: canvas, antialias: true, alpha: true}); }
|
||||
catch(e){ return; } // no WebGL: keep the flat ring
|
||||
|
||||
var R = 1, TUBE = 0.085;
|
||||
var scene = new THREE.Scene();
|
||||
var camera = new THREE.PerspectiveCamera(30, 1, 0.1, 20);
|
||||
camera.position.set(0, 0, 5.2);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.75));
|
||||
var sun = new THREE.DirectionalLight(0xffffff, 1.1);
|
||||
sun.position.set(2, 3, 4);
|
||||
scene.add(sun);
|
||||
|
||||
var group = new THREE.Group();
|
||||
scene.add(group);
|
||||
|
||||
function cssColor(name){
|
||||
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return new THREE.Color(v || "#888888");
|
||||
}
|
||||
function point(deg, r){
|
||||
var a = deg * Math.PI / 180; // 0 deg = top, clockwise
|
||||
return new THREE.Vector3(Math.sin(a) * r, Math.cos(a) * r, 0);
|
||||
}
|
||||
class ArcCurve extends THREE.Curve {
|
||||
constructor(a0, a1){ super(); this.a0 = a0; this.a1 = a1; }
|
||||
getPoint(t, target){
|
||||
return (target || new THREE.Vector3()).copy(point(this.a0 + (this.a1 - this.a0) * t, R));
|
||||
}
|
||||
}
|
||||
|
||||
var data = window.cycleRingData || null;
|
||||
var meshes = [];
|
||||
|
||||
function clear(){
|
||||
meshes.forEach(function(m){
|
||||
group.remove(m); m.geometry.dispose(); m.material.dispose();
|
||||
});
|
||||
meshes = [];
|
||||
}
|
||||
function add(mesh){ group.add(mesh); meshes.push(mesh); }
|
||||
function mat(c){ return new THREE.MeshStandardMaterial({color: c, roughness: 0.45, metalness: 0.05}); }
|
||||
|
||||
function build(){
|
||||
clear();
|
||||
if (!data) return;
|
||||
var cols = {
|
||||
menstrual: cssColor("--period"),
|
||||
follicular: cssColor("--ovulation-soft"),
|
||||
fertile: cssColor("--fertile"),
|
||||
luteal: cssColor("--pregnancy-soft"),
|
||||
ink: cssColor("--ink")
|
||||
};
|
||||
var arcs = [
|
||||
[0, data.perEnd, cols.menstrual],
|
||||
[data.perEnd, data.fertStart, cols.follicular],
|
||||
[data.fertStart, data.fertEnd, cols.fertile],
|
||||
[data.lutStart, 360, cols.luteal]
|
||||
];
|
||||
arcs.forEach(function(a){
|
||||
var end = Math.max(a[1], a[0] + 0.5);
|
||||
var tube = new THREE.Mesh(new THREE.TubeGeometry(new ArcCurve(a[0], end), 48, TUBE, 16, false), mat(a[2]));
|
||||
add(tube);
|
||||
[a[0], end].forEach(function(deg){ // round end caps
|
||||
var cap = new THREE.Mesh(new THREE.SphereGeometry(TUBE, 16, 12), mat(a[2]));
|
||||
cap.position.copy(point(deg, R));
|
||||
add(cap);
|
||||
});
|
||||
});
|
||||
var marker = new THREE.Mesh(new THREE.SphereGeometry(TUBE * 1.9, 24, 16), mat(cols.ink));
|
||||
marker.position.copy(point(data.todayAngle, R));
|
||||
marker.position.z = TUBE * 0.6;
|
||||
add(marker);
|
||||
}
|
||||
|
||||
function resize(){
|
||||
var w = svg.getBoundingClientRect().width || 220;
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(w, w, false);
|
||||
canvas.style.width = canvas.style.height = w + "px";
|
||||
}
|
||||
|
||||
var visible = false, raf = 0;
|
||||
function frame(t){
|
||||
raf = 0;
|
||||
var s = t / 1000;
|
||||
group.rotation.x = -0.55 + (reduce ? 0 : Math.sin(s * 0.6) * 0.12);
|
||||
group.rotation.y = reduce ? 0.25 : Math.sin(s * 0.4) * 0.45;
|
||||
renderer.render(scene, camera);
|
||||
if (visible && !reduce) raf = requestAnimationFrame(frame);
|
||||
}
|
||||
function kick(){ if (!raf) raf = requestAnimationFrame(frame); }
|
||||
|
||||
// Show the 3D ring; hide the flat arcs but keep the "CYCLE DAY" text.
|
||||
art.classList.add("has-3d");
|
||||
art.insertBefore(canvas, svg);
|
||||
resize();
|
||||
build();
|
||||
kick();
|
||||
|
||||
window.addEventListener("cycle:ring", function(e){ data = e.detail; build(); kick(); });
|
||||
window.addEventListener("resize", function(){ resize(); kick(); });
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function(){ build(); kick(); });
|
||||
|
||||
// Only animate while the ring is actually on screen (saves battery).
|
||||
new IntersectionObserver(function(entries){
|
||||
visible = entries[0].isIntersecting;
|
||||
if (visible) { resize(); kick(); }
|
||||
}).observe(canvas);
|
||||
} catch(err) {
|
||||
// Anything went wrong: leave the flat ring alone.
|
||||
var c = document.querySelector(".ring3d"); if (c) c.remove();
|
||||
var h = document.querySelector(".hero-art.has-3d"); if (h) h.classList.remove("has-3d");
|
||||
if (window.console) console.warn("3D ring disabled:", err && err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// three.js is big: fetch it only after the page is up, so the page shows first.
|
||||
function loadThree(){
|
||||
var tag = document.createElement("script");
|
||||
tag.src = "vendor/three.min.js";
|
||||
tag.onload = start;
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
function schedule(){
|
||||
if (window.THREE) { start(); }
|
||||
else if (window.requestIdleCallback) { requestIdleCallback(loadThree, {timeout: 2000}); }
|
||||
else { setTimeout(loadThree, 300); }
|
||||
}
|
||||
if (document.readyState === "complete") schedule();
|
||||
else window.addEventListener("load", schedule);
|
||||
})();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"ad-creative-video": {
|
||||
"source": "iart-ai/ad-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/ad-creative-video/SKILL.md",
|
||||
"computedHash": "94c33e3435abd802773c4b7e6058f3d47456bc56354d87b1e6fc2b689757735d"
|
||||
},
|
||||
"launch-video": {
|
||||
"source": "iart-ai/ad-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/launch-video/SKILL.md",
|
||||
"computedHash": "a55b82d2ca76de5bb80a1217594b11d1bf1ae4485b487bcad7ab256e2f116790"
|
||||
},
|
||||
"testimonial-video": {
|
||||
"source": "iart-ai/ad-video-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/testimonial-video/SKILL.md",
|
||||
"computedHash": "0d71fa501ffc13fc979d4d93865785c16741c6b4041ccbb3926eaf0da0f486da"
|
||||
}
|
||||
}
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
/* CycleWise AU - pregnancy timeline motion (Step 3).
|
||||
- The week slider's track fills with colour up to the current week.
|
||||
It grows the first time you scroll to it, then follows every week change.
|
||||
- The baby-size picture pops a little when the week changes.
|
||||
- The 40 week buttons wave in the first time they appear.
|
||||
Cosmetic only: without GSAP the slider simply shows its normal look. */
|
||||
(function(){
|
||||
"use strict";
|
||||
var slider = document.getElementById("week-slider");
|
||||
var badge = document.getElementById("wk-badge");
|
||||
if (!slider || !badge) return;
|
||||
|
||||
var hasGsap = !!window.gsap;
|
||||
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
var animate = hasGsap && !reduce;
|
||||
|
||||
var fill = { f: 0 };
|
||||
var target = 0;
|
||||
var introDone = false;
|
||||
|
||||
function paint(){ slider.style.setProperty("--f", fill.f.toFixed(4)); }
|
||||
function currentWeek(){
|
||||
var n = parseInt((badge.textContent || "").replace(/\D/g, ""), 10);
|
||||
return isNaN(n) ? (parseInt(slider.value, 10) || 1) : Math.max(1, Math.min(40, n));
|
||||
}
|
||||
function goTo(f){
|
||||
target = f;
|
||||
if (!introDone) return; // wait until the slider is on screen
|
||||
if (!animate) { fill.f = f; paint(); return; }
|
||||
gsap.to(fill, { f: f, duration: 0.6, ease: "power2.out", overwrite: true, onUpdate: paint });
|
||||
}
|
||||
|
||||
// Follow every week change, whether from the slider, a week button or the calendar.
|
||||
var lastWeek = currentWeek();
|
||||
target = (lastWeek - 1) / 39;
|
||||
new MutationObserver(function(){
|
||||
var w = currentWeek();
|
||||
if (w === lastWeek) return;
|
||||
lastWeek = w;
|
||||
goTo((w - 1) / 39);
|
||||
var blob = document.querySelector("#wk-blob svg");
|
||||
if (animate && blob) {
|
||||
gsap.fromTo(blob, { scale: 0.8, transformOrigin: "50% 50%" },
|
||||
{ scale: 1, duration: 0.5, ease: "back.out(2)", clearProps: "transform" });
|
||||
}
|
||||
}).observe(badge, { childList: true, characterData: true, subtree: true });
|
||||
|
||||
// First time the slider is visible: grow the fill from empty up to this week.
|
||||
new IntersectionObserver(function(entries, obs){
|
||||
if (!entries[0].isIntersecting) return;
|
||||
obs.disconnect();
|
||||
introDone = true;
|
||||
if (!animate) { fill.f = target; paint(); return; }
|
||||
gsap.to(fill, { f: target, duration: 1.2, ease: "power2.out", onUpdate: paint });
|
||||
}).observe(slider);
|
||||
|
||||
// First time the week buttons are visible: wave them in.
|
||||
var strip = document.getElementById("week-filmstrip");
|
||||
if (strip && animate) {
|
||||
new IntersectionObserver(function(entries, obs){
|
||||
if (!entries[0].isIntersecting) return;
|
||||
obs.disconnect();
|
||||
gsap.from(strip.querySelectorAll(".film-item"), {
|
||||
opacity: 0, scale: 0.8, duration: 0.4, stagger: 0.02, ease: "power2.out", clearProps: "all"
|
||||
});
|
||||
}).observe(strip);
|
||||
}
|
||||
})();
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
/*!
|
||||
* ScrollTrigger 3.12.5
|
||||
* https://gsap.com
|
||||
*
|
||||
* @license Copyright 2024, GreenSock. All rights reserved.
|
||||
* Subject to the terms at https://gsap.com/standard-license or for Club GSAP members, the agreement issued with that membership.
|
||||
* @author: Jack Doyle, jack@greensock.com
|
||||
*/
|
||||
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e=e||self).window=e.window||{})}(this,function(e){"use strict";function _defineProperties(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}function r(){return Ce||"undefined"!=typeof window&&(Ce=window.gsap)&&Ce.registerPlugin&&Ce}function z(e,t){return~Le.indexOf(e)&&Le[Le.indexOf(e)+1][t]}function A(e){return!!~t.indexOf(e)}function B(e,t,r,n,o){return e.addEventListener(t,r,{passive:!1!==n,capture:!!o})}function C(e,t,r,n){return e.removeEventListener(t,r,!!n)}function F(){return De&&De.isPressed||Ie.cache++}function G(r,n){function dd(e){if(e||0===e){o&&(Se.history.scrollRestoration="manual");var t=De&&De.isPressed;e=dd.v=Math.round(e)||(De&&De.iOS?1:0),r(e),dd.cacheID=Ie.cache,t&&i("ss",e)}else(n||Ie.cache!==dd.cacheID||i("ref"))&&(dd.cacheID=Ie.cache,dd.v=r());return dd.v+dd.offset}return dd.offset=0,r&&dd}function J(e,t){return(t&&t._ctx&&t._ctx.selector||Ce.utils.toArray)(e)[0]||("string"==typeof e&&!1!==Ce.config().nullTargetWarn?console.warn("Element not found:",e):null)}function K(t,e){var r=e.s,n=e.sc;A(t)&&(t=ke.scrollingElement||Pe);var o=Ie.indexOf(t),i=n===Fe.sc?1:2;~o||(o=Ie.push(t)-1),Ie[o+i]||B(t,"scroll",F);var a=Ie[o+i],s=a||(Ie[o+i]=G(z(t,r),!0)||(A(t)?n:G(function(e){return arguments.length?t[r]=e:t[r]})));return s.target=t,a||(s.smooth="smooth"===Ce.getProperty(t,"scrollBehavior")),s}function L(e,t,o){function Cd(e,t){var r=ze();t||n<r-s?(a=i,i=e,l=s,s=r):o?i+=e:i=a+(e-a)/(r-l)*(s-l)}var i=e,a=e,s=ze(),l=s,n=t||50,c=Math.max(500,3*n);return{update:Cd,reset:function reset(){a=i=o?0:i,l=s=0},getVelocity:function getVelocity(e){var t=l,r=a,n=ze();return!e&&0!==e||e===i||Cd(e),s===l||c<n-l?0:(i+(o?r:-r))/((o?n:s)-t)*1e3}}}function M(e,t){return t&&!e._gsapAllow&&e.preventDefault(),e.changedTouches?e.changedTouches[0]:e}function N(e){var t=Math.max.apply(Math,e),r=Math.min.apply(Math,e);return Math.abs(t)>=Math.abs(r)?t:r}function O(){(Ae=Ce.core.globals().ScrollTrigger)&&Ae.core&&function _integrate(){var e=Ae.core,r=e.bridge||{},t=e._scrollers,n=e._proxies;t.push.apply(t,Ie),n.push.apply(n,Le),Ie=t,Le=n,i=function _bridge(e,t){return r[e](t)}}()}function P(e){return Ce=e||r(),!Te&&Ce&&"undefined"!=typeof document&&document.body&&(Se=window,Pe=(ke=document).documentElement,Me=ke.body,t=[Se,ke,Pe,Me],Ce.utils.clamp,Be=Ce.core.context||function(){},Oe="onpointerenter"in Me?"pointer":"mouse",Ee=k.isTouch=Se.matchMedia&&Se.matchMedia("(hover: none), (pointer: coarse)").matches?1:"ontouchstart"in Se||0<navigator.maxTouchPoints||0<navigator.msMaxTouchPoints?2:0,Re=k.eventTypes=("ontouchstart"in Pe?"touchstart,touchmove,touchcancel,touchend":"onpointerdown"in Pe?"pointerdown,pointermove,pointercancel,pointerup":"mousedown,mousemove,mouseup,mouseup").split(","),setTimeout(function(){return o=0},500),O(),Te=1),Te}var Ce,Te,Se,ke,Pe,Me,Ee,Oe,Ae,t,De,Re,Be,o=1,qe=[],Ie=[],Le=[],ze=Date.now,i=function _bridge(e,t){return t},n="scrollLeft",a="scrollTop",Ye={s:n,p:"left",p2:"Left",os:"right",os2:"Right",d:"width",d2:"Width",a:"x",sc:G(function(e){return arguments.length?Se.scrollTo(e,Fe.sc()):Se.pageXOffset||ke[n]||Pe[n]||Me[n]||0})},Fe={s:a,p:"top",p2:"Top",os:"bottom",os2:"Bottom",d:"height",d2:"Height",a:"y",op:Ye,sc:G(function(e){return arguments.length?Se.scrollTo(Ye.sc(),e):Se.pageYOffset||ke[a]||Pe[a]||Me[a]||0})};Ye.op=Fe,Ie.cache=0;var k=(Observer.prototype.init=function init(e){Te||P(Ce)||console.warn("Please gsap.registerPlugin(Observer)"),Ae||O();var o=e.tolerance,a=e.dragMinimum,t=e.type,i=e.target,r=e.lineHeight,n=e.debounce,s=e.preventDefault,l=e.onStop,c=e.onStopDelay,u=e.ignore,f=e.wheelSpeed,d=e.event,p=e.onDragStart,g=e.onDragEnd,h=e.onDrag,v=e.onPress,b=e.onRelease,m=e.onRight,y=e.onLeft,x=e.onUp,w=e.onDown,_=e.onChangeX,T=e.onChangeY,S=e.onChange,k=e.onToggleX,E=e.onToggleY,D=e.onHover,R=e.onHoverEnd,q=e.onMove,I=e.ignoreCheck,z=e.isNormalizer,Y=e.onGestureStart,H=e.onGestureEnd,X=e.onWheel,W=e.onEnable,V=e.onDisable,U=e.onClick,j=e.scrollSpeed,G=e.capture,Q=e.allowClicks,Z=e.lockAxis,$=e.onLockAxis;function cf(){return xe=ze()}function df(e,t){return(se.event=e)&&u&&~u.indexOf(e.target)||t&&he&&"touch"!==e.pointerType||I&&I(e,t)}function ff(){var e=se.deltaX=N(me),t=se.deltaY=N(ye),r=Math.abs(e)>=o,n=Math.abs(t)>=o;S&&(r||n)&&S(se,e,t,me,ye),r&&(m&&0<se.deltaX&&m(se),y&&se.deltaX<0&&y(se),_&&_(se),k&&se.deltaX<0!=le<0&&k(se),le=se.deltaX,me[0]=me[1]=me[2]=0),n&&(w&&0<se.deltaY&&w(se),x&&se.deltaY<0&&x(se),T&&T(se),E&&se.deltaY<0!=ce<0&&E(se),ce=se.deltaY,ye[0]=ye[1]=ye[2]=0),(ne||re)&&(q&&q(se),re&&(h(se),re=!1),ne=!1),ie&&!(ie=!1)&&$&&$(se),oe&&(X(se),oe=!1),ee=0}function gf(e,t,r){me[r]+=e,ye[r]+=t,se._vx.update(e),se._vy.update(t),n?ee=ee||requestAnimationFrame(ff):ff()}function hf(e,t){Z&&!ae&&(se.axis=ae=Math.abs(e)>Math.abs(t)?"xLine truncated
|
||||
|
||||
Vendored
-21
@@ -1,21 +0,0 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2010-2023 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
/*!
|
||||
* GSAP 3.12.5
|
||||
* https://gsap.com
|
||||
*
|
||||
* @license Copyright 2024, GreenSock. All rights reserved.
|
||||
* Subject to the terms at https://gsap.com/standard-license or for Club GSAP members, the agreement issued with that membership.
|
||||
* @author: Jack Doyle, jack@greensock.com
|
||||
*/
|
||||
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).window=t.window||{})}(this,function(e){"use strict";function _inheritsLoose(t,e){t.prototype=Object.create(e.prototype),(t.prototype.constructor=t).__proto__=e}function _assertThisInitialized(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}function r(t){return"string"==typeof t}function s(t){return"function"==typeof t}function t(t){return"number"==typeof t}function u(t){return void 0===t}function v(t){return"object"==typeof t}function w(t){return!1!==t}function x(){return"undefined"!=typeof window}function y(t){return s(t)||r(t)}function P(t){return(i=yt(t,ot))&&ze}function Q(t,e){return console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}function R(t,e){return!e&&console.warn(t)}function S(t,e){return t&&(ot[t]=e)&&i&&(i[t]=e)||ot}function T(){return 0}function ea(t){var e,r,i=t[0];if(v(i)||s(i)||(t=[t]),!(e=(i._gsap||{}).harness)){for(r=gt.length;r--&&!gt[r].targetTest(i););e=gt[r]}for(r=t.length;r--;)t[r]&&(t[r]._gsap||(t[r]._gsap=new Vt(t[r],e)))||t.splice(r,1);return t}function fa(t){return t._gsap||ea(Mt(t))[0]._gsap}function ga(t,e,r){return(r=t[e])&&s(r)?t[e]():u(r)&&t.getAttribute&&t.getAttribute(e)||r}function ha(t,e){return(t=t.split(",")).forEach(e)||t}function ia(t){return Math.round(1e5*t)/1e5||0}function ja(t){return Math.round(1e7*t)/1e7||0}function ka(t,e){var r=e.charAt(0),i=parseFloat(e.substr(2));return t=parseFloat(t),"+"===r?t+i:"-"===r?t-i:"*"===r?t*i:t/i}function la(t,e){for(var r=e.length,i=0;t.indexOf(e[i])<0&&++i<r;);return i<r}function ma(){var t,e,r=dt.length,i=dt.slice(0);for(ct={},t=dt.length=0;t<r;t++)(e=i[t])&&e._lazy&&(e.render(e._lazy[0],e._lazy[1],!0)._lazy=0)}function na(t,e,r,i){dt.length&&!L&&ma(),t.render(e,r,i||L&&e<0&&(t._initted||t._startAt)),dt.length&&!L&&ma()}function oa(t){var e=parseFloat(t);return(e||0===e)&&(t+"").match(at).length<2?e:r(t)?t.trim():t}function pa(t){return t}function qa(t,e){for(var r in e)r in t||(t[r]=e[r]);return t}function ta(t,e){for(var r in e)"__proto__"!==r&&"constructor"!==r&&"prototype"!==r&&(t[r]=v(e[r])?ta(t[r]||(t[r]={}),e[r]):e[r]);return t}function ua(t,e){var r,i={};for(r in t)r in e||(i[r]=t[r]);return i}function va(t){var e=t.parent||I,r=t.keyframes?function _setKeyframeDefaults(i){return function(t,e){for(var r in e)r in t||"duration"===r&&i||"ease"===r||(t[r]=e[r])}}(Z(t.keyframes)):qa;if(w(t.inherit))for(;e;)r(t,e.vars.defaults),e=e.parent||e._dp;return t}function xa(t,e,r,i,n){void 0===r&&(r="_first"),void 0===i&&(i="_last");var a,s=t[i];if(n)for(a=e[n];s&&s[n]>a;)s=s._prev;return s?(e._next=s._next,s._next=e):(e._next=t[r],t[r]=e),e._next?e._next._prev=e:t[i]=e,e._prev=s,e.parent=e._dp=t,e}function ya(t,e,r,i){void 0===r&&(r="_first"),void 0===i&&(i="_last");var n=e._prev,a=e._next;n?n._next=a:t[r]===e&&(t[r]=a),a?a._prev=n:t[i]===e&&(t[i]=n),e._next=e._prev=e.parent=null}function za(t,e){t.parent&&(!e||t.parent.autoRemoveChildren)&&t.parent.remove&&t.parent.remove(t),t._act=0}function Aa(t,e){if(t&&(!e||e._end>t._dur||e._start<0))for(var r=t;r;)r._dirty=1,r=r.parent;return t}function Ca(t,e,r,i){return t._startAt&&(L?t._startAt.revert(ht):t.vars.immediateRender&&!t.vars.autoRevert||t._startAt.render(e,!0,i))}function Ea(t){return t._repeat?Tt(t._tTime,t=t.duration()+t._rDelay)*t:0}function Ga(t,e){return(t-e._start)*e._ts+(0<=e._ts?0:e._dirty?e.totalDuration():e._tDur)}function Ha(t){return t._end=ja(t._start+(t._tDur/Math.abs(t._ts||t._rts||X)||0))}function Ia(t,e){var r=t._dp;return r&&r.smoothChildTiming&&t._ts&&(t._start=ja(r._time-(0<t._ts?e/t._ts:((t._dirty?t.totalDuration():t._tDur)-e)/-t._ts)),Ha(t),r._dirty||Aa(r,t)),t}function Ja(t,e){var r;if((e._time||!e._dur&&e._initted||e._start<t._time&&(e._dur||!e.add))&&(r=Ga(t.rawTime(),e),(!e._dur||Ot(0,e.totalDuration(),r)-e._tTime>X)&&e.render(r,!0)),Aa(t,e)._dp&&t._initted&&t._time>=t._dur&&t._ts){if(t._dur<t.duration())for(r=t;r._dp;)0<=r.rawTime()&&r.totalTime(r._tTime),r=r._dp;t._zTime=-X}}function Ka(e,r,i,n){return r.parent&&za(r),r._start=ja((t(i)?i:i||e!==I?xt(e,i,r):e._time)+r._delay),r._end=ja(r._start+(r.totalDuration()/Math.abs(r.timeScale())||0)),xa(e,r,"_first","_last",e._sort?"_start":0),bt(r)||(e._recent=r),n||Ja(e,r),e._ts<0&&Ia(e,e._tTime),e}function La(t,e){return(ot.ScrollTrigger||Q("scrollTrigger",e))&&ot.ScrollTrigger.create(e,t)}function Ma(t,e,r,i,n){return Qt(t,e,n),t._initted?!r&&t._pt&&!L&&(t._dur&&!1!==t.vars.lazy||!t._dur&&t.vars.lazy)&&f!==Rt.frame?(dt.push(t),t._lazy=[n,i],1):void 0:1}function Ra(t,e,r,i){var n=t._repeat,a=ja(e)||0,s=t._tTime/t._tDur;return s&&!i&&(t._time*=a/t._dur),t._dur=a,t._tDur=n?n<0?1e10:ja(a*(n+1)+t._rDelay*n):a,0<s&&!i&&Ia(t,t._tTime=t._tDur*s),t.parent&&Ha(t),r||Aa(t.parent,t),t}function Sa(t){return t instanceof Xt?Aa(t):Ra(t,t._dur)}function Va(e,r,i){var n,aLine truncated
|
||||
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
console.warn('Scripts "build/three.js" and "build/three.min.js" are deprecated with r150+, and will be removed with r160. Please use ES Modules or alternatives: https://threejs.org/docs/index.html#manual/en/introduction/Installation'),
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2010-2023 Three.js Authors
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="160",n=1,i=2,r=3,s=0,a=1,o=100,l=204,c=205,h=0,u=1,d=2,p=0,m=1,f=2,g=3,_=4,v=5,x=6,y="attached",M="detached",S=300,b=301,T=302,E=303,w=304,A=306,R=1e3,C=1001,L=1002,P=1003,I=1004,U=1005,D=1006,N=1007,O=1008,F=1009,B=1012,z=1013,G=1014,H=1015,V=1016,k=1017,W=1018,X=1020,j=1023,q=1026,Y=1027,Z=1029,J=1031,K=1033,$=33776,Q=33777,tt=33778,et=33779,nt=35840,it=35841,rt=35842,st=35843,at=36196,ot=37492,lt=37496,ct=37808,ht=37809,ut=37810,dt=37811,pt=37812,mt=37813,ft=37814,gt=37815,_t=37816,vt=37817,xt=37818,yt=37819,Mt=37820,St=37821,bt=36492,Tt=36494,Et=36495,wt=36284,At=36285,Rt=36286,Ct=2300,Lt=2301,Pt=2302,It=2400,Ut=2401,Dt=2402,Nt=2500,Ot=2501,Ft=3e3,Bt=3001,zt="",Gt="srgb",Ht="srgb-linear",Vt="display-p3",kt="display-p3-linear",Wt="linear",Xt="srgb",jt="rec709",qt="p3",Yt=7680,Zt=35044,Jt="300 es",Kt=1035,$t=2e3,Qt=2001;class te{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const ee=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let ne=1234567;const ie=Math.PI/180,re=180/Math.PI;function se(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(ee[255&t]+ee[t>>8&255]+ee[t>>16&255]+ee[t>>24&255]+"-"+ee[255&e]+ee[e>>8&255]+"-"+ee[e>>16&15|64]+ee[e>>24&255]+"-"+ee[63&n|128]+ee[n>>8&255]+"-"+ee[n>>16&255]+ee[n>>24&255]+ee[255&i]+ee[i>>8&255]+ee[i>>16&255]+ee[i>>24&255]).toLowerCase()}function ae(t,e,n){return Math.max(e,Math.min(n,t))}function oe(t,e){return(t%e+e)%e}function le(t,e,n){return(1-n)*t+n*e}function ce(t){return 0==(t&t-1)&&0!==t}function he(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function ue(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function de(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const pe={DEG2RAD:ie,RAD2DEG:re,generateUUID:se,clamp:ae,euclideanModulo:oe,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:le,damp:function(t,e,n,i){return le(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(oe(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatLine truncated
|
||||
Reference in new issue
Block a user