Compare commits

..
Author SHA1 Message Date
Claude a7b0e631b9 Add ad-video skills (ad-creative, launch, testimonial)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6qqzefQ2TxgZetJ7beKU
2026-10-18 21:50:04 +00:00
27 changed files with 1406 additions and 1160 deletions

No files matched your search

Whitespace-only changes.
+163
View File
@@ -0,0 +1,163 @@
---
name: ad-creative-video
description: This skill should be used when the user asks to "make a video ad", "create an animated ad", "build a performance/UGC-style ad", "batch-produce ad creative variations", "generate ad variants for A/B testing", "swap headline/offer/CTA across many ad versions", or "export one ad in multiple aspect ratios for Meta/TikTok/Reels". Covers ad hook structures, hook→CTA message-match, data-driven variant generation (1 template × CSV = N ads), multi-aspect export, and platform specs.
version: 0.1.0
---
# Ad Creative Video
Build ONE motion-graphics ad template, then mass-produce variants from a data table — different hooks, offers, CTAs, and products — so a performance marketer can A/B test dozens of versions across placements and aspect ratios. The craft is making the template fully data-driven and message-matched, so 40 variants stay on-brand and only the tested variable changes.
## When to use
- Performance/UGC-style video ads where the first 1.5–3s must stop the scroll.
- Batch variant generation: one template rendered against many rows (headline/offer/CTA/product swaps) for A/B testing.
- Multi-aspect export of the same ad for Meta Feed, Reels/Stories, TikTok, YouTube.
Not for sale/discount countdown promos, and not for testimonial/review-driven ads — those are different structures. Stay on the batch-variation, test-everything angle.
## The two rules that make variants worth running
1. **Isolate one variable per variant.** A test only teaches something if exactly one thing changes. Hold layout, motion, colors, and timing constant; swap *only* the field under test (hook, or offer, or CTA). Mixing two changes makes the winner uninterpretable.
2. **Message-match the hook to the CTA.** The promise made in the first 3 seconds must be the promise the button pays off. A "stop wasting 2 hours a day" hook ends on "Save 2 hours — try free," not a generic "Shop now." The hook and CTA are a matched pair in every row of the data table.
## Ad anatomy (15–30s)
| Beat | Job | Budget |
|---|---|---|
| Hook (0–3s) | Stop the scroll; state the problem or pattern-interrupt | 0–3s |
| Context | Make the viewer feel the problem / agitate | 3–8s |
| Payoff | Show the product as the solution, one clear benefit | 8–18s |
| Proof | One concrete number, demo, or result | 18–25s |
| CTA (last 3s) | Single action, message-matched to the hook | hold ≥2s |
Land the hook's emotional trigger before the 2-second mark — judgment forms in ~1.7s and scroll speed keeps rising. Test the hook FIRST; it moves CPA more than any other element.
## Hook taxonomy (the field you vary most)
| Hook type | Template | When it wins |
|---|---|---|
| Problem-first | "I didn't realize how much X was costing me until…" | pain is felt but unnamed |
| Pattern interrupt | "This is going to sound controversial, but…" | crowded feed, generic category |
| Curiosity gap | "Nobody talks about the one thing that…" | educated, skeptical audience |
| Immediate benefit | "Cut your X in half in 7 days" | clear, quantifiable outcome |
| Direct callout | "If you do X every morning, stop." | sharp audience segment |
Keep a column of 5–10 hook strings and let the batch render produce one ad per hook against the same body — that is the cleanest hook test possible.
## Data-driven template (everything is a prop)
Hardcode nothing the marketer might A/B test. The composition reads a single `variant` object; the renderer never edits the component. Make every animated value a pure function of `useCurrentFrame()` so each frame renders deterministically (no CSS transitions, no library timers — they desync the render).
```tsx
import {useCurrentFrame, interpolate, AbsoluteFill, useVideoConfig} from "remotion";
type Variant = {hook: string; benefit: string; proof: string; cta: string; bg: string; accent: string};
export const AdTemplate: React.FC<{v: Variant}> = ({v}) => {
const frame = useCurrentFrame(); const {fps} = useVideoConfig();
const hookIn = interpolate(frame, [0, 8], [40, 0], {extrapolateRight: "clamp"});
const ctaIn = interpolate(frame, [fps * 22, fps * 23], [0, 1], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
return (
<AbsoluteFill style={{background: v.bg, fontFamily: "Inter, sans-serif"}}>
<h1 style={{transform: `translateY(${hookIn}px)`, opacity: interpolate(frame,[0,8],[0,1])}}>{v.hook}</h1>
{frame > fps * 8 && <p>{v.benefit}</p>}
{frame > fps * 18 && <strong>{v.proof}</strong>}
<button style={{opacity: ctaIn, background: v.accent}}>{v.cta}</button>
</AbsoluteFill>
);
};
```
See `references/batch-ad-pipeline.md` for the complete template, the CSV→props parser with validation, and the brand-lock theme object.
## 1 template × CSV = N variants
The payoff: author the ad once, then let a CSV drive the matrix. Each row is one fully-formed, message-matched variant. Render once per row.
```bash
# variants.csv: id,hook,benefit,proof,cta,bg,accent → one MP4 per row
node csv-to-props.js variants.csv ./props # writes props/<id>.json per row
for f in props/*.json; do
id=$(basename "$f" .json)
npx remotion render AdTemplate "out/${id}.mp4" --props="$f"
done
```
To build a clean test matrix, hold every column constant and vary one: 5 hooks × 1 body = 5 ads (hook test); then take the winning hook × 3 CTAs (CTA test). `references/batch-ad-pipeline.md` has a matrix generator that expands variable lists into the CSV so combinatorial tests stay one-variable-at-a-time.
## Multi-aspect export
Each placement wants a native aspect. Compose against a center safe zone so one master reframes cleanly to all of them — don't letterbox, re-center.
| Aspect | Placements | Resolution | Keep-clear safe zone |
|---|---|---|---|
| 9:16 | Reels, Stories, TikTok In-Feed | 1080×1920 | top ~14%, bottom ~20–35% (UI/caption), sides ~6% |
| 4:5 | Meta Feed (best feed real estate) | 1080×1350 | center 90%; CTA out of bottom 10% |
| 1:1 | Feed, broad reach | 1080×1080 | center square survives every crop |
| 16:9 | YouTube, in-stream | 1920×1080 | center 90% |
Design the hook text, product, and CTA inside the **1:1 center square** so they survive every crop. Drive aspect from a prop and render each per variant:
```bash
for ar in 9x16 4x5 1x1; do
npx remotion render AdTemplate "out/${id}-${ar}.mp4" --props="props/${id}.json" \
--props-merge="{\"aspect\":\"${ar}\"}"
done
```
Keep critical content out of the 9:16 bottom third — that is where the platform stacks captions, the CTA button, and the username. `references/platform-specs.md` has exact pixel safe zones and per-platform duration/format limits.
## Output checklist
- One variable per variant; layout/motion/timing identical across a test.
- Hook lands its trigger before 2s; hook and CTA are message-matched in every row.
- Every animated value is a pure function of `useCurrentFrame()` — no CSS/library timers.
- Dataset is a prop; nothing testable is hardcoded; one template renders the whole CSV.
- Each variant exported 9:16 + 4:5 + 1:1 (+16:9 if needed), key content in the center square.
- Brand stays locked across all variants via a single theme object.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
**Output contract:**
- A Remotion ad template registered as `<Composition>` (+ zod `schema` + `defaultProps`), every animated value frame-driven (no CSS transitions / library timers / `Date.now()` / `Math.random()`).
- Deliverable = the rendered `out/*.mp4` per variant per aspect (plus the project + CSV, so the marketer re-renders on new rows).
**Verify loop — render stills → inspect → encode.** Cheap PNGs first, full encode only once they're clean. Render with the **shipped** props (the real row), not just `defaultProps`.
```bash
# Frame-exact stills across the hook→CTA arc, with a real variant's props
npx remotion still AdTemplate out/f-hook.png --frame=12 --props=props/v1.json # hook readable < 2s
npx remotion still AdTemplate out/f-mid.png --frame=300 --props=props/v1.json # benefit/proof
npx remotion still AdTemplate out/f-cta.png --frame=689 --props=props/v1.json # CTA, message-matched
# inspect each: fidelity (hook / offer / proof / CTA text exact, brand bg+accent right)
# AND artifacts (text overflow, off-canvas, CTA inside the 9:16 bottom third, missing font, wrong row binding)
```
**Multi-aspect / batch — verify one variant in EACH aspect before batch-rendering the matrix.** A layout bug repeats across every row × aspect; catch it once.
```bash
for ar in 9x16 4x5 1x1; do # one representative variant, every target aspect
npx remotion still AdTemplate "out/v1-${ar}.png" --frame=300 \
--props=props/v1.json --props-merge="{\"aspect\":\"${ar}\"}"
done
# stills clean in all aspects? → then batch-render every row × aspect:
for f in props/*.json; do id=$(basename "$f" .json); for ar in 9x16 4x5 1x1; do
npx remotion render AdTemplate "out/${id}-${ar}.mp4" --props="$f" --props-merge="{\"aspect\":\"${ar}\"}"
done; done
npx remotion render AdTemplate out/demo.gif --codec=gif --props=props/v1.json # README demo
```
**Before you finish:**
1. `npx remotion still` renders cleanly at hook / mid / CTA — no errors, no missing fonts/assets.
2. Hook/offer/proof/CTA text exact and brand colors right; nothing in the 9:16 bottom third or outside the center safe zone.
3. Frame-driven only — no CSS/library timers, `Date.now()`, or `Math.random()`.
4. Shipped row's props render correctly (not just `defaultProps`); one variable per variant holds.
5. One variant verified in 9:16 + 4:5 + 1:1 before the batch; MP4s play; (optional) GIF for the README.
## Reference files
- `references/batch-ad-pipeline.md` — the full runnable Remotion ad template, the CSV→typed-props parser with validation, a one-variable test-matrix generator, the brand-lock theme object, and the complete batch + multi-aspect render script.
- `references/platform-specs.md` — exact 2025/2026 pixel safe zones, durations, aspect ratios, and format limits for Meta Feed/Reels/Stories, TikTok In-Feed, and YouTube, plus a hook→CTA message-match worksheet.
@@ -0,0 +1,289 @@
# Batch Ad Pipeline — full template, parser, matrix, render
This is the complete, runnable implementation behind the SKILL.md overview: a data-driven Remotion ad template, a CSV→props parser with validation, a one-variable test-matrix generator, a brand-lock theme, and the batch + multi-aspect render scripts. Hardcode nothing a marketer might want to A/B test.
## 1. The brand-lock theme
Keep every brand-controlled value in one object so 40 variants stay consistent and only the *tested* fields change per row. The variant data supplies copy and per-campaign accents; the theme supplies the locked look.
```ts
// theme.ts
export const theme = {
fontFamily: "Inter, system-ui, sans-serif",
ink: "#0B0B0F",
paper: "#FFFFFF",
radius: 20,
hookSize: 76, // px, on the 1080-wide master
bodySize: 44,
proofSize: 52,
ctaSize: 40,
pad: 80, // outer padding; also the side safe margin
durationInSeconds: 25,
} as const;
```
## 2. Aspect config
Drive layout from a single aspect prop so one composition reframes to every placement. Dimensions and the keep-clear bottom band differ per aspect.
```ts
// aspects.ts
export type Aspect = "9x16" | "4x5" | "1x1" | "16x9";
export const ASPECTS: Record<Aspect, {w: number; h: number; bottomSafe: number}> = {
"9x16": {w: 1080, h: 1920, bottomSafe: 540}, // captions + CTA + username stack here
"4x5": {w: 1080, h: 1350, bottomSafe: 135},
"1x1": {w: 1080, h: 1080, bottomSafe: 110},
"16x9": {w: 1920, h: 1080, bottomSafe: 110},
};
```
## 3. The data-driven template
Every animated value is a pure function of `useCurrentFrame()` — no CSS transitions, no GSAP/library timers (they desync deterministic rendering and flicker in the export). The component reads one `variant` plus an `aspect`; the renderer never edits this file.
```tsx
// AdTemplate.tsx
import {
AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring, Sequence,
} from "remotion";
import {theme} from "./theme";
import {ASPECTS, Aspect} from "./aspects";
export type Variant = {
id: string;
hook: string; // 0–3s scroll-stopper
benefit: string; // the single payoff claim
proof: string; // one concrete number / result
cta: string; // message-matched to the hook
accent: string; // per-campaign accent color
bg: string; // background color
};
export const AdTemplate: React.FC<{v: Variant; aspect: Aspect}> = ({v, aspect}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const {bottomSafe} = ASPECTS[aspect];
// Hook rises + fades in over the first 8 frames, holds, then eases up and out at 8s.
const hookY = interpolate(frame, [0, 8], [48, 0], {extrapolateRight: "clamp"});
const hookOpacity =
interpolate(frame, [0, 8], [0, 1], {extrapolateRight: "clamp"}) *
interpolate(frame, [fps * 7, fps * 8], [1, 0], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
// CTA springs in for impact in the last 3s and holds.
const ctaProgress = spring({frame: frame - fps * 22, fps, config: {damping: 14, stiffness: 120}});
return (
<AbsoluteFill style={{backgroundColor: v.bg, fontFamily: theme.fontFamily, color: theme.ink}}>
<AbsoluteFill style={{padding: theme.pad, paddingBottom: bottomSafe, justifyContent: "center"}}>
{/* HOOK 0–8s */}
<Sequence durationInFrames={fps * 8}>
<h1 style={{fontSize: theme.hookSize, fontWeight: 800, lineHeight: 1.05,
transform: `translateY(${hookY}px)`, opacity: hookOpacity}}>
{v.hook}
</h1>
</Sequence>
{/* BENEFIT 8–18s */}
<Sequence from={fps * 8} durationInFrames={fps * 10}>
<FadeUp delay={0}>
<p style={{fontSize: theme.bodySize, fontWeight: 600}}>{v.benefit}</p>
</FadeUp>
</Sequence>
{/* PROOF 18–22s */}
<Sequence from={fps * 18} durationInFrames={fps * 4}>
<FadeUp delay={0}>
<strong style={{fontSize: theme.proofSize, color: v.accent,
fontVariantNumeric: "tabular-nums"}}>{v.proof}</strong>
</FadeUp>
</Sequence>
{/* CTA last 3s, message-matched to the hook */}
<Sequence from={fps * 22}>
<button style={{
alignSelf: "flex-start", marginTop: 40, padding: "22px 44px",
fontSize: theme.ctaSize, fontWeight: 700, border: "none",
borderRadius: theme.radius, color: theme.paper, backgroundColor: v.accent,
transform: `scale(${interpolate(ctaProgress, [0, 1], [0.85, 1])})`,
opacity: ctaProgress,
}}>
{v.cta}
</button>
</Sequence>
</AbsoluteFill>
</AbsoluteFill>
);
};
const FadeUp: React.FC<{children: React.ReactNode; delay: number}> = ({children, delay}) => {
const frame = useCurrentFrame();
const o = interpolate(frame - delay, [0, 10], [0, 1], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
const y = interpolate(frame - delay, [0, 10], [24, 0], {extrapolateLeft: "clamp", extrapolateRight: "clamp"});
return <div style={{opacity: o, transform: `translateY(${y}px)`}}>{children}</div>;
};
```
## 4. Register the composition (aspect + variant as props)
```tsx
// Root.tsx
import {Composition} from "remotion";
import {AdTemplate} from "./AdTemplate";
import {ASPECTS} from "./aspects";
import {theme} from "./theme";
const defaultVariant = {
id: "demo", hook: "Spending 2 hrs/day on reports?", benefit: "Auto-build them in one click.",
proof: "Teams save 11 hrs a week.", cta: "Save 2 hours — try free", accent: "#4F46E5", bg: "#FFFFFF",
};
export const RemotionRoot = () => {
const aspect = "9x16" as const; // overridden per-render via --props
const {w, h} = ASPECTS[aspect];
return (
<Composition
id="AdTemplate"
component={AdTemplate}
durationInFrames={theme.durationInSeconds * 30}
fps={30}
width={w}
height={h}
defaultProps={{v: defaultVariant, aspect}}
// Resolve real dimensions from the incoming aspect prop at render time:
calculateMetadata={({props}) => {
const a = ASPECTS[(props as any).aspect ?? "9x16"];
return {width: a.w, height: a.h};
}}
/>
);
};
```
`calculateMetadata` lets a single composition output any aspect from the `aspect` prop — no separate composition per ratio.
## 5. CSV → typed props, with validation
The dataset is the input. Parse, validate every row (a bad hex or empty CTA must fail loudly, not render a broken ad), and write one props file per variant.
```js
// csv-to-props.js usage: node csv-to-props.js variants.csv ./props [aspect]
const fs = require("fs");
const path = require("path");
const [, , csvPath, outDir, aspect = "9x16"] = process.argv;
const REQUIRED = ["id", "hook", "benefit", "proof", "cta", "accent", "bg"];
const isHex = (s) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s);
const parseCSV = (text) => {
const [head, ...lines] = text.trim().split(/\r?\n/);
const cols = head.split(",").map((c) => c.trim());
return lines.filter(Boolean).map((line) => {
// simple split; wrap fields containing commas in double quotes in the CSV
const cells = line.match(/("([^"]|"")*"|[^,]*)/g).filter((_, i, a) => i < a.length - 1);
const row = {};
cols.forEach((c, i) => (row[c] = (cells[i] ?? "").replace(/^"|"$/g, "").replace(/""/g, '"').trim()));
return row;
});
};
const rows = parseCSV(fs.readFileSync(csvPath, "utf8"));
fs.mkdirSync(outDir, {recursive: true});
const seen = new Set();
let errors = 0;
rows.forEach((row, n) => {
const where = `row ${n + 2} (${row.id || "no-id"})`;
REQUIRED.forEach((k) => { if (!row[k]) { console.error(`✗ ${where}: missing "${k}"`); errors++; } });
if (seen.has(row.id)) { console.error(`✗ ${where}: duplicate id`); errors++; }
seen.add(row.id);
if (row.hook && row.hook.length > 60) console.warn(`⚠ ${where}: hook >60 chars may clip in 9:16`);
["accent", "bg"].forEach((k) => { if (row[k] && !isHex(row[k])) { console.error(`✗ ${where}: "${k}" not a hex color`); errors++; } });
if (errors) return;
fs.writeFileSync(path.join(outDir, `${row.id}.json`), JSON.stringify({v: row, aspect}, null, 2));
});
if (errors) { console.error(`\n${errors} error(s) — no broken ads written.`); process.exit(1); }
console.log(`✓ ${rows.length} variant prop files written to ${outDir}`);
```
Example `variants.csv` (one fully message-matched ad per row):
```csv
id,hook,benefit,proof,cta,accent,bg
v01_problem,Spending 2 hrs/day on reports?,Auto-build them in one click.,Teams save 11 hrs a week.,Save 2 hours — try free,#4F46E5,#FFFFFF
v02_interrupt,Your reporting tool is lying to you.,See the real numbers instantly.,Teams save 11 hrs a week.,See the real numbers — free,#DC2626,#0B0B0F
v03_curiosity,Nobody talks about this reporting trick.,One click builds the whole report.,Teams save 11 hrs a week.,Try the trick — free,#059669,#FFFFFF
```
Note the CTA mirrors the hook in every row — that is the message-match discipline encoded into the data.
## 6. Test-matrix generator — one variable at a time
A test only teaches something if exactly one field changes. This expands a base variant plus a list of values for ONE field into a CSV, so a hook test or a CTA test stays clean and combinatorial mistakes are impossible.
```js
// make-matrix.js usage: node make-matrix.js > variants.csv
const base = {benefit: "Auto-build them in one click.", proof: "Teams save 11 hrs a week.",
cta: "Save 2 hours — try free", accent: "#4F46E5", bg: "#FFFFFF"};
// Vary ONLY this field; everything else is held identical across the test.
const FIELD = "hook";
const values = [
"Spending 2 hrs/day on reports?",
"Your reporting tool is lying to you.",
"Nobody talks about this reporting trick.",
"Cut report time in half this week.",
"If you build reports by hand, stop.",
];
const esc = (s) => (/[",]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
const cols = ["id", "hook", "benefit", "proof", "cta", "accent", "bg"];
console.log(cols.join(","));
values.forEach((val, i) => {
const row = {...base, [FIELD]: val, id: `${FIELD}_${String(i + 1).padStart(2, "0")}`};
console.log(cols.map((c) => esc(row[c])).join(","));
});
```
To run a CTA test next, set `FIELD = "cta"`, paste the *winning* hook into `base.hook`, and list CTA variants. Same one-variable rule, next stage of the funnel.
## 7. Batch + multi-aspect render
Render every variant in every aspect a campaign needs. Concurrency keeps large batches fast.
```bash
#!/usr/bin/env bash
# render-all.sh one MP4 per (variant × aspect)
set -euo pipefail
node make-matrix.js > variants.csv # 1) build the one-variable test matrix
ASPECTS=("9x16" "4x5" "1x1") # placements this campaign needs
for ar in "${ASPECTS[@]}"; do
node csv-to-props.js variants.csv "props/$ar" "$ar" # 2) typed, validated props per aspect
for f in "props/$ar"/*.json; do
id=$(basename "$f" .json)
npx remotion render AdTemplate "out/${id}_${ar}.mp4" \
--props="$f" --concurrency=4 --log=error # 3) render
done
done
echo "Done. $(ls out/*.mp4 | wc -l) ad variants in ./out"
```
5 hooks × 3 aspects = 15 MP4s from one template and one command. Add a CTA stage and the same template produces the next test wave with zero new component code.
## Common pitfalls
- **Two variables changed at once** — the winner is uninterpretable. The matrix generator prevents this; keep using it.
- **Hook clips in 9:16** — long hooks overflow; the parser warns past 60 chars. Keep hooks short and high in the frame.
- **CTA hidden behind platform UI** — anything in the 9:16 bottom third gets covered by captions and the CTA button. The `bottomSafe` padding reserves that band.
- **Drift across variants** — accent/copy live in the data, everything else in `theme`. If a variant looks off-brand, the fix is the theme, not the row.
- **Animation flicker in export** — caused by CSS transitions or library timers. Every value must derive from `useCurrentFrame()`.
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can mass-produce ad-creative variants from one template × a data table — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=ad-video-skills&utm_content=ref_footer&utm_term=ad-creative-video)
@@ -0,0 +1,72 @@
# Platform Specs & Message-Match Worksheet
Exact pixel safe zones, durations, aspect ratios, and format limits per placement (2025/2026), plus a worksheet for building message-matched hook→CTA pairs before they go into the CSV. Verify against each platform's current Ads Manager docs before a big spend — placements and safe zones shift.
## Aspect ratio → placement map
| Aspect | Resolution | Native placements |
|---|---|---|
| 9:16 | 1080×1920 | TikTok In-Feed, Instagram/Facebook Reels, Stories, YouTube Shorts |
| 4:5 | 1080×1350 | Meta Feed (largest feed real estate, recommended for Feed video) |
| 1:1 | 1080×1080 | Meta Feed, broad/automatic placements, X |
| 16:9 | 1920×1080 | YouTube in-stream, landscape feed, in-article |
Master rule: design the hook, product, and CTA inside the **1:1 center square** so a single composition reframes to every aspect by re-centering, not letterboxing. Black bars on a vertical feed read as "ad" and underperform.
## Safe zones (keep-clear bands)
Platform UI — captions, CTA chips, usernames, profile icons, progress bars — overlaps the frame. Keep all critical content out of these bands.
| Aspect / placement | Top keep-clear | Bottom keep-clear | Sides |
|---|---|---|---|
| 9:16 Reels/Stories (Meta) | ~14% (~270px on 1920h) | ~20–35% (~384–672px) | ~6% (~65px) |
| 9:16 TikTok In-Feed | ~130px | ~484px (right-side icon rail + caption) | right ~140px |
| 4:5 Meta Feed | ~5% | ~10% (keep CTA above it) | ~5% |
| 1:1 / 16:9 | center 90% safe | center 90% safe | center 90% safe |
The 9:16 bottom third is the single most violated zone — captions, the CTA button, and the handle stack there. The `bottomSafe` padding in `batch-ad-pipeline.md` reserves it.
## Duration & format limits
| Placement | Sweet spot | Hard limits | Format |
|---|---|---|---|
| Meta Feed (4:5/1:1) | 6–15s | up to 240 min, ≤4GB | MP4/MOV, H.264 |
| Meta Reels/Stories (9:16) | 8–15s | Reels ≤90s, Stories ≤2 min | MP4/MOV |
| TikTok In-Feed (9:16) | 15–30s (TikTok cites 21–34s) | up to 10 min (non-Spark) | MP4/MOV, ≥540×960 |
| YouTube in-stream (16:9) | 15–30s | skippable after 5s | MP4 |
Practical default for performance video: **15–30s, with the hook trigger landing before 2s.** Most ad-account data shows attention falls off a cliff after ~15s, so front-load everything that matters.
## Hook → CTA message-match worksheet
Fill one row per ad *before* writing the CSV. The CTA must pay off the exact promise the hook made; if it doesn't, the click is wasted and the test is noisy. Each completed row becomes one record in `variants.csv`.
| Hook (0–3s promise) | Hook type | Single benefit | Proof point | CTA (pays off the hook) |
|---|---|---|---|---|
| "Spending 2 hrs/day on reports?" | problem-first | one-click report build | "save 11 hrs/week" | "Save 2 hours — try free" |
| "Your reporting tool is lying to you." | pattern interrupt | shows real numbers instantly | "save 11 hrs/week" | "See the real numbers — free" |
| "Nobody talks about this reporting trick." | curiosity gap | one click builds it | "save 11 hrs/week" | "Try the trick — free" |
| "Cut report time in half this week." | immediate benefit | auto-built reports | "save 11 hrs/week" | "Cut it in half — free" |
| "If you build reports by hand, stop." | direct callout | automate it now | "save 11 hrs/week" | "Automate yours — free" |
Bad pairing to avoid: a "stop wasting 2 hours" hook ending on a generic "Shop now" — the promise and the payoff don't connect, so even a great hook converts poorly.
## Testing order (which field to vary first)
Run tests in funnel order; isolate one field per wave using the matrix generator:
1. **Hook** — biggest lever on CPA; test 5–8 hooks against one identical body.
2. **Offer / benefit** — take the winning hook, vary the core promise.
3. **CTA** — winning hook + offer, vary the closing action.
4. **Format / aspect** — same winning ad, compare placements.
Budget guide many teams use: ~60% to proven winners, ~30% to variations of winners, ~10% to fresh concepts. Run each test long enough for a real sample (commonly several hundred impressions per variant, ~1 week to smooth daily swings) before declaring a winner.
## Pre-flight checklist per variant
- Hook trigger lands before 2s and fits the frame (≤~60 chars in 9:16).
- CTA visibly pays off the hook's specific promise.
- Critical content inside the center square and outside every keep-clear band.
- Duration within the placement's sweet spot.
- Exported in every aspect the campaign's placements require.
- Exactly one field differs from the other variants in this test wave.
Whitespace-only changes.
+137
View File
@@ -0,0 +1,137 @@
---
name: launch-video
description: This skill should be used when the user asks to "make a launch video", "create a product hype/sizzle film", "build a teaser/trailer for a drop", "do a reveal montage with an end-card CTA", "cut a hero video to the beat", or "export a launch film in 16:9/9:16/1:1". Covers the hook→tease→reveal→feature-montage→end-card arc and multi-aspect export.
version: 0.1.0
---
# Launch Video
Make a premium, high-energy 15–60s launch film that stops the scroll and converts. Build the classic arc — hook → tease → reveal → feature montage → end-card CTA — cut to the music, and export for every platform aspect.
## When to use
- Product drops, feature announcements, trailers, hype reels (15–60s).
- "Sizzle" hero videos for a landing page.
- Beat-synced reveals where the brand moment lands on the drop.
## The arc
| Beat | Job | Typical share (of 30s) |
|---|---|---|
| Hook (0–3s) | A striking frame/motion that stops the scroll | 0–3s |
| Tease | Hint the product; build curiosity, beat-synced | 3–9s |
| Reveal | Product/logo hits on the music drop | 9–13s |
| Feature montage | Fast, kinetic, one benefit per shot | 13–25s |
| End card | Logo + tagline + CTA, clean hold | 25–30s |
Scale the same proportions for 15s (tighter) or 60s (longer montage, never a longer hook).
## Two non-negotiable rules
1. **Sound design leads picture.** Lock the track first, mark the beats and the drop, then cut visuals to those marks. Never score a finished cut — the reveal lands on the drop, not near it.
2. **Quality over quantity.** A few flawless shots beat many mediocre ones. Cut any shot that isn't premium.
## Beat-synced reveal on the drop
Find the drop's exact timecode in the audio, then time the reveal so the brand mark snaps to full at that frame, with a micro-overshoot for impact.
```js
import gsap from "gsap";
const DROP = 9.0; // seconds, measured from the track
const tl = gsap.timeline({ paused: true });
// tease builds tension up to the drop
tl.to(".tease", { opacity: 1, scale: 1.05, duration: DROP, ease: "power1.in" })
// reveal SNAPS on the drop frame
.set(".logo", { opacity: 1 }, DROP)
.fromTo(".logo", { scale: 1.18 }, { scale: 1.0, duration: .18, ease: "power3.out" }, DROP)
.fromTo(".flash", { opacity: .9 }, { opacity: 0, duration: .25 }, DROP); // white hit
audio.addEventListener("play", () => tl.play());
```
Cut on transients, not on a fixed grid: the hardest cuts go on kick/snare hits; ramp speed (time-remap) into the drop, then hard-cut out of it.
## Kinetic feature montage
One benefit per shot, ~0.6–1.0s each, hard cuts on the beat. Each shot = a bold keyword + a single supporting visual.
```css
.feature { opacity:0; }
.feature.in .kw { animation: pop .35s cubic-bezier(.22,1,.36,1) forwards; }
.feature.in .word { display:inline-block; }
@keyframes pop { from { opacity:0; transform:translateY(24px) } to { opacity:1; transform:none } }
```
```js
// drive shots off beat marks measured from the track
const beats = [13.0, 13.8, 14.6, 15.4, 16.2]; // seconds
beats.forEach((t, i) => scheduleAt(t, () => showFeature(i)));
```
Keep one motion language across the montage (same enter curve, same exit) so speed reads as confidence, not chaos.
## End card
Hold the logo + tagline + CTA still and clean for ≥2s. No busy motion competing with the CTA; let the brand settle. Land the brand moment on the strongest remaining beat, then a crisp button/URL.
## Multi-aspect export
Compose with a center safe zone so one master crops cleanly to all aspects.
| Aspect | Use | Resolution | Safe zone |
|---|---|---|---|
| 16:9 | YouTube, landing hero, X | 1920×1080 | keep key content within center 90% |
| 9:16 | Reels, TikTok, Shorts, Stories | 1080×1920 | text within center 80% h; avoid top 12% / bottom 18% (UI) |
| 1:1 | Feed posts | 1080×1080 | center square of the 16:9 frame |
Design the hero/logo/CTA inside the **1:1 center square** so it survives every crop. Render the 16:9 master, then reframe (don't just letterbox) 9:16 and 1:1 from the same project.
## Output checklist
- Track locked first; reveal lands exactly on the drop.
- Hook earns the first 3 seconds.
- Montage: one benefit per shot, hard cuts on beats, consistent motion language.
- End card holds ≥2s with one clear CTA.
- 16:9 + 9:16 + 1:1 exports, key content in the center safe zone.
- Premium-only shots — nothing mediocre survives the cut.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
If the launch film is built/rendered in Remotion, treat it as the heavy tier: register a `<Composition>` (+ zod `schema` + `defaultProps`), drive every value off `useCurrentFrame()` — no GSAP timelines / audio-event callbacks / `Date.now()` / `Math.random()` at render time (bake beat + drop timecodes into props). Deliverable = the rendered `out/*.mp4` per aspect (plus the project).
**Verify loop — render stills → inspect → encode.** Sample the arc, with the **shipped** props (real headline/tagline/CTA), not just `defaultProps`.
```bash
# Frame-exact stills across hook → reveal → end-card
npx remotion still Launch out/f-hook.png --frame=24 --props=props/launch.json # hook earns first 3s
npx remotion still Launch out/f-reveal.png --frame=270 --props=props/launch.json # logo SNAPS on the drop frame
npx remotion still Launch out/f-end.png --frame=899 --props=props/launch.json # end card: logo+tagline+CTA, clean hold
# inspect each: fidelity (product/logo, tagline, CTA text exact, brand colors right, reveal at the drop frame)
# AND artifacts (text overflow, off-canvas, CTA in the 9:16 bottom third, missing font, flash frame stuck on)
```
**Multi-aspect — verify the master in EACH aspect before encoding all of them.** A reframe bug (hero pushed off the safe zone) repeats across every aspect; catch it once.
```bash
for ar in 16x9 9x16 1x1; do # check reveal + end card per aspect
npx remotion still Launch "out/reveal-${ar}.png" --frame=270 \
--props=props/launch.json --props-merge="{\"aspect\":\"${ar}\"}"
done
# stills clean in all aspects? → then encode each:
for ar in 16x9 9x16 1x1; do
npx remotion render Launch "out/launch-${ar}.mp4" --props=props/launch.json --props-merge="{\"aspect\":\"${ar}\"}"
done
npx remotion render Launch out/demo.gif --codec=gif --props=props/launch.json # README demo
```
**Before you finish:**
1. `npx remotion still` renders cleanly at hook / reveal / end card — no errors, no missing fonts/assets.
2. Logo/tagline/CTA text exact, brand colors right; reveal lands on the drop frame; key content inside the center safe zone, clear of the 9:16 bottom third.
3. Frame-driven only — no GSAP/audio-callback timing, `Date.now()`, or `Math.random()` (beats/drop baked into props).
4. Shipped props render correctly (not just `defaultProps`); the end card holds ≥2s with one CTA.
5. Master verified in 16:9 + 9:16 + 1:1 before encoding; MP4s play; (optional) GIF for the README.
## Reference files
- `references/launch-structure.md` — a full shot-by-shot template with timecodes for a 30s film (scalable to 15–60s), beat-synced reveal timing, kinetic-montage shot recipes, the sound-design-leads-picture workflow, and detailed multi-aspect safe-area maps for 16:9 / 9:16 / 1:1.
@@ -0,0 +1,142 @@
# Launch Video Structure — Cookbook
Shot-by-shot template, beat-sync timing, montage recipes, and multi-aspect maps for a premium 15–60s launch film.
## Sound-design-leads-picture workflow
1. **Pick/commission the track first.** The track defines the film's length and energy.
2. **Mark the structure in the audio**: intro, build, the **drop** (the single biggest moment), and the kick/snare transients through the montage. Note exact timecodes.
3. **Map beats → cuts.** Hard cuts land on transients; the reveal lands on the drop frame.
4. **Build picture to the marks.** Never finish a cut and then look for music. Score-first guarantees the reveal hits.
5. **Mix last**: add risers into the drop, an impact/whoosh on the reveal, and let the end-card breathe (drop music low, or a clean tag).
## Shot-by-shot template — 30s film (scalable)
```
00:00–00:03 HOOK
Visual : single striking frame — extreme close detail / impossible motion
Motion : slow push-in or a fast whip that resolves; NO logo yet
Audio : sub-bass swell begins, sparse
Cut : on first audible beat at ~03s
00:03–00:09 TEASE
Visual : fragments/silhouettes of the product, never the full thing
Motion : light leaks, partial reveals, parallax; tension rising
Audio : riser building toward the drop
Cut : quick cuts accelerating into the drop
00:09–00:13 REVEAL ← THE DROP at 00:09
Visual : product/logo SNAPS to full on the drop frame
Motion : scale 1.18→1.0 over 0.18s + white flash-out; settle clean
Audio : drop hits + impact stinger on the exact frame
Hold : let it sit ~1.2s before moving
00:13–00:25 FEATURE MONTAGE
Visual : one benefit per shot, ~0.6–1.0s each (≈8–10 shots)
Motion : bold keyword pop + one supporting visual; consistent enter/exit
Audio : hard cuts on kick/snare; energy sustained
Pattern: KEYWORD + UI/product moment, repeat with rhythm
00:25–00:30 END CARD
Visual : logo + tagline + CTA, centered, still
Motion : minimal — a soft settle, then hold ≥2s
Audio : music resolves; clean sonic tag
CTA : one action (URL or button), nothing competing
```
### Re-proportioning
- **15s**: Hook 0–2s · Tease 2–5s · Reveal 5–7s · Montage 7–12s (4–5 shots) · End card 12–15s.
- **60s**: same hook (still ≤3s) · longer tease and a richer montage (14–16 shots, grouped in waves) · end card ≥3s.
The hook never gets longer — only the montage scales.
## Beat-synced reveal (timing detail)
```js
import gsap from "gsap";
const DROP = 9.0; // measured drop timecode (s)
const tl = gsap.timeline({ paused: true });
// build tension toward the drop
tl.fromTo(".tease", { scale: 1.0, opacity: .6 },
{ scale: 1.08, opacity: 1, duration: DROP, ease: "power1.in" });
// REVEAL exactly on the drop
tl.set(".logo", { opacity: 1 }, DROP);
tl.fromTo(".logo", { scale: 1.18 }, { scale: 1.0, duration: .18, ease: "power3.out" }, DROP);
tl.fromTo(".flash", { opacity: .9 }, { opacity: 0, duration: .25, ease: "power2.out" }, DROP);
tl.to(".tease", { opacity: 0, duration: .12 }, DROP);
// hold the reveal
tl.to(".logo", { scale: 1.0, duration: 1.2 }); // dwell
audio.addEventListener("play", () => tl.play());
```
Speed-ramp into the drop (Remotion time-remap idea): play the last ~0.5s of the tease faster, then hard-cut to the reveal on the drop frame for a snap.
## Kinetic montage shot recipe
Each shot: a bold keyword + one product/UI visual, ~0.6–1.0s, hard cut on the beat.
```css
.feature { position:absolute; inset:0; opacity:0; }
.feature.in { opacity:1; }
.feature .word { display:inline-block; opacity:0; transform:translateY(26px); }
.feature.in .word { animation: pop .32s cubic-bezier(.22,1,.36,1) forwards; }
.feature.in .word:nth-child(2){ animation-delay:.04s }
.feature.in .word:nth-child(3){ animation-delay:.08s }
@keyframes pop { to { opacity:1; transform:none; } }
```
```js
// schedule shots on measured beat marks
const beats = [13.0, 13.8, 14.6, 15.4, 16.2, 17.0, 17.8, 18.6]; // seconds
let i = 0;
function loop(now){
const t = (now - start) / 1000;
if (i < beats.length && t >= beats[i]) { showFeature(i); i++; }
if (i < beats.length) requestAnimationFrame(loop);
}
```
Montage discipline: keep ONE enter curve and ONE exit across all shots; vary content, never grammar. Alternate visual rhythm (full-frame ↔ detail) but keep cut cadence locked to the beat.
## Multi-aspect safe-area maps
Compose so a single master reframes to every aspect. Place hero/logo/CTA inside the smallest common safe zone — the 1:1 center square.
```
16:9 1920×1080
Safe: center 90% (96px H / 54px V margins)
Use : YouTube, landing hero, X timeline
9:16 1080×1920
Safe: text within center 80% height
Avoid: top 12% (status/clock area), bottom 18% (caption + UI buttons)
Use : Reels, TikTok, Shorts, Stories
1:1 1080×1080
Safe: this square == the design anchor; put logo/CTA here
Use : feed posts
```
Export workflow:
1. Build the 16:9 master with all key content inside the 1:1 center square.
2. For 9:16, **reframe** (pan/scale per shot to keep the subject centered) — do not letterbox.
3. For 1:1, crop the center square; verify text isn't clipped.
4. Re-check captions/CTA against each platform's UI overlay zones before export.
## Final QA checklist
- [ ] Reveal lands on the exact drop frame, with overshoot + flash.
- [ ] Hook holds attention in the first 3s without showing the product.
- [ ] Every montage shot is one benefit, premium quality, on a beat.
- [ ] End card holds ≥2s, single CTA, nothing competing.
- [ ] 16:9 / 9:16 / 1:1 each pass safe-area and caption checks.
- [ ] Audio mixed: riser → impact on drop → clean tag on end card.
---
Nail the reveal beat and the sound-leads-picture cut, and a launch film lands. Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=ad-video-skills&utm_content=skill_footer&utm_term=launch-video)** — the AI motion agent for editable, on-brand motion graphics.
Whitespace-only changes.
+196
View File
@@ -0,0 +1,196 @@
---
name: testimonial-video
description: This skill should be used when the user asks to "make an animated testimonial", "create a quote video", "build a review video", "turn a customer testimonial into a graphic", "animate a customer review", "make a social proof video", "add an animated star rating", or "turn a list of reviews into videos". Covers quote typography, staggered line reveals, kinetic emphasis on key phrases, animated star ratings, the author block (name/role/avatar/company), and templating one card across many quotes.
version: 0.1.0
---
# Testimonial Video
Animate a written quote, review, or testimonial into a short, trustworthy social-proof clip. The craft is typographic, not flashy: a clean quote card where the words land in reading order, the key phrase gets emphasis exactly when it should, the stars fill to the real score, and the author signs off. One card design, driven by props, becomes a video for every review on the list.
## When to use
- Customer review → a 6–12s vertical/feed clip (the headline use case).
- A pull-quote or testimonial graphic with author name, role, avatar, company.
- An animated star rating (including half/fractional scores like 4.5).
- A batch: a list/CSV of reviews → one video each, same template.
Do **not** reach here for sale/discount promos or generic ad creative — this skill is strictly the quote / social-proof angle.
## The one rule: trust before motion
A testimonial earns its job by feeling *real*, not produced. Restraint reads as credibility; over-animation reads as an ad people skip.
1. **Reveal in reading order.** The eye must finish a line before the next arrives. Stagger lines (or clauses), never all words at once, and never reorder for visual flair.
2. **Quote the words exactly.** Do not paraphrase, trim mid-sentence, or invent a rating. If you emphasize a phrase, it must be the customer's phrase.
3. **The author is the proof.** A quote with no attributable human is just a slogan. Name + role + company (and avatar if real) is non-negotiable.
## The anatomy of a quote card
| Element | Job | Timing |
|---|---|---|
| Quote mark motif | Signals "this is a quote" before words read | Frame 0, scales/fades in first |
| Quote body | The testimonial, revealed line-by-line | Staggered, ~6–10 frames apart |
| Emphasis span | The one phrase that sells | Lands *after* its line settles |
| Star rating | The score, filled to the real value | Fills left→right after the quote |
| Author block | Name / role / company / avatar | Signs off last, slides up |
Build in that order; reveal in that order. The viewer reads the quote, believes it, sees the score, then learns who said it.
## Staggered line reveal
Split the quote into lines (or clauses), then offset each one's entrance. In Remotion, drive every line off `useCurrentFrame()` so it renders deterministically — never a CSS transition or a JS timer, which desync from the frame renderer.
```jsx
import { useCurrentFrame, spring, useVideoConfig } from "remotion";
const QuoteBody = ({ lines, startAt = 8, stagger = 6 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<blockquote>
{lines.map((line, i) => {
const enter = spring({ frame: frame - startAt - i * stagger, fps,
config: { damping: 200 } }); // 0→1, no overshoot
return (
<span key={i} style={{ display: "block",
opacity: enter,
transform: `translateY(${(1 - enter) * 14}px)` }}>
{line}
</span>
);
})}
</blockquote>
);
};
```
Keep one motion language: same easing, same 14px rise, every line. Speed reads as confidence only when it's consistent. See `references/quote-card.md` for the full composition.
## Kinetic emphasis on the key phrase
Pick the single phrase that carries the testimonial ("saved us 10 hours a week") and let it arrive *after* its line has settled — a highlight sweep, a weight shift, or a color pop. Emphasis competing with the reveal cancels both.
```jsx
const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// a highlighter wiping in behind the phrase, left→right
<mark style={{ background:
`linear-gradient(90deg, var(--accent) ${sweep}%, transparent ${sweep}%)`,
color: "inherit", padding: "0 .08em" }}>{phrase}</mark>
```
Emphasize at most one span per quote. Two highlights is no highlight.
## Animated star rating (fractional)
Render the real score, including halves. Use one full row of star glyphs as a mask, then wipe a filled layer to `rating / max` width — this handles 4.5 as cleanly as 5.0, with no half-star sprite.
```jsx
const StarRating = ({ rating, max = 5, fillStart = 30, fillDur = 14 }) => {
const frame = useCurrentFrame();
const target = (rating / max) * 100;
const w = interpolate(frame, [fillStart, fillStart + fillDur], [0, target],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<div className="stars" role="img" aria-label={`${rating} out of ${max} stars`}>
<div className="track">★★★★★</div>
<div className="fill" style={{ width: `${w}%` }}>★★★★★</div>
</div>
);
};
```
```css
.stars { position: relative; display: inline-block; font-size: 40px; letter-spacing: 4px; }
.track { color: rgba(255,255,255,.22); }
.fill { position: absolute; inset: 0; overflow: hidden; white-space: nowrap;
color: var(--accent); } /* clipped by width → fractional fill */
```
Fill left→right after the quote reads. Never animate stars before the words — the score must feel earned, not pre-loaded. Always set `aria-label` to the real value.
## The author block
Sign off last. The block slides up under the stars and holds still — stillness is what makes attribution read as fact.
```jsx
<figcaption className="author">
{avatar && <img src={avatar} alt="" className="avatar" />}
<div>
<span className="name">{name}</span>
<span className="role">{role}{company && `, ${company}`}</span>
</div>
</figcaption>
```
Use a real avatar or none — a generic placeholder face actively destroys trust. Name is the loudest line in the block; role/company is secondary weight.
## Template × reviews — one video per quote
The payoff: make every field a prop, hardcode nothing, then render once per review.
```jsx
export const Testimonial = ({ quote, highlight, rating, name, role, company, avatar }) => { /* … */ };
```
```bash
for f in reviews/*.json; do
npx remotion render Testimonial "out/$(basename "$f" .json).mp4" --props="$f"
done
```
Keep colors/fonts/quote-mark/layout in one theme object so 50 testimonials stay brand-locked and only the words and score change. See `references/batch-and-typography.md` for the review schema, CSV→props parsing, quote-typography craft (smart quotes, hanging punctuation, line-splitting, orphans), and the batch script.
## Output checklist
- Lines reveal in reading order; one consistent enter motion, ~6–10 frame stagger.
- The quote is verbatim; exactly one emphasized span, landing after its line settles.
- Stars fill to the *real* score (halves supported) after the quote, with a correct `aria-label`.
- Author block (name + role + company, real avatar or none) signs off last and holds still.
- Every animated value derives from `useCurrentFrame()` — no CSS/JS timers.
- Every field is a prop; one template renders every review in the folder.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
**Output contract:**
- A Remotion `Testimonial` registered as `<Composition>` (+ zod `schema` + `defaultProps`), every value frame-driven (no CSS transitions / JS timers / `Date.now()` / `Math.random()`).
- Deliverable = the rendered `out/*.mp4` per review per aspect (plus the project + review data, so the user re-renders on new quotes).
**Verify loop — render stills → inspect → encode.** Cheap PNGs first; render with the **shipped** review's props, not just `defaultProps`.
```bash
# Frame-exact stills across the reveal order: quote → stars → author
npx remotion still Testimonial out/f-quote.png --frame=30 --props=reviews/r1.json # lines in reading order, emphasis span
npx remotion still Testimonial out/f-stars.png --frame=48 --props=reviews/r1.json # stars filled to the REAL score (e.g. 4.5)
npx remotion still Testimonial out/f-author.png --frame=215 --props=reviews/r1.json # author block signed off, holding still
# inspect each: fidelity (quote VERBATIM, exactly one emphasized span, star fill matches rating, name/role/company exact)
# AND artifacts (text overflow/orphans, off-canvas, author in the 9:16 bottom third, missing font, broken avatar)
```
**Multi-aspect / batch — verify one review in EACH target aspect before batch-rendering the list.** A layout bug (overflowing quote, clipped author) repeats across every review × aspect; catch it once.
```bash
for ar in 9x16 1x1 16x9; do # one representative review, every aspect
npx remotion still Testimonial "out/r1-${ar}.png" --frame=215 \
--props=reviews/r1.json --props-merge="{\"aspect\":\"${ar}\"}"
done
# stills clean in all aspects? → then batch-render the list:
for f in reviews/*.json; do id=$(basename "$f" .json); for ar in 9x16 1x1 16x9; do
npx remotion render Testimonial "out/${id}-${ar}.mp4" --props="$f" --props-merge="{\"aspect\":\"${ar}\"}"
done; done
npx remotion render Testimonial out/demo.gif --codec=gif --props=reviews/r1.json # README demo
```
**Before you finish:**
1. `npx remotion still` renders cleanly at quote / stars / author — no errors, no missing fonts/avatar.
2. Quote is verbatim with one emphasized span; stars fill to the real score; name/role/company exact and inside the safe zone (clear of the 9:16 bottom third).
3. Frame-driven only — no CSS/JS timers, `Date.now()`, or `Math.random()`.
4. Shipped review's props render correctly (not just `defaultProps`); `aria-label` carries the real rating.
5. One review verified in 9:16 + 1:1 + 16:9 before the batch; MP4s play; (optional) GIF for the README.
## Reference files
- `references/quote-card.md` — a complete runnable Remotion `Testimonial` composition: staggered line reveal, key-phrase highlight sweep, fractional star fill, author block, theme object, and the timing map for a 9:16 card. Plus a dependency-free HTML/CSS variant.
- `references/batch-and-typography.md` — the review prop schema, CSV/JSON→props parsing and validation, quote typography craft (smart quotes, hanging quotation marks, line/clause splitting, orphan control, legibility in motion), star-rating accessibility, and the template×reviews batch render script for N videos.
@@ -0,0 +1,140 @@
# Batch & Typography
How to feed many reviews into one card design, plus the quote-typography craft that separates a credible testimonial from a sloppy one.
## Review prop schema
One object per testimonial. Keep it flat so a CSV row maps to it directly.
```ts
type Review = {
quote: string; // verbatim — never paraphrased or trimmed mid-sentence
highlight?: string; // EXACT substring of `quote`; one phrase only
rating: number; // 0–max, halves allowed (4.5)
max?: number; // default 5
name: string; // required — a quote with no name is not social proof
role?: string;
company?: string;
avatar?: string; // real photo URL, or omit (never a stock face)
};
```
### Validation (fail loud, before rendering 50 videos)
```ts
function validate(r: Review): string[] {
const errs: string[] = [];
if (!r.quote?.trim()) errs.push("quote is empty");
if (!r.name?.trim()) errs.push("name is required (no anonymous testimonials)");
const max = r.max ?? 5;
if (r.rating < 0 || r.rating > max) errs.push(`rating ${r.rating} out of 0–${max}`);
if (r.highlight && !r.quote.includes(r.highlight))
errs.push(`highlight "${r.highlight}" is not a substring of the quote`);
return errs;
}
```
The `highlight ⊂ quote` check is the important one: it guarantees you only ever emphasize words the customer actually wrote. If the highlight isn't an exact substring, the emphasis silently disappears in the card — so block the render instead.
## CSV → props
Reviews usually arrive as a spreadsheet export. Map columns to the schema and coerce the rating.
```js
import { parse } from "csv-parse/sync";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
// CSV columns: quote,highlight,rating,name,role,company,avatar
const rows = parse(readFileSync("reviews.csv"), { columns: true, trim: true });
mkdirSync("reviews", { recursive: true });
rows.forEach((row, i) => {
const r = {
quote: row.quote,
highlight: row.highlight || undefined,
rating: Number(row.rating),
name: row.name,
role: row.role || undefined,
company: row.company || undefined,
avatar: row.avatar || undefined,
};
const errs = validate(r);
if (errs.length) { console.error(`Row ${i + 1} (${r.name}):`, errs); process.exit(1); }
writeFileSync(`reviews/${String(i).padStart(3, "0")}-${slug(r.name)}.json`,
JSON.stringify(r, null, 2));
});
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
```
## Batch render — one video per review
```bash
mkdir -p out
for f in reviews/*.json; do
name=$(basename "$f" .json)
npx remotion render Testimonial "out/$name.mp4" --props="$f"
done
```
Everything visual lives in the single `theme` object in the composition (colors, fonts, quote-mark glyph, spacing). Because only the props change per render, 50 testimonials stay pixel-for-pixel brand-locked while the words and score vary. To re-theme the whole set, edit one object and re-run the loop.
For multi-aspect (9:16 feed + 1:1 post + 16:9 site), register the composition at each size with a shared center-safe layout and loop over both `reviews/*.json` and the aspect list.
## Quote typography craft
Motion is the easy part; legible, well-set type is what makes the quote trustworthy.
### Smart quotes, always
Straight quotes (`"`) read as un-proofed. Convert to typographic quotes before rendering.
```js
const smarten = (s) => s
.replace(/(^|[\s([{])"/g, "$1“").replace(/"/g, "”") // “ ”
.replace(/(^|[\s([{])'/g, "$1‘").replace(/'/g, "’") // ‘ ’
.replace(/--/g, "—"); // em dash
```
### Hanging quotation marks
A leading quote glyph indents the first line and breaks the flush-left edge. Hang it into the margin so the text block stays optically aligned — the gold standard for set quotes.
```css
blockquote { hanging-punctuation: first; } /* Safari/WebKit */
/* Cross-engine fallback: pull the opening mark left by its own width */
blockquote::before { content: "\201C"; margin-left: -0.55em; color: var(--muted); }
```
When the card uses a large decorative quote mark (as in `quote-card.md`), drop the inline opening glyph so the mark isn't doubled.
### Splitting into lines / clauses
Reveal in reading order means splitting on meaning, not on a fixed character count. Prefer clause boundaries (`. ! ? , ; —`), then pack to a comfortable measure (~32–40 chars at card size). The `toLines` helper in `quote-card.md` does exactly this. Never split a short quote into one-word-per-line — that reads as a ransom note, not a testimonial.
### Orphans & widows
A single dangling word on the last line looks broken. Bind the last two words with a non-breaking space so they wrap together.
```js
const noOrphan = (s) => s.replace(/ ([^ ]+)\s*$/, " $1");
```
### Legibility in motion
- Use a font that stays readable while moving — avoid thin weights and tight tracking; a serif or a humanist sans at 500–600 weight holds up best on a rising line.
- Animate **opacity + a small translate** only. Blur, rotation, and scale on body text hurt readability mid-reveal.
- Contrast: body text ≥ 4.5:1 against the card; the highlight accent should pass against both the card and the ink it sits behind.
- One emphasized span per quote. A second highlight halves the weight of the first.
## Star-rating accessibility & correctness
- The visual stars are decorative; expose the value to assistive tech with `role="img"` and `aria-label="4.5 out of 5 stars"` on the container (already in `quote-card.md`).
- For fractional scores, the width-clip method (`rating / max * 100%`) is exact for any decimal — no rounding to the nearest half-star, so 4.3 renders as 4.3.
- Never animate the stars before the quote is read, and never show more filled stars than the data supports. A faked rating is the fastest way to lose the trust the testimonial was meant to build.
- If a review has no rating, omit the star row entirely rather than defaulting to 5 — an invented score is worse than none.
## Pacing notes
- Hold the finished card still for the back half of the clip (≥3s) so it's screenshot-able and re-shareable — shares are the whole point of social proof.
- For a sequence of testimonials in one video, give each card a full read (~3–4s) and hard-cut between them; cross-dissolving quotes makes both unreadable.
@@ -0,0 +1,241 @@
# Quote Card — complete runnable component
A full testimonial card you can drop into a Remotion project, plus a zero-dependency HTML/CSS variant for non-Remotion pipelines. Both reveal in reading order, emphasize one phrase, fill stars to the real score, and sign off with the author.
## Timing map (9:16, 30fps, ~10s)
| Frame | Beat |
|---|---|
| 0–8 | Quote mark scales in; card settles |
| 8–48 | Quote body, line-by-line (8 + i×6) |
| 44–54 | Key-phrase highlight sweep (after its line settles) |
| 56–70 | Stars fill left→right to `rating/max` |
| 72–88 | Author block slides up, holds |
| 88–300 | Everything holds still (screenshot-able) |
The card holds motionless for the back half — stillness is what makes a testimonial read as a fact rather than an ad.
## Remotion composition
```tsx
import {
AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Img,
} from "remotion";
export type Review = {
quote: string; // verbatim testimonial
highlight?: string; // exact substring of `quote` to emphasize
rating: number; // e.g. 4.5
max?: number; // default 5
name: string;
role?: string;
company?: string;
avatar?: string; // real photo URL, or omit
};
const theme = {
bg: "#0E1116",
ink: "#F5F7FA",
muted: "rgba(245,247,250,.55)",
accent: "#FFC24B",
font: "Inter, system-ui, sans-serif",
quoteFont: "'Newsreader', Georgia, serif",
quoteMark: "“", // left double quotation mark
};
// Split a quote into clauses for reading-order reveal.
// Prefer sentence/clause boundaries; fall back to ~32-char wraps.
const toLines = (q: string): string[] => {
const parts = q.match(/[^.!?,;]+[.!?,;]?\s*/g) ?? [q];
const lines: string[] = [];
let buf = "";
for (const p of parts) {
if ((buf + p).length > 34 && buf) { lines.push(buf.trim()); buf = p; }
else buf += p;
}
if (buf.trim()) lines.push(buf.trim());
return lines;
};
// Render a line, wrapping the highlight substring in <mark> if present.
const Line: React.FC<{ text: string; highlight?: string; sweep: number }> = ({
text, highlight, sweep,
}) => {
if (!highlight || !text.includes(highlight)) return <>{text}</>;
const [before, after] = text.split(highlight);
return (
<>
{before}
<mark
style={{
background: `linear-gradient(90deg, ${theme.accent} ${sweep}%, transparent ${sweep}%)`,
color: "inherit",
padding: "0 .08em",
borderRadius: 3,
WebkitBoxDecorationBreak: "clone",
}}
>
{highlight}
</mark>
{after}
</>
);
};
export const Testimonial: React.FC<Review> = ({
quote, highlight, rating, max = 5, name, role, company, avatar,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const lines = toLines(quote);
// Quote mark
const markIn = spring({ frame, fps, config: { damping: 14 } });
// Highlight sweep, after the body has revealed
const hlStart = 8 + lines.length * 6 + 4;
const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
});
// Stars fill, after the quote
const fillStart = hlStart + 14;
const target = (rating / max) * 100;
const starW = interpolate(frame, [fillStart, fillStart + 14], [0, target], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
});
// Author block slides up last
const authStart = fillStart + 16;
const auth = spring({ frame: frame - authStart, fps, config: { damping: 200 } });
return (
<AbsoluteFill style={{ background: theme.bg, fontFamily: theme.font,
padding: 120, justifyContent: "center", color: theme.ink }}>
<div
aria-hidden
style={{ fontFamily: theme.quoteFont, fontSize: 200, lineHeight: .6,
color: theme.accent, opacity: markIn, transform: `scale(${markIn})`,
transformOrigin: "left top" }}
>
{theme.quoteMark}
</div>
<blockquote style={{ fontFamily: theme.quoteFont, fontSize: 64,
lineHeight: 1.25, margin: "24px 0 48px", fontWeight: 500 }}>
{lines.map((line, i) => {
const enter = spring({ frame: frame - (8 + i * 6), fps,
config: { damping: 200 } });
return (
<span key={i} style={{ display: "block", opacity: enter,
transform: `translateY(${(1 - enter) * 14}px)` }}>
<Line text={line} highlight={highlight} sweep={sweep} />
</span>
);
})}
</blockquote>
{/* Stars */}
<div role="img" aria-label={`${rating} out of ${max} stars`}
style={{ position: "relative", display: "inline-block", fontSize: 52,
letterSpacing: 6, marginBottom: 56 }}>
<div style={{ color: "rgba(245,247,250,.20)" }}>{"★".repeat(max)}</div>
<div style={{ position: "absolute", inset: 0, overflow: "hidden",
whiteSpace: "nowrap", width: `${starW}%`, color: theme.accent }}>
{"★".repeat(max)}
</div>
</div>
{/* Author */}
<figcaption style={{ display: "flex", alignItems: "center", gap: 20,
opacity: auth, transform: `translateY(${(1 - auth) * 16}px)` }}>
{avatar && (
<Img src={avatar} alt=""
style={{ width: 88, height: 88, borderRadius: "50%", objectFit: "cover" }} />
)}
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 38, fontWeight: 700 }}>{name}</span>
<span style={{ fontSize: 30, color: theme.muted }}>
{[role, company].filter(Boolean).join(", ")}
</span>
</div>
</figcaption>
</AbsoluteFill>
);
};
```
Register it with a duration long enough for the back-half hold:
```tsx
<Composition id="Testimonial" component={Testimonial}
durationInFrames={300} fps={30} width={1080} height={1920}
defaultProps={{
quote: "We shipped in days, not months. Onboarding saved us 10 hours a week.",
highlight: "saved us 10 hours a week",
rating: 4.5, name: "Dana Reyes", role: "Head of Ops", company: "Northwind",
}} />
```
## Why these choices
- **`spring` with `damping: 200`** gives a clean rise with no overshoot — testimonials should feel composed, not bouncy. The quote mark uses lighter damping (14) for a touch of life on the one decorative element.
- **Highlight as a gradient `<mark>`** wipes a real highlighter behind the phrase without a second element or a mask, and survives line wraps via `box-decoration-break: clone`.
- **Stars as two stacked glyph rows clipped by width** render any fractional score (4.5, 4.3) with no half-star asset, and the filled row inherits the brand accent.
- **Author after stars** matches reading psychology: claim → proof → who. Reordering weakens trust.
## Zero-dependency HTML/CSS variant
For pipelines that capture a headless-browser timeline (or just a web embed). Animations are CSS `@keyframes` with per-line `animation-delay`; if rendering frame-exact video, prefer the Remotion version above so frames stay deterministic.
```html
<figure class="card" style="--accent:#FFC24B; --rating:4.5; --max:5">
<div class="qmark" aria-hidden="true">&ldquo;</div>
<blockquote>
<span style="--i:0">We shipped in days, not months.</span>
<span style="--i:1">Onboarding <mark>saved us 10 hours a week.</mark></span>
</blockquote>
<div class="stars" role="img" aria-label="4.5 out of 5 stars">
<div class="track">★★★★★</div><div class="fill">★★★★★</div>
</div>
<figcaption>
<img src="dana.jpg" alt="" class="avatar">
<div><b>Dana Reyes</b><small>Head of Ops, Northwind</small></div>
</figcaption>
</figure>
```
```css
.card { --bg:#0E1116; --ink:#F5F7FA; background:var(--bg); color:var(--ink);
font-family:Inter,system-ui,sans-serif; padding:120px; max-width:1080px; }
.qmark { font:200px/.6 'Newsreader',Georgia,serif; color:var(--accent);
animation:pop .4s cubic-bezier(.22,1,.36,1) both; }
blockquote { font:500 64px/1.25 'Newsreader',Georgia,serif; margin:24px 0 48px; }
blockquote span { display:block; opacity:0;
animation:rise .5s cubic-bezier(.22,1,.36,1) both;
animation-delay:calc(.27s + var(--i) * .2s); }
mark { background:linear-gradient(90deg,var(--accent) 100%,transparent 100%);
color:inherit; padding:0 .08em; border-radius:3px;
-webkit-box-decoration-break:clone; box-decoration-break:clone;
animation:sweep .35s ease-out both; animation-delay:1s; }
@keyframes sweep { from { background-size:0 100% } to { background-size:100% 100% } }
.stars { position:relative; display:inline-block; font-size:52px; letter-spacing:6px; }
.track { color:rgba(245,247,250,.20); }
.fill { position:absolute; inset:0; overflow:hidden; white-space:nowrap;
color:var(--accent); width:0; animation:fill .5s ease-out 1.3s forwards; }
@keyframes fill { to { width:calc(var(--rating) / var(--max) * 100%) } }
figcaption { display:flex; align-items:center; gap:20px; margin-top:56px;
opacity:0; animation:rise .5s ease-out 1.8s both; }
.avatar { width:88px; height:88px; border-radius:50%; object-fit:cover; }
figcaption b { font-size:38px; } figcaption small { display:block; font-size:30px;
color:rgba(245,247,250,.55); }
@keyframes pop { from { opacity:0; transform:scale(.7) } to { opacity:1; transform:none } }
@keyframes rise { from { opacity:0; transform:translateY(14px) } to { opacity:1; transform:none } }
```
The CSS `mark` sweep uses `background-size` (animatable) rather than a gradient color-stop, since color-stop percentages don't animate in CSS. The star `--rating/--max` math is the same fractional fill as the Remotion version.
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can turn a list of reviews/quotes into testimonial videos, swap text, and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=ad-video-skills&utm_content=ref_footer&utm_term=testimonial-video)
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/ad-creative-video
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/launch-video
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/testimonial-video
-1
View File
@@ -1 +0,0 @@
ff37d1cd
-1
View File
@@ -1 +0,0 @@
.hallmark/shots/
-5
View File
@@ -1,5 +0,0 @@
shots/
before/
before.json
before.txt
compare.html
-31
View File
@@ -1,31 +0,0 @@
[
{
"shape": "Marquee Hero",
"theme": "squeeze",
"axes": "mid/geometric-sans/warm",
"hero": "Banner",
"nav": "edge-aligned",
"footer": "letter close",
"imagery": "photographs, library 1, natural",
"photos": [
"worm-bay"
],
"entry": "BeingOne front page",
"sections": [
"Hero",
"Path",
"Finder",
"Checks",
"Privacy",
"Talk",
"Family+Safety",
"Questions+Join",
"Footer"
],
"signature": "match finder with a tappable map of Australia and a moving score",
"waived": [],
"prompt": "BeingOne marriage site for Australians",
"date": "2026-10-23",
"mode": "build"
}
]
-65
View File
@@ -1,65 +0,0 @@
# BeingOne: plan (PRD)
Tagline: Real people. Real profiles. Real marriage.
For: Australians, and the families of Australians, who want to find a life partner. English only. Australia only.
Written for: Keith. Plain words on purpose.
## The big idea
BeingOne is a marriage website where every profile is checked to be a real person. People find matches, talk safely, and families can be part of it.
## The 13 core features (from Keith's plan)
1. Sign up and log in with email.
2. Make a profile: photos, age, city, job, things you love, a short "about me".
3. Check the profile is real: phone check, photo check, ID check, and a quick look by a person on the team.
4. Search by Australian city, age and shared interests.
5. Match score: a number showing how well two people fit.
6. Photo privacy: you decide who sees your photos.
7. Send an interest. The other person accepts or says no thanks.
8. Private chat that opens only when both say yes.
9. Family view: a parent or sibling helps look at profiles.
10. Safety tools: block, report, hide your profile.
11. Free plan and paid plan (payments).
12. Admin area for the BeingOne team: review profiles, handle reports, see numbers.
13. Emails and phone alerts: new matches, interests, messages.
## Phase 1 (today): the new look, with working demos
What you get: one front page in a new design. Everything on it is a demo that runs in your browser, with made-up sample people.
| Feature | Where it shows up on the page |
| --- | --- |
| 1 Sign up, log in | The join card at the bottom (Join and Log in tabs). It does not save anything yet. |
| 2 Profile | Shown through the sample people and the checks demo. The real profile editor is Phase 3. |
| 3 Checks | "Every profile is checked": run the four checks on a sample profile. |
| 4 Search | "Find your match across Australia": map of Australia, city, ages, interests. |
| 5 Match score | The score and bar on every sample person. |
| 6 Photo privacy | "You choose who sees your photos": pick who can see, pick who is looking. |
| 7 Interest, accept | "Chat opens when both say yes": accept or say no thanks. |
| 8 Private chat | Same section: the chat unlocks after Accept. |
| 9 Family view | "Family view" tile: pick who helps you look. |
| 10 Safety tools | "Safety tools" tile: block, report, hide. |
| 11 Plans | The "What does it cost?" answer. The paid price is left blank until you give it. |
| 12 Admin | Mentioned in "Who runs the site?". The real admin area is Phase 7 and is a separate private page. |
| 13 Alerts | The "Will I get alerts?" answer. Real alerts are Phase 6. |
Tests, in this order:
1. The page loads with no errors.
2. Every button, chip, slider, tab and form reacts.
3. Real connections: none yet, so nothing to test.
4. Security: no passwords, no keys, no calls to other servers for data. The forms only show a message on screen.
## Phases 2 to 8 (one at a time, each must work before the next)
2. Real sign-up and login (needs a host, a domain, a privacy policy and a consent step).
3. Real profiles and photos, with private photo storage.
4. Real checks (phone code, ID check through a trusted provider, team review screen).
5. Real search and matching on real profiles.
6. Private chat, block and report, email and phone alerts.
7. Payments (test mode first) and the admin area.
8. Launch checks: speed, phones, privacy rules, backups, a trial with friends and family.
## Important
Marriage sites hold very private details. In Australia some of them are "sensitive information" under the Privacy Act 1988, so people must clearly agree to how they are used. Plan the privacy policy and consent step in Phase 2. Please check with a lawyer before launch. This is not legal advice.
-42
View File
@@ -1,42 +0,0 @@
# BeingOne
Real people. Real profiles. Real marriage.
BeingOne is a marriage website for Australians and their families. Every profile is checked to be a real person. People find matches, talk safely, and families can be part of it.
This is **Phase 1**: the front page, in its new design, with working demos that run in the browser. The people on it are made up. Nothing is saved and nothing is sent anywhere yet.
## See it
There is no build step. Open `index.html` in a browser.
## What is in this folder
| File | What it is |
| --- | --- |
| `index.html` | The page |
| `style.css` | How it looks (colours, fonts, spacing, motion) |
| `tokens.css`, `base.css` | The colour and font settings, and the basic rules |
| `app.js` | What the buttons, map, search, checks, chat and forms do |
| `images/hero.jpg` | The photo at the top of the page (swap in the original) |
| `PRD.md` | The plan: 13 features and the 8 phases |
## What the demos do
- **Search bar and map of Australia:** pick a city, ages and interests. Made-up people appear with a match score.
- **Four checks:** run the phone, photo, ID and team checks on a sample profile.
- **Photo privacy:** choose who can see a photo, and who is looking.
- **Interest and chat:** accept an interest and a private chat opens.
- **Family view and safety tools:** pick who helps you look; block, report and hide.
- **Join and log in:** forms that do not send anything yet.
## Before it goes live
- The fonts load from Google Fonts. The beach photo loads from Wikimedia Commons (CC BY-SA 4.0, credited in the footer). Download both into this folder if you want the page to make no outside calls.
- Replace `images/hero.jpg` with the original photo file.
- The paid-plan price is a blank box on the page until you decide it.
- Marriage sites hold very private details. In Australia some of them count as "sensitive information" under the Privacy Act 1988, so people must clearly agree to how it is used. Plan the privacy policy and consent step in Phase 2, and check with a lawyer before launch. This is not legal advice.
## Next: Phase 2
Real sign-up and login. This needs a hosting place (for example Vercel and Supabase) and a domain name.
-313
View File
@@ -1,313 +0,0 @@
(() => {
'use strict';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* made-up sample people; nothing here is a real person (g: w = woman, m = man) */
const PEOPLE = [
{ n: 'Mia', g: 'w', a: 28, c: 'Sydney', l: ['beach', 'coffee', 'travel'], t: 'a' },
{ n: 'Arjun', g: 'm', a: 31, c: 'Melbourne', l: ['footy', 'cooking', 'music'], t: 'b' },
{ n: 'Hannah', g: 'w', a: 27, c: 'Brisbane', l: ['beach', 'hiking', 'pets'], t: 'c' },
{ n: 'Wei', g: 'm', a: 30, c: 'Perth', l: ['coffee', 'travel', 'hiking'], t: 'd' },
{ n: 'Sana', g: 'w', a: 29, c: 'Adelaide', l: ['cooking', 'music', 'pets'], t: 'a' },
{ n: 'Tom', g: 'm', a: 33, c: 'Hobart', l: ['hiking', 'footy', 'coffee'], t: 'b' },
{ n: 'Priya', g: 'w', a: 26, c: 'Sydney', l: ['music', 'cooking', 'beach'], t: 'c' },
{ n: 'Jack', g: 'm', a: 35, c: 'Melbourne', l: ['footy', 'beach', 'pets'], t: 'd' },
{ n: 'Elena', g: 'w', a: 32, c: 'Brisbane', l: ['travel', 'coffee', 'music'], t: 'a' },
{ n: 'Dev', g: 'm', a: 34, c: 'Perth', l: ['footy', 'cooking', 'hiking'], t: 'b' },
{ n: 'Grace', g: 'w', a: 30, c: 'Canberra', l: ['hiking', 'coffee', 'pets'], t: 'c' },
{ n: 'Sam', g: 'm', a: 28, c: 'Darwin', l: ['beach', 'footy', 'travel'], t: 'd' },
{ n: 'Lena', g: 'w', a: 38, c: 'Sydney', l: ['travel', 'music', 'pets'], t: 'a' },
{ n: 'Omar', g: 'm', a: 29, c: 'Melbourne', l: ['coffee', 'cooking', 'hiking'], t: 'c' }
];
/* ---------- scroll reveal, once ---------- */
const revealEls = $$('[data-reveal]');
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver((es) => es.forEach((e) => {
if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
}), { rootMargin: '0px 0px -10% 0px' });
revealEls.forEach((el) => io.observe(el));
} else {
revealEls.forEach((el) => el.classList.add('is-in'));
}
/* ---------- mobile menu ---------- */
const menu = $('.nav__menu'), links = $('#nav-links');
menu.addEventListener('click', () => {
const open = links.classList.toggle('is-open');
menu.setAttribute('aria-expanded', String(open));
menu.textContent = open ? 'Close' : 'Menu';
});
links.addEventListener('click', (e) => {
if (e.target.closest('a')) { links.classList.remove('is-open'); menu.setAttribute('aria-expanded', 'false'); menu.textContent = 'Menu'; }
});
/* ---------- match finder (the quick search bar drives it too) ---------- */
const st = { city: 'Anywhere', gender: 'any', from: 25, to: 40, likes: new Set(), sent: new Set() };
const from = $('#f-from'), to = $('#f-to'), oFrom = $('#o-from'), oTo = $('#o-to');
const results = $('#results'), status = $('#finder-status'), mapCity = $('#map-city');
const qFor = $('#q-for'), qAge = $('#q-age'), qCity = $('#q-city');
const pins = $$('.pin');
function scoreOf(p) {
const mid = (st.from + st.to) / 2, half = Math.max((st.to - st.from) / 2, 1);
const ageFit = Math.max(0, 1 - Math.abs(p.a - mid) / (half + 4));
const shared = p.l.filter((x) => st.likes.has(x)).length;
const likeFit = st.likes.size ? Math.min(1, shared / Math.min(st.likes.size, 3)) : 0.5;
const cityFit = st.city === 'Anywhere' ? 0.7 : 1;
return Math.round(55 + 45 * (0.5 * likeFit + 0.3 * ageFit + 0.2 * cityFit));
}
function tile(r, i) {
const p = r.p, sent = st.sent.has(p.n);
const tags = p.l.map((x) => '<li' + (st.likes.has(x) ? ' class="is-shared"' : '') + '>' + cap(x) + '</li>').join('');
return '<article class="person" style="--i:' + i + '">' +
'<div class="person__who"><span class="avatar" style="--tone:var(--tone-' + p.t + ')" aria-hidden="true">' + p.n.charAt(0) + '</span>' +
'<div><h3>' + p.n + ', ' + p.a + '</h3><p class="label">' + p.c + '</p></div></div>' +
'<div><div class="score"><b data-to="' + r.score + '">' + r.score + '</b><span class="label" title="Counts shared interests, city and age">match</span></div>' +
'<div class="bar" role="img" aria-label="Match score ' + r.score + ' out of 100"><i style="--s:' + r.score + '"></i></div></div>' +
'<ul class="tags">' + tags + '</ul>' +
'<button class="btn btn--sm" type="button" data-interest="' + p.n + '" aria-pressed="' + sent + '">' + (sent ? 'Interest sent' : 'Send interest') + '</button>' +
'</article>';
}
/* scores count up from 55 to their value, once per render */
function countUp(el) {
const to = +el.dataset.to;
if (reduced) { el.textContent = to; return; }
const t0 = performance.now(), dur = 900, start = 55;
el.textContent = start;
(function step(now) {
const k = Math.min(1, (now - t0) / dur), e = 1 - Math.pow(1 - k, 3);
el.textContent = Math.round(start + (to - start) * e);
if (k < 1) requestAnimationFrame(step);
})(t0);
}
function renderFinder() {
const pool = PEOPLE
.filter((p) => (st.city === 'Anywhere' || p.c === st.city) && (st.gender === 'any' || p.g === st.gender) && p.a >= st.from && p.a <= st.to)
.map((p) => ({ p, score: scoreOf(p) }))
.sort((x, y) => y.score - x.score || x.p.n.localeCompare(y.p.n));
const top = pool.slice(0, 4);
results.innerHTML = top.length
? top.map(tile).join('')
: '<p class="results__empty">No one fits yet. Widen the ages or try another city.</p>';
$$('.score b', results).forEach(countUp);
status.textContent = top.length ? top.length + ' of ' + pool.length + ' shown.' : '';
mapCity.textContent = st.city === 'Anywhere' ? 'All of Australia' : st.city;
qCity.value = st.city;
pins.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.city === st.city)));
}
function setAges(a, b) {
st.from = a; st.to = b; from.value = a; to.value = b;
oFrom.textContent = a; oTo.textContent = b;
}
pins.forEach((b) => b.addEventListener('click', () => {
st.city = st.city === b.dataset.city ? 'Anywhere' : b.dataset.city;
if (st.city === b.dataset.city && !reduced) {
const ping = document.createElement('span');
ping.className = 'ping';
ping.addEventListener('animationend', () => ping.remove(), { once: true });
b.appendChild(ping);
}
renderFinder();
}));
from.addEventListener('input', () => {
st.from = +from.value;
if (st.from > st.to) { st.to = st.from; to.value = st.to; }
oFrom.textContent = st.from; oTo.textContent = st.to; renderFinder();
});
to.addEventListener('input', () => {
st.to = +to.value;
if (st.to < st.from) { st.from = st.to; from.value = st.from; }
oFrom.textContent = st.from; oTo.textContent = st.to; renderFinder();
});
$$('#f-likes input').forEach((c) => c.addEventListener('change', () => {
c.checked ? st.likes.add(c.value) : st.likes.delete(c.value);
renderFinder();
}));
results.addEventListener('click', (e) => {
const b = e.target.closest('[data-interest]');
if (!b) return;
const name = b.dataset.interest;
st.sent.has(name) ? st.sent.delete(name) : st.sent.add(name);
const on = st.sent.has(name);
b.setAttribute('aria-pressed', String(on));
b.textContent = on ? 'Interest sent' : 'Send interest';
});
/* quick search bar: each choice updates the finder, the button also scrolls to the people */
function applyQuick() {
st.gender = qFor.value;
st.city = qCity.value;
const [a, b] = qAge.value.split('-').map(Number);
setAges(a, b);
renderFinder();
}
[qFor, qAge, qCity].forEach((el) => el.addEventListener('change', applyQuick));
$('#quick').addEventListener('submit', (e) => {
e.preventDefault();
applyQuick();
results.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'center' });
});
renderFinder();
/* ---------- the four checks ---------- */
const rows = $$('[data-check]'), vStatus = $('#v-status'), vText = $('#v-status-text'), vBar = $('#v-progress');
const timers = new Map();
function updateChecks() {
const n = rows.filter((r) => r.classList.contains('is-done')).length;
vStatus.dataset.state = n === rows.length ? 'done' : 'idle';
vText.textContent = n === rows.length ? 'Verified' : n ? n + ' of 4 passed' : 'Not verified yet';
vBar.style.setProperty('--p', n / rows.length);
}
function runCheck(row) {
if (row.classList.contains('is-done') || timers.has(row)) return;
const btn = $('[data-run]', row);
btn.setAttribute('aria-busy', 'true'); btn.disabled = true; btn.textContent = 'Checking…';
timers.set(row, setTimeout(() => {
timers.delete(row);
row.classList.add('is-done');
btn.hidden = true; btn.removeAttribute('aria-busy');
$('.passed', row).hidden = false;
updateChecks();
}, 800));
}
rows.forEach((r) => $('[data-run]', r).addEventListener('click', () => runCheck(r)));
$('#v-all').addEventListener('click', () => rows.forEach((r, i) => setTimeout(() => runCheck(r), i * 250)));
$('#v-reset').addEventListener('click', () => {
timers.forEach((t) => clearTimeout(t)); timers.clear();
rows.forEach((r) => {
r.classList.remove('is-done');
const btn = $('[data-run]', r);
btn.hidden = false; btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = 'Run';
$('.passed', r).hidden = true;
});
updateChecks();
});
/* ---------- photo privacy ---------- */
const photo = $('#photo'), photoState = $('#photo-state');
const rank = { stranger: 0, verified: 1, accepted: 2 };
function renderPhoto() {
const setting = $('input[name="setting"]:checked').value;
const viewer = $('input[name="viewer"]:checked').value;
const can = setting === 'everyone' ||
(setting === 'verified' && rank[viewer] >= 1) ||
(setting === 'accepted' && rank[viewer] >= 2);
photo.classList.toggle('is-hidden', !can);
photoState.textContent = can ? 'Visible to this visitor' : 'Hidden from this visitor';
}
$$('input[name="setting"], input[name="viewer"]').forEach((r) => r.addEventListener('change', renderPhoto));
renderPhoto();
/* ---------- interest, accept, chat ---------- */
const chat = $('#chat'), log = $('#chat-log'), tResult = $('#t-result');
const accept = $('#t-accept'), decline = $('#t-decline'), tReset = $('#t-reset');
const chatForm = $('#chat-form'), chatInput = $('#chat-input');
function addLine(text, cls) {
const li = document.createElement('li');
if (cls) li.className = cls;
li.textContent = text;
log.appendChild(li);
log.scrollTop = log.scrollHeight;
}
function decide(yes) {
accept.hidden = true; decline.hidden = true; tReset.hidden = false;
tResult.textContent = yes ? 'You both said yes. Say hello.' : 'You said no thanks. Nothing opens.';
if (yes) {
chat.classList.remove('is-locked');
addLine('You and Priya both said yes.', 'note');
chatInput.focus();
}
}
accept.addEventListener('click', () => decide(true));
decline.addEventListener('click', () => decide(false));
tReset.addEventListener('click', () => {
accept.hidden = false; decline.hidden = false; tReset.hidden = true;
tResult.textContent = ''; log.textContent = '';
chat.classList.add('is-locked'); chatInput.value = '';
});
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const v = chatInput.value.trim();
if (!v) { chatInput.focus(); return; }
addLine(v, 'me'); chatInput.value = ''; chatInput.focus();
});
/* ---------- family view and safety tools ---------- */
const famStatus = $('#fam-status');
function renderFam() {
const on = $$('#fam input:checked').map((c) => c.value);
famStatus.textContent = !on.length ? 'Just you for now.'
: (on.length === 1 ? on[0] : on.slice(0, -1).join(', ') + ' and ' + on[on.length - 1]) +
(on.length === 1 ? ' is' : ' are') + ' looking with you.';
}
$$('#fam input').forEach((c) => c.addEventListener('change', renderFam));
const safe = $('#safe-status'), sBlock = $('#s-block'), sHide = $('#s-hide'), sReport = $('#s-report');
let blocked = false;
sBlock.addEventListener('click', () => {
blocked = !blocked;
$('span', sBlock).textContent = blocked ? 'Unblock' : 'Block';
sBlock.setAttribute('aria-pressed', String(blocked));
safe.textContent = blocked ? 'Jack is blocked. He cannot message you.' : 'Jack is unblocked.';
});
sReport.addEventListener('click', () => { safe.textContent = 'Demo only: nothing was sent to our team.'; });
sHide.addEventListener('click', () => {
const on = sHide.getAttribute('aria-pressed') !== 'true';
sHide.setAttribute('aria-pressed', String(on));
$('span', sHide).textContent = on ? 'Show my profile' : 'Hide my profile';
safe.textContent = on ? 'Your profile is hidden from search.' : 'Your profile shows in search.';
});
/* ---------- join card: tabs and forms that post nowhere yet ---------- */
const tabs = $$('[role="tab"]', $('#join-card'));
const panels = { join: $('#panel-join'), login: $('#panel-login') };
function openTab(name, focus) {
tabs.forEach((t) => {
const on = t.id === 'tab-' + name;
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
if (on && focus) t.focus();
});
Object.keys(panels).forEach((k) => { panels[k].hidden = k !== name; });
}
tabs.forEach((t, i) => {
t.addEventListener('click', () => openTab(t.id.replace('tab-', '')));
t.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault();
const next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
openTab(next.id.replace('tab-', ''), true);
});
});
$$('[data-open-tab]').forEach((a) => a.addEventListener('click', () => openTab(a.dataset.openTab)));
openTab('join');
$$('form[data-endpoint]').forEach((form) => {
const help = $('.form-help', form);
form.addEventListener('input', (e) => { e.target.removeAttribute && e.target.removeAttribute('aria-invalid'); });
form.addEventListener('submit', (e) => {
e.preventDefault();
help.classList.remove('is-error');
const bad = $$('input', form).filter((i) => !i.checkValidity());
$$('input', form).forEach((i) => i.toggleAttribute('aria-invalid', bad.includes(i)));
if (bad.length) {
help.classList.add('is-error');
help.textContent = bad[0].type === 'email'
? 'That email looks incomplete. Write it as name@example.com.'
: 'Please fill in the highlighted field.';
bad[0].focus();
return;
}
if (!form.dataset.endpoint) { help.textContent = 'Nothing was sent: this form is not connected yet.'; return; }
});
});
})();
-23
View File
@@ -1,23 +0,0 @@
*, *::before, *::after { box-sizing: border-box; }
html, body { overflow-x: clip; }
html { scrollbar-color: var(--color-muted) var(--color-paper); scroll-padding-top: var(--space-3xl); }
body { margin: 0; background: var(--color-paper); color: var(--color-ink); font-family: var(--font-body); font-size: var(--text-md); line-height: 1.55; -webkit-font-smoothing: antialiased; caret-color: var(--color-accent); }
::selection { background: color-mix(in oklab, var(--color-accent) 22%, transparent); color: var(--color-ink); }
:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 0.16em; }
h1, h2, h3 { margin: 0; font-family: var(--font-display); font-synthesis: none; overflow-wrap: break-word; min-width: 0; text-wrap: balance; }
p { margin: 0; text-wrap: pretty; }
p:not([class]) { max-width: 65ch; }
img, video { max-width: 100%; height: auto; display: block; }
svg { display: block; }
[hidden] { display: none !important; }
button, input, select, textarea { font: inherit; }
.container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); box-sizing: border-box; }
.num, .figure, td.num { font-variant-numeric: tabular-nums; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
[data-slot] { position: relative; overflow: hidden; background: linear-gradient(160deg, var(--color-paper-2, var(--color-paper)), var(--color-paper-3, var(--color-paper))); }
[data-slot] > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1; }
[data-slot]:has(> img) > :not(img):not([data-keep]) { display: none !important; }
@media (max-width: 60rem) { :root { --gutter: 24px; --section-gap-s: 80px; --section-gap: 96px; --section-gap-l: 120px; } }
@media (max-width: 40rem) { :root { --gutter: 16px; --section-gap-s: 56px; --section-gap: 72px; --section-gap-l: 96px; --gap-columns: 16px; } }
@media (prefers-reduced-motion: reduce) { *:not(.keep-motion), *:not(.keep-motion)::before, *:not(.keep-motion)::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

-346
View File
@@ -1,346 +0,0 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BeingOne: marriage profiles for Australians, every one checked</title>
<meta name="description" content="BeingOne is a marriage website for Australians and their families where every profile is checked to be a real person.">
<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=DM+Serif+Display:ital@0;1&family=Figtree:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script>document.documentElement.classList.add('js');</script>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="i-phone" viewBox="0 0 24 24"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol>
<symbol id="i-id" viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M5.5 16c.5-1.5 2-2.5 3.5-2.5s3 1 3.5 2.5M15 10h4M15 14h3"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></symbol>
<symbol id="i-ban" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></symbol>
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><path d="M4 22v-7"/></symbol>
<symbol id="i-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/></symbol>
</svg>
<a class="skip" href="#main">Skip to content</a>
<header class="nav">
<div class="container nav__bar">
<a class="wordmark" href="#top" aria-label="BeingOne, back to the top">
<svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><circle cx="12" cy="16" r="8"/><circle cx="20" cy="16" r="8"/></svg>
<span>Being<b>One</b></span>
</a>
<nav class="nav__links" id="nav-links" aria-label="Main">
<a href="#find">Discover</a>
<a href="#results">Curated matches</a>
<a href="#talk">Messages</a>
<a href="#safety">Safety centre</a>
<a href="#help">Help</a>
<a class="only-sm" href="#join" data-open-tab="login">Sign in</a>
</nav>
<div class="nav__actions">
<a class="nav__signin" href="#join" data-open-tab="login">Sign in</a>
<a class="btn btn--sm" href="#join" data-open-tab="join">Join</a>
<button class="nav__menu btn btn--sm" type="button" aria-expanded="false" aria-controls="nav-links">Menu</button>
</div>
</div>
</header>
<main id="main">
<section class="hero" id="top" aria-labelledby="hero-title">
<figure class="hero__photo" data-slot="hero" data-alt="A bride and groom holding hands outdoors as guests throw petals">
<img src="images/hero.jpg" width="998" height="770" alt="A bride and groom holding hands outdoors as guests throw petals" fetchpriority="high">
</figure>
<div class="container hero__inner">
<div class="hero__text">
<h1 id="hero-title">
<span class="line" style="--i:0"><span>Real people.</span></span>
<span class="line" style="--i:1"><span>Real profiles.</span></span>
<span class="line line--it" style="--i:2"><span>Real marriage.</span></span>
</h1>
<p class="lead hero__lead">Every profile is checked, so you meet real people across Australia.</p>
<a class="btn btn--primary hero__cta" href="#join" data-open-tab="join">Create your profile<svg class="icon" aria-hidden="true"><use href="#i-arrow"/></svg></a>
</div>
</div>
<div class="container quick__wrap">
<form class="quick" id="quick" onsubmit="return false" aria-label="Quick search">
<div class="quick__field">
<label for="q-for">I’m looking for</label>
<select id="q-for"><option value="any">Anyone</option><option value="w">A woman</option><option value="m">A man</option></select>
</div>
<div class="quick__field">
<label for="q-age">Age</label>
<select id="q-age"><option value="21-60">Any age</option><option value="21-29">21 to 29</option><option value="30-39">30 to 39</option><option value="40-49">40 to 49</option><option value="50-60">50 to 60</option></select>
</div>
<div class="quick__field">
<label for="q-city">Location</label>
<select id="q-city"><option>Anywhere</option><option>Adelaide</option><option>Brisbane</option><option>Canberra</option><option>Darwin</option><option>Hobart</option><option>Melbourne</option><option>Perth</option><option>Sydney</option></select>
</div>
<button class="btn btn--primary quick__go" type="submit"><svg class="icon" aria-hidden="true"><use href="#i-search"/></svg>Search</button>
</form>
</div>
</section>
<section class="band band--path" id="path" aria-label="How it goes">
<div class="container">
<ol class="path">
<li data-reveal style="--i:0"><a href="#join">Join</a></li>
<li data-reveal style="--i:1"><a href="#verified">Get checked</a></li>
<li data-reveal style="--i:2"><a href="#find">Find</a></li>
<li data-reveal style="--i:3"><a href="#talk">Say yes</a></li>
<li data-reveal style="--i:4"><a href="#talk">Chat</a></li>
</ol>
</div>
</section>
<section class="sec finder" id="find" aria-labelledby="find-title">
<div class="container">
<div class="grid finder__top">
<h2 id="find-title" data-reveal>Find your match across Australia</h2>
<p class="lead" data-reveal style="--i:1">Pick a city, ages and what you love.</p>
</div>
<div class="grid finder__body">
<div class="map" data-reveal role="group" aria-label="Map of Australia. Tap a city to search there.">
<svg class="map__svg" viewBox="-40 -20 605 516" role="img" aria-label="Outline of Australia">
<path d="M381.3 9.8L393.8 56.0L416.3 70.0L422.5 96.6L435.0 130.2L458.7 144.2L465.0 155.4L485.0 187.6L505.0 208.6L513.7 224.0L512.5 245.0L520.0 260.4L512.5 284.2L506.3 308.0L490.0 334.6L485.0 348.6L476.2 366.8L473.8 385.0L450.0 392.0L430.0 407.4L412.5 397.6L393.8 403.2L362.5 393.4L350.0 385.0L343.8 358.4L326.2 359.1L331.3 347.2L321.2 336.0L300.0 347.2L277.5 322.0L237.5 301.0L212.5 303.8L175.0 312.2L143.8 333.2L123.8 334.6L87.5 348.6L73.8 350.0L38.7 341.6L46.3 326.2L47.5 308.0L32.5 263.2L17.5 224.0L18.8 196.0L26.2 165.2L58.8 149.8L82.5 144.2L118.8 126.0L127.5 112.0L147.5 91.0L168.8 77.0L186.3 54.6L212.5 70.0L221.2 68.6L225.0 46.2L235.0 35.0L256.3 21.0L275.0 28.0L310.0 30.8L306.3 49.0L297.5 72.8L322.5 91.0L343.8 105.0L360.0 103.6L370.0 77.0L368.8 49.0L376.2 30.8ZM408.7 429.8L453.8 432.6L453.8 450.8L441.3 469.0L425.0 470.4L415.0 450.8Z"/>
</svg>
<button class="pin" type="button" data-city="Perth" aria-pressed="false" aria-label="Perth" title="Perth" style="--x:14.6%;--y:63.4%;--i:0"></button>
<button class="pin" type="button" data-city="Adelaide" aria-pressed="false" aria-label="Adelaide" title="Adelaide" style="--x:61.6%;--y:71.5%;--i:1"></button>
<button class="pin" type="button" data-city="Darwin" aria-pressed="false" aria-label="Darwin" title="Darwin" style="--x:45.5%;--y:10.5%;--i:2"></button>
<button class="pin" type="button" data-city="Brisbane" aria-pressed="false" aria-label="Brisbane" title="Brisbane" style="--x:91.4%;--y:51.3%;--i:3"></button>
<button class="pin" type="button" data-city="Sydney" aria-pressed="false" aria-label="Sydney" title="Sydney" style="--x:87.6%;--y:68.6%;--i:4"></button>
<button class="pin" type="button" data-city="Canberra" aria-pressed="false" aria-label="Canberra" title="Canberra" style="--x:83.3%;--y:72.5%;--i:5"></button>
<button class="pin" type="button" data-city="Melbourne" aria-pressed="false" aria-label="Melbourne" title="Melbourne" style="--x:74.7%;--y:79.3%;--i:6"></button>
<button class="pin" type="button" data-city="Hobart" aria-pressed="false" aria-label="Hobart" title="Hobart" style="--x:79.6%;--y:93.1%;--i:7"></button>
<p class="map__city" id="map-city">All of Australia</p>
</div>
<form class="controls" id="finder-form" data-reveal style="--i:1" onsubmit="return false" aria-label="Match finder">
<div class="controls__ages">
<div class="field">
<div class="field__row"><label for="f-from">From</label><output id="o-from" for="f-from">25</output></div>
<input id="f-from" type="range" min="21" max="60" value="25">
</div>
<div class="field">
<div class="field__row"><label for="f-to">To</label><output id="o-to" for="f-to">40</output></div>
<input id="f-to" type="range" min="21" max="60" value="40">
</div>
</div>
<fieldset class="choices" id="f-likes">
<legend>You love</legend>
<input type="checkbox" id="l-beach" value="beach"><label for="l-beach">Beach</label>
<input type="checkbox" id="l-footy" value="footy"><label for="l-footy">Footy</label>
<input type="checkbox" id="l-coffee" value="coffee"><label for="l-coffee">Coffee</label>
<input type="checkbox" id="l-hiking" value="hiking"><label for="l-hiking">Hiking</label>
<input type="checkbox" id="l-cooking" value="cooking"><label for="l-cooking">Cooking</label>
<input type="checkbox" id="l-travel" value="travel"><label for="l-travel">Travel</label>
<input type="checkbox" id="l-music" value="music"><label for="l-music">Music</label>
<input type="checkbox" id="l-pets" value="pets"><label for="l-pets">Pets</label>
</fieldset>
</form>
</div>
<div class="results" id="results" aria-live="polite"></div>
<p class="visually-hidden" id="finder-status" role="status"></p>
<p class="finder__note">Sample people, made up for this demo.</p>
</div>
</section>
<section class="sec band--navy on-dark checks" id="verified" aria-labelledby="verified-title">
<div class="container grid">
<div class="head">
<h2 id="verified-title" data-reveal>Every profile is checked</h2>
<p class="lead" data-reveal style="--i:1">Try the four checks.</p>
</div>
<div class="checks__panel" data-reveal style="--i:2">
<div class="checks__who">
<span class="avatar" style="--tone:var(--tone-a)" aria-hidden="true">M</span>
<div><h3>Mia, 28</h3><p class="label">Sydney</p></div>
<span class="status" id="v-status" data-state="idle" role="status"><svg class="icon icon--sm" aria-hidden="true"><use href="#i-shield"/></svg><span id="v-status-text">Not verified yet</span></span>
</div>
<div class="progress" aria-hidden="true"><i id="v-progress" style="--p:0"></i></div>
<ul class="rows" id="v-rows">
<li data-check>
<svg class="icon idle" aria-hidden="true"><use href="#i-phone"/></svg><svg class="icon tick" aria-hidden="true"><use href="#i-check"/></svg>
<div><h3>Phone check</h3><p>A code arrives by text.</p></div>
<button class="btn btn--sm" type="button" data-run>Run</button><span class="passed" hidden>Passed</span>
</li>
<li data-check>
<svg class="icon idle" aria-hidden="true"><use href="#i-camera"/></svg><svg class="icon tick" aria-hidden="true"><use href="#i-check"/></svg>
<div><h3>Photo check</h3><p>Checked as real.</p></div>
<button class="btn btn--sm" type="button" data-run>Run</button><span class="passed" hidden>Passed</span>
</li>
<li data-check>
<svg class="icon idle" aria-hidden="true"><use href="#i-id"/></svg><svg class="icon tick" aria-hidden="true"><use href="#i-check"/></svg>
<div><h3>ID check</h3><p>Matched to your photo.</p></div>
<button class="btn btn--sm" type="button" data-run>Run</button><span class="passed" hidden>Passed</span>
</li>
<li data-check>
<svg class="icon idle" aria-hidden="true"><use href="#i-users"/></svg><svg class="icon tick" aria-hidden="true"><use href="#i-check"/></svg>
<div><h3>Team look</h3><p>A quick look by our team.</p></div>
<button class="btn btn--sm" type="button" data-run>Run</button><span class="passed" hidden>Passed</span>
</li>
</ul>
<div class="checks__foot">
<button class="btn btn--sm" type="button" id="v-all">Run all four</button>
<button class="btn btn--sm" type="button" id="v-reset">Start again</button>
</div>
</div>
</div>
</section>
<section class="sec sec--tall" id="privacy" aria-labelledby="privacy-title">
<div class="container grid">
<figure class="privacy__photo" id="photo" data-reveal>
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg/1280px-Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg" srcset="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg/960px-Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg 960w, https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg/1280px-Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg 1280w, https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg/1920px-Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg 1920w" sizes="(min-width: 60rem) 58vw, 100vw" width="6718" height="4479" alt="A beach below orange sandstone cliffs, standing in for a profile photo" loading="lazy">
<img class="blur" src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg/960px-Peterborough_%28AU%29%2C_Port_Campbell_National_Park%2C_Worm_Bay_--_2019_--_0863.jpg" width="6718" height="4479" alt="" aria-hidden="true" loading="lazy">
<span class="privacy__lock" aria-hidden="true"><span class="privacy__disc"><svg class="icon"><use href="#i-lock"/></svg></span></span>
<span class="privacy__badge" role="status"><svg class="icon icon--sm icon-open" aria-hidden="true"><use href="#i-eye"/></svg><svg class="icon icon--sm icon-shut" aria-hidden="true"><use href="#i-lock"/></svg><span id="photo-state">Hidden from this visitor</span></span>
</figure>
<form class="privacy__panel" onsubmit="return false" aria-label="Photo privacy">
<h2 id="privacy-title" data-reveal>You choose who sees your photos</h2>
<fieldset class="choices choices--stack" id="p-setting">
<legend>Photos are visible to</legend>
<input type="radio" name="setting" id="s-everyone" value="everyone"><label for="s-everyone">Everyone</label>
<input type="radio" name="setting" id="s-verified" value="verified" checked><label for="s-verified">Verified members</label>
<input type="radio" name="setting" id="s-accepted" value="accepted"><label for="s-accepted">People I accept</label>
<input type="radio" name="setting" id="s-me" value="me"><label for="s-me">Only me</label>
</fieldset>
<fieldset class="choices" id="p-viewer">
<legend>Right now, a visitor is</legend>
<input type="radio" name="viewer" id="v-stranger" value="stranger" checked><label for="v-stranger">A stranger</label>
<input type="radio" name="viewer" id="v-member" value="verified"><label for="v-member">A verified member</label>
<input type="radio" name="viewer" id="v-accepted" value="accepted"><label for="v-accepted">Someone I accepted</label>
</fieldset>
</form>
</div>
</section>
<section class="band talk" id="talk" aria-labelledby="talk-title">
<div class="container">
<div class="talk__top"><h2 id="talk-title" data-reveal>Chat opens when both say yes</h2></div>
<div class="grid talk__body">
<div class="invite" data-reveal>
<div class="invite__who">
<span class="avatar" style="--tone:var(--tone-c)" aria-hidden="true">P</span>
<div><h3>Priya sent you an interest</h3><p class="label">26, Sydney</p></div>
</div>
<div class="invite__actions" id="invite-actions">
<button class="btn" type="button" id="t-accept">Accept</button>
<button class="btn" type="button" id="t-decline">No thanks</button>
<button class="btn btn--sm" type="button" id="t-reset" hidden>Start again</button>
</div>
<p class="invite__result" id="t-result" role="status"></p>
</div>
<div class="chat on-dark is-locked" id="chat" data-reveal style="--i:1">
<ul class="chat__log" id="chat-log" role="log" aria-label="Chat with Priya"></ul>
<form class="chat__form" id="chat-form" autocomplete="off">
<label class="visually-hidden" for="chat-input">Message</label>
<input id="chat-input" name="message" type="text" maxlength="300" enterkeyhint="send">
<button class="btn btn--sm btn--primary" type="submit"><svg class="icon" aria-hidden="true"><use href="#i-send"/></svg>Send</button>
</form>
<div class="chat__lock"><svg class="icon" aria-hidden="true"><use href="#i-lock"/></svg><span>Chat opens when you both say yes.</span></div>
</div>
</div>
</div>
</section>
<section class="sec sec--tall" id="family" aria-label="Family view and safety tools">
<div class="container grid together">
<div class="tile tile--wide" data-reveal>
<h2>Family view</h2>
<p class="lead">A parent or sibling can look at profiles with you.</p>
<fieldset class="choices" id="fam">
<legend>Who helps you look</legend>
<input type="checkbox" id="h-mum" value="Mum"><label for="h-mum">Mum</label>
<input type="checkbox" id="h-dad" value="Dad"><label for="h-dad">Dad</label>
<input type="checkbox" id="h-sister" value="Sister"><label for="h-sister">Sister</label>
<input type="checkbox" id="h-brother" value="Brother"><label for="h-brother">Brother</label>
</fieldset>
<p class="live" id="fam-status" role="status">Just you for now.</p>
</div>
<div class="tile tile--narrow on-dark" id="safety" data-reveal style="--i:1">
<h2>Safety tools</h2>
<div class="safety__who">
<span class="avatar" style="--tone:var(--tone-d)" aria-hidden="true">J</span>
<div><h3>Jack, 35</h3><p class="label">Melbourne</p></div>
</div>
<div class="safety__actions">
<button class="btn btn--sm" type="button" id="s-block"><svg class="icon" aria-hidden="true"><use href="#i-ban"/></svg><span>Block</span></button>
<button class="btn btn--sm" type="button" id="s-report"><svg class="icon" aria-hidden="true"><use href="#i-flag"/></svg><span>Report</span></button>
<button class="btn btn--sm" type="button" id="s-hide" aria-pressed="false"><svg class="icon" aria-hidden="true"><use href="#i-lock"/></svg><span>Hide my profile</span></button>
</div>
<p class="live" id="safe-status" role="status"></p>
</div>
</div>
</section>
<section class="band join" id="join" aria-labelledby="join-title">
<div class="container grid">
<div class="head">
<h2 id="join-title" data-reveal>Make your profile</h2>
<dl class="qa" id="help" data-reveal style="--i:1">
<div><dt>What does it cost?</dt><dd>A free plan and a paid plan. Paid price: <span class="fill" data-fill="paid-price">to be announced</span></dd></div>
<div><dt>Will I get alerts?</dt><dd>Email and phone alerts for matches, interests and messages.</dd></div>
<div><dt>Who runs the site?</dt><dd>Our team reviews profiles and reports.</dd></div>
</dl>
</div>
<div class="card" id="join-card" data-reveal style="--i:2">
<div class="tabs" role="tablist" aria-label="Join or log in">
<button type="button" role="tab" id="tab-join" aria-selected="true" aria-controls="panel-join">Join</button>
<button type="button" role="tab" id="tab-login" aria-selected="false" aria-controls="panel-login" tabindex="-1">Log in</button>
</div>
<div class="panel" role="tabpanel" id="panel-join" aria-labelledby="tab-join">
<form data-endpoint="" novalidate class="panel">
<div class="field"><label for="j-name">First name</label><input id="j-name" name="name" type="text" autocomplete="given-name" required></div>
<div class="field"><label for="j-email">Email address</label><input id="j-email" name="email" type="email" autocomplete="email" inputmode="email" required></div>
<div class="field"><label for="j-for">Joining for</label>
<select id="j-for" name="for"><option>Myself</option><option>My son</option><option>My daughter</option><option>My brother or sister</option></select>
</div>
<button class="btn btn--primary" type="submit">Create your profile<svg class="icon" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<p class="form-help" aria-live="polite"></p>
</form>
</div>
<div class="panel" role="tabpanel" id="panel-login" aria-labelledby="tab-login">
<form data-endpoint="" novalidate class="panel">
<div class="field"><label for="l-email">Email address</label><input id="l-email" name="email" type="email" autocomplete="email" inputmode="email" required></div>
<button class="btn btn--primary" type="submit">Log in<svg class="icon" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<p class="form-help" aria-live="polite"></p>
</form>
</div>
</div>
</div>
</section>
</main>
<footer class="foot on-dark">
<div class="container">
<p class="foot__sign">Warmly, the BeingOne team.</p>
<div class="foot__row">
<p>For Australians and their families. <time datetime="2026-10">October 2026</time></p>
<ul class="foot__links">
<li><a href="#find">Discover</a></li>
<li><a href="#verified">Checks</a></li>
<li><a href="#privacy">Photos</a></li>
<li><a href="#talk">Messages</a></li>
<li><a href="#join">Join</a></li>
</ul>
</div>
<p data-credits>Photographs: beach by <a href="https://commons.wikimedia.org/wiki/File:Peterborough_(AU),_Port_Campbell_National_Park,_Worm_Bay_--_2019_--_0863.jpg">Dietmar Rabich</a> (CC BY-SA 4.0, Wikimedia Commons)</p>
</div>
</footer>
<script>document.querySelectorAll('[data-slot] img').forEach((i) => { if (i.complete && !i.naturalWidth) i.remove(); else i.addEventListener('error', () => i.remove(), { once: true }); });</script>
<script src="app.js"></script>
</body>
</html>
-309
View File
@@ -1,309 +0,0 @@
/* Hallmark · shape: Split Hero With Floating Search · genre: editorial · theme: custom (by request: the user's own reference screenshot) · job: sign up · idea: "a marriage site that opens like a wedding morning: warm paper, a serif that speaks plainly, and every demo a visitor can touch" · vibe: "warm cream, navy ink, quietly lively"
* paper: oklch(98.4% 0.012 85) · accent: oklch(56% 0.15 38) · display: DM Serif Display · body: Figtree · hero: text left, the couple's photo bleeding off the right edge, a search bar floating across its foot · nav: edge-aligned · footer: letter close · imagery: photographs, user's own 1, library 1, natural · signature: the floating search bar that drives the Australia map and the scored matches
* axes: light/roman-serif/warm · posture: restrained · contrast: pass · fold: pass · v2.0.0 */
/* Hallmark · pre-emit critique: P4 H4 E4 S4 R3 V3 · weakest: R, the page now holds a lot of motion; cut every loop and kept only one-time entrances, hover lifts and state changes */
/* Hallmark · waive 65 · the user's own reference screenshot pins cream paper, a high-contrast serif and a terracotta accent for BeingOne · the look is the client's brief, not a model default */
/* Hallmark · waive 38ii · guard: true italic of the display face is loaded, font-synthesis none, one line of one h1 only · the closing line of the hero head mirrors the BeingOne reference the user supplied */
/* Hallmark · waive 38iii · one italic closing line of the hero head, once on the page · it mirrors the BeingOne reference the user supplied */
/* Hallmark · dismissed 59 · the path strip under the search bar is one row of five links that shows how the visitor moves through BeingOne, so it is wayfinding and not a content section */
/* Hallmark · dismissed F7 · the pill radius sits on controls only: nav links, choice chips, map pins and their ping ring are all pressed to act */
@import url("./tokens.css");
@import url("./base.css");
:root {
--color-navy-3: oklch(20% 0.06 255);
--color-accent-on-dark: oklch(70% 0.14 38);
--nav-h: 76px;
--color-rule-on-navy: oklch(44% 0.05 255);
}
/* type */
h1, h2 { font-weight: 400; letter-spacing: var(--tracking-display); }
h1 { max-width: none; font-size: clamp(3.25rem, 6.4vw, 6rem); line-height: 1; }
h2 { max-width: 22ch; font-size: clamp(2.25rem, 3.6vw, 3.5rem); line-height: 1.04; }
h3 { max-width: 40ch; font-weight: 400; font-size: var(--text-title); line-height: 1.2; letter-spacing: -0.01em; }
.lead { font-size: var(--text-lead); line-height: 1.45; max-width: 46ch; color: var(--color-ink-2); }
.label { font-size: var(--text-sm); font-weight: 700; color: var(--color-muted); }
.muted { color: var(--color-muted); }
body { font-weight: 500; }
/* local dark surfaces: components keep using the same tokens */
.on-dark { --color-ink: var(--color-on-navy); --color-ink-2: var(--color-on-navy); --color-muted: var(--color-on-navy-muted); --color-paper-2: var(--color-navy-2); --color-paper-3: var(--color-navy-3); --color-rule: var(--color-rule-on-navy); --color-card: var(--color-navy-2); --color-card-ink: var(--color-on-navy); background: var(--color-navy); color: var(--color-on-navy); }
/* frame */
.skip { position: absolute; left: var(--space-md); top: calc(-1 * var(--space-3xl)); z-index: var(--z-toast); padding: var(--space-xs) var(--space-md); background: var(--color-card); color: var(--color-card-ink); border-radius: var(--radius-control); font-weight: 700; }
.skip:focus-visible { top: var(--space-md); }
.grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gap-columns); }
.sec { padding-block: var(--space-3xl); }
.sec--tall { padding-block: var(--space-4xl); }
.band { background: var(--color-paper-2); padding-block: var(--section-gap-s); }
.band--navy { background: var(--color-navy); color: var(--color-on-navy); padding-block: var(--section-gap-s); }
.head { display: grid; gap: var(--space-md); align-content: start; }
.icon { width: 24px; height: 24px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.icon--sm { width: 20px; height: 20px; }
.fill { padding: 0 0.25em; border: 1px dashed color-mix(in oklch, var(--color-muted) 70%, transparent); border-radius: 3px; color: var(--color-muted); -webkit-box-decoration-break: clone; box-decoration-break: clone; }
/* nav: edge-aligned */
.nav { position: sticky; top: 0; z-index: var(--z-sticky-nav); height: var(--nav-h); background: var(--color-paper); border-bottom: var(--rule-hair) solid var(--color-rule); }
.nav__bar { height: 100%; display: flex; align-items: center; justify-content: space-between; gap: var(--space-lg); }
.wordmark { display: inline-flex; align-items: center; gap: var(--space-xs); font: 400 var(--text-2xl)/1 var(--font-display); letter-spacing: -0.01em; text-decoration: none; color: var(--color-ink); }
.wordmark b { font-weight: 400; color: var(--color-accent); }
.mark { width: 34px; height: 34px; fill: none; stroke: var(--color-accent); stroke-width: 2.5; transition: transform var(--dur-long) var(--ease-out); }
.wordmark:hover .mark { transform: rotate(-18deg); }
.nav__links { display: flex; align-items: center; gap: var(--space-xs); }
.nav__links a { padding: var(--space-xs) var(--space-md); border-radius: var(--radius-pill); text-decoration: none; font-weight: 700; white-space: nowrap; transition: background-color var(--dur-short) var(--ease-out); }
.nav__links a:hover { background: var(--color-paper-2); }
.nav__actions { display: flex; align-items: center; gap: var(--space-md); }
.nav__signin { white-space: nowrap; font-weight: 700; color: var(--color-accent); text-decoration: none; padding: var(--space-xs); }
.nav__signin:hover { text-decoration: underline; text-decoration-thickness: 2px; }
.nav__actions .nav__menu { display: none; }
.nav__links .only-sm { display: none; }
@media (max-width: 68rem) {
.nav__actions .nav__menu { display: inline-flex; }
.nav__links { position: absolute; top: var(--nav-h); left: 0; right: 0; flex-direction: column; align-items: stretch; gap: 0; padding: var(--space-md) var(--gutter); background: var(--color-paper); border-bottom: var(--rule-hair) solid var(--color-rule); display: none; }
.nav__links.is-open { display: flex; }
.nav__links a { padding: var(--space-md) 0; border-radius: 0; border-top: var(--rule-hair) solid var(--color-rule); }
.nav__links a:first-child { border-top: 0; }
}
@media (max-width: 40rem) { .nav__signin { display: none; } .nav__links .only-sm { display: block; } .nav__actions { gap: var(--space-xs); } }
/* controls */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-xs); min-height: 52px; padding: 0 var(--space-lg); border: var(--rule-bar) solid var(--color-ink); border-radius: var(--radius-control); background: transparent; color: var(--color-ink); font: 700 var(--text-md)/1.25 var(--font-body); text-decoration: none; white-space: nowrap; cursor: pointer; transition: transform var(--dur-short) var(--ease-out), background-color var(--dur-short) var(--ease-out), color var(--dur-short) var(--ease-out), border-color var(--dur-short) var(--ease-out), box-shadow var(--dur-short) var(--ease-out); }
.btn:hover { background: var(--color-paper-3); }
.btn:active { transform: translateY(1px); }
.btn--sm { min-height: 44px; padding: 0 var(--space-md); font-size: var(--text-sm); }
.btn--primary { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-accent-ink); box-shadow: 0 14px 24px -14px color-mix(in oklab, var(--color-accent) 80%, transparent); }
.btn--primary:hover { background: var(--color-accent-2); border-color: var(--color-accent-2); transform: translateY(-2px); }
.btn--primary:active { transform: translateY(1px); }
.btn .icon { width: 20px; height: 20px; transition: transform var(--dur-short) var(--ease-out); }
.btn:hover .icon { transform: translateX(4px); }
.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.55; cursor: not-allowed; transform: none; }
.btn[aria-busy="true"] { cursor: progress; }
.btn[aria-pressed="true"] { background: var(--color-paper-3); box-shadow: inset 0 0 0 2px var(--color-ink); }
.on-dark .btn--primary { color: var(--color-accent-ink); }
.on-dark .btn--primary:hover { background: var(--color-accent-2); }
.choices { position: relative; display: flex; flex-wrap: wrap; gap: var(--space-xs); border: 0; margin: 0; padding: 0; min-width: 0; }
.choices legend { padding: 0; margin-bottom: var(--space-sm); font-size: var(--text-sm); font-weight: 700; color: var(--color-muted); }
.choices input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.choices label { display: inline-flex; align-items: center; min-height: 44px; padding: 0 var(--space-md); border: var(--rule-bar) solid var(--color-rule); border-radius: var(--radius-pill); background: var(--color-card); font-weight: 700; font-size: var(--text-sm); cursor: pointer; transition: transform var(--dur-short) var(--ease-out), background-color var(--dur-short) var(--ease-out), color var(--dur-short) var(--ease-out), border-color var(--dur-short) var(--ease-out); }
.choices label:hover { border-color: var(--color-ink); }
.choices label:active { transform: translateY(1px); }
.choices input:checked + label { background: var(--color-ink); border-color: var(--color-ink); color: var(--color-paper); }
.on-dark .choices input:checked + label { color: var(--color-navy); }
.choices input:focus-visible + label { outline: 2px solid var(--color-focus); outline-offset: 3px; }
.choices--stack { flex-direction: column; flex-wrap: nowrap; }
.choices--stack label { border-radius: var(--radius-control); width: 100%; }
.field { display: grid; gap: var(--space-xs); }
.field label { font-size: var(--text-sm); font-weight: 700; }
.field input:not([type="range"]), .field select, .chat__form input { width: 100%; min-height: 52px; padding: 0 var(--space-md); border: var(--rule-bar) solid var(--color-muted); border-radius: var(--radius-control); background: var(--color-card); color: var(--color-card-ink); outline: 2px solid transparent; outline-offset: -3px; transition: border-color var(--dur-short) var(--ease-out); }
.field input:not([type="range"]):hover, .field select:hover { border-color: var(--color-ink); }
.field input:not([type="range"]):focus-visible, .field select:focus-visible, .chat__form input:focus-visible { outline-color: var(--color-focus); border-color: var(--color-focus); }
.field input[aria-invalid] { border-color: var(--color-error); }
.field input[type="range"] { width: 100%; accent-color: var(--color-accent); min-height: 32px; cursor: pointer; }
.field output { font: 400 var(--text-xl)/1 var(--font-display); font-variant-numeric: tabular-nums; }
.field__row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-md); }
/* hero: photo bleeds off the right edge, head on the left */
.hero { position: relative; background: var(--color-paper); }
.hero__inner { position: relative; z-index: 2; padding-block: var(--space-2xl) var(--space-xl); }
.hero__text { display: grid; gap: var(--space-lg); justify-items: start; max-width: 46%; }
.hero h1 .line { display: block; overflow: hidden; padding: 0 0.12em 0.12em 0; margin-bottom: -0.12em; }
.hero h1 .line > span { display: block; animation: line-up 1000ms var(--ease-out) backwards; animation-delay: calc(var(--i) * 120ms + 120ms); }
.hero h1 .line--it > span { font-style: italic; }
.hero__lead { animation: rise 800ms var(--ease-out) 650ms backwards; }
.hero__cta { animation: rise 800ms var(--ease-out) 780ms backwards; }
.hero__photo { position: absolute; z-index: 1; top: 0; right: 0; bottom: var(--space-2xl); width: 50%; margin: 0; border-radius: var(--radius-card) 0 0 var(--radius-card); animation: curtain 1300ms var(--ease-in-out) 200ms backwards; }
.hero__photo > img { object-position: 60% 40%; animation: settle 2200ms var(--ease-out) 200ms backwards; }
@keyframes line-up { from { transform: translateY(110%); } }
@keyframes rise { from { opacity: 0; transform: translateY(16px); } }
@keyframes curtain { from { clip-path: inset(0 0 0 100% round var(--radius-card)); } to { clip-path: inset(0 0 0 0 round var(--radius-card)); } }
@keyframes settle { from { transform: scale(1.16); } }
@media (max-width: 60rem) {
.hero__inner { padding-block: var(--space-2xl) var(--space-xl); }
.hero__text { max-width: none; }
.hero__photo { position: relative; width: 100%; aspect-ratio: 4 / 3; border-radius: 0; }
.hero { display: flex; flex-direction: column; }
.hero__inner { order: 1; }
.hero__photo { order: 2; }
.quick__wrap { order: 3; }
}
/* floating search bar */
.quick__wrap { position: relative; z-index: 5; margin-bottom: calc(-1 * var(--space-2xl)); }
.quick { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) auto; gap: var(--space-md); align-items: end; max-width: 1120px; margin-inline: auto; padding: var(--space-lg); background: var(--color-card); border-radius: var(--radius-card); box-shadow: var(--shadow-card); animation: rise-lg 900ms var(--ease-out) 900ms backwards; }
.quick__field { display: grid; gap: var(--space-2xs); }
.quick__field label { font-size: var(--text-sm); font-weight: 700; color: var(--color-muted); }
.quick select { width: 100%; min-height: 52px; padding: 0 var(--space-md); border: var(--rule-bar) solid var(--color-muted); border-radius: var(--radius-control); background: var(--color-card); color: var(--color-card-ink); font-weight: 700; transition: border-color var(--dur-short) var(--ease-out); }
.quick select:hover { border-color: var(--color-ink); }
.quick select:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.quick__go { min-width: 160px; }
@keyframes rise-lg { from { opacity: 0; transform: translateY(40px); } }
@media (max-width: 60rem) { .quick__wrap { margin-bottom: 0; padding-block: var(--space-lg); } .quick { grid-template-columns: minmax(0, 1fr); } }
/* path: a real ol */
.band--path { padding-block: calc(var(--space-2xl) + var(--space-md)) var(--space-2xl); }
.path { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--gap-columns); counter-reset: step; }
.path li { counter-increment: step; position: relative; display: grid; gap: var(--space-2xs); align-content: start; padding-top: var(--space-lg); }
.path li::after { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--color-ink); transform-origin: left; transition: transform 1000ms var(--ease-out); transition-delay: calc(var(--i, 0) * 120ms + 150ms); }
.js .path li:not(.is-in)::after { transform: scaleX(0); }
.path li::before { content: counter(step); font: 400 clamp(3.5rem, 6vw, 5.25rem)/0.95 var(--font-display); color: var(--color-accent); font-variant-numeric: lining-nums tabular-nums; }
.path a { font: 700 var(--text-lg)/1.15 var(--font-body); text-decoration: none; }
.path a:hover { text-decoration: underline; text-decoration-color: var(--color-accent); text-decoration-thickness: 3px; text-underline-offset: 0.25em; }
@media (max-width: 60rem) { .path { grid-template-columns: minmax(0, 1fr); gap: var(--space-md); } .path li { grid-template-columns: 72px minmax(0, 1fr); align-items: center; } .path li::before { font-size: var(--text-3xl); } }
/* finder */
.finder__top { align-items: end; margin-bottom: var(--space-2xl); }
.finder__top h2 { grid-column: 1 / 8; }
.finder__top .lead { grid-column: 8 / 13; }
.finder__body { align-items: stretch; }
.map { grid-column: 1 / 6; position: relative; aspect-ratio: 605 / 516; background: var(--color-paper-2); border-radius: var(--radius-card); }
.map svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.map path { fill: var(--color-land); stroke: var(--color-card); stroke-width: 3; stroke-linejoin: round; }
.pin { position: absolute; left: var(--x); top: var(--y); width: 36px; height: 36px; transform: translate(-50%, -50%); padding: 0; border: 0; background: transparent; cursor: pointer; border-radius: var(--radius-pill); }
.pin::before { content: ""; position: absolute; inset: 10px; border-radius: var(--radius-pill); background: var(--color-ink); box-shadow: 0 0 0 3px var(--color-card); transition: transform var(--dur-short) var(--ease-out), background-color var(--dur-short) var(--ease-out); }
.js .map.is-in .pin::before { animation: pop 600ms var(--ease-out) backwards; animation-delay: calc(var(--i) * 90ms + 250ms); }
.pin:hover::before { transform: scale(1.4); }
.pin[aria-pressed="true"]::before { background: var(--color-accent); transform: scale(1.9); }
.pin:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 0; }
.ping { position: absolute; inset: 8px; border-radius: var(--radius-pill); border: 2px solid var(--color-accent); pointer-events: none; animation: ping 800ms var(--ease-out) both; }
.map__city { position: absolute; left: var(--space-md); bottom: var(--space-md); padding: var(--space-xs) var(--space-md); border-radius: var(--radius-control); background: var(--color-card); box-shadow: var(--shadow-card); font-size: var(--text-sm); font-weight: 700; }
@keyframes pop { from { transform: scale(0); } }
@keyframes ping { from { opacity: 0.9; transform: scale(0.6); } to { opacity: 0; transform: scale(2.8); } }
.controls { grid-column: 6 / 13; display: grid; gap: var(--space-xl); align-content: center; padding: var(--space-xl); background: var(--color-paper-2); border-radius: var(--radius-card); }
.controls__ages { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--gap-columns); }
.results { margin-top: var(--space-xl); display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--gap-columns); min-height: 300px; scroll-margin-top: var(--space-3xl); }
.person { display: grid; gap: var(--space-md); align-content: space-between; padding: var(--space-lg); background: var(--color-card); border-radius: var(--radius-card); box-shadow: var(--shadow-card); animation: rise var(--dur-long) var(--ease-out) backwards; animation-delay: calc(var(--i, 0) * 80ms); transition: transform var(--dur-short) var(--ease-out), box-shadow var(--dur-short) var(--ease-out); }
.person:hover { transform: translateY(-5px); box-shadow: var(--shadow-lift); }
.person__who { display: flex; align-items: center; gap: var(--space-md); }
.person h3 { font-size: var(--text-xl); }
.avatar { width: 56px; height: 56px; flex-shrink: 0; display: grid; place-items: center; border-radius: var(--radius-pill); background: var(--tone, var(--tone-a)); color: var(--color-navy); font: 400 var(--text-xl)/1 var(--font-display); }
.score { display: flex; align-items: baseline; gap: var(--space-xs); }
.score b { font: 400 var(--text-3xl)/1 var(--font-display); font-variant-numeric: lining-nums tabular-nums; }
.bar { height: 8px; margin-top: var(--space-xs); border-radius: var(--radius-pill); background: var(--color-paper-3); overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--color-accent); transform-origin: left; transform: scaleX(calc(var(--s, 0) / 100)); animation: grow 1100ms var(--ease-out) backwards; animation-delay: calc(var(--i, 0) * 80ms + 200ms); }
.tags { display: flex; flex-wrap: wrap; gap: var(--space-xs); padding: 0; margin: 0; list-style: none; }
.tags li { padding: var(--space-3xs) var(--space-sm); border-radius: var(--radius-pill); border: var(--rule-bar) solid var(--color-rule); font-size: var(--text-sm); font-weight: 700; transition: background-color var(--dur-short) var(--ease-out); }
.tags li.is-shared { background: var(--color-paper-3); border-color: var(--color-paper-3); }
.results__empty { grid-column: 1 / -1; align-self: center; font: 400 var(--text-2xl)/1.2 var(--font-display); max-width: 30ch; animation: rise var(--dur-long) var(--ease-out) backwards; }
.finder__note { margin-top: var(--space-md); font-size: var(--text-sm); color: var(--color-muted); }
@keyframes grow { from { transform: scaleX(0); } }
@media (max-width: 60rem) {
.finder__top h2, .finder__top .lead, .map, .controls { grid-column: 1 / -1; }
.finder__top .lead { margin-top: var(--space-md); }
.controls { margin-top: var(--space-lg); padding: var(--space-lg); }
.results { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 40rem) { .results { grid-template-columns: minmax(0, 1fr); } .controls__ages { grid-template-columns: minmax(0, 1fr); } }
/* checks */
.checks .head { grid-column: 1 / 6; }
.checks__panel { grid-column: 7 / 13; background: var(--color-paper-2); border-radius: var(--radius-card); padding: var(--space-lg); display: grid; gap: var(--space-md); }
.checks__who { display: flex; align-items: center; gap: var(--space-md); }
.checks__who h3 { font-size: var(--text-xl); }
.status { margin-left: auto; display: inline-flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) var(--space-md); border-radius: var(--radius-pill); background: var(--color-paper-3); font-size: var(--text-sm); font-weight: 700; }
.status .icon { display: none; }
.status[data-state="done"] { background: var(--color-on-navy); color: var(--color-navy); animation: settle-in 600ms var(--ease-out) both; }
.status[data-state="done"] .icon { display: block; }
@keyframes settle-in { from { transform: scale(0.9); opacity: 0.4; } }
.progress { height: 6px; border-radius: var(--radius-pill); background: var(--color-paper-3); overflow: hidden; }
.progress i { display: block; height: 100%; background: var(--color-accent-on-dark); transform-origin: left; transform: scaleX(var(--p, 0)); transition: transform 700ms var(--ease-out); }
.rows { list-style: none; margin: 0; padding: 0; }
.rows > li { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; gap: var(--space-md); align-items: center; padding-block: var(--space-md); border-top: var(--rule-hair) solid var(--color-rule); }
.rows h3 { font: 700 var(--text-md)/1.3 var(--font-body); letter-spacing: 0; }
.rows p { font-size: var(--text-sm); color: var(--color-muted); }
.rows .tick { display: none; color: var(--color-accent-on-dark); }
.rows .is-done .tick { display: block; animation: pop 500ms var(--ease-out) backwards; }
.rows .is-done .idle { display: none; }
.rows .passed { font-weight: 700; font-size: var(--text-sm); }
.checks__foot { display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); border-top: var(--rule-hair) solid var(--color-rule); padding-top: var(--space-md); }
@media (max-width: 60rem) { .checks .head, .checks__panel { grid-column: 1 / -1; } .checks__panel { margin-top: var(--space-xl); } }
@media (max-width: 40rem) { .rows > li { grid-template-columns: 24px minmax(0, 1fr); } .rows .btn, .rows .passed { grid-column: 2; justify-self: start; } .checks__who { flex-wrap: wrap; } }
/* photo privacy */
.privacy__photo { grid-column: 1 / 8; position: relative; aspect-ratio: 4 / 3; border-radius: var(--radius-card); overflow: hidden; background: var(--color-paper-3); box-shadow: var(--shadow-card); margin: 0; }
.privacy__photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 35% 40%; filter: saturate(1.06) contrast(1.04); }
.privacy__photo .blur { filter: blur(28px) saturate(1.06); transform: scale(1.15); transition: opacity var(--dur-long) var(--ease-in-out); }
.js .privacy__photo:not(.is-hidden) .blur { opacity: 0; }
.privacy__badge { position: absolute; left: var(--space-lg); bottom: var(--space-lg); display: inline-flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) var(--space-md); border-radius: var(--radius-pill); background: var(--color-card); font-size: var(--text-sm); font-weight: 700; z-index: 2; box-shadow: var(--shadow-card); }
.privacy__lock { position: absolute; inset: 0; display: grid; place-items: center; z-index: 2; transition: opacity var(--dur-long) var(--ease-in-out); }
.privacy__disc { display: grid; place-items: center; width: 112px; height: 112px; border-radius: var(--radius-pill); background: var(--color-card); box-shadow: var(--shadow-card); transition: transform var(--dur-long) var(--ease-out); }
.privacy__disc .icon { width: 44px; height: 44px; }
.js .privacy__photo:not(.is-hidden) .privacy__lock { opacity: 0; }
.js .privacy__photo:not(.is-hidden) .privacy__disc { transform: scale(0.7); }
.privacy__badge .icon-open, .privacy__badge .icon-shut { display: none; }
.privacy__photo:not(.is-hidden) .icon-open { display: block; }
.privacy__photo.is-hidden .icon-shut { display: block; }
.privacy__panel { grid-column: 9 / 13; display: grid; gap: var(--space-lg); align-content: center; }
@media (max-width: 60rem) { .privacy__photo, .privacy__panel { grid-column: 1 / -1; } .privacy__panel { margin-top: var(--space-xl); } }
/* talk */
.talk__top { margin-bottom: var(--space-2xl); }
.talk__body { align-items: stretch; }
.invite { grid-column: 1 / 6; display: grid; gap: var(--space-lg); align-content: space-between; padding: var(--space-xl); background: var(--color-card); border-radius: var(--radius-card); box-shadow: var(--shadow-card); }
.invite__who { display: flex; align-items: center; gap: var(--space-md); }
.invite h3 { font-size: var(--text-xl); }
.invite__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); }
.invite__result { min-height: 1lh; font-weight: 700; }
.chat { grid-column: 6 / 13; position: relative; display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 380px; padding: var(--space-lg); border-radius: var(--radius-card); }
.chat__log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; justify-content: flex-end; gap: var(--space-xs); overflow-y: auto; }
.chat__log li { max-width: 80%; padding: var(--space-xs) var(--space-md); border-radius: var(--radius-control); background: var(--color-paper-2); animation: rise var(--dur-short) var(--ease-out) backwards; overflow-wrap: break-word; }
.chat__log li.me { align-self: flex-end; background: var(--color-accent); color: var(--color-accent-ink); }
.chat__log li.note { align-self: center; max-width: 100%; background: transparent; color: var(--color-muted); font-size: var(--text-sm); font-weight: 700; }
.chat__form { display: flex; gap: var(--space-sm); margin-top: var(--space-md); }
.chat__form input { flex: 1; min-width: 0; }
.chat.is-locked .chat__log, .chat.is-locked .chat__form { visibility: hidden; }
.chat__lock { position: absolute; inset: 0; display: none; place-items: center; align-content: center; gap: var(--space-sm); text-align: center; padding: var(--space-lg); font: 400 var(--text-2xl)/1.2 var(--font-display); }
.chat__lock .icon { width: 48px; height: 48px; margin-inline: auto; }
.chat.is-locked .chat__lock { display: grid; }
@media (max-width: 60rem) { .invite, .chat { grid-column: 1 / -1; } .chat { margin-top: var(--space-lg); } }
/* family and safety */
.together { align-items: stretch; row-gap: var(--gap-columns); }
.tile { display: grid; gap: var(--space-lg); align-content: start; padding: var(--space-xl); background: var(--color-paper-2); border-radius: var(--radius-card); }
.tile--wide { grid-column: 1 / 8; }
.tile--narrow { grid-column: 8 / 13; }
.tile h2 { font-size: clamp(2rem, 3vw, 3rem); }
.tile .lead { color: var(--color-ink-2); }
.safety__who { display: flex; align-items: center; gap: var(--space-md); }
.safety__who h3 { font-size: var(--text-xl); }
.safety__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); }
.live { min-height: 1lh; font-weight: 700; }
@media (max-width: 60rem) { .tile--wide, .tile--narrow { grid-column: 1 / -1; } .tile { padding: var(--space-lg); } }
/* questions and join */
.join .head { grid-column: 1 / 7; align-content: start; gap: var(--space-xl); }
.qa { margin: 0; display: grid; }
.qa > div { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: var(--space-md); padding-block: var(--space-md); border-top: var(--rule-hair) solid var(--color-rule); }
.qa dt { font: 700 var(--text-md)/1.3 var(--font-body); }
.qa dd { margin: 0; color: var(--color-ink-2); }
.card { grid-column: 8 / 13; background: var(--color-card); color: var(--color-card-ink); border-radius: var(--radius-card); box-shadow: var(--shadow-card); padding: var(--space-xl); display: grid; gap: var(--space-lg); align-content: start; }
.tabs { display: flex; gap: var(--space-xs); border-bottom: var(--rule-bar) solid var(--color-rule); }
.tabs button { min-height: 44px; padding: 0 var(--space-md); border: 0; border-bottom: 4px solid transparent; margin-bottom: calc(-1 * var(--space-3xs)); background: transparent; color: inherit; font-weight: 700; cursor: pointer; transition: border-color var(--dur-short) var(--ease-out); }
.tabs button:hover { border-bottom-color: var(--color-rule); }
.tabs button[aria-selected="true"] { border-bottom-color: var(--color-accent); }
.form-help { min-height: 1lh; font-size: var(--text-sm); }
.form-help.is-error { color: var(--color-error); font-weight: 700; }
.panel { display: grid; gap: var(--space-md); }
@media (max-width: 60rem) { .join .head, .card { grid-column: 1 / -1; } .card { margin-top: var(--space-xl); padding: var(--space-lg); } .qa > div { grid-template-columns: minmax(0, 1fr); gap: var(--space-2xs); } }
/* footer: letter close */
.foot { padding-block: var(--space-3xl) var(--space-xl); }
.foot__sign { font: 400 var(--text-display-s)/1.05 var(--font-display); letter-spacing: var(--tracking-display); max-width: 18ch; text-wrap: balance; }
.foot__row { margin-top: var(--space-2xl); display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-lg); font-size: var(--text-sm); }
.foot__links { display: flex; flex-wrap: wrap; gap: var(--space-lg); list-style: none; margin: 0; padding: 0; }
.foot__links a { font-weight: 700; }
.foot__links a:hover { text-decoration-color: var(--color-accent-on-dark); text-decoration-thickness: 3px; }
.foot [data-credits] { margin-top: var(--space-lg); color: var(--color-muted); font-size: var(--text-xs); max-width: 80ch; }
/* one scroll reveal, visible without script */
.js [data-reveal] { opacity: 0; transform: translateY(18px); transition: opacity 800ms var(--ease-out), transform 800ms var(--ease-out); transition-delay: calc(var(--i, 0) * 110ms); }
.js [data-reveal].is-in { opacity: 1; transform: none; }
.js .person[data-reveal], .js li[data-reveal] { transition-property: opacity, transform; }
@media (prefers-reduced-motion: reduce) {
.js [data-reveal] { opacity: 1; transform: none; transition: none; }
.hero h1 .line > span, .hero__lead, .hero__cta, .hero__photo, .hero__photo > img, .quick, .person, .bar i, .chat__log li, .ping, .status, .rows .is-done .tick, .js .map.is-in .pin::before { animation: none; }
.bar i { transform: scaleX(calc(var(--s, 0) / 100)); }
.js .path li:not(.is-in)::after { transform: none; }
}
-24
View File
@@ -1,24 +0,0 @@
:root {
--space-3xs: 2px; --space-2xs: 4px; --space-xs: 8px; --space-sm: 12px; --space-md: 16px; --space-lg: 24px; --space-xl: 40px; --space-2xl: 64px; --space-3xl: 96px; --space-4xl: 144px;
--text-xs: 0.8125rem; --text-sm: 0.875rem; --text-md: 1rem; --text-lg: 1.25rem; --text-xl: 1.5625rem; --text-2xl: 1.953rem; --text-3xl: 2.441rem;
--text-display-s: clamp(2.25rem, 4vw + 0.5rem, 3.75rem); --text-display: clamp(2.75rem, 5vw + 1rem, 5.25rem);
--text-section: clamp(2rem, 2.5vw + 1rem, 3.5rem); --text-title: clamp(1.25rem, 0.75vw + 0.875rem, 1.625rem); --text-lead: clamp(1.125rem, 0.35vw + 1rem, 1.375rem);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in: cubic-bezier(0.7, 0, 0.84, 0); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--dur-micro: 120ms; --dur-short: 220ms; --dur-long: 420ms;
--color-error: oklch(52% 0.19 27); --color-success: oklch(50% 0.12 150);
--container: 1440px; --gutter: 64px; --gap-columns: 32px; --section-gap-s: 120px; --section-gap: 144px; --section-gap-l: 176px;
--z-base: 1; --z-raised: 10; --z-dropdown: 100; --z-sticky: 200; --z-sticky-nav: 300; --z-modal: 400; --z-toast: 500; --z-tooltip: 600;
--color-paper: oklch(98.4% 0.012 85); --color-paper-2: oklch(95.6% 0.02 80); --color-paper-3: oklch(91.5% 0.03 75);
--color-ink: oklch(25% 0.07 255); --color-ink-2: oklch(36% 0.065 255); --color-muted: oklch(46% 0.045 255);
--color-rule: oklch(87% 0.025 80);
--color-accent: oklch(56% 0.15 38); --color-accent-2: oklch(49% 0.15 38); --color-accent-ink: oklch(99% 0.008 85); --color-focus: oklch(58% 0.19 38);
--color-card: oklch(99.6% 0.006 85); --color-card-ink: oklch(25% 0.07 255);
--color-navy: oklch(25% 0.07 255); --color-navy-2: oklch(31% 0.07 255); --color-on-navy: oklch(97% 0.015 85); --color-on-navy-muted: oklch(82% 0.03 255);
--color-land: oklch(88% 0.04 250);
--font-display: "DM Serif Display", ui-serif, Georgia, serif; --font-body: "Figtree", ui-sans-serif, system-ui, sans-serif; --font-outlier: var(--font-display);
--radius-card: 24px; --radius-control: 14px; --radius-pill: 999px; --rule-hair: 1px; --rule-bar: 2px;
--shadow-card: 0 18px 40px -22px oklch(25% 0.07 255 / 0.35), 0 2px 6px oklch(25% 0.07 255 / 0.06);
--shadow-lift: 0 26px 48px -24px oklch(25% 0.07 255 / 0.45), 0 3px 8px oklch(25% 0.07 255 / 0.08);
--tracking-display: -0.025em;
--tone-a: oklch(86% 0.08 20); --tone-b: oklch(92% 0.09 95); --tone-c: oklch(88% 0.07 150); --tone-d: oklch(86% 0.06 235);
}
+23
View File
@@ -0,0 +1,23 @@
{
"version": 1,
"skills": {
"ad-creative-video": {
"source": "iart-ai/ad-video-skills",
"sourceType": "github",
"skillPath": "skills/ad-creative-video/SKILL.md",
"computedHash": "94c33e3435abd802773c4b7e6058f3d47456bc56354d87b1e6fc2b689757735d"
},
"launch-video": {
"source": "iart-ai/ad-video-skills",
"sourceType": "github",
"skillPath": "skills/launch-video/SKILL.md",
"computedHash": "a55b82d2ca76de5bb80a1217594b11d1bf1ae4485b487bcad7ab256e2f116790"
},
"testimonial-video": {
"source": "iart-ai/ad-video-skills",
"sourceType": "github",
"skillPath": "skills/testimonial-video/SKILL.md",
"computedHash": "0d71fa501ffc13fc979d4d93865785c16741c6b4041ccbb3926eaf0da0f486da"
}
}
}