Compare commits

..
Author SHA1 Message Date
Claude 95826a1f19 Self-host fonts (Fraunces, Work Sans, IBM Plex Mono); drop Google Fonts
No third-party requests remain; CSP tightened to font-src/style-src 'self'.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSDgjk1Agjy9Z2UH2n8bc3
2026-10-15 00:31:30 +00:00
Claude cd5b21514d Polish: proper HTML document, strict CSP, lazy-load three.js, a11y fixes
- add doctype, lang and viewport (page was in quirks mode, 980px on phones)
- move inline script to app.js; add Content-Security-Policy meta
- load three.js after page load
- label week slider, wire tab/tabpanel aria, fix dark-mode week numbers

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSDgjk1Agjy9Z2UH2n8bc3
2026-10-15 00:28:35 +00:00
Claude 8c9bf89676 Add pregnancy timeline motion: growing slider fill, size pop, week-button wave
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSDgjk1Agjy9Z2UH2n8bc3
2026-10-15 00:24:38 +00:00
Claude b5f5518c1e Add 3D cycle ring (Three.js) with flat-ring fallback
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSDgjk1Agjy9Z2UH2n8bc3
2026-10-15 00:22:53 +00:00
Claude 251b3af5ed Add GSAP scroll-reveal animations (self-hosted, respects reduced motion)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSDgjk1Agjy9Z2UH2n8bc3
2026-10-15 00:19:40 +00:00
45 changed files with 1179 additions and 2392 deletions

No files matched your search

@@ -1,24 +0,0 @@
# Caption Animation
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area. For short-form creators burning open captions onto vertical video.
## When it activates
- "Add animated captions," "make TikTok captions," or "karaoke captions."
- "Word-by-word subtitles," "auto subtitles," or "highlight the active word."
- "Burn in captions to a video" or "sync captions to a voiceover/narration."
- "Turn an SRT/transcript into animated text."
## Example prompts
- "Turn this Whisper transcript into karaoke captions with a per-word pop."
- "Restyle these auto subtitles into a branded, Hormozi-style reveal."
- "Burn word-level captions onto my voiceover and export a sidecar SRT."
## What's inside
- `SKILL.md` — the transcribe → normalize → page → animate → place → export pipeline.
- `references/word-timed-captions.md` — deeper guidance on word-level timing and animation.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=caption-animation)** — the AI motion agent for editable, on-brand motion graphics.
-177
View File
@@ -1,177 +0,0 @@
---
name: caption-animation
description: This skill should be used when the user asks to "add animated captions", "make TikTok captions", "karaoke captions", "word-by-word subtitles", "auto subtitles", "highlight the active word", "burn in captions to a video", "sync captions to a voiceover/narration", or "turn an SRT/transcript into animated text". Covers word-level timing from Whisper, per-word pop/scale-in, active-word highlight, 9:16 safe-area placement, readable type, and burn-in vs sidecar SRT/VTT.
version: 0.1.0
---
# Caption Animation
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area.
## When to use
- Word-by-word or karaoke captions on short-form vertical video (9:16).
- Burning open captions onto a voiceover/narration track from a transcript.
- Turning a Whisper/SRT transcript into animated text in Remotion or on the web.
- Restyling auto-generated subtitles into a branded, "Hormozi-style" reveal.
## The pipeline
| Stage | Job | Tool |
|---|---|---|
| Transcribe | Audio → word-level timestamps | Whisper (`@remotion/install-whisper-cpp`), AssemblyAI |
| Normalize | → `{ text, startMs, endMs }[]` tokens | `@remotion/captions` `Caption` type |
| Page | Group words into 1–4 word "pages" | `createTikTokStyleCaptions()` |
| Animate | Per-word pop + active-word highlight | Remotion `spring()` / CSS |
| Place | Safe-area, readable type | layout rules below |
| Export | Burn-in (MP4) and/or sidecar SRT/VTT | Remotion render / file emit |
## Two non-negotiable rules
1. **Word-level timing, not line-level.** Karaoke reads as magic only when each word lands on the syllable. Always transcribe to word timestamps; never fake them by splitting a line evenly over its duration — drift is instantly visible.
2. **Readability beats style.** Captions are read on a phone, in sunlight, muted. Bold sans-serif, heavy stroke or shadow, high contrast first; decoration second. A caption nobody can read is decoration, not a caption.
## Word timing from a transcript
Whisper emits word-level timestamps directly. Normalize every source into one flat token shape so the renderer never cares where the words came from:
```ts
// Caption token — the one shape everything downstream consumes
type Token = { text: string; startMs: number; endMs: number };
// Parse an SRT cue block "00:00:01,200 --> 00:00:01,640" into ms
const toMs = (t: string) => {
const [h, m, rest] = t.split(":");
const [s, ms] = rest.split(",");
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
};
```
If only sentence-level cues exist, re-transcribe for word timing — do not interpolate. Whisper's `medium.en` is ~2x faster than `large` and accurate enough for clean voice audio.
## Per-word pop-in (Remotion)
Drive each word's scale and opacity off a spring anchored to its own `startMs`. The micro-overshoot is what makes it feel "alive".
```tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = (token.startMs / 1000) * fps; // word's own entrance frame
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
const scale = interpolate(p, [0, 1], [0.6, 1]); // pop from 60% → 100% (overshoots)
return (
<span style={{
display: "inline-block",
transform: `scale(${scale})`,
opacity: interpolate(p, [0, 1], [0, 1]),
color: active ? "#FFE45E" : "#FFFFFF", // active-word highlight
transition: "color 80ms linear",
}}>{token.text}&nbsp;</span>
);
};
```
Compute `active` by testing `frame` against each token's `[startMs, endMs]`. Keep the highlight a single accent color or a filled "box" behind the live word — never animate every word's color at once.
## Pages, not walls of text
Show 1–4 words at a time. Use Remotion's `createTikTokStyleCaptions()` (or group manually) and tune `combineTokensWithinMilliseconds`: ~200–500ms for true word-by-word, ~1000–1500ms for short readable phrases.
```ts
import { createTikTokStyleCaptions } from "@remotion/captions";
const { pages } = createTikTokStyleCaptions({
captions, combineTokensWithinMilliseconds: 1200, // ~2–3 words per page
});
```
## Web/CSS pop (no Remotion)
For a DOM/live player, give each word its own `animation-delay` equal to `startMs`:
```css
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
.word.active { color:#FFE45E; }
```
```js
words.forEach(w => { const el = mk(w.text); el.style.animationDelay = `${w.startMs}ms`; track.append(el); });
```
## Readable type and safe-area placement (9:16, 1080×1920)
| Property | Value | Why |
|---|---|---|
| Font | Bold/ExtraBold sans (Montserrat, Inter, Helvetica) | Reads on small, busy screens |
| Size | 56–80px (≈8% of frame height), min 45px | Legible muted on a phone |
| Stroke | 2–6px solid black outline | Survives any background |
| Shadow | Soft drop shadow as backup to stroke | Separation on bright frames |
| Case | Uppercase or sentence; high contrast fill | Punch + scannability |
| Vertical pos | Center band, ~62–70% down | Above the UI, below the action |
| Bottom safe | Keep clear of bottom ~280px / 15% | Avoids caption/CTA/audio UI |
| Side safe | Keep within center 80% width | Avoids right-rail icons |
Place captions in the center band — not pinned to the very bottom, where the platform UI (username, audio, buttons) lives. Pad text to a max of ~2 lines.
## Sync to a voiceover / narration
The transcript already carries the timing of the exact audio. Lay that same audio under the composition and keep timestamps in the audio's timebase — captions and voice stay locked with zero manual nudging. If the VO is re-recorded, re-transcribe; never hand-shift offsets.
## Burn-in vs sidecar SRT/VTT
| | Burn-in (open) | Sidecar SRT/VTT (closed) |
|---|---|---|
| Where | Pixels in the MP4 | Separate `.srt`/`.vtt` file |
| Social (TikTok/Reels) | Required — guaranteed, styleable | Often ignored by the platform |
| Accessibility (ADA/WCAG) | Does not satisfy alone | Required (toggleable) |
| Best practice | Burn animated captions for social | Also ship a sidecar for the web/SEO |
Emit both: burn the animated captions for social, and write a plain SRT/VTT from the same tokens for accessible/SEO playback.
## Output checklist
- Word-level timestamps (real, from transcription) — no even-split fakery.
- Per-word pop with micro-overshoot; one accent color for the active word.
- 1–4 words per page; never a wall of text.
- Bold sans, 56–80px, 2–6px stroke + shadow, high contrast.
- Center band, clear of bottom ~280px and side rails.
- Audio laid under composition; timestamps in the audio timebase.
- Ship burn-in for social and a sidecar SRT/VTT for accessibility.
## 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`.
Captions ship as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — all word motion frame-driven off `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` (burned-in) + the project + the sidecar SRT/VTT. 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Word timing is the thing that breaks; check it at exact frames before you spend an encode.
```bash
# Stills at start / a sampled active-word frame / end — WITH SHIPPED PROPS (real tokens + audio)
npx remotion still Captions out/f-start.png --frame=0 --props='{"captionsSrc":"vo.json"}'
npx remotion still Captions out/f-mid.png --frame=90 --props='{"captionsSrc":"vo.json"}'
npx remotion still Captions out/f-end.png --frame=N --props='{"captionsSrc":"vo.json"}' # N = durationInFrames-1
# Inspect each PNG:
# - the word highlighted at frame 90 is the word whose [startMs,endMs] contains 90/fps (no drift)
# - burn-in legible: bold sans, stroke+shadow holds, no clipping
# - 9:16 safe area: caption sits in the center band, clear of top ~12% and bottom ~20-35% (captions/CTA/audio UI) and the right action rail
npx remotion render Captions out/captions.mp4 --props='{"captionsSrc":"vo.json"}' # encode once stills are right
npx remotion render Captions out/demo.gif --codec=gif # README proof clip
```
Use `npx remotion compositions` to read `durationInFrames`/`fps` and pick the active-word + end frames.
**Before you finish:**
1. Stills render cleanly at frame 0, a mid active-word frame, and last — no missing font/audio.
2. The correct word is highlighted at the sampled frame (frame/fps lands inside its token); no even-split fakery.
3. Burn-in is legible (stroke+shadow) and the caption is fully inside the 9:16 safe area at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers.
5. Shipped props (real tokens, not just `defaultProps`) render correctly; MP4 + sidecar SRT/VTT emitted, GIF optional.
## Reference files
- `references/word-timed-captions.md` — end-to-end build: Whisper transcription and the `Caption` type, a full SRT parser, Remotion word-timed component with active-highlight, manual paging, an SRT/VTT emitter, per-platform safe-area maps, and a readable-type spec sheet.
@@ -1,254 +0,0 @@
# Word-Timed Captions — End-to-End Build
A complete, runnable path from audio/transcript to animated burn-in captions plus a sidecar SRT/VTT. Code targets Remotion for burn-in and plain JS for the web; the data shapes are framework-agnostic.
## 1. The one data shape
Everything normalizes to a flat array of word tokens. Get to this shape and the renderer never cares about the source.
```ts
export type Token = { text: string; startMs: number; endMs: number };
export type Page = { startMs: number; endMs: number; tokens: Token[] };
```
Remotion's `@remotion/captions` uses a compatible `Caption` type:
```ts
type Caption = { text: string; startMs: number; endMs: number; timestampMs: number | null; confidence: number | null };
```
## 2. Transcribe to word-level timing (Whisper)
Word timestamps are non-negotiable for karaoke. Whisper produces them natively.
```bash
npx @remotion/install-whisper-cpp # one-time: installs whisper.cpp + a model
```
```ts
import { transcribe, toCaptions } from "@remotion/install-whisper-cpp";
const { transcription } = await transcribe({
inputPath: "voiceover.wav", // 16kHz mono WAV is ideal for whisper.cpp
model: "medium.en", // medium.en ≈ 2x faster than large, accurate on clean voice
tokenLevelTimestamps: true, // <-- the key flag: per-word, not per-line
whisperPath: "./whisper.cpp",
});
const { captions } = toCaptions({ transcription }); // → Caption[]
```
Tips:
- Feed clean voice audio (denoise/normalize first). Background music wrecks word boundaries.
- `large` is most accurate but 2–3x real-time on a laptop. Use `medium.en` unless accuracy is critical.
- Cloud alternatives (AssemblyAI, Deepgram, OpenAI Whisper API) also return word timing — map their `words[]` into `Token` the same way.
Map any token shape into `Token`:
```ts
const toTokens = (caps: Caption[]): Token[] =>
caps.map(c => ({ text: c.text.trim(), startMs: c.startMs, endMs: c.endMs }));
```
## 3. Parse an existing SRT into tokens
When handed an SRT, parse cues; if cues are sentence-level, re-transcribe for word timing rather than interpolating.
```ts
const toMs = (t: string) => {
const [h, m, rest] = t.trim().split(":");
const [s, ms] = rest.split(",");
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
};
export function parseSrt(src: string): Token[] {
return src.trim().split(/\n\s*\n/).map(block => {
const lines = block.split("\n");
const time = lines.find(l => l.includes("-->"))!;
const [a, b] = time.split("-->");
const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
return { text, startMs: toMs(a), endMs: toMs(b) };
});
}
```
## 4. Page the tokens (1–4 words at a time)
Either use Remotion's helper or group manually. Manual grouping by gap + max-words keeps full control:
```ts
export function paginate(tokens: Token[], maxWords = 3, maxGapMs = 1200): Page[] {
const pages: Page[] = [];
let cur: Token[] = [];
for (const t of tokens) {
const span = cur.length ? t.endMs - cur[0].startMs : 0;
if (cur.length >= maxWords || span > maxGapMs) {
if (cur.length) pages.push(pageOf(cur)); cur = [];
}
cur.push(t);
}
if (cur.length) pages.push(pageOf(cur));
return pages;
}
const pageOf = (ts: Token[]): Page => ({ startMs: ts[0].startMs, endMs: ts.at(-1)!.endMs, tokens: ts });
```
Remotion equivalent:
```ts
import { createTikTokStyleCaptions } from "@remotion/captions";
const { pages } = createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds: 1200 });
// 200–500ms → word-by-word; 1000–1500ms → short readable phrases
```
## 5. Remotion component: word-timed with active highlight
```tsx
import { AbsoluteFill, Audio, Sequence, useCurrentFrame, useVideoConfig,
spring, interpolate, staticFile } from "remotion";
const CAPTION_STYLE: React.CSSProperties = {
fontFamily: "Montserrat, Inter, sans-serif",
fontWeight: 800,
fontSize: 72, // ~8% of 1920px height
color: "#fff",
textAlign: "center",
textTransform: "uppercase",
WebkitTextStroke: "5px #000", // heavy outline survives any background
paintOrder: "stroke fill", // stroke behind fill, not clipping glyphs
textShadow: "0 4px 16px rgba(0,0,0,.55)",
lineHeight: 1.1,
padding: "0 80px", // keep inside center 80% width
};
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = (token.startMs / 1000) * fps;
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
return (
<span style={{
display: "inline-block",
transform: `scale(${interpolate(p, [0, 1], [0.6, 1])})`,
opacity: interpolate(p, [0, 1], [0, 1]),
color: active ? "#FFE45E" : undefined,
transition: "color 80ms linear",
}}>{token.text}&nbsp;</span>
);
};
export const Captions: React.FC<{ pages: Page[]; audio: string }> = ({ pages, audio }) => {
const { fps } = useVideoConfig();
const f = (ms: number) => Math.round((ms / 1000) * fps);
const frame = useCurrentFrame();
return (
<AbsoluteFill>
<Audio src={staticFile(audio)} /> {/* same audio the transcript came from */}
{pages.map((pg, i) => (
<Sequence key={i} from={f(pg.startMs)} durationInFrames={Math.max(1, f(pg.endMs - pg.startMs))}>
<AbsoluteFill style={{ justifyContent: "flex-start", alignItems: "center", paddingTop: "62%" }}>
<div style={CAPTION_STYLE}>
{pg.tokens.map((t, j) => {
const now = (frame / fps) * 1000;
return <Word key={j} token={t} active={now >= t.startMs && now < t.endMs} />;
})}
</div>
</AbsoluteFill>
</Sequence>
))}
</AbsoluteFill>
);
};
```
Because the `<Audio>` and the timestamps share one timebase, voice and captions stay locked with no manual nudging. Re-record the VO → re-transcribe → done.
## 6. Web/CSS path (live DOM player)
```js
function renderTrack(tokens, mountEl) {
tokens.forEach(t => {
const el = document.createElement("span");
el.className = "word";
el.textContent = t.text + " ";
el.style.animationDelay = `${t.startMs}ms`; // each word pops on its own start
mountEl.append(el);
});
}
// drive .active off the media element's currentTime
audio.addEventListener("timeupdate", () => {
const ms = audio.currentTime * 1000;
[...track.children].forEach((el, i) =>
el.classList.toggle("active", ms >= tokens[i].startMs && ms < tokens[i].endMs));
});
```
```css
.cap { font: 800 72px/1.1 Montserrat, sans-serif; text-transform: uppercase;
color:#fff; -webkit-text-stroke:5px #000; paint-order:stroke fill;
text-shadow:0 4px 16px rgba(0,0,0,.55); text-align:center; }
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
.word.active { color:#FFE45E; }
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
```
## 7. Emit a sidecar SRT / VTT from the same tokens
Burn-in alone does not satisfy ADA/WCAG and many platforms ignore uploaded tracks — so ship both. Group tokens into readable cues and serialize:
```ts
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
const stamp = (ms: number, sep: string) => {
const h = Math.floor(ms / 3.6e6), m = Math.floor(ms % 3.6e6 / 6e4),
s = Math.floor(ms % 6e4 / 1000), x = ms % 1000;
return `${pad(h)}:${pad(m)}:${pad(s)}${sep}${pad(x, 3)}`;
};
export const toSrt = (pages: Page[]) => pages.map((p, i) =>
`${i + 1}\n${stamp(p.startMs, ",")} --> ${stamp(p.endMs, ",")}\n${p.tokens.map(t => t.text).join(" ")}`
).join("\n\n") + "\n";
export const toVtt = (pages: Page[]) => "WEBVTT\n\n" + pages.map(p =>
`${stamp(p.startMs, ".")} --> ${stamp(p.endMs, ".")}\n${p.tokens.map(t => t.text).join(" ")}`
).join("\n\n") + "\n";
```
Use SRT for the broadest platform support; VTT when styling/positioning cues or serving on the web.
## 8. Per-platform safe areas (1080×1920, 9:16)
| Platform | Bottom keep-clear | Top keep-clear | Side keep-clear | Notes |
|---|---|---|---|---|
| TikTok | ~340px | ~120px | ~110px right rail | Captions, audio title, action buttons crowd the bottom-right |
| Instagram Reels | ~280px | ~130px | ~60px | Username/CTA bar at bottom |
| YouTube Shorts | ~240px | ~100px | ~120px right rail | Title + channel at bottom-left |
| Facebook Reels | ~300px | ~130px | ~80px | Similar to Reels |
Safe rule across all: place caption baseline at ~62–70% of frame height, keep text within the center 80% width, and never let it enter the bottom ~15%.
## 9. Readable-type spec sheet
| Property | Recommended | Floor |
|---|---|---|
| Weight | 800 (ExtraBold) | 700 |
| Size @1080p | 56–80px | 45px |
| Stroke | 4–6px solid #000 | 2px |
| Shadow | `0 4px 16px rgba(0,0,0,.55)` | soft 1px |
| Active accent | one color (e.g. #FFE45E) or filled box | — |
| Lines on screen | 1–2 | 2 max |
| Words per page | 1–4 | 5 max |
Set `paint-order: stroke fill` so a thick stroke sits behind the glyph instead of eating it. Test on a bright frame and a busy frame; if it survives both, it ships.
## 10. Render
```bash
npx remotion render src/index.ts Captions out/captions.mp4 \
--props='{"pages": ..., "audio": "voiceover.wav"}'
```
A 10-minute video with animated captions can take 30–60 min to render locally; render long-form in segments, or render on Remotion Lambda for parallel speed.
---
## 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 auto-time captions to a narration track and one-click burn them in from a template — change the text/audio and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=caption-animation)
-25
View File
@@ -1,25 +0,0 @@
# Countdown Video
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch and flash-sale countdowns, and looping backgrounds. For creators who need a timer that lands on zero on the exact frame.
## When it activates
- "Make a countdown timer video" or "create a countdown animation."
- "Build a stream starting soon countdown" or "do an event/launch/sale countdown."
- "Animate a mm:ss or dd:hh:mm:ss timer."
- "Add digit roll/flip transitions to a counter" or "loop a countdown background."
## Example prompts
- "Build a 10-minute 'be right back' livestream countdown."
- "Make a dd:hh:mm:ss launch countdown to a fixed target date."
- "Add flip transitions to an mm:ss timer that hits zero on the exact frame."
## What's inside
- `SKILL.md` — frame-accurate (drift-free) timers, time formatting, and a configurable duration prop.
- `references/countdown-cookbook.md` — recipes for common countdown scenarios.
- `references/digit-flip.md` — digit flip/roll transition techniques.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=countdown-video)** — the AI motion agent for editable, on-brand motion graphics.
-152
View File
@@ -1,152 +0,0 @@
---
name: countdown-video
description: This skill should be used when the user asks to "make a countdown timer video", "create a countdown animation", "build a stream starting soon countdown", "do an event/launch/sale countdown", "animate a mm:ss or dd:hh:mm:ss timer", "add digit roll/flip transitions to a counter", or "loop a countdown background". Covers frame-accurate (drift-free) timers, digit flip/roll motion, time formatting, and a configurable duration prop.
version: 0.1.0
---
# Countdown Video
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch/sale countdowns, and looping backgrounds.
## When to use
- "Starting soon" / "be right back" livestream screens with a 5–10 min timer.
- Launch and flash-sale countdowns (dd:hh:mm:ss down to a target date; mm:ss for urgency).
- Any animated counter where the number must land on zero on the exact frame.
## The one rule: drive the number from the clock, never a tick
A `setInterval(fn, 1000)` countdown **drifts** — the callback fires *at least* 1000 ms later, never exactly, and stacks up when the tab is throttled or a render frame is slow. Reports of ~1 second lost per minute are common. The fix is the same for rendered video and live web: compute remaining time as a pure function of an authoritative clock, so a late frame self-corrects on the next frame instead of accumulating error.
| Context | Authoritative clock | Remaining time |
|---|---|---|
| Remotion render | `useCurrentFrame()` | `total − frame/fps` |
| Web (live page) | `performance.now()` or `Date.now()` | `targetMs − now` |
| To a fixed date | `Date.now()` | `targetTimestamp − Date.now()` |
Never store remaining time in state and decrement it. Recompute it every frame.
## Frame-accurate countdown (Remotion → MP4)
`useCurrentFrame()` is deterministic: frame N is always rendered at exactly `N/fps` seconds. Subtract from the total and the timer is drift-free by construction.
```tsx
import { useCurrentFrame, useVideoConfig } from "remotion";
export const Countdown: React.FC<{ durationInSeconds: number }> = ({
durationInSeconds,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// remaining seconds, clamped at 0 — ceil so "1" shows for its full second
const elapsed = frame / fps;
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
return <div style={{ fontVariantNumeric: "tabular-nums" }}>{formatMMSS(remaining)}</div>;
};
```
`durationInSeconds` is a **prop**, not a constant — the same composition renders a 5-minute stream timer or a 30-second sale timer by changing one input. Set the composition's `durationInFrames` to `durationInSeconds * fps` so the video is exactly as long as the countdown.
## Web timer (live page, requestAnimationFrame)
For a live page, drive an rAF loop from `performance.now()` and only repaint when the displayed second changes (no work 60×/sec for a 1 Hz number):
```js
const start = performance.now();
let lastShown = -1;
function tick(now) {
const remaining = Math.max(0, Math.ceil(duration - (now - start) / 1000));
if (remaining !== lastShown) { render(remaining); lastShown = remaining; }
if (remaining > 0) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
```
Counting down to a fixed date is the same idea with `Date.now()`: `Math.max(0, target - Date.now())`. See `references/countdown-cookbook.md` for the full date-target version with timezone handling.
## Time formatting
Break a remaining-seconds integer into fields with division + modulo, then zero-pad. Pick the format to fit the duration — never show `00d 00h 05m` when minutes is all that matters.
```js
const pad = (n) => String(n).padStart(2, "0");
const formatMMSS = (s) => `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
const formatDHMS = (s) => {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60), sec = s % 60;
return `${pad(d)}:${pad(h)}:${pad(m)}:${pad(sec)}`;
};
```
| Format | Use | Drop fields when |
|---|---|---|
| `mm:ss` | stream "starting soon", short timers (<1h) | — |
| `hh:mm:ss` | flash sale, same-day event | hide `dd` while 0 |
| `dd:hh:mm:ss` | launch / multi-day countdown | hide leading 0 units for cleaner read |
Always set `font-variant-numeric: tabular-nums` (or a monospace digit font) so digits keep equal width and the layout does not jitter as numbers change.
## Digit roll / flip transitions
A flat number swap reads as cheap; the rolling "odometer" look sells the countdown. Render each digit as a vertical strip 0–9 and translate it by `-digit × digitHeight`. Animate the *position*, not the text content, so the change is a glide, not a pop.
```css
.reel { height: 1em; overflow: hidden; }
.reel .col { transition: transform .45s cubic-bezier(.22,1,.36,1); }
/* col holds stacked 0..9; shift by -value em */
```
In Remotion, drive the strip offset off the frame instead of a CSS transition (so it is deterministic). The flip-card style (top half folds down over the bottom) and the per-digit reel are both detailed, with runnable components, in `references/digit-flip.md`.
## Looping background
Stream screens hold for minutes, so the background must loop seamlessly. Build motion on a cycle that divides evenly into the timeline (e.g. a gradient or particle drift whose period is `loopFrames`), and read it as `frame % loopFrames` so there is no visible seam at the wrap. Keep it low-contrast and slow — the number is the subject; the background is ambience. Recipes in `references/countdown-cookbook.md`.
## Output checklist
- Remaining time recomputed from `useCurrentFrame()` / `performance.now()` every frame — no `setInterval` decrement.
- Timer hits `00:00` on the exact intended frame; `durationInFrames === durationInSeconds × fps`.
- Duration is an input prop, not a hardcoded constant.
- Digits use `tabular-nums`; layout does not shift as numbers change.
- Format matches the span (mm:ss vs dd:hh:mm:ss); leading zero units dropped when distracting.
- Background loop period divides the timeline evenly; no seam at the wrap.
## 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`.
The countdown ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, `durationInSeconds` a prop) — the timer is a pure function of `useCurrentFrame()`, never `setInterval` / `Date.now()` / `Math.random()`. Set `durationInFrames = durationInSeconds * fps`. Deliverable = `out/*.mp4` + the project. 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** The whole point is frame-accuracy, so check the *digit value* at exact frames before encoding.
```bash
# Stills at start / mid / end — WITH SHIPPED PROPS (the real duration you'll render)
npx remotion still Countdown out/f-start.png --frame=0 --props='{"durationInSeconds":300}'
npx remotion still Countdown out/f-mid.png --frame=N --props='{"durationInSeconds":300}'
npx remotion still Countdown out/f-end.png --frame=L --props='{"durationInSeconds":300}' # L = durationInFrames-1
# Inspect each PNG — the digits must read EXACTLY the frame/fps math:
# - at frame N, displayed = ceil(durationInSeconds - N/fps) (e.g. 300s @ 30fps, frame 4500 → 150s → "02:30")
# - last frame reads 00:00 (or the end-state hold); no drift, no off-by-one second
# - tabular-nums: layout does not jitter between frames
# - 9:16 safe area: the timer + brand block sit clear of top ~12% and bottom ~20-35% UI and the right action rail
npx remotion render Countdown out/countdown.mp4 --props='{"durationInSeconds":300}' # encode once digits verify
npx remotion render Countdown out/demo.gif --codec=gif # README proof clip
```
Use `npx remotion compositions` to confirm `durationInFrames`/`fps`, then compute the frame for any second you want to check.
**Before you finish:**
1. Stills render cleanly at frame 0, mid, and last — no errors.
2. The digit value at frame N equals `ceil(durationInSeconds - N/fps)` exactly; last frame hits 00:00 on the intended frame (no drift / off-by-one).
3. Timer + brand stay inside the 9:16 safe area at every checked frame.
4. Frame-driven only — no `setInterval` / `Date.now()` / timers; `tabular-nums` so layout holds.
5. Shipped `durationInSeconds` (not just `defaultProps`) renders correctly; MP4 encoded, GIF optional.
## Reference files
- `references/countdown-cookbook.md` — complete runnable Remotion `<Countdown>` (configurable duration, mm:ss / dd:hh:mm:ss, end-state hold), the count-down-to-a-fixed-date web component with timezone handling, a "starting soon" stream-screen layout (brand block + social handles + looping gradient background), and the seamless-loop background pattern.
- `references/digit-flip.md` — two deterministic digit-animation components: the odometer reel (vertical 0–9 strip driven by frame) and the flip-card (top-half fold) effect, plus when each suits launch vs sale vs stream.
@@ -1,205 +0,0 @@
# Countdown Cookbook
Complete, runnable recipes. Every value is derived from an authoritative clock (frame or timestamp) so nothing drifts.
## 1. Configurable Remotion countdown (mm:ss + dd:hh:mm:ss)
A single composition that handles any duration and either format. Drop into `src/Countdown.tsx`.
```tsx
import React from "react";
import { useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
const pad = (n: number) => String(n).padStart(2, "0");
const formatTime = (totalSeconds: number, mode: "mm:ss" | "dd:hh:mm:ss") => {
const s = Math.max(0, totalSeconds);
if (mode === "mm:ss") {
return `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
}
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
// drop leading day field once it is zero, for a cleaner read
return d > 0
? `${pad(d)}d ${pad(h)}:${pad(m)}:${pad(sec)}`
: `${pad(h)}:${pad(m)}:${pad(sec)}`;
};
export type CountdownProps = {
durationInSeconds: number;
format?: "mm:ss" | "dd:hh:mm:ss";
label?: string;
};
export const Countdown: React.FC<CountdownProps> = ({
durationInSeconds,
format = "mm:ss",
label = "",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// ceil so the last whole second ("1") is shown for its full duration,
// and the display reaches "0" exactly on the final frame.
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
const ended = remaining === 0;
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
background: "#0b0d17",
color: "#fff",
fontFamily: "Inter, system-ui, sans-serif",
}}
>
{label && (
<div style={{ fontSize: 36, opacity: 0.7, marginBottom: 16, letterSpacing: 4 }}>
{label.toUpperCase()}
</div>
)}
<div
style={{
fontSize: 180,
fontWeight: 800,
fontVariantNumeric: "tabular-nums", // equal-width digits → no jitter
lineHeight: 1,
color: ended ? "#ff4d4d" : "#fff",
}}
>
{ended ? formatTime(0, format) : formatTime(remaining, format)}
</div>
</AbsoluteFill>
);
};
```
Register it so `durationInFrames` matches the countdown exactly — the video ends when the timer does:
```tsx
import { Composition } from "remotion";
import { Countdown } from "./Countdown";
const FPS = 30;
const DURATION_SECONDS = 300; // 5-minute "starting soon" timer
export const RemotionRoot = () => (
<Composition
id="Countdown"
component={Countdown}
durationInFrames={DURATION_SECONDS * FPS}
fps={FPS}
width={1920}
height={1080}
defaultProps={{ durationInSeconds: DURATION_SECONDS, format: "mm:ss", label: "Starting soon" }}
/>
);
```
Render any duration/format without touching code — duration is a prop:
```bash
npx remotion render Countdown out/sale.mp4 \
--props='{"durationInSeconds":86400,"format":"dd:hh:mm:ss","label":"Sale ends in"}'
```
To hold a final state (e.g. "WE'RE LIVE") instead of ending on `00:00`, extend `durationInFrames` past the countdown and branch on `ended`.
## 2. Count down to a fixed date (live web component)
For a real-time page that targets a calendar moment. Drift-free because remaining time is `target − Date.now()` recomputed each frame, never a decrement.
```js
// targetISO must include an offset, e.g. "2026-07-01T09:00:00-07:00",
// so the deadline is unambiguous regardless of the viewer's timezone.
function startCountdown(targetISO, el) {
const target = new Date(targetISO).getTime();
const pad = (n) => String(n).padStart(2, "0");
let lastShown = -1;
function tick() {
const remainingMs = Math.max(0, target - Date.now());
const s = Math.floor(remainingMs / 1000);
if (s !== lastShown) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
el.textContent = `${pad(d)}:${pad(h)}:${pad(m)}:${pad(s % 60)}`;
lastShown = s;
}
if (remainingMs > 0) requestAnimationFrame(tick);
else el.textContent = "00:00:00:00"; // or swap to an "It's live" state
}
requestAnimationFrame(tick);
}
```
Timezone notes:
- Bake the offset into the target string (`-07:00`, `Z`). A bare `"2026-07-01T09:00:00"` is parsed as the viewer's *local* time and every viewer sees a different deadline.
- To show "ends 9am in *your* timezone everywhere," instead pick the wall-clock time per locale and let each viewer's `Date` resolve it — but that is a different deadline per region. Decide which you want before building.
## 3. "Starting soon" stream screen
Livestream lead-in screens hold 5–10 minutes: a clean brand block, social handles, and a calm looping background, with the timer as the focal point. Keep it low-clutter — chat and music carry the wait; the screen just signals "we're about to start."
```tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
export const StartingSoon: React.FC<{ durationInSeconds: number; handle: string; brand: string }> = ({
durationInSeconds,
handle,
brand,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
const pad = (n: number) => String(n).padStart(2, "0");
// seamless looping gradient: angle cycles over loopFrames with no seam
const loopFrames = fps * 8;
const angle = interpolate(frame % loopFrames, [0, loopFrames], [0, 360]);
return (
<AbsoluteFill
style={{
background: `linear-gradient(${angle}deg, #141a3a, #0b0d17 60%)`,
color: "#fff",
fontFamily: "Inter, system-ui, sans-serif",
justifyContent: "center",
alignItems: "center",
}}
>
<div style={{ fontSize: 64, fontWeight: 800, marginBottom: 8 }}>{brand}</div>
<div style={{ fontSize: 28, opacity: 0.6, letterSpacing: 6, marginBottom: 40 }}>
STARTING SOON
</div>
<div style={{ fontSize: 200, fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>
{pad(Math.floor(remaining / 60))}:{pad(remaining % 60)}
</div>
<div style={{ position: "absolute", bottom: 60, fontSize: 30, opacity: 0.7 }}>{handle}</div>
</AbsoluteFill>
);
};
```
Export as MP4 and add it as a Media/Video source in OBS, or use a looping playout for a browser source. Common durations: 300s (5 min) or 600s (10 min) at 1920×1080.
## 4. Seamless looping background
Any motion whose period divides the timeline evenly wraps without a seam. Read animation phase as `frame % loopFrames` so the value at the wrap equals the value at frame 0.
```tsx
const loopFrames = fps * 6; // one full cycle every 6 seconds
const phase = (frame % loopFrames) / loopFrames; // 0 → 1, repeats
const driftY = Math.sin(phase * Math.PI * 2) * 40; // returns to start each cycle
```
Use sine/cosine (inherently periodic) or `interpolate(frame % loopFrames, [0, loopFrames], [a, a])` with matching start/end so there is no jump. Keep contrast low and motion slow — the number is the subject.
---
## 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 render countdown videos from a template with a set time and brand — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=countdown-video)
@@ -1,146 +0,0 @@
# Digit Flip & Roll Transitions
Two ways to animate a digit changing. Both are driven by the frame (deterministic) so they render identically every time — no CSS transition clock that the renderer can disagree with.
Pick by tone:
| Effect | Feel | Best for |
|---|---|---|
| Odometer reel | smooth, continuous glide | launch countdowns, premium/minimal timers |
| Flip card | mechanical "snap", split-flap board | sale urgency, retro/airport-board style |
## Shared idea
A single displayed digit is a stack of glyphs `0..9` laid out vertically. Showing digit `d` means translating the stack so glyph `d` sits in the window: `translateY(-d × digitHeight)`. Animate the *offset*, not the text, and you get a roll. The trick for a countdown is that the offset is continuous: between whole seconds the digit eases from its old value toward its new one.
## Odometer reel (Remotion, frame-driven)
```tsx
import React from "react";
import { useCurrentFrame, useVideoConfig, interpolate, Easing } from "remotion";
const DIGIT_H = 160; // px per glyph
const ROLL_FRAMES = 12; // how long a single digit roll takes
// Animated single digit: rolls from `prev` to `value` over the last ROLL_FRAMES
// of the current second.
const Reel: React.FC<{ value: number; prev: number; secondProgress: number }> = ({
value,
prev,
secondProgress,
}) => {
// secondProgress: 0→1 across the current whole second.
// Start the roll near the end of the second so the number "settles" on change.
const t = interpolate(secondProgress, [0.7, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.bezier(0.22, 1, 0.36, 1),
});
// continuous position between prev and value
const shown = prev + (value - prev) * t;
const offset = -shown * DIGIT_H;
return (
<div style={{ height: DIGIT_H, width: 110, overflow: "hidden", position: "relative" }}>
<div style={{ transform: `translateY(${offset}px)` }}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<div
key={n}
style={{
height: DIGIT_H,
lineHeight: `${DIGIT_H}px`,
textAlign: "center",
fontSize: 140,
fontWeight: 800,
fontVariantNumeric: "tabular-nums",
}}
>
{n}
</div>
))}
</div>
</div>
);
};
// Renders an mm:ss reel where each digit rolls.
export const ReelClock: React.FC<{ durationInSeconds: number }> = ({ durationInSeconds }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const elapsed = frame / fps;
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
const prevRemaining = Math.max(0, remaining + 1); // value one second ago
const secondProgress = elapsed % 1; // 0→1 within the current second
const digits = (s: number) =>
[Math.floor(s / 600) % 10, Math.floor(s / 60) % 10, Math.floor(s / 10) % 10, s % 10];
const cur = digits(remaining);
const prev = digits(prevRemaining);
return (
<div style={{ display: "flex", gap: 8, color: "#fff", background: "#0b0d17", padding: 40 }}>
{cur.map((d, i) => (
<React.Fragment key={i}>
{i === 2 && <div style={{ fontSize: 140, fontWeight: 800 }}>:</div>}
<Reel value={d} prev={prev[i]} secondProgress={secondProgress} />
</React.Fragment>
))}
</div>
);
};
```
Notes:
- Each digit only rolls when it actually changes; a digit whose `prev === value` sits still because `shown` stays constant.
- A `9 → 0` rollover rolls *backward* through the strip (the cheap-looking part). To make tens/units roll the natural way on a carry, render an extra `0` glyph below the `9` and clamp — covered as an exercise; for most timers the backward roll at the tens boundary is unnoticeable at speed.
- `ROLL_FRAMES` is unused above because the roll is tied to `secondProgress`; expose it instead if a fixed-length roll is preferred.
## Flip card (split-flap)
The top half of the old digit folds down over a static bottom half, revealing the new digit. Pure CSS keyframe version — fine for a live web page; for Remotion, drive the `rotateX` off `secondProgress` the same way as the reel so it stays deterministic.
```css
.flip { position: relative; width: 110px; height: 160px; perspective: 600px; }
.flip .card {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 140px; font-weight: 800; font-variant-numeric: tabular-nums;
backface-visibility: hidden;
}
.flip .top { clip-path: inset(0 0 50% 0); transform-origin: bottom; }
.flip .bottom { clip-path: inset(50% 0 0 0); }
/* on change, animate the leaf folding down */
.flip .leaf {
position: absolute; inset: 0; transform-origin: bottom;
animation: fold .45s cubic-bezier(.36,0,.66,-0.2) forwards;
}
@keyframes fold {
0% { transform: rotateX(0deg); }
100% { transform: rotateX(-90deg); }
}
```
```js
// swap the digit and replay the fold whenever the value changes
function setFlip(el, newDigit) {
if (el.dataset.v === String(newDigit)) return; // no change → no flip
el.dataset.v = String(newDigit);
el.querySelector(".top").textContent = newDigit;
el.querySelector(".bottom").textContent = newDigit;
const leaf = el.querySelector(".leaf");
leaf.textContent = newDigit;
leaf.style.animation = "none";
void leaf.offsetWidth; // reflow → restart the animation
leaf.style.animation = "";
}
```
Drive `setFlip` from the same `requestAnimationFrame` + `performance.now()` loop in the cookbook (only call it when the displayed second changes), so the flips fire on real time and never drift.
## Choosing
- **Launch / premium:** odometer reel, slow ease, large tabular digits, muted palette.
- **Flash sale:** flip card for the mechanical urgency, or reel with a faster roll and a hot accent color on `mm:ss`.
- **Stream "starting soon":** either works, but keep it calm — a single reel on `mm:ss` reads better than a busy split-flap for a 5–10 minute hold.
-24
View File
@@ -1,24 +0,0 @@
# Lower Thirds
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers. For creators producing interviews, panels, podcasts, and broadcast-style titles.
## When it activates
- "Make an animated lower third" or "add lower thirds."
- "Create a name tag/name lower third" or "do a speaker name overlay."
- "Build broadcast titles" or "animate a title/name/role caption."
- "Template lower thirds across a list of names."
## Example prompts
- "Add a lower third with the speaker's name and role, branded to my colors."
- "Build a staggered slide-in name plate for a podcast guest."
- "Template one lower-third design across a roster of 12 panelists."
## What's inside
- `SKILL.md` — enter/exit animation, in/out timing and dwell, safe-area conventions, and brand styling.
- `references/lower-third-component.md` — a reusable lower-third component.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=lower-thirds)** — the AI motion agent for editable, on-brand motion graphics.
-146
View File
@@ -1,146 +0,0 @@
---
name: lower-thirds
description: This skill should be used when the user asks to "make an animated lower third", "add lower thirds", "create a name tag/name lower third", "build broadcast titles", "animate a title/name/role caption", "do a speaker name overlay", or "template lower thirds across a list of names". Covers enter/exit animation (slide/wipe/mask reveal), in/out timing & dwell, safe-area & broadcast conventions, name-vs-role type hierarchy, brand styling, and rendering one design across many names.
version: 0.1.0
---
# Lower Thirds
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers.
## When to use
- Speaker/interview name tags, panel rosters, talking-head captions.
- Broadcast-style titles, news straps, podcast/webinar name plates.
- Templating one design across a list of names/titles → many clips (the payoff).
## Anatomy
A lower third is two text lines plus a graphic bed, placed in the lower band:
| Element | Role | Type treatment |
|---|---|---|
| **Name** (primary) | Who | Largest, heaviest, tightest tracking |
| **Role/title** (secondary) | What/where | ~45–60% of name size, lighter weight, often a brand accent color |
| **Bar / panel / accent** | Reads against any background | Solid or 70–85% opacity; an accent rule ties to brand |
Keep it to two lines. A name line + role line is the whole job — extra lines erode the hierarchy and crowd the safe area.
## Three rules that prevent cheap-looking titles
1. **Never hard-cut a title on or off.** A bare appear/disappear reads as a mistake. Even a 12-frame fade or slide makes it feel produced.
2. **Stagger, don't slam.** Animate the bar first, then the name, then the role, each offset by 3–6 frames. Everything arriving at once feels flat; a short cascade feels designed.
3. **Hold long enough to read twice.** Roughly 3–5s on screen (read it aloud twice). Too short and it's missed; too long and it nags. Animate in/out, never mid-segment.
## Enter & exit animations
Pick one motion language and reuse it. The most reliable, premium-reading reveals:
| Reveal | Feel | How |
|---|---|---|
| **Slide + fade** | Clean, default | Translate in ~24–40px while fading; ease-out on enter |
| **Wipe bar** | Broadcast | A bar scales/sweeps in (`scaleX`/clip), text fades behind it |
| **Mask reveal** | Premium | Clip text to a moving rectangle so letters "wipe on" |
Use asymmetric easing: a snappy `ease-out` (`cubic-bezier(.22,1,.36,1)`) on enter, a quicker `ease-in` on exit. Exit faster than you entered (~⅔ the duration) so the title gets out of the way.
```css
/* Mask reveal: text wipes on left→right, no plugin needed */
.lt-name { clip-path: inset(0 100% 0 0); animation: wipe .5s cubic-bezier(.22,1,.36,1) forwards; }
@keyframes wipe { to { clip-path: inset(0 0 0 0); } }
```
## In/out timing & dwell
Think in three phases. Budget frames, not vibes (at 30fps):
| Phase | Budget | Note |
|---|---|---|
| Enter | 12–18f (0.4–0.6s) | Staggered: bar → name → role |
| Hold | 90–150f (3–5s) | The readable dwell; scale with line length |
| Exit | 8–12f (0.27–0.4s) | Faster than enter |
In code, drive every state from the current frame (or a paused timeline), not wall-clock time — a video renderer demands deterministic frames. Express enter/hold/exit as a single progress curve so the same component works live in a browser and frame-by-frame in a renderer.
## Safe area & broadcast conventions
The lower third must survive every screen and platform UI. Compose inside the **title-safe area** (inner ~90% / margin 5%) and respect platform chrome:
| Frame | Anchor | Avoid |
|---|---|---|
| 16:9 (1920×1080) | Left edge at ~5% margin; baseline ~80–85% down | Outer 5% (title-safe), action-safe outer 10% |
| 9:16 (1080×1920) | Same left margin; **lift the band up** | Bottom ~18% (captions/UI), top ~12% |
| 1:1 (1080×1080) | Lower band, center-left | Outer 5% |
Anchor to the **left** and grow rightward so names of different lengths stay pinned and never re-center awkwardly. Keep the role line within the same left margin as the name. For 9:16, do not park the title at the very bottom — platform captions and buttons sit there.
## Type hierarchy & brand styling
Hierarchy is the whole design. Make the name unmistakably dominant:
- **Size contrast** ≥ 1.6× (name vs role). If unsure, push the name bigger, not the role.
- **Weight + color**: name in bold/near-white; role in a lighter weight and a **brand accent** color (one accent, used consistently).
- **Contrast for legibility**: text rides over unknown footage. Use a solid/semi-opaque bar, a subtle scrim, or a soft text shadow so it reads over both bright and dark plates.
- **Brand lock**: pin font, accent color, bar style, and corner radius in one theme object so every title across a series matches.
## Templating one design across a list
The reason to build this in code: change the text/data, re-export — no re-animating. Make name/role/theme **props**, never hardcoded, then map a roster to instances.
```jsx
const ROSTER = [
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
{ name: "Alan Turing", role: "Mathematician" },
];
// One <LowerThird> design; map the list → many instances (sequence them in a renderer)
{ROSTER.map((p, i) => <LowerThird key={p.name} {...p} theme={BRAND} startFrame={i * 150} />)}
```
In a renderer, make the roster an input prop and render once per row (`--props`) to emit one branded clip per person — same animation, same brand, only the text changes. See `references/lower-third-component.md` for the full runnable component and batch script.
## Output checklist
- Two lines only: dominant name, secondary role (≥1.6× size contrast).
- Animated in and out (no hard cut); enter staggered bar→name→role; exit faster.
- Hold 3–5s (readable twice); motion only at the ends, never mid-segment.
- Left-anchored inside title-safe; 9:16 band lifted above the bottom-18% UI zone.
- Text legible over any plate (bar/scrim/shadow); one brand accent throughout.
- Name/role/theme are props — one template renders the whole roster.
## 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`.
The lower third ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, name/role/theme as props) — enter/dwell/exit all driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per roster row). 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Sample the three phases so you catch a stagger or safe-area bug before encoding (or batching the whole roster).
```bash
# Stills at enter / dwell / exit — WITH SHIPPED PROPS (a real roster row, not defaults)
npx remotion still LowerThird out/f-enter.png --frame=10 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
npx remotion still LowerThird out/f-dwell.png --frame=75 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
npx remotion still LowerThird out/f-exit.png --frame=N --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
# Inspect each PNG — focus the DWELL frame (fully on screen):
# - hierarchy holds: name dominant (>=1.6x role), role in brand accent; both fully revealed, not mid-wipe
# - brand lock: font, accent color, bar/scrim correct; legible over the plate
# - 9:16 safe area: band LIFTED above the bottom ~20-35% UI zone, clear of top ~12% and the right action rail; left-anchored inside title-safe
# - enter/exit frames show partial reveal (animation actually runs), not a hard cut
npx remotion render LowerThird out/lower-third.mp4 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
npx remotion render LowerThird out/demo.gif --codec=gif # README proof clip
```
**Batch roster**: verify ONE row via stills *before* rendering all names — catch a layout/overflow bug once, not N times. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the dwell + exit frames.
**Before you finish:**
1. Stills render cleanly at enter, dwell, and exit — no missing font/brand assets.
2. At the dwell frame: name dominant over role, brand accent + bar correct, fully (not partly) revealed.
3. The band is inside the 9:16 safe area — lifted above the bottom UI zone, clear of top and right rail — at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; enter/exit show motion, never a hard cut.
5. A real roster row (not `defaultProps`) renders correctly; MP4 encoded, GIF optional.
## Reference files
- `references/lower-third-component.md` — a complete runnable lower-third: a Remotion component with staggered spring enter, readable hold, and faster exit driven purely by frame; a typed theme object for brand lock; the list→many-instances sequencing pattern; a per-row batch render script; plus a dependency-free CSS/HTML version of the same design with mask-reveal and wipe-bar variants.
@@ -1,268 +0,0 @@
# Lower-Third Component — runnable code
A complete, production-grade lower third you can drop in. Two paths:
1. **Remotion** (frame-deterministic, renders to MP4, batches over a roster).
2. **CSS/HTML** (no dependencies, for the web or a quick preview) with mask-reveal and wipe-bar variants.
Both share the same design language: a left-anchored bar, a dominant name, a brand-accent role line, a staggered enter, a readable hold, and a faster exit.
---
## 1. Remotion component (props-driven, frame-deterministic)
The animation is a pure function of `frame`, so it renders identically every time and stays in sync with the renderer. Enter staggers the bar, name, then role; exit is computed from the end of the clip so the title leaves faster than it arrived.
```tsx
// LowerThird.tsx
import {
AbsoluteFill, Sequence, interpolate, spring,
useCurrentFrame, useVideoConfig,
} from "remotion";
// --- Brand lock: change once, every title matches -------------------------
export type Theme = {
font: string;
bar: string; // panel/bar color
name: string; // primary text color
role: string; // accent color for the role line
radius: number;
marginPct: number; // left/safe margin as % of width
};
export const BRAND: Theme = {
font: "Inter, system-ui, sans-serif",
bar: "rgba(12,14,22,0.86)",
name: "#FFFFFF",
role: "#6EE7F0", // single brand accent
radius: 10,
marginPct: 5,
};
export type LowerThirdProps = {
name: string;
role: string;
theme?: Theme;
holdSeconds?: number; // readable dwell, default 4s
};
// Staggered enter via spring; exit via a quick fade/slide near the end.
const useEnterExit = (delayFrames: number, holdSeconds: number) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// ENTER: spring 0→1, offset by delayFrames (the cascade)
const enter = spring({
frame: frame - delayFrames,
fps,
config: { damping: 200, stiffness: 180, mass: 0.6 },
});
// EXIT: faster than enter — last 10 frames of the clip
const exitStart = durationInFrames - 10;
const exit = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = enter * exit;
const x = interpolate(enter, [0, 1], [-32, 0]); // slide in from left
return { opacity, x };
};
const Line: React.FC<{
delay: number; holdSeconds: number; children: React.ReactNode;
size: number; weight: number; color: string; tracking?: number;
}> = ({ delay, holdSeconds, children, size, weight, color, tracking = 0 }) => {
const { opacity, x } = useEnterExit(delay, holdSeconds);
return (
<div style={{
opacity,
transform: `translateX(${x}px)`,
fontSize: size, fontWeight: weight, color,
letterSpacing: tracking, lineHeight: 1.15, whiteSpace: "nowrap",
}}>
{children}
</div>
);
};
export const LowerThird: React.FC<LowerThirdProps> = ({
name, role, theme = BRAND, holdSeconds = 4,
}) => {
const { width, height } = useVideoConfig();
const bar = useEnterExit(0, holdSeconds); // bar leads the cascade
const left = (width * theme.marginPct) / 100;
const baseline = height * 0.80; // sit ~80% down, inside title-safe
return (
<AbsoluteFill style={{ fontFamily: theme.font }}>
<div style={{
position: "absolute", left, top: baseline,
opacity: bar.opacity,
transform: `translateX(${bar.x}px)`,
}}>
{/* panel behind text guarantees legibility over any plate */}
<div style={{
background: theme.bar, borderRadius: theme.radius,
padding: "14px 22px 16px",
borderLeft: `4px solid ${theme.role}`, // accent rule ties to brand
backdropFilter: "blur(6px)",
}}>
<Line delay={4} holdSeconds={holdSeconds} size={46} weight={800} color={theme.name} tracking={-0.5}>
{name}
</Line>
<Line delay={8} holdSeconds={holdSeconds} size={26} weight={500} color={theme.role}>
{role}
</Line>
</div>
</div>
</AbsoluteFill>
);
};
```
Why each choice matters:
- **`spring` per line, offset by `delay`** gives the bar→name→role cascade. Same offsets everywhere keep titles consistent.
- **Exit derived from `durationInFrames`** means any clip length exits cleanly without per-instance tuning, and it's quicker than the enter (10f vs the spring's settle).
- **`whiteSpace: nowrap` + left anchor** keeps long and short names pinned to the same edge — no awkward re-centering across a roster.
- **`borderLeft` accent + semi-opaque panel** is the brand tie plus the legibility guarantee over unknown footage.
### Roster → many instances (one design, a whole panel)
Sequence the same component down a list. Each person gets a slot; only the text changes.
```tsx
// Roster.tsx
import { Series } from "remotion";
import { LowerThird, BRAND } from "./LowerThird";
const ROSTER = [
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
{ name: "Alan Turing", role: "Mathematician" },
];
export const Roster: React.FC = () => (
<Series>
{ROSTER.map((p) => (
<Series.Sequence key={p.name} durationInFrames={150 /* ~5s @30fps */}>
<LowerThird name={p.name} role={p.role} theme={BRAND} />
</Series.Sequence>
))}
</Series>
);
```
### Batch: one branded clip per person
Make the roster an input prop and render once per row, so each speaker becomes a standalone overlay clip — identical animation and brand, only the text differs.
```bash
#!/usr/bin/env bash
# render one transparent-friendly lower-third clip per person in roster.json
# roster.json: [{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}, ...]
set -euo pipefail
mkdir -p out
jq -c '.[]' roster.json | while read -r row; do
slug=$(echo "$row" | jq -r '.name' | tr '[:upper:] ' '[:lower:]-')
npx remotion render LowerThird "out/${slug}.mp4" --props="$row"
done
```
For overlay-ready output with transparency, render the `LowerThird` composition (transparent background) to a codec that keeps alpha, e.g. `--codec=prores --prores-profile=4444`, or `--image-format=png` for a PNG sequence.
---
## 2. CSS / HTML version (no dependencies)
The same design without a framework — for the web, OBS browser sources, or a fast look-test. Includes both a **slide+fade** default and a **mask-reveal** name, plus a **wipe-bar** variant.
```html
<div class="lt" style="--accent:#6EE7F0">
<div class="lt-bar">
<div class="lt-name">Ada Lovelace</div>
<div class="lt-role">Founder, Analytical Engines</div>
</div>
</div>
<style>
:root { font-family: Inter, system-ui, sans-serif; }
.lt {
position: absolute; left: 5%; top: 80%; /* title-safe, left-anchored */
}
.lt-bar {
display: inline-block;
background: rgba(12,14,22,0.86);
backdrop-filter: blur(6px);
border-left: 4px solid var(--accent);
border-radius: 10px;
padding: 14px 22px 16px;
/* bar leads the cascade: slide + fade in, then a faster fade out */
animation: lt-in .5s cubic-bezier(.22,1,.36,1) both,
lt-out .3s ease-in 4.5s forwards; /* hold ~4s, exit faster */
}
.lt-name {
font-size: 46px; font-weight: 800; color: #fff; letter-spacing: -.5px;
line-height: 1.15; white-space: nowrap;
/* MASK REVEAL: letters wipe on left→right, staggered after the bar */
clip-path: inset(0 100% 0 0);
animation: lt-wipe .5s cubic-bezier(.22,1,.36,1) .12s forwards;
}
.lt-role {
font-size: 26px; font-weight: 500; color: var(--accent);
line-height: 1.15; white-space: nowrap;
opacity: 0;
animation: lt-fade .4s ease-out .22s forwards; /* role arrives last */
}
@keyframes lt-in { from { opacity: 0; transform: translateX(-32px); }
to { opacity: 1; transform: none; } }
@keyframes lt-out { to { opacity: 0; transform: translateX(-16px); } }
@keyframes lt-wipe { to { clip-path: inset(0 0 0 0); } }
@keyframes lt-fade { to { opacity: 1; } }
/* Mobile/9:16: lift the band above the bottom UI/caption zone */
@media (max-aspect-ratio: 1/1) { .lt { top: 70%; } }
</style>
```
### Wipe-bar variant (broadcast feel)
Swap the bar animation so a colored bar sweeps in and the text rides behind it:
```css
.lt-bar.wipe {
position: relative; overflow: hidden;
animation: lt-in .45s cubic-bezier(.22,1,.36,1) both,
lt-out .3s ease-in 4.5s forwards;
}
.lt-bar.wipe::before {
content: ""; position: absolute; inset: 0;
background: var(--accent);
transform-origin: left;
animation: lt-sweep .55s cubic-bezier(.76,0,.24,1) both; /* sweep across, then reveal */
}
@keyframes lt-sweep { 0% { transform: scaleX(0); } 60% { transform: scaleX(1); }
100% { transform: scaleX(0); transform-origin: right; } }
```
The CSS clock (`animation`) is fine for live/web playback. For a deterministic MP4, prefer the Remotion path above — a frame renderer and CSS timers disagree, which causes flicker or desync at export.
---
## Quick reference
| Need | Knob |
|---|---|
| Slower/faster reveal | enter duration 12–18f / `.5s` |
| Longer dwell | `holdSeconds` / the `4.5s` exit delay |
| Bigger name dominance | name `fontSize` (keep ≥1.6× the role) |
| Rebrand everything | the `BRAND` theme object / `--accent` |
| 9:16 placement | `baseline 0.80→0.70` / the media query |
---
## 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 generate branded lower-thirds from a list of names/titles, one-click — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=lower-thirds)
-24
View File
@@ -1,24 +0,0 @@
# Short-Form Video
Engineer a 9:16 video that survives the swipe. Short-form is a retention machine, not a small launch film: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish. For creators making Reels, TikToks, and YouTube Shorts (7–60s, vertical).
## When it activates
- "Make a Reels/TikTok/YouTube Shorts video" or "build a short-form video template."
- "Write a hook for a short" or "fix the first 3 seconds."
- "Edit for retention," "add pattern interrupts," or "pace cuts for a vertical video."
- "Make a video loop" or "set up 9:16 safe areas."
## Example prompts
- "Build a reusable 9:16 short-form template — one design, many topics."
- "Fix the drop-off in this Reel: tighten pacing and add pattern interrupts."
- "Write three hook options for a 30-second educational short."
## What's inside
- `SKILL.md` — the retention arc, hook grammar, and pacing rules.
- `references/retention-pacing.md` — deeper guidance on retention editing and pacing.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=short-form-video)** — the AI motion agent for editable, on-brand motion graphics.
-155
View File
@@ -1,155 +0,0 @@
---
name: short-form-video
description: This skill should be used when the user asks to "make a Reels/TikTok/YouTube Shorts video", "build a short-form video template", "write a hook for a short", "edit for retention", "add pattern interrupts", "make a video loop", "fix the first 3 seconds", "pace cuts for a vertical video", or "set up 9:16 safe areas". Covers the hook→retention→loop grammar of vertical short-form (distinct from a launch hype film).
version: 0.1.0
---
# Short-Form Video
Engineer a 9:16 video that survives the swipe. Short-form is not a small launch film — it is a retention machine: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish.
## When to use
- Reels / TikTok / YouTube Shorts (7–60s, vertical 9:16).
- Retention editing: fixing drop-off, adding pattern interrupts, tightening pacing.
- Hooks, seamless loops, repeatable short-form templates (one design, many topics).
## Story structure (the retention contract)
Short-form storytelling is a contract with the viewer's thumb: every second must re-earn the next one. The narrative is shaped *backwards from the retention curve*, not forwards from an intro. Lock these before touching the timeline.
- **One idea per video.** A short carries exactly one payoff. A second idea splits attention and the curve sags in the middle — make it a second video instead. If you can't name the single takeaway in a sentence, you don't have a short yet.
- **Open a loop in the first ~3s, close it last.** The hook poses a question or gap (the open loop); the payoff answers it. The viewer stays because the loop is open — so never answer it early, and never bury the payoff under setup.
- **No dead air.** Every beat either advances the idea or resets attention. Cut anything that does neither. Silence, slow ramps, throat-clearing intros ("hey guys, in this video…") are where viewers swipe.
- **Why the curve, not the cut, is the unit of work.** The algorithm distributes whatever holds the per-second retention curve flat; a "good" edit that flattens the curve loses to an ugly one that keeps it up. You are editing the *curve*, and the hook gates everything after it — fix it first.
The narrative spine (the `Hook → Setup → Body → Payoff/loop` arc is budgeted in the table below):
| Move | Story job | Failure if missed |
|---|---|---|
| Hook | Open the loop; promise the payoff | Swipe in first 3s |
| Tension hold | Keep the gap open through the body | Flat middle, mid-drop |
| Payoff | Close the loop — the one idea, delivered | "Wasted my time," no share |
| Loop / CTA | Feed back to frame 0, or one clear ask | No re-watch, no action |
(`The retention arc` table below assigns seconds to each move; this section is the narrative logic and ordering behind it. `Hook grammar` lists the hook archetypes.)
## The one metric that drives everything
**Retention, not production value.** The algorithm distributes whatever holds viewers; ~87% decide to stay or swipe inside 3 seconds, and the threshold for distribution sits near **70%+ average view-through** (Shorts ~73%, TikTok ~78%, Reels ~65%). Every decision below trades against that curve. A flawless shot that bores at 0:04 loses to an ugly one that holds.
## The retention arc
| Beat | Job | Budget (of a 20s short) |
|---|---|---|
| Hook (0–1s) | One frame + one line that creates an open loop | 0–1s |
| Setup | Pay off *why watch* — promise, stakes, or the gap | 1–4s |
| Body | Deliver value in beats; one idea per beat | 4–17s |
| Payoff / loop | Close the loop — or feed it back to frame 0 | 17–20s |
Scale the *body*, never the hook. A 60s short has a longer body and more beats — the same 1-second hook.
## Hook grammar (first ~1s)
The hook is a sentence + a visual that opens a curiosity gap the brain needs closed. Pick one pattern and commit:
| Hook type | Shape | Best for |
|---|---|---|
| Open loop | "The reason your X keeps Y…" (answer delayed) | most content |
| Direct promise | "3 ways to ___ in 30 seconds" | educational / listicle |
| Pattern interrupt | snap-zoom / whip-pan / visual mismatch in frame 1 | entertainment |
| Negation | "Stop doing ___" / "You're ___ wrong" | strong opinions |
Two rules: the hook line is **on-screen as text by frame 1** (85% watch muted), and the most striking *visual* is also the first frame — never a slow ramp-in. Hold a strong opening frame; do not fade up from black.
## Pattern interrupts & cut rhythm
A pattern interrupt is any change that resets attention: a hard cut, zoom punch, b-roll insert, sound effect, caption pop, or angle change. In short-form, density is high — a *visual change every ~2–4 seconds*; videos with an interrupt roughly every 4s hold ~58% vs ~41% for a static talking head. The killer is a flat middle: TikTok throttles distribution when the watch-through curve flattens, so never stitch the body from low-energy takes.
```js
// Drive every interrupt off the FRAME, not wall-clock — renders deterministically.
import { useCurrentFrame } from "remotion";
const fps = 30;
const cuts = [0, 1.2, 3.0, 5.4, 8.0, 11.2, 14.0]; // seconds — uneven on purpose
const frame = useCurrentFrame();
const shot = cuts.findIndex((t, i) => frame < (cuts[i + 1] ?? Infinity) * fps);
```
Vary the interval — a metronome reads as boredom; uneven cuts read as momentum. A punch-in zoom is the cheapest interrupt that needs no new footage:
```jsx
import { useCurrentFrame, interpolate, spring, useVideoConfig } from "remotion";
const PunchIn = ({ at, children }) => {
const frame = useCurrentFrame(); const { fps } = useVideoConfig();
const s = spring({ frame: frame - at * fps, fps, config: { damping: 12, stiffness: 200 } });
const scale = interpolate(s, [0, 1], [1, 1.12]); // snap to 112% on the beat
return <div style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</div>;
};
```
## Captions
Most viewers watch muted, so on-screen captions are not optional — they carry the content and double as a pattern interrupt when each phrase pops in. Keep 4–7 words per line, high contrast, inside the safe area. **Do not re-implement caption animation here** — use the `caption-animation` skill for word-by-word timing, karaoke highlight, and styling; this skill only places captions on the retention timeline and inside the 9:16 safe zone.
## Loops & re-watch
A seamless loop counts each re-watch as fresh watch-time — a clean 8s loop watched 3× is 300% view-through, the strongest possible signal. Two techniques: match the **last frame to the first** (same composition, position, color) so the cut is invisible, and write a **sentence loop** — phrase the hook so the payoff feeds back into frame 0 ("…and that's why I never — " → loops to the start). Best length for re-watch is ~7–15s.
## 9:16 safe areas
Compose inside the universal safe zone so platform UI never covers text or faces.
| Zone | Pixels (1080×1920) | Avoid |
|---|---|---|
| Universal safe (all platforms) | center **900×1400** | anything critical outside it |
| Top | keep clear ~120px | profile / sound UI |
| Bottom | keep clear ~320px | captions, CTA, hashtags, audio bar |
| Right | keep clear ~120px | like / comment / share rail |
Put the hook text and key subject in the center 900×1400 box. Reserve the bottom ~320px even if it looks empty in the editor — that is where the platform paints its own captions and buttons.
## Output checklist
- Hook line is on-screen text by frame 1; strongest visual is the first frame (no fade-up).
- One open loop / promise set in the first ~4s.
- A visual pattern interrupt every ~2–4s; intervals uneven; no flat middle.
- Captions present (via `caption-animation`), 4–7 words, inside safe area.
- Loop closed — matched first/last frame or a sentence that feeds frame 0.
- Composed inside the 900×1400 center box; bottom 320px / top 120px / right 120px kept clear.
- Every animated value derived from `useCurrentFrame()` — no CSS/library timers.
## 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`.
The short ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — every cut, punch-in, and interrupt driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per topic). 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Three frames test the three things that make or break retention: the hook, the pacing, and the loop seam.
```bash
# Stills at hook / mid-body / last frame — WITH SHIPPED PROPS (the real topic, not defaults)
npx remotion still Short out/f-hook.png --frame=10 --props='{"topic":"...","hook":"..."}' # ~first 3s reads instantly
npx remotion still Short out/f-mid.png --frame=N --props='{"topic":"...","hook":"..."}'
npx remotion still Short out/f-end.png --frame=L --props='{"topic":"...","hook":"..."}' # L = durationInFrames-1
# Inspect each PNG:
# - HOOK frame (~frame 0-ish / first 3s): hook line is on-screen text and reads instantly; strongest visual already up (no fade-up)
# - LOOP seam: compare f-hook(frame 0) vs f-end(last frame) — same composition/position/color for an invisible loop cut
# - 9:16 safe area: hook + key subject inside the center 900x1400 box; clear of top ~12%, bottom ~20-35% (captions/CTA/audio), right action rail
npx remotion render Short out/short.mp4 --props='{"topic":"...","hook":"..."}' # encode once stills verify
npx remotion render Short out/demo.gif --codec=gif # README proof clip
```
**Per topic / batch**: verify ONE topic via stills *before* rendering all of them. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the mid + last frames.
**Before you finish:**
1. Stills render cleanly at the hook, mid, and last frame — no errors.
2. The hook frame reads instantly (on-screen text + strongest visual up front, no fade-up); first vs last frame match for a clean loop seam.
3. Hook + subject + captions sit inside the 9:16 center 900×1400 safe box at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; cuts uneven, no flat middle.
5. A real topic's shipped props (not `defaultProps`) render correctly; MP4 encoded, GIF optional.
## Reference files
- `references/retention-pacing.md` — a complete runnable Remotion `<Short>` composition: a frame-driven shot scheduler with uneven cuts, the PunchIn pattern interrupt, an on-screen hook, a 9:16 safe-area overlay, and a seamless first/last-frame loop. Plus a hook-pattern library, a retention-curve debugging method, per-platform safe-zone maps, and the template×topic batch-render pattern.
@@ -1,209 +0,0 @@
# Retention Pacing — Short-Form Video Reference
Deep reference for the `short-form-video` skill: a full runnable Remotion short, a hook library, a retention-debugging method, per-platform safe zones, and batch output. All timing is frame-driven so renders are deterministic.
## 1. A complete `<Short>` composition
One self-contained vertical short: frame-driven shot scheduler with uneven cuts, a punch-in pattern interrupt, an on-screen hook, a 9:16 safe-area overlay (dev only), and a seamless loop. Topic content is a prop, so the same file renders many shorts.
```tsx
// Short.tsx — 1080×1920, 30fps. Register at 600 frames (20s) for a clean loop.
import {
AbsoluteFill, useCurrentFrame, useVideoConfig,
interpolate, spring, Sequence, Img,
} from "remotion";
type Beat = { at: number; text: string; src?: string }; // at = seconds
export type ShortProps = {
hook: string; // on-screen by frame 1
beats: Beat[]; // body, one idea each
loopText: string; // sentence that feeds back to frame 0
};
// --- frame-driven shot scheduler: uneven cuts read as momentum ---
const useShot = (cuts: number[]) => {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
let i = 0;
for (let k = 0; k < cuts.length; k++) if (frame >= cuts[k] * fps) i = k;
const startF = cuts[i] * fps;
const endF = (cuts[i + 1] ?? cuts[i] + 2) * fps;
const local = (frame - startF) / (endF - startF); // 0→1 within shot
return { index: i, local, startF };
};
// --- punch-in: the cheapest pattern interrupt, snaps on the cut ---
const PunchIn: React.FC<{ startF: number; to?: number; children: React.ReactNode }> = ({
startF, to = 1.1, children,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const s = spring({ frame: frame - startF, fps, config: { damping: 13, stiffness: 220 } });
const scale = interpolate(s, [0, 1], [1, to]);
return <AbsoluteFill style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</AbsoluteFill>;
};
// --- hook: text present from frame 0, micro-overshoot, no fade-up ---
const Hook: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 9, stiffness: 260 } });
const scale = interpolate(pop, [0, 1], [0.9, 1]);
return (
<div style={{
position: "absolute", top: 360, left: 90, right: 90, // inside 900-wide safe box
transform: `scale(${scale})`, transformOrigin: "0 0",
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 76, lineHeight: 1.05,
color: "#fff", textShadow: "0 4px 24px rgba(0,0,0,.55)",
}}>{text}</div>
);
};
export const Short: React.FC<ShortProps> = ({ hook, beats, loopText }) => {
const { fps, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame();
const cuts = [0, ...beats.map((b) => b.at)]; // hook shot + one per beat
const { index, startF } = useShot(cuts);
const current = index === 0 ? null : beats[index - 1];
// seamless loop: match the LAST 0.5s back toward the opening frame
const tailF = durationInFrames - frame;
const loopFade = interpolate(tailF, [0, fps * 0.5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ backgroundColor: "#0b0b0f" }}>
<PunchIn startF={startF}>
{current?.src && <Img src={current.src} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
</PunchIn>
{index === 0 && <Hook text={hook} />}
{/* body caption — placement only; styling/word-timing belongs to caption-animation */}
{current && (
<div style={{
position: "absolute", left: 90, right: 90, bottom: 380, // above the 320px bottom UI band
fontFamily: "Inter, sans-serif", fontWeight: 700, fontSize: 60, color: "#fff",
textAlign: "center", textShadow: "0 3px 18px rgba(0,0,0,.6)",
}}>{current.text}</div>
)}
{/* loop sentence rises as the clip ends, easing the eye back to frame 0 */}
<div style={{
position: "absolute", left: 90, right: 90, bottom: 380, opacity: 1 - loopFade,
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 64, color: "#fff", textAlign: "center",
}}>{loopText}</div>
<SafeZone />{/* remove for final render */}
</AbsoluteFill>
);
};
```
Register it with a duration that *is* the loop length:
```ts
// Root.tsx
import { Composition } from "remotion";
import { Short } from "./Short";
export const Root = () => (
<Composition
id="Short" component={Short}
durationInFrames={600} fps={30} width={1080} height={1920}
defaultProps={{
hook: "You're editing Shorts wrong —",
beats: [
{ at: 1.2, text: "Cut every 2–4 seconds", src: "b1.jpg" },
{ at: 4.0, text: "Never on a fixed grid", src: "b2.jpg" },
{ at: 7.5, text: "Kill the flat middle", src: "b3.jpg" },
{ at: 11.0, text: "Match last frame to first", src: "b4.jpg" },
],
loopText: "…and that's why mine loop forever",
}}
/>
);
```
## 2. The 9:16 safe-area overlay (dev-only)
Render this on top while editing; delete it before the final export.
```tsx
import { AbsoluteFill } from "remotion";
export const SafeZone: React.FC = () => (
<AbsoluteFill style={{ pointerEvents: "none" }}>
{/* universal safe box: center 900×1400 in 1080×1920 */}
<div style={{ position: "absolute", left: 90, top: 260, width: 900, height: 1400, outline: "2px dashed #39d", opacity: .6 }} />
{/* platform UI bands to keep clear */}
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 120, background: "rgba(255,0,0,.12)" }} />
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: 320, background: "rgba(255,0,0,.12)" }} />
<div style={{ position: "absolute", bottom: 320, top: 120, right: 0, width: 120, background: "rgba(255,128,0,.10)" }} />
</AbsoluteFill>
);
```
## 3. Hook pattern library
The hook is a sentence that opens a gap the brain must close, plus the most striking frame as frame 0. Copyable shapes:
| Pattern | Template | Note |
|---|---|---|
| Open loop | "The real reason your ___ keeps ___ …" | answer withheld until payoff |
| Direct promise | "___ in 30 seconds, no ___ needed" | name the payoff + the timebox |
| Negation | "Stop ___. You're doing it backwards." | strong-opinion content |
| Pattern interrupt | snap-zoom / whip / hard visual mismatch | entertainment, no text needed |
| Stakes | "This ___ cost me $___ — don't repeat it" | story / case-study |
| Listicle | "3 ___ that ___ (the 3rd one ___ )" | tees up a forward reference |
Rules that matter more than the wording: (1) the hook text is on-screen by frame 1 — most viewers are muted; (2) frame 0 is the best frame, never a fade-up from black; (3) one gap only — stacking two hooks closes neither.
## 4. Retention-curve debugging
Treat the per-second retention graph (TikTok Analytics, YT Studio "Audience retention", Reels) as a profiler.
| Symptom on the curve | Likely cause | Fix |
|---|---|---|
| Cliff at 0:00–0:02 | weak hook / slow first frame | new hook line; cut the ramp-in; lead with the best frame |
| Steady gentle decline | normal — leave it | — |
| Flat-then-drop in the middle | low-energy body, cuts too sparse | add interrupts; tighten cut spacing to 2–4s |
| Bump back up near the end | viewers are looping | lean in — tighten the loop, shorten total length |
| Spike at one timestamp | a re-watched moment | front-load that beat or make it the hook |
Target ≥70% average view-through. Iterate the hook first — it moves the curve more than any other edit because it gates everything after it.
## 5. Per-platform safe zones
All on a 1080×1920 canvas. The universal box (center 900×1400) is the intersection; design inside it and one master fits every platform.
| Platform | Top clear | Bottom clear | Right clear | Notes |
|---|---|---|---|---|
| Universal (use this) | ~120px | ~320px | ~120px | center 900×1400 holds on all |
| TikTok | ~108px | ~320px | ~120px | caption + button rail at bottom-right |
| Instagram Reels | ~120px | ~310px | ~84px | audio bar + caption bottom |
| YouTube Shorts | ~120px | ~300px | ~60px | channel + subscribe bottom band |
| TikTok Ads | ~108px | ~370px | ~120px | extra room for the CTA button |
When unsure, design to the *largest* margin (TikTok Ads bottom 370px) and nothing ever clips.
## 6. Template × topic — batch output
The payoff of code-driven shorts: lock the design once, swap only the props, render N videos. Make topic content a prop (already done above), keep one theme object, and loop over JSON files.
```jsx
// theme.ts — one source of truth so 50 shorts stay on-brand
export const theme = { font: "Inter", hookSize: 76, bodySize: 60, bg: "#0b0b0f", fg: "#fff", accent: "#39d" };
```
```bash
# render the same Short template for every topic file in /topics
for f in topics/*.json; do
name=$(basename "$f" .json)
npx remotion render Short "out/$name.mp4" --props="$f"
done
```
Each `topics/*.json` is one `ShortProps` object (`hook`, `beats`, `loopText`). Keep the hook line, cut points, and loop sentence per-topic; everything else stays fixed so the series reads as one brand.
---
## 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 batch-produce short-form videos from one template × data — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=short-form-video)
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/caption-animation
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/countdown-video
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/lower-thirds
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/short-form-video
+45
View File
@@ -0,0 +1,45 @@
/* 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);
});
});
})();
+539
View File
@@ -0,0 +1,539 @@
(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();
})();
+93
View File
@@ -0,0 +1,93 @@
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.
+93
View File
@@ -0,0 +1,93 @@
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.
+93
View File
@@ -0,0 +1,93 @@
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.
+15
View File
@@ -0,0 +1,15 @@
/* 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.
+32 -550
View File
@@ -1,7 +1,11 @@
<!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="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">
<link rel="stylesheet" href="fonts/fonts.css">
<style>
:root{
--paper:#FBF7F2;
@@ -131,6 +135,7 @@ 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;
@@ -144,7 +149,11 @@ 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;}
.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 svg{width:100%;max-width:220px;height:auto;}
.stat-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}
@@ -262,7 +271,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-soft) 0%, var(--pregnancy-soft) 100%);
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%);
outline:none;cursor:pointer;
}
input[type="range"].week-slider::-webkit-slider-thumb{
@@ -297,6 +306,7 @@ 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;
@@ -310,7 +320,8 @@ 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">
@@ -324,15 +335,15 @@ footer.note{
<span class="tag">Menstrual &amp; Pregnancy Charting</span>
</div>
<nav class="mode-switch" role="tablist" aria-label="Tracking mode">
<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>
<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>
</nav>
</header>
<main>
<!-- ================= PERIOD ================= -->
<section id="mode-period" class="mode-panel active">
<section id="mode-period" class="mode-panel active" role="tabpanel" aria-labelledby="tab-period">
<div class="hero">
<div>
<span class="eyebrow" style="color:var(--period)">01 — Menstrual Cycle</span>
@@ -398,7 +409,7 @@ footer.note{
</section>
<!-- ================= OVULATION ================= -->
<section id="mode-ovulation" class="mode-panel">
<section id="mode-ovulation" class="mode-panel" role="tabpanel" aria-labelledby="tab-ovulation">
<div class="hero">
<div>
<span class="eyebrow" style="color:var(--ovulation)">02 — Fertile Window</span>
@@ -446,7 +457,7 @@ footer.note{
</section>
<!-- ================= PREGNANCY ================= -->
<section id="mode-pregnancy" class="mode-panel">
<section id="mode-pregnancy" class="mode-panel" role="tabpanel" aria-labelledby="tab-pregnancy">
<div class="hero">
<div>
<span class="eyebrow" style="color:var(--pregnancy)">03 — Forty Weeks</span>
@@ -476,7 +487,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" min="1" max="40" value="20">
<input type="range" class="week-slider" id="week-slider" aria-label="Pregnancy week, 1 to 40" 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>
@@ -524,540 +535,11 @@ footer.note{
</footer>
</div>
<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>
<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>
+151
View File
@@ -0,0 +1,151 @@
/* 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);
})();
-29
View File
@@ -1,29 +0,0 @@
{
"version": 1,
"skills": {
"caption-animation": {
"source": "iart-ai/tiktok-video-skills",
"sourceType": "github",
"skillPath": "skills/caption-animation/SKILL.md",
"computedHash": "e42c4828d3c6115dfb4d8fd3c3fd485b8e6a89c35a8835eee6dae39fb83210a4"
},
"countdown-video": {
"source": "iart-ai/tiktok-video-skills",
"sourceType": "github",
"skillPath": "skills/countdown-video/SKILL.md",
"computedHash": "dafb9127ededa76d0df08be73adc46e18d6ec3955153f6ecfd0f928984ce95b2"
},
"lower-thirds": {
"source": "iart-ai/tiktok-video-skills",
"sourceType": "github",
"skillPath": "skills/lower-thirds/SKILL.md",
"computedHash": "502c1a8a491e477dd855eb1a45469c09f6ff46a72ea841838d358d0a6a9fd790"
},
"short-form-video": {
"source": "iart-ai/tiktok-video-skills",
"sourceType": "github",
"skillPath": "skills/short-form-video/SKILL.md",
"computedHash": "893ea19615f3199159b9fe94f05e37a5e538e7e0c3af7f77f9f9bed7ff933423"
}
}
}
+68
View File
@@ -0,0 +1,68 @@
/* 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);
}
})();
+11
View File
@@ -0,0 +1,11 @@
/*!
* 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
+21
View File
@@ -0,0 +1,21 @@
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.
+11
View File
@@ -0,0 +1,11 @@
/*!
* 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
+7
View File
@@ -0,0 +1,7 @@
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