Compare commits

..
Author SHA1 Message Date
Claude 53388fe977 Add TikTok video skills (caption-animation, countdown-video, lower-thirds, short-form-video)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011XhxckAYjGMLrLECWuKBzg
2026-10-18 21:47:45 +00:00
keithranaandClaude Sonnet 5.5 0b42fdeae1 Add remotion-video skill (#1)
* Add remotion-video skill

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yhnsd9RDz7jDSUUX7oEcH

* Add Phase 2 templates to remotion-video skill

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yhnsd9RDz7jDSUUX7oEcH

* Fix captions template background; document browser flag after test render

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yhnsd9RDz7jDSUUX7oEcH

* Add audio/voiceover template (Phase 3), tested with real render

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yhnsd9RDz7jDSUUX7oEcH

* Document font loading and its network requirement; captions-over-video verified

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yhnsd9RDz7jDSUUX7oEcH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 10:42:46 +11:00
53 changed files with 2138 additions and 5307 deletions

No files matched your search

@@ -0,0 +1,24 @@
# Caption Animation
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area. For short-form creators burning open captions onto vertical video.
## When it activates
- "Add animated captions," "make TikTok captions," or "karaoke captions."
- "Word-by-word subtitles," "auto subtitles," or "highlight the active word."
- "Burn in captions to a video" or "sync captions to a voiceover/narration."
- "Turn an SRT/transcript into animated text."
## Example prompts
- "Turn this Whisper transcript into karaoke captions with a per-word pop."
- "Restyle these auto subtitles into a branded, Hormozi-style reveal."
- "Burn word-level captions onto my voiceover and export a sidecar SRT."
## What's inside
- `SKILL.md` — the transcribe → normalize → page → animate → place → export pipeline.
- `references/word-timed-captions.md` — deeper guidance on word-level timing and animation.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=caption-animation)** — the AI motion agent for editable, on-brand motion graphics.
+177
View File
@@ -0,0 +1,177 @@
---
name: caption-animation
description: This skill should be used when the user asks to "add animated captions", "make TikTok captions", "karaoke captions", "word-by-word subtitles", "auto subtitles", "highlight the active word", "burn in captions to a video", "sync captions to a voiceover/narration", or "turn an SRT/transcript into animated text". Covers word-level timing from Whisper, per-word pop/scale-in, active-word highlight, 9:16 safe-area placement, readable type, and burn-in vs sidecar SRT/VTT.
version: 0.1.0
---
# Caption Animation
Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area.
## When to use
- Word-by-word or karaoke captions on short-form vertical video (9:16).
- Burning open captions onto a voiceover/narration track from a transcript.
- Turning a Whisper/SRT transcript into animated text in Remotion or on the web.
- Restyling auto-generated subtitles into a branded, "Hormozi-style" reveal.
## The pipeline
| Stage | Job | Tool |
|---|---|---|
| Transcribe | Audio → word-level timestamps | Whisper (`@remotion/install-whisper-cpp`), AssemblyAI |
| Normalize | → `{ text, startMs, endMs }[]` tokens | `@remotion/captions` `Caption` type |
| Page | Group words into 1–4 word "pages" | `createTikTokStyleCaptions()` |
| Animate | Per-word pop + active-word highlight | Remotion `spring()` / CSS |
| Place | Safe-area, readable type | layout rules below |
| Export | Burn-in (MP4) and/or sidecar SRT/VTT | Remotion render / file emit |
## Two non-negotiable rules
1. **Word-level timing, not line-level.** Karaoke reads as magic only when each word lands on the syllable. Always transcribe to word timestamps; never fake them by splitting a line evenly over its duration — drift is instantly visible.
2. **Readability beats style.** Captions are read on a phone, in sunlight, muted. Bold sans-serif, heavy stroke or shadow, high contrast first; decoration second. A caption nobody can read is decoration, not a caption.
## Word timing from a transcript
Whisper emits word-level timestamps directly. Normalize every source into one flat token shape so the renderer never cares where the words came from:
```ts
// Caption token — the one shape everything downstream consumes
type Token = { text: string; startMs: number; endMs: number };
// Parse an SRT cue block "00:00:01,200 --> 00:00:01,640" into ms
const toMs = (t: string) => {
const [h, m, rest] = t.split(":");
const [s, ms] = rest.split(",");
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
};
```
If only sentence-level cues exist, re-transcribe for word timing — do not interpolate. Whisper's `medium.en` is ~2x faster than `large` and accurate enough for clean voice audio.
## Per-word pop-in (Remotion)
Drive each word's scale and opacity off a spring anchored to its own `startMs`. The micro-overshoot is what makes it feel "alive".
```tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = (token.startMs / 1000) * fps; // word's own entrance frame
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
const scale = interpolate(p, [0, 1], [0.6, 1]); // pop from 60% → 100% (overshoots)
return (
<span style={{
display: "inline-block",
transform: `scale(${scale})`,
opacity: interpolate(p, [0, 1], [0, 1]),
color: active ? "#FFE45E" : "#FFFFFF", // active-word highlight
transition: "color 80ms linear",
}}>{token.text}&nbsp;</span>
);
};
```
Compute `active` by testing `frame` against each token's `[startMs, endMs]`. Keep the highlight a single accent color or a filled "box" behind the live word — never animate every word's color at once.
## Pages, not walls of text
Show 1–4 words at a time. Use Remotion's `createTikTokStyleCaptions()` (or group manually) and tune `combineTokensWithinMilliseconds`: ~200–500ms for true word-by-word, ~1000–1500ms for short readable phrases.
```ts
import { createTikTokStyleCaptions } from "@remotion/captions";
const { pages } = createTikTokStyleCaptions({
captions, combineTokensWithinMilliseconds: 1200, // ~2–3 words per page
});
```
## Web/CSS pop (no Remotion)
For a DOM/live player, give each word its own `animation-delay` equal to `startMs`:
```css
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
.word.active { color:#FFE45E; }
```
```js
words.forEach(w => { const el = mk(w.text); el.style.animationDelay = `${w.startMs}ms`; track.append(el); });
```
## Readable type and safe-area placement (9:16, 1080×1920)
| Property | Value | Why |
|---|---|---|
| Font | Bold/ExtraBold sans (Montserrat, Inter, Helvetica) | Reads on small, busy screens |
| Size | 56–80px (≈8% of frame height), min 45px | Legible muted on a phone |
| Stroke | 2–6px solid black outline | Survives any background |
| Shadow | Soft drop shadow as backup to stroke | Separation on bright frames |
| Case | Uppercase or sentence; high contrast fill | Punch + scannability |
| Vertical pos | Center band, ~62–70% down | Above the UI, below the action |
| Bottom safe | Keep clear of bottom ~280px / 15% | Avoids caption/CTA/audio UI |
| Side safe | Keep within center 80% width | Avoids right-rail icons |
Place captions in the center band — not pinned to the very bottom, where the platform UI (username, audio, buttons) lives. Pad text to a max of ~2 lines.
## Sync to a voiceover / narration
The transcript already carries the timing of the exact audio. Lay that same audio under the composition and keep timestamps in the audio's timebase — captions and voice stay locked with zero manual nudging. If the VO is re-recorded, re-transcribe; never hand-shift offsets.
## Burn-in vs sidecar SRT/VTT
| | Burn-in (open) | Sidecar SRT/VTT (closed) |
|---|---|---|
| Where | Pixels in the MP4 | Separate `.srt`/`.vtt` file |
| Social (TikTok/Reels) | Required — guaranteed, styleable | Often ignored by the platform |
| Accessibility (ADA/WCAG) | Does not satisfy alone | Required (toggleable) |
| Best practice | Burn animated captions for social | Also ship a sidecar for the web/SEO |
Emit both: burn the animated captions for social, and write a plain SRT/VTT from the same tokens for accessible/SEO playback.
## Output checklist
- Word-level timestamps (real, from transcription) — no even-split fakery.
- Per-word pop with micro-overshoot; one accent color for the active word.
- 1–4 words per page; never a wall of text.
- Bold sans, 56–80px, 2–6px stroke + shadow, high contrast.
- Center band, clear of bottom ~280px and side rails.
- Audio laid under composition; timestamps in the audio timebase.
- Ship burn-in for social and a sidecar SRT/VTT for accessibility.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
Captions ship as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — all word motion frame-driven off `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` (burned-in) + the project + the sidecar SRT/VTT. 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Word timing is the thing that breaks; check it at exact frames before you spend an encode.
```bash
# Stills at start / a sampled active-word frame / end — WITH SHIPPED PROPS (real tokens + audio)
npx remotion still Captions out/f-start.png --frame=0 --props='{"captionsSrc":"vo.json"}'
npx remotion still Captions out/f-mid.png --frame=90 --props='{"captionsSrc":"vo.json"}'
npx remotion still Captions out/f-end.png --frame=N --props='{"captionsSrc":"vo.json"}' # N = durationInFrames-1
# Inspect each PNG:
# - the word highlighted at frame 90 is the word whose [startMs,endMs] contains 90/fps (no drift)
# - burn-in legible: bold sans, stroke+shadow holds, no clipping
# - 9:16 safe area: caption sits in the center band, clear of top ~12% and bottom ~20-35% (captions/CTA/audio UI) and the right action rail
npx remotion render Captions out/captions.mp4 --props='{"captionsSrc":"vo.json"}' # encode once stills are right
npx remotion render Captions out/demo.gif --codec=gif # README proof clip
```
Use `npx remotion compositions` to read `durationInFrames`/`fps` and pick the active-word + end frames.
**Before you finish:**
1. Stills render cleanly at frame 0, a mid active-word frame, and last — no missing font/audio.
2. The correct word is highlighted at the sampled frame (frame/fps lands inside its token); no even-split fakery.
3. Burn-in is legible (stroke+shadow) and the caption is fully inside the 9:16 safe area at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers.
5. Shipped props (real tokens, not just `defaultProps`) render correctly; MP4 + sidecar SRT/VTT emitted, GIF optional.
## Reference files
- `references/word-timed-captions.md` — end-to-end build: Whisper transcription and the `Caption` type, a full SRT parser, Remotion word-timed component with active-highlight, manual paging, an SRT/VTT emitter, per-platform safe-area maps, and a readable-type spec sheet.
@@ -0,0 +1,254 @@
# Word-Timed Captions — End-to-End Build
A complete, runnable path from audio/transcript to animated burn-in captions plus a sidecar SRT/VTT. Code targets Remotion for burn-in and plain JS for the web; the data shapes are framework-agnostic.
## 1. The one data shape
Everything normalizes to a flat array of word tokens. Get to this shape and the renderer never cares about the source.
```ts
export type Token = { text: string; startMs: number; endMs: number };
export type Page = { startMs: number; endMs: number; tokens: Token[] };
```
Remotion's `@remotion/captions` uses a compatible `Caption` type:
```ts
type Caption = { text: string; startMs: number; endMs: number; timestampMs: number | null; confidence: number | null };
```
## 2. Transcribe to word-level timing (Whisper)
Word timestamps are non-negotiable for karaoke. Whisper produces them natively.
```bash
npx @remotion/install-whisper-cpp # one-time: installs whisper.cpp + a model
```
```ts
import { transcribe, toCaptions } from "@remotion/install-whisper-cpp";
const { transcription } = await transcribe({
inputPath: "voiceover.wav", // 16kHz mono WAV is ideal for whisper.cpp
model: "medium.en", // medium.en ≈ 2x faster than large, accurate on clean voice
tokenLevelTimestamps: true, // <-- the key flag: per-word, not per-line
whisperPath: "./whisper.cpp",
});
const { captions } = toCaptions({ transcription }); // → Caption[]
```
Tips:
- Feed clean voice audio (denoise/normalize first). Background music wrecks word boundaries.
- `large` is most accurate but 2–3x real-time on a laptop. Use `medium.en` unless accuracy is critical.
- Cloud alternatives (AssemblyAI, Deepgram, OpenAI Whisper API) also return word timing — map their `words[]` into `Token` the same way.
Map any token shape into `Token`:
```ts
const toTokens = (caps: Caption[]): Token[] =>
caps.map(c => ({ text: c.text.trim(), startMs: c.startMs, endMs: c.endMs }));
```
## 3. Parse an existing SRT into tokens
When handed an SRT, parse cues; if cues are sentence-level, re-transcribe for word timing rather than interpolating.
```ts
const toMs = (t: string) => {
const [h, m, rest] = t.trim().split(":");
const [s, ms] = rest.split(",");
return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
};
export function parseSrt(src: string): Token[] {
return src.trim().split(/\n\s*\n/).map(block => {
const lines = block.split("\n");
const time = lines.find(l => l.includes("-->"))!;
const [a, b] = time.split("-->");
const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
return { text, startMs: toMs(a), endMs: toMs(b) };
});
}
```
## 4. Page the tokens (1–4 words at a time)
Either use Remotion's helper or group manually. Manual grouping by gap + max-words keeps full control:
```ts
export function paginate(tokens: Token[], maxWords = 3, maxGapMs = 1200): Page[] {
const pages: Page[] = [];
let cur: Token[] = [];
for (const t of tokens) {
const span = cur.length ? t.endMs - cur[0].startMs : 0;
if (cur.length >= maxWords || span > maxGapMs) {
if (cur.length) pages.push(pageOf(cur)); cur = [];
}
cur.push(t);
}
if (cur.length) pages.push(pageOf(cur));
return pages;
}
const pageOf = (ts: Token[]): Page => ({ startMs: ts[0].startMs, endMs: ts.at(-1)!.endMs, tokens: ts });
```
Remotion equivalent:
```ts
import { createTikTokStyleCaptions } from "@remotion/captions";
const { pages } = createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds: 1200 });
// 200–500ms → word-by-word; 1000–1500ms → short readable phrases
```
## 5. Remotion component: word-timed with active highlight
```tsx
import { AbsoluteFill, Audio, Sequence, useCurrentFrame, useVideoConfig,
spring, interpolate, staticFile } from "remotion";
const CAPTION_STYLE: React.CSSProperties = {
fontFamily: "Montserrat, Inter, sans-serif",
fontWeight: 800,
fontSize: 72, // ~8% of 1920px height
color: "#fff",
textAlign: "center",
textTransform: "uppercase",
WebkitTextStroke: "5px #000", // heavy outline survives any background
paintOrder: "stroke fill", // stroke behind fill, not clipping glyphs
textShadow: "0 4px 16px rgba(0,0,0,.55)",
lineHeight: 1.1,
padding: "0 80px", // keep inside center 80% width
};
const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = (token.startMs / 1000) * fps;
const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
return (
<span style={{
display: "inline-block",
transform: `scale(${interpolate(p, [0, 1], [0.6, 1])})`,
opacity: interpolate(p, [0, 1], [0, 1]),
color: active ? "#FFE45E" : undefined,
transition: "color 80ms linear",
}}>{token.text}&nbsp;</span>
);
};
export const Captions: React.FC<{ pages: Page[]; audio: string }> = ({ pages, audio }) => {
const { fps } = useVideoConfig();
const f = (ms: number) => Math.round((ms / 1000) * fps);
const frame = useCurrentFrame();
return (
<AbsoluteFill>
<Audio src={staticFile(audio)} /> {/* same audio the transcript came from */}
{pages.map((pg, i) => (
<Sequence key={i} from={f(pg.startMs)} durationInFrames={Math.max(1, f(pg.endMs - pg.startMs))}>
<AbsoluteFill style={{ justifyContent: "flex-start", alignItems: "center", paddingTop: "62%" }}>
<div style={CAPTION_STYLE}>
{pg.tokens.map((t, j) => {
const now = (frame / fps) * 1000;
return <Word key={j} token={t} active={now >= t.startMs && now < t.endMs} />;
})}
</div>
</AbsoluteFill>
</Sequence>
))}
</AbsoluteFill>
);
};
```
Because the `<Audio>` and the timestamps share one timebase, voice and captions stay locked with no manual nudging. Re-record the VO → re-transcribe → done.
## 6. Web/CSS path (live DOM player)
```js
function renderTrack(tokens, mountEl) {
tokens.forEach(t => {
const el = document.createElement("span");
el.className = "word";
el.textContent = t.text + " ";
el.style.animationDelay = `${t.startMs}ms`; // each word pops on its own start
mountEl.append(el);
});
}
// drive .active off the media element's currentTime
audio.addEventListener("timeupdate", () => {
const ms = audio.currentTime * 1000;
[...track.children].forEach((el, i) =>
el.classList.toggle("active", ms >= tokens[i].startMs && ms < tokens[i].endMs));
});
```
```css
.cap { font: 800 72px/1.1 Montserrat, sans-serif; text-transform: uppercase;
color:#fff; -webkit-text-stroke:5px #000; paint-order:stroke fill;
text-shadow:0 4px 16px rgba(0,0,0,.55); text-align:center; }
.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
.word.active { color:#FFE45E; }
@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
```
## 7. Emit a sidecar SRT / VTT from the same tokens
Burn-in alone does not satisfy ADA/WCAG and many platforms ignore uploaded tracks — so ship both. Group tokens into readable cues and serialize:
```ts
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
const stamp = (ms: number, sep: string) => {
const h = Math.floor(ms / 3.6e6), m = Math.floor(ms % 3.6e6 / 6e4),
s = Math.floor(ms % 6e4 / 1000), x = ms % 1000;
return `${pad(h)}:${pad(m)}:${pad(s)}${sep}${pad(x, 3)}`;
};
export const toSrt = (pages: Page[]) => pages.map((p, i) =>
`${i + 1}\n${stamp(p.startMs, ",")} --> ${stamp(p.endMs, ",")}\n${p.tokens.map(t => t.text).join(" ")}`
).join("\n\n") + "\n";
export const toVtt = (pages: Page[]) => "WEBVTT\n\n" + pages.map(p =>
`${stamp(p.startMs, ".")} --> ${stamp(p.endMs, ".")}\n${p.tokens.map(t => t.text).join(" ")}`
).join("\n\n") + "\n";
```
Use SRT for the broadest platform support; VTT when styling/positioning cues or serving on the web.
## 8. Per-platform safe areas (1080×1920, 9:16)
| Platform | Bottom keep-clear | Top keep-clear | Side keep-clear | Notes |
|---|---|---|---|---|
| TikTok | ~340px | ~120px | ~110px right rail | Captions, audio title, action buttons crowd the bottom-right |
| Instagram Reels | ~280px | ~130px | ~60px | Username/CTA bar at bottom |
| YouTube Shorts | ~240px | ~100px | ~120px right rail | Title + channel at bottom-left |
| Facebook Reels | ~300px | ~130px | ~80px | Similar to Reels |
Safe rule across all: place caption baseline at ~62–70% of frame height, keep text within the center 80% width, and never let it enter the bottom ~15%.
## 9. Readable-type spec sheet
| Property | Recommended | Floor |
|---|---|---|
| Weight | 800 (ExtraBold) | 700 |
| Size @1080p | 56–80px | 45px |
| Stroke | 4–6px solid #000 | 2px |
| Shadow | `0 4px 16px rgba(0,0,0,.55)` | soft 1px |
| Active accent | one color (e.g. #FFE45E) or filled box | — |
| Lines on screen | 1–2 | 2 max |
| Words per page | 1–4 | 5 max |
Set `paint-order: stroke fill` so a thick stroke sits behind the glyph instead of eating it. Test on a bright frame and a busy frame; if it survives both, it ships.
## 10. Render
```bash
npx remotion render src/index.ts Captions out/captions.mp4 \
--props='{"pages": ..., "audio": "voiceover.wav"}'
```
A 10-minute video with animated captions can take 30–60 min to render locally; render long-form in segments, or render on Remotion Lambda for parallel speed.
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can auto-time captions to a narration track and one-click burn them in from a template — change the text/audio and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=caption-animation)
+25
View File
@@ -0,0 +1,25 @@
# Countdown Video
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch and flash-sale countdowns, and looping backgrounds. For creators who need a timer that lands on zero on the exact frame.
## When it activates
- "Make a countdown timer video" or "create a countdown animation."
- "Build a stream starting soon countdown" or "do an event/launch/sale countdown."
- "Animate a mm:ss or dd:hh:mm:ss timer."
- "Add digit roll/flip transitions to a counter" or "loop a countdown background."
## Example prompts
- "Build a 10-minute 'be right back' livestream countdown."
- "Make a dd:hh:mm:ss launch countdown to a fixed target date."
- "Add flip transitions to an mm:ss timer that hits zero on the exact frame."
## What's inside
- `SKILL.md` — frame-accurate (drift-free) timers, time formatting, and a configurable duration prop.
- `references/countdown-cookbook.md` — recipes for common countdown scenarios.
- `references/digit-flip.md` — digit flip/roll transition techniques.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=countdown-video)** — the AI motion agent for editable, on-brand motion graphics.
+152
View File
@@ -0,0 +1,152 @@
---
name: countdown-video
description: This skill should be used when the user asks to "make a countdown timer video", "create a countdown animation", "build a stream starting soon countdown", "do an event/launch/sale countdown", "animate a mm:ss or dd:hh:mm:ss timer", "add digit roll/flip transitions to a counter", or "loop a countdown background". Covers frame-accurate (drift-free) timers, digit flip/roll motion, time formatting, and a configurable duration prop.
version: 0.1.0
---
# Countdown Video
Make a countdown that ends exactly when it should. The whole craft is one idea: derive the remaining time from the frame (or a monotonic timestamp), then format and animate that number — never count down with a `setInterval`. Covers livestream "starting soon" screens, launch/sale countdowns, and looping backgrounds.
## When to use
- "Starting soon" / "be right back" livestream screens with a 5–10 min timer.
- Launch and flash-sale countdowns (dd:hh:mm:ss down to a target date; mm:ss for urgency).
- Any animated counter where the number must land on zero on the exact frame.
## The one rule: drive the number from the clock, never a tick
A `setInterval(fn, 1000)` countdown **drifts** — the callback fires *at least* 1000 ms later, never exactly, and stacks up when the tab is throttled or a render frame is slow. Reports of ~1 second lost per minute are common. The fix is the same for rendered video and live web: compute remaining time as a pure function of an authoritative clock, so a late frame self-corrects on the next frame instead of accumulating error.
| Context | Authoritative clock | Remaining time |
|---|---|---|
| Remotion render | `useCurrentFrame()` | `total − frame/fps` |
| Web (live page) | `performance.now()` or `Date.now()` | `targetMs − now` |
| To a fixed date | `Date.now()` | `targetTimestamp − Date.now()` |
Never store remaining time in state and decrement it. Recompute it every frame.
## Frame-accurate countdown (Remotion → MP4)
`useCurrentFrame()` is deterministic: frame N is always rendered at exactly `N/fps` seconds. Subtract from the total and the timer is drift-free by construction.
```tsx
import { useCurrentFrame, useVideoConfig } from "remotion";
export const Countdown: React.FC<{ durationInSeconds: number }> = ({
durationInSeconds,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// remaining seconds, clamped at 0 — ceil so "1" shows for its full second
const elapsed = frame / fps;
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
return <div style={{ fontVariantNumeric: "tabular-nums" }}>{formatMMSS(remaining)}</div>;
};
```
`durationInSeconds` is a **prop**, not a constant — the same composition renders a 5-minute stream timer or a 30-second sale timer by changing one input. Set the composition's `durationInFrames` to `durationInSeconds * fps` so the video is exactly as long as the countdown.
## Web timer (live page, requestAnimationFrame)
For a live page, drive an rAF loop from `performance.now()` and only repaint when the displayed second changes (no work 60×/sec for a 1 Hz number):
```js
const start = performance.now();
let lastShown = -1;
function tick(now) {
const remaining = Math.max(0, Math.ceil(duration - (now - start) / 1000));
if (remaining !== lastShown) { render(remaining); lastShown = remaining; }
if (remaining > 0) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
```
Counting down to a fixed date is the same idea with `Date.now()`: `Math.max(0, target - Date.now())`. See `references/countdown-cookbook.md` for the full date-target version with timezone handling.
## Time formatting
Break a remaining-seconds integer into fields with division + modulo, then zero-pad. Pick the format to fit the duration — never show `00d 00h 05m` when minutes is all that matters.
```js
const pad = (n) => String(n).padStart(2, "0");
const formatMMSS = (s) => `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
const formatDHMS = (s) => {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60), sec = s % 60;
return `${pad(d)}:${pad(h)}:${pad(m)}:${pad(sec)}`;
};
```
| Format | Use | Drop fields when |
|---|---|---|
| `mm:ss` | stream "starting soon", short timers (<1h) | — |
| `hh:mm:ss` | flash sale, same-day event | hide `dd` while 0 |
| `dd:hh:mm:ss` | launch / multi-day countdown | hide leading 0 units for cleaner read |
Always set `font-variant-numeric: tabular-nums` (or a monospace digit font) so digits keep equal width and the layout does not jitter as numbers change.
## Digit roll / flip transitions
A flat number swap reads as cheap; the rolling "odometer" look sells the countdown. Render each digit as a vertical strip 0–9 and translate it by `-digit × digitHeight`. Animate the *position*, not the text content, so the change is a glide, not a pop.
```css
.reel { height: 1em; overflow: hidden; }
.reel .col { transition: transform .45s cubic-bezier(.22,1,.36,1); }
/* col holds stacked 0..9; shift by -value em */
```
In Remotion, drive the strip offset off the frame instead of a CSS transition (so it is deterministic). The flip-card style (top half folds down over the bottom) and the per-digit reel are both detailed, with runnable components, in `references/digit-flip.md`.
## Looping background
Stream screens hold for minutes, so the background must loop seamlessly. Build motion on a cycle that divides evenly into the timeline (e.g. a gradient or particle drift whose period is `loopFrames`), and read it as `frame % loopFrames` so there is no visible seam at the wrap. Keep it low-contrast and slow — the number is the subject; the background is ambience. Recipes in `references/countdown-cookbook.md`.
## Output checklist
- Remaining time recomputed from `useCurrentFrame()` / `performance.now()` every frame — no `setInterval` decrement.
- Timer hits `00:00` on the exact intended frame; `durationInFrames === durationInSeconds × fps`.
- Duration is an input prop, not a hardcoded constant.
- Digits use `tabular-nums`; layout does not shift as numbers change.
- Format matches the span (mm:ss vs dd:hh:mm:ss); leading zero units dropped when distracting.
- Background loop period divides the timeline evenly; no seam at the wrap.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
The countdown ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, `durationInSeconds` a prop) — the timer is a pure function of `useCurrentFrame()`, never `setInterval` / `Date.now()` / `Math.random()`. Set `durationInFrames = durationInSeconds * fps`. Deliverable = `out/*.mp4` + the project. 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** The whole point is frame-accuracy, so check the *digit value* at exact frames before encoding.
```bash
# Stills at start / mid / end — WITH SHIPPED PROPS (the real duration you'll render)
npx remotion still Countdown out/f-start.png --frame=0 --props='{"durationInSeconds":300}'
npx remotion still Countdown out/f-mid.png --frame=N --props='{"durationInSeconds":300}'
npx remotion still Countdown out/f-end.png --frame=L --props='{"durationInSeconds":300}' # L = durationInFrames-1
# Inspect each PNG — the digits must read EXACTLY the frame/fps math:
# - at frame N, displayed = ceil(durationInSeconds - N/fps) (e.g. 300s @ 30fps, frame 4500 → 150s → "02:30")
# - last frame reads 00:00 (or the end-state hold); no drift, no off-by-one second
# - tabular-nums: layout does not jitter between frames
# - 9:16 safe area: the timer + brand block sit clear of top ~12% and bottom ~20-35% UI and the right action rail
npx remotion render Countdown out/countdown.mp4 --props='{"durationInSeconds":300}' # encode once digits verify
npx remotion render Countdown out/demo.gif --codec=gif # README proof clip
```
Use `npx remotion compositions` to confirm `durationInFrames`/`fps`, then compute the frame for any second you want to check.
**Before you finish:**
1. Stills render cleanly at frame 0, mid, and last — no errors.
2. The digit value at frame N equals `ceil(durationInSeconds - N/fps)` exactly; last frame hits 00:00 on the intended frame (no drift / off-by-one).
3. Timer + brand stay inside the 9:16 safe area at every checked frame.
4. Frame-driven only — no `setInterval` / `Date.now()` / timers; `tabular-nums` so layout holds.
5. Shipped `durationInSeconds` (not just `defaultProps`) renders correctly; MP4 encoded, GIF optional.
## Reference files
- `references/countdown-cookbook.md` — complete runnable Remotion `<Countdown>` (configurable duration, mm:ss / dd:hh:mm:ss, end-state hold), the count-down-to-a-fixed-date web component with timezone handling, a "starting soon" stream-screen layout (brand block + social handles + looping gradient background), and the seamless-loop background pattern.
- `references/digit-flip.md` — two deterministic digit-animation components: the odometer reel (vertical 0–9 strip driven by frame) and the flip-card (top-half fold) effect, plus when each suits launch vs sale vs stream.
@@ -0,0 +1,205 @@
# Countdown Cookbook
Complete, runnable recipes. Every value is derived from an authoritative clock (frame or timestamp) so nothing drifts.
## 1. Configurable Remotion countdown (mm:ss + dd:hh:mm:ss)
A single composition that handles any duration and either format. Drop into `src/Countdown.tsx`.
```tsx
import React from "react";
import { useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
const pad = (n: number) => String(n).padStart(2, "0");
const formatTime = (totalSeconds: number, mode: "mm:ss" | "dd:hh:mm:ss") => {
const s = Math.max(0, totalSeconds);
if (mode === "mm:ss") {
return `${pad(Math.floor(s / 60))}:${pad(s % 60)}`;
}
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
// drop leading day field once it is zero, for a cleaner read
return d > 0
? `${pad(d)}d ${pad(h)}:${pad(m)}:${pad(sec)}`
: `${pad(h)}:${pad(m)}:${pad(sec)}`;
};
export type CountdownProps = {
durationInSeconds: number;
format?: "mm:ss" | "dd:hh:mm:ss";
label?: string;
};
export const Countdown: React.FC<CountdownProps> = ({
durationInSeconds,
format = "mm:ss",
label = "",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// ceil so the last whole second ("1") is shown for its full duration,
// and the display reaches "0" exactly on the final frame.
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
const ended = remaining === 0;
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
background: "#0b0d17",
color: "#fff",
fontFamily: "Inter, system-ui, sans-serif",
}}
>
{label && (
<div style={{ fontSize: 36, opacity: 0.7, marginBottom: 16, letterSpacing: 4 }}>
{label.toUpperCase()}
</div>
)}
<div
style={{
fontSize: 180,
fontWeight: 800,
fontVariantNumeric: "tabular-nums", // equal-width digits → no jitter
lineHeight: 1,
color: ended ? "#ff4d4d" : "#fff",
}}
>
{ended ? formatTime(0, format) : formatTime(remaining, format)}
</div>
</AbsoluteFill>
);
};
```
Register it so `durationInFrames` matches the countdown exactly — the video ends when the timer does:
```tsx
import { Composition } from "remotion";
import { Countdown } from "./Countdown";
const FPS = 30;
const DURATION_SECONDS = 300; // 5-minute "starting soon" timer
export const RemotionRoot = () => (
<Composition
id="Countdown"
component={Countdown}
durationInFrames={DURATION_SECONDS * FPS}
fps={FPS}
width={1920}
height={1080}
defaultProps={{ durationInSeconds: DURATION_SECONDS, format: "mm:ss", label: "Starting soon" }}
/>
);
```
Render any duration/format without touching code — duration is a prop:
```bash
npx remotion render Countdown out/sale.mp4 \
--props='{"durationInSeconds":86400,"format":"dd:hh:mm:ss","label":"Sale ends in"}'
```
To hold a final state (e.g. "WE'RE LIVE") instead of ending on `00:00`, extend `durationInFrames` past the countdown and branch on `ended`.
## 2. Count down to a fixed date (live web component)
For a real-time page that targets a calendar moment. Drift-free because remaining time is `target − Date.now()` recomputed each frame, never a decrement.
```js
// targetISO must include an offset, e.g. "2026-07-01T09:00:00-07:00",
// so the deadline is unambiguous regardless of the viewer's timezone.
function startCountdown(targetISO, el) {
const target = new Date(targetISO).getTime();
const pad = (n) => String(n).padStart(2, "0");
let lastShown = -1;
function tick() {
const remainingMs = Math.max(0, target - Date.now());
const s = Math.floor(remainingMs / 1000);
if (s !== lastShown) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
el.textContent = `${pad(d)}:${pad(h)}:${pad(m)}:${pad(s % 60)}`;
lastShown = s;
}
if (remainingMs > 0) requestAnimationFrame(tick);
else el.textContent = "00:00:00:00"; // or swap to an "It's live" state
}
requestAnimationFrame(tick);
}
```
Timezone notes:
- Bake the offset into the target string (`-07:00`, `Z`). A bare `"2026-07-01T09:00:00"` is parsed as the viewer's *local* time and every viewer sees a different deadline.
- To show "ends 9am in *your* timezone everywhere," instead pick the wall-clock time per locale and let each viewer's `Date` resolve it — but that is a different deadline per region. Decide which you want before building.
## 3. "Starting soon" stream screen
Livestream lead-in screens hold 5–10 minutes: a clean brand block, social handles, and a calm looping background, with the timer as the focal point. Keep it low-clutter — chat and music carry the wait; the screen just signals "we're about to start."
```tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from "remotion";
export const StartingSoon: React.FC<{ durationInSeconds: number; handle: string; brand: string }> = ({
durationInSeconds,
handle,
brand,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const remaining = Math.max(0, Math.ceil(durationInSeconds - frame / fps));
const pad = (n: number) => String(n).padStart(2, "0");
// seamless looping gradient: angle cycles over loopFrames with no seam
const loopFrames = fps * 8;
const angle = interpolate(frame % loopFrames, [0, loopFrames], [0, 360]);
return (
<AbsoluteFill
style={{
background: `linear-gradient(${angle}deg, #141a3a, #0b0d17 60%)`,
color: "#fff",
fontFamily: "Inter, system-ui, sans-serif",
justifyContent: "center",
alignItems: "center",
}}
>
<div style={{ fontSize: 64, fontWeight: 800, marginBottom: 8 }}>{brand}</div>
<div style={{ fontSize: 28, opacity: 0.6, letterSpacing: 6, marginBottom: 40 }}>
STARTING SOON
</div>
<div style={{ fontSize: 200, fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>
{pad(Math.floor(remaining / 60))}:{pad(remaining % 60)}
</div>
<div style={{ position: "absolute", bottom: 60, fontSize: 30, opacity: 0.7 }}>{handle}</div>
</AbsoluteFill>
);
};
```
Export as MP4 and add it as a Media/Video source in OBS, or use a looping playout for a browser source. Common durations: 300s (5 min) or 600s (10 min) at 1920×1080.
## 4. Seamless looping background
Any motion whose period divides the timeline evenly wraps without a seam. Read animation phase as `frame % loopFrames` so the value at the wrap equals the value at frame 0.
```tsx
const loopFrames = fps * 6; // one full cycle every 6 seconds
const phase = (frame % loopFrames) / loopFrames; // 0 → 1, repeats
const driftY = Math.sin(phase * Math.PI * 2) * 40; // returns to start each cycle
```
Use sine/cosine (inherently periodic) or `interpolate(frame % loopFrames, [0, loopFrames], [a, a])` with matching start/end so there is no jump. Keep contrast low and motion slow — the number is the subject.
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can render countdown videos from a template with a set time and brand — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=countdown-video)
@@ -0,0 +1,146 @@
# Digit Flip & Roll Transitions
Two ways to animate a digit changing. Both are driven by the frame (deterministic) so they render identically every time — no CSS transition clock that the renderer can disagree with.
Pick by tone:
| Effect | Feel | Best for |
|---|---|---|
| Odometer reel | smooth, continuous glide | launch countdowns, premium/minimal timers |
| Flip card | mechanical "snap", split-flap board | sale urgency, retro/airport-board style |
## Shared idea
A single displayed digit is a stack of glyphs `0..9` laid out vertically. Showing digit `d` means translating the stack so glyph `d` sits in the window: `translateY(-d × digitHeight)`. Animate the *offset*, not the text, and you get a roll. The trick for a countdown is that the offset is continuous: between whole seconds the digit eases from its old value toward its new one.
## Odometer reel (Remotion, frame-driven)
```tsx
import React from "react";
import { useCurrentFrame, useVideoConfig, interpolate, Easing } from "remotion";
const DIGIT_H = 160; // px per glyph
const ROLL_FRAMES = 12; // how long a single digit roll takes
// Animated single digit: rolls from `prev` to `value` over the last ROLL_FRAMES
// of the current second.
const Reel: React.FC<{ value: number; prev: number; secondProgress: number }> = ({
value,
prev,
secondProgress,
}) => {
// secondProgress: 0→1 across the current whole second.
// Start the roll near the end of the second so the number "settles" on change.
const t = interpolate(secondProgress, [0.7, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.bezier(0.22, 1, 0.36, 1),
});
// continuous position between prev and value
const shown = prev + (value - prev) * t;
const offset = -shown * DIGIT_H;
return (
<div style={{ height: DIGIT_H, width: 110, overflow: "hidden", position: "relative" }}>
<div style={{ transform: `translateY(${offset}px)` }}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<div
key={n}
style={{
height: DIGIT_H,
lineHeight: `${DIGIT_H}px`,
textAlign: "center",
fontSize: 140,
fontWeight: 800,
fontVariantNumeric: "tabular-nums",
}}
>
{n}
</div>
))}
</div>
</div>
);
};
// Renders an mm:ss reel where each digit rolls.
export const ReelClock: React.FC<{ durationInSeconds: number }> = ({ durationInSeconds }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const elapsed = frame / fps;
const remaining = Math.max(0, Math.ceil(durationInSeconds - elapsed));
const prevRemaining = Math.max(0, remaining + 1); // value one second ago
const secondProgress = elapsed % 1; // 0→1 within the current second
const digits = (s: number) =>
[Math.floor(s / 600) % 10, Math.floor(s / 60) % 10, Math.floor(s / 10) % 10, s % 10];
const cur = digits(remaining);
const prev = digits(prevRemaining);
return (
<div style={{ display: "flex", gap: 8, color: "#fff", background: "#0b0d17", padding: 40 }}>
{cur.map((d, i) => (
<React.Fragment key={i}>
{i === 2 && <div style={{ fontSize: 140, fontWeight: 800 }}>:</div>}
<Reel value={d} prev={prev[i]} secondProgress={secondProgress} />
</React.Fragment>
))}
</div>
);
};
```
Notes:
- Each digit only rolls when it actually changes; a digit whose `prev === value` sits still because `shown` stays constant.
- A `9 → 0` rollover rolls *backward* through the strip (the cheap-looking part). To make tens/units roll the natural way on a carry, render an extra `0` glyph below the `9` and clamp — covered as an exercise; for most timers the backward roll at the tens boundary is unnoticeable at speed.
- `ROLL_FRAMES` is unused above because the roll is tied to `secondProgress`; expose it instead if a fixed-length roll is preferred.
## Flip card (split-flap)
The top half of the old digit folds down over a static bottom half, revealing the new digit. Pure CSS keyframe version — fine for a live web page; for Remotion, drive the `rotateX` off `secondProgress` the same way as the reel so it stays deterministic.
```css
.flip { position: relative; width: 110px; height: 160px; perspective: 600px; }
.flip .card {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 140px; font-weight: 800; font-variant-numeric: tabular-nums;
backface-visibility: hidden;
}
.flip .top { clip-path: inset(0 0 50% 0); transform-origin: bottom; }
.flip .bottom { clip-path: inset(50% 0 0 0); }
/* on change, animate the leaf folding down */
.flip .leaf {
position: absolute; inset: 0; transform-origin: bottom;
animation: fold .45s cubic-bezier(.36,0,.66,-0.2) forwards;
}
@keyframes fold {
0% { transform: rotateX(0deg); }
100% { transform: rotateX(-90deg); }
}
```
```js
// swap the digit and replay the fold whenever the value changes
function setFlip(el, newDigit) {
if (el.dataset.v === String(newDigit)) return; // no change → no flip
el.dataset.v = String(newDigit);
el.querySelector(".top").textContent = newDigit;
el.querySelector(".bottom").textContent = newDigit;
const leaf = el.querySelector(".leaf");
leaf.textContent = newDigit;
leaf.style.animation = "none";
void leaf.offsetWidth; // reflow → restart the animation
leaf.style.animation = "";
}
```
Drive `setFlip` from the same `requestAnimationFrame` + `performance.now()` loop in the cookbook (only call it when the displayed second changes), so the flips fire on real time and never drift.
## Choosing
- **Launch / premium:** odometer reel, slow ease, large tabular digits, muted palette.
- **Flash sale:** flip card for the mechanical urgency, or reel with a faster roll and a hot accent color on `mm:ss`.
- **Stream "starting soon":** either works, but keep it calm — a single reel on `mm:ss` reads better than a busy split-flap for a 5–10 minute hold.
+24
View File
@@ -0,0 +1,24 @@
# Lower Thirds
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers. For creators producing interviews, panels, podcasts, and broadcast-style titles.
## When it activates
- "Make an animated lower third" or "add lower thirds."
- "Create a name tag/name lower third" or "do a speaker name overlay."
- "Build broadcast titles" or "animate a title/name/role caption."
- "Template lower thirds across a list of names."
## Example prompts
- "Add a lower third with the speaker's name and role, branded to my colors."
- "Build a staggered slide-in name plate for a podcast guest."
- "Template one lower-third design across a roster of 12 panelists."
## What's inside
- `SKILL.md` — enter/exit animation, in/out timing and dwell, safe-area conventions, and brand styling.
- `references/lower-third-component.md` — a reusable lower-third component.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=lower-thirds)** — the AI motion agent for editable, on-brand motion graphics.
+146
View File
@@ -0,0 +1,146 @@
---
name: lower-thirds
description: This skill should be used when the user asks to "make an animated lower third", "add lower thirds", "create a name tag/name lower third", "build broadcast titles", "animate a title/name/role caption", "do a speaker name overlay", or "template lower thirds across a list of names". Covers enter/exit animation (slide/wipe/mask reveal), in/out timing & dwell, safe-area & broadcast conventions, name-vs-role type hierarchy, brand styling, and rendering one design across many names.
version: 0.1.0
---
# Lower Thirds
Build a clean, branded lower third — the name/role caption that lives in the bottom band of the frame — that animates in, holds long enough to read, and exits without stealing the shot. The craft is restraint: a crisp reveal, a strong type hierarchy, correct safe-area placement, and one template that drives a whole roster of speakers.
## When to use
- Speaker/interview name tags, panel rosters, talking-head captions.
- Broadcast-style titles, news straps, podcast/webinar name plates.
- Templating one design across a list of names/titles → many clips (the payoff).
## Anatomy
A lower third is two text lines plus a graphic bed, placed in the lower band:
| Element | Role | Type treatment |
|---|---|---|
| **Name** (primary) | Who | Largest, heaviest, tightest tracking |
| **Role/title** (secondary) | What/where | ~45–60% of name size, lighter weight, often a brand accent color |
| **Bar / panel / accent** | Reads against any background | Solid or 70–85% opacity; an accent rule ties to brand |
Keep it to two lines. A name line + role line is the whole job — extra lines erode the hierarchy and crowd the safe area.
## Three rules that prevent cheap-looking titles
1. **Never hard-cut a title on or off.** A bare appear/disappear reads as a mistake. Even a 12-frame fade or slide makes it feel produced.
2. **Stagger, don't slam.** Animate the bar first, then the name, then the role, each offset by 3–6 frames. Everything arriving at once feels flat; a short cascade feels designed.
3. **Hold long enough to read twice.** Roughly 3–5s on screen (read it aloud twice). Too short and it's missed; too long and it nags. Animate in/out, never mid-segment.
## Enter & exit animations
Pick one motion language and reuse it. The most reliable, premium-reading reveals:
| Reveal | Feel | How |
|---|---|---|
| **Slide + fade** | Clean, default | Translate in ~24–40px while fading; ease-out on enter |
| **Wipe bar** | Broadcast | A bar scales/sweeps in (`scaleX`/clip), text fades behind it |
| **Mask reveal** | Premium | Clip text to a moving rectangle so letters "wipe on" |
Use asymmetric easing: a snappy `ease-out` (`cubic-bezier(.22,1,.36,1)`) on enter, a quicker `ease-in` on exit. Exit faster than you entered (~⅔ the duration) so the title gets out of the way.
```css
/* Mask reveal: text wipes on left→right, no plugin needed */
.lt-name { clip-path: inset(0 100% 0 0); animation: wipe .5s cubic-bezier(.22,1,.36,1) forwards; }
@keyframes wipe { to { clip-path: inset(0 0 0 0); } }
```
## In/out timing & dwell
Think in three phases. Budget frames, not vibes (at 30fps):
| Phase | Budget | Note |
|---|---|---|
| Enter | 12–18f (0.4–0.6s) | Staggered: bar → name → role |
| Hold | 90–150f (3–5s) | The readable dwell; scale with line length |
| Exit | 8–12f (0.27–0.4s) | Faster than enter |
In code, drive every state from the current frame (or a paused timeline), not wall-clock time — a video renderer demands deterministic frames. Express enter/hold/exit as a single progress curve so the same component works live in a browser and frame-by-frame in a renderer.
## Safe area & broadcast conventions
The lower third must survive every screen and platform UI. Compose inside the **title-safe area** (inner ~90% / margin 5%) and respect platform chrome:
| Frame | Anchor | Avoid |
|---|---|---|
| 16:9 (1920×1080) | Left edge at ~5% margin; baseline ~80–85% down | Outer 5% (title-safe), action-safe outer 10% |
| 9:16 (1080×1920) | Same left margin; **lift the band up** | Bottom ~18% (captions/UI), top ~12% |
| 1:1 (1080×1080) | Lower band, center-left | Outer 5% |
Anchor to the **left** and grow rightward so names of different lengths stay pinned and never re-center awkwardly. Keep the role line within the same left margin as the name. For 9:16, do not park the title at the very bottom — platform captions and buttons sit there.
## Type hierarchy & brand styling
Hierarchy is the whole design. Make the name unmistakably dominant:
- **Size contrast** ≥ 1.6× (name vs role). If unsure, push the name bigger, not the role.
- **Weight + color**: name in bold/near-white; role in a lighter weight and a **brand accent** color (one accent, used consistently).
- **Contrast for legibility**: text rides over unknown footage. Use a solid/semi-opaque bar, a subtle scrim, or a soft text shadow so it reads over both bright and dark plates.
- **Brand lock**: pin font, accent color, bar style, and corner radius in one theme object so every title across a series matches.
## Templating one design across a list
The reason to build this in code: change the text/data, re-export — no re-animating. Make name/role/theme **props**, never hardcoded, then map a roster to instances.
```jsx
const ROSTER = [
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
{ name: "Alan Turing", role: "Mathematician" },
];
// One <LowerThird> design; map the list → many instances (sequence them in a renderer)
{ROSTER.map((p, i) => <LowerThird key={p.name} {...p} theme={BRAND} startFrame={i * 150} />)}
```
In a renderer, make the roster an input prop and render once per row (`--props`) to emit one branded clip per person — same animation, same brand, only the text changes. See `references/lower-third-component.md` for the full runnable component and batch script.
## Output checklist
- Two lines only: dominant name, secondary role (≥1.6× size contrast).
- Animated in and out (no hard cut); enter staggered bar→name→role; exit faster.
- Hold 3–5s (readable twice); motion only at the ends, never mid-segment.
- Left-anchored inside title-safe; 9:16 band lifted above the bottom-18% UI zone.
- Text legible over any plate (bar/scrim/shadow); one brand accent throughout.
- Name/role/theme are props — one template renders the whole roster.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
The lower third ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`, name/role/theme as props) — enter/dwell/exit all driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per roster row). 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Sample the three phases so you catch a stagger or safe-area bug before encoding (or batching the whole roster).
```bash
# Stills at enter / dwell / exit — WITH SHIPPED PROPS (a real roster row, not defaults)
npx remotion still LowerThird out/f-enter.png --frame=10 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
npx remotion still LowerThird out/f-dwell.png --frame=75 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
npx remotion still LowerThird out/f-exit.png --frame=N --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
# Inspect each PNG — focus the DWELL frame (fully on screen):
# - hierarchy holds: name dominant (>=1.6x role), role in brand accent; both fully revealed, not mid-wipe
# - brand lock: font, accent color, bar/scrim correct; legible over the plate
# - 9:16 safe area: band LIFTED above the bottom ~20-35% UI zone, clear of top ~12% and the right action rail; left-anchored inside title-safe
# - enter/exit frames show partial reveal (animation actually runs), not a hard cut
npx remotion render LowerThird out/lower-third.mp4 --props='{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}'
npx remotion render LowerThird out/demo.gif --codec=gif # README proof clip
```
**Batch roster**: verify ONE row via stills *before* rendering all names — catch a layout/overflow bug once, not N times. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the dwell + exit frames.
**Before you finish:**
1. Stills render cleanly at enter, dwell, and exit — no missing font/brand assets.
2. At the dwell frame: name dominant over role, brand accent + bar correct, fully (not partly) revealed.
3. The band is inside the 9:16 safe area — lifted above the bottom UI zone, clear of top and right rail — at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; enter/exit show motion, never a hard cut.
5. A real roster row (not `defaultProps`) renders correctly; MP4 encoded, GIF optional.
## Reference files
- `references/lower-third-component.md` — a complete runnable lower-third: a Remotion component with staggered spring enter, readable hold, and faster exit driven purely by frame; a typed theme object for brand lock; the list→many-instances sequencing pattern; a per-row batch render script; plus a dependency-free CSS/HTML version of the same design with mask-reveal and wipe-bar variants.
@@ -0,0 +1,268 @@
# Lower-Third Component — runnable code
A complete, production-grade lower third you can drop in. Two paths:
1. **Remotion** (frame-deterministic, renders to MP4, batches over a roster).
2. **CSS/HTML** (no dependencies, for the web or a quick preview) with mask-reveal and wipe-bar variants.
Both share the same design language: a left-anchored bar, a dominant name, a brand-accent role line, a staggered enter, a readable hold, and a faster exit.
---
## 1. Remotion component (props-driven, frame-deterministic)
The animation is a pure function of `frame`, so it renders identically every time and stays in sync with the renderer. Enter staggers the bar, name, then role; exit is computed from the end of the clip so the title leaves faster than it arrived.
```tsx
// LowerThird.tsx
import {
AbsoluteFill, Sequence, interpolate, spring,
useCurrentFrame, useVideoConfig,
} from "remotion";
// --- Brand lock: change once, every title matches -------------------------
export type Theme = {
font: string;
bar: string; // panel/bar color
name: string; // primary text color
role: string; // accent color for the role line
radius: number;
marginPct: number; // left/safe margin as % of width
};
export const BRAND: Theme = {
font: "Inter, system-ui, sans-serif",
bar: "rgba(12,14,22,0.86)",
name: "#FFFFFF",
role: "#6EE7F0", // single brand accent
radius: 10,
marginPct: 5,
};
export type LowerThirdProps = {
name: string;
role: string;
theme?: Theme;
holdSeconds?: number; // readable dwell, default 4s
};
// Staggered enter via spring; exit via a quick fade/slide near the end.
const useEnterExit = (delayFrames: number, holdSeconds: number) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// ENTER: spring 0→1, offset by delayFrames (the cascade)
const enter = spring({
frame: frame - delayFrames,
fps,
config: { damping: 200, stiffness: 180, mass: 0.6 },
});
// EXIT: faster than enter — last 10 frames of the clip
const exitStart = durationInFrames - 10;
const exit = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = enter * exit;
const x = interpolate(enter, [0, 1], [-32, 0]); // slide in from left
return { opacity, x };
};
const Line: React.FC<{
delay: number; holdSeconds: number; children: React.ReactNode;
size: number; weight: number; color: string; tracking?: number;
}> = ({ delay, holdSeconds, children, size, weight, color, tracking = 0 }) => {
const { opacity, x } = useEnterExit(delay, holdSeconds);
return (
<div style={{
opacity,
transform: `translateX(${x}px)`,
fontSize: size, fontWeight: weight, color,
letterSpacing: tracking, lineHeight: 1.15, whiteSpace: "nowrap",
}}>
{children}
</div>
);
};
export const LowerThird: React.FC<LowerThirdProps> = ({
name, role, theme = BRAND, holdSeconds = 4,
}) => {
const { width, height } = useVideoConfig();
const bar = useEnterExit(0, holdSeconds); // bar leads the cascade
const left = (width * theme.marginPct) / 100;
const baseline = height * 0.80; // sit ~80% down, inside title-safe
return (
<AbsoluteFill style={{ fontFamily: theme.font }}>
<div style={{
position: "absolute", left, top: baseline,
opacity: bar.opacity,
transform: `translateX(${bar.x}px)`,
}}>
{/* panel behind text guarantees legibility over any plate */}
<div style={{
background: theme.bar, borderRadius: theme.radius,
padding: "14px 22px 16px",
borderLeft: `4px solid ${theme.role}`, // accent rule ties to brand
backdropFilter: "blur(6px)",
}}>
<Line delay={4} holdSeconds={holdSeconds} size={46} weight={800} color={theme.name} tracking={-0.5}>
{name}
</Line>
<Line delay={8} holdSeconds={holdSeconds} size={26} weight={500} color={theme.role}>
{role}
</Line>
</div>
</div>
</AbsoluteFill>
);
};
```
Why each choice matters:
- **`spring` per line, offset by `delay`** gives the bar→name→role cascade. Same offsets everywhere keep titles consistent.
- **Exit derived from `durationInFrames`** means any clip length exits cleanly without per-instance tuning, and it's quicker than the enter (10f vs the spring's settle).
- **`whiteSpace: nowrap` + left anchor** keeps long and short names pinned to the same edge — no awkward re-centering across a roster.
- **`borderLeft` accent + semi-opaque panel** is the brand tie plus the legibility guarantee over unknown footage.
### Roster → many instances (one design, a whole panel)
Sequence the same component down a list. Each person gets a slot; only the text changes.
```tsx
// Roster.tsx
import { Series } from "remotion";
import { LowerThird, BRAND } from "./LowerThird";
const ROSTER = [
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
{ name: "Alan Turing", role: "Mathematician" },
];
export const Roster: React.FC = () => (
<Series>
{ROSTER.map((p) => (
<Series.Sequence key={p.name} durationInFrames={150 /* ~5s @30fps */}>
<LowerThird name={p.name} role={p.role} theme={BRAND} />
</Series.Sequence>
))}
</Series>
);
```
### Batch: one branded clip per person
Make the roster an input prop and render once per row, so each speaker becomes a standalone overlay clip — identical animation and brand, only the text differs.
```bash
#!/usr/bin/env bash
# render one transparent-friendly lower-third clip per person in roster.json
# roster.json: [{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}, ...]
set -euo pipefail
mkdir -p out
jq -c '.[]' roster.json | while read -r row; do
slug=$(echo "$row" | jq -r '.name' | tr '[:upper:] ' '[:lower:]-')
npx remotion render LowerThird "out/${slug}.mp4" --props="$row"
done
```
For overlay-ready output with transparency, render the `LowerThird` composition (transparent background) to a codec that keeps alpha, e.g. `--codec=prores --prores-profile=4444`, or `--image-format=png` for a PNG sequence.
---
## 2. CSS / HTML version (no dependencies)
The same design without a framework — for the web, OBS browser sources, or a fast look-test. Includes both a **slide+fade** default and a **mask-reveal** name, plus a **wipe-bar** variant.
```html
<div class="lt" style="--accent:#6EE7F0">
<div class="lt-bar">
<div class="lt-name">Ada Lovelace</div>
<div class="lt-role">Founder, Analytical Engines</div>
</div>
</div>
<style>
:root { font-family: Inter, system-ui, sans-serif; }
.lt {
position: absolute; left: 5%; top: 80%; /* title-safe, left-anchored */
}
.lt-bar {
display: inline-block;
background: rgba(12,14,22,0.86);
backdrop-filter: blur(6px);
border-left: 4px solid var(--accent);
border-radius: 10px;
padding: 14px 22px 16px;
/* bar leads the cascade: slide + fade in, then a faster fade out */
animation: lt-in .5s cubic-bezier(.22,1,.36,1) both,
lt-out .3s ease-in 4.5s forwards; /* hold ~4s, exit faster */
}
.lt-name {
font-size: 46px; font-weight: 800; color: #fff; letter-spacing: -.5px;
line-height: 1.15; white-space: nowrap;
/* MASK REVEAL: letters wipe on left→right, staggered after the bar */
clip-path: inset(0 100% 0 0);
animation: lt-wipe .5s cubic-bezier(.22,1,.36,1) .12s forwards;
}
.lt-role {
font-size: 26px; font-weight: 500; color: var(--accent);
line-height: 1.15; white-space: nowrap;
opacity: 0;
animation: lt-fade .4s ease-out .22s forwards; /* role arrives last */
}
@keyframes lt-in { from { opacity: 0; transform: translateX(-32px); }
to { opacity: 1; transform: none; } }
@keyframes lt-out { to { opacity: 0; transform: translateX(-16px); } }
@keyframes lt-wipe { to { clip-path: inset(0 0 0 0); } }
@keyframes lt-fade { to { opacity: 1; } }
/* Mobile/9:16: lift the band above the bottom UI/caption zone */
@media (max-aspect-ratio: 1/1) { .lt { top: 70%; } }
</style>
```
### Wipe-bar variant (broadcast feel)
Swap the bar animation so a colored bar sweeps in and the text rides behind it:
```css
.lt-bar.wipe {
position: relative; overflow: hidden;
animation: lt-in .45s cubic-bezier(.22,1,.36,1) both,
lt-out .3s ease-in 4.5s forwards;
}
.lt-bar.wipe::before {
content: ""; position: absolute; inset: 0;
background: var(--accent);
transform-origin: left;
animation: lt-sweep .55s cubic-bezier(.76,0,.24,1) both; /* sweep across, then reveal */
}
@keyframes lt-sweep { 0% { transform: scaleX(0); } 60% { transform: scaleX(1); }
100% { transform: scaleX(0); transform-origin: right; } }
```
The CSS clock (`animation`) is fine for live/web playback. For a deterministic MP4, prefer the Remotion path above — a frame renderer and CSS timers disagree, which causes flicker or desync at export.
---
## Quick reference
| Need | Knob |
|---|---|
| Slower/faster reveal | enter duration 12–18f / `.5s` |
| Longer dwell | `holdSeconds` / the `4.5s` exit delay |
| Bigger name dominance | name `fontSize` (keep ≥1.6× the role) |
| Rebrand everything | the `BRAND` theme object / `--accent` |
| 9:16 placement | `baseline 0.80→0.70` / the media query |
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can generate branded lower-thirds from a list of names/titles, one-click — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=lower-thirds)
+24
View File
@@ -0,0 +1,24 @@
# Short-Form Video
Engineer a 9:16 video that survives the swipe. Short-form is a retention machine, not a small launch film: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish. For creators making Reels, TikToks, and YouTube Shorts (7–60s, vertical).
## When it activates
- "Make a Reels/TikTok/YouTube Shorts video" or "build a short-form video template."
- "Write a hook for a short" or "fix the first 3 seconds."
- "Edit for retention," "add pattern interrupts," or "pace cuts for a vertical video."
- "Make a video loop" or "set up 9:16 safe areas."
## Example prompts
- "Build a reusable 9:16 short-form template — one design, many topics."
- "Fix the drop-off in this Reel: tighten pacing and add pattern interrupts."
- "Write three hook options for a 30-second educational short."
## What's inside
- `SKILL.md` — the retention arc, hook grammar, and pacing rules.
- `references/retention-pacing.md` — deeper guidance on retention editing and pacing.
---
Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=short-form-video)** — the AI motion agent for editable, on-brand motion graphics.
+155
View File
@@ -0,0 +1,155 @@
---
name: short-form-video
description: This skill should be used when the user asks to "make a Reels/TikTok/YouTube Shorts video", "build a short-form video template", "write a hook for a short", "edit for retention", "add pattern interrupts", "make a video loop", "fix the first 3 seconds", "pace cuts for a vertical video", or "set up 9:16 safe areas". Covers the hook→retention→loop grammar of vertical short-form (distinct from a launch hype film).
version: 0.1.0
---
# Short-Form Video
Engineer a 9:16 video that survives the swipe. Short-form is not a small launch film — it is a retention machine: a hook that earns the first second, pattern interrupts that reset attention every few seconds, and often a loop that buys watch-time past 100%. The craft is timing, not polish.
## When to use
- Reels / TikTok / YouTube Shorts (7–60s, vertical 9:16).
- Retention editing: fixing drop-off, adding pattern interrupts, tightening pacing.
- Hooks, seamless loops, repeatable short-form templates (one design, many topics).
## Story structure (the retention contract)
Short-form storytelling is a contract with the viewer's thumb: every second must re-earn the next one. The narrative is shaped *backwards from the retention curve*, not forwards from an intro. Lock these before touching the timeline.
- **One idea per video.** A short carries exactly one payoff. A second idea splits attention and the curve sags in the middle — make it a second video instead. If you can't name the single takeaway in a sentence, you don't have a short yet.
- **Open a loop in the first ~3s, close it last.** The hook poses a question or gap (the open loop); the payoff answers it. The viewer stays because the loop is open — so never answer it early, and never bury the payoff under setup.
- **No dead air.** Every beat either advances the idea or resets attention. Cut anything that does neither. Silence, slow ramps, throat-clearing intros ("hey guys, in this video…") are where viewers swipe.
- **Why the curve, not the cut, is the unit of work.** The algorithm distributes whatever holds the per-second retention curve flat; a "good" edit that flattens the curve loses to an ugly one that keeps it up. You are editing the *curve*, and the hook gates everything after it — fix it first.
The narrative spine (the `Hook → Setup → Body → Payoff/loop` arc is budgeted in the table below):
| Move | Story job | Failure if missed |
|---|---|---|
| Hook | Open the loop; promise the payoff | Swipe in first 3s |
| Tension hold | Keep the gap open through the body | Flat middle, mid-drop |
| Payoff | Close the loop — the one idea, delivered | "Wasted my time," no share |
| Loop / CTA | Feed back to frame 0, or one clear ask | No re-watch, no action |
(`The retention arc` table below assigns seconds to each move; this section is the narrative logic and ordering behind it. `Hook grammar` lists the hook archetypes.)
## The one metric that drives everything
**Retention, not production value.** The algorithm distributes whatever holds viewers; ~87% decide to stay or swipe inside 3 seconds, and the threshold for distribution sits near **70%+ average view-through** (Shorts ~73%, TikTok ~78%, Reels ~65%). Every decision below trades against that curve. A flawless shot that bores at 0:04 loses to an ugly one that holds.
## The retention arc
| Beat | Job | Budget (of a 20s short) |
|---|---|---|
| Hook (0–1s) | One frame + one line that creates an open loop | 0–1s |
| Setup | Pay off *why watch* — promise, stakes, or the gap | 1–4s |
| Body | Deliver value in beats; one idea per beat | 4–17s |
| Payoff / loop | Close the loop — or feed it back to frame 0 | 17–20s |
Scale the *body*, never the hook. A 60s short has a longer body and more beats — the same 1-second hook.
## Hook grammar (first ~1s)
The hook is a sentence + a visual that opens a curiosity gap the brain needs closed. Pick one pattern and commit:
| Hook type | Shape | Best for |
|---|---|---|
| Open loop | "The reason your X keeps Y…" (answer delayed) | most content |
| Direct promise | "3 ways to ___ in 30 seconds" | educational / listicle |
| Pattern interrupt | snap-zoom / whip-pan / visual mismatch in frame 1 | entertainment |
| Negation | "Stop doing ___" / "You're ___ wrong" | strong opinions |
Two rules: the hook line is **on-screen as text by frame 1** (85% watch muted), and the most striking *visual* is also the first frame — never a slow ramp-in. Hold a strong opening frame; do not fade up from black.
## Pattern interrupts & cut rhythm
A pattern interrupt is any change that resets attention: a hard cut, zoom punch, b-roll insert, sound effect, caption pop, or angle change. In short-form, density is high — a *visual change every ~2–4 seconds*; videos with an interrupt roughly every 4s hold ~58% vs ~41% for a static talking head. The killer is a flat middle: TikTok throttles distribution when the watch-through curve flattens, so never stitch the body from low-energy takes.
```js
// Drive every interrupt off the FRAME, not wall-clock — renders deterministically.
import { useCurrentFrame } from "remotion";
const fps = 30;
const cuts = [0, 1.2, 3.0, 5.4, 8.0, 11.2, 14.0]; // seconds — uneven on purpose
const frame = useCurrentFrame();
const shot = cuts.findIndex((t, i) => frame < (cuts[i + 1] ?? Infinity) * fps);
```
Vary the interval — a metronome reads as boredom; uneven cuts read as momentum. A punch-in zoom is the cheapest interrupt that needs no new footage:
```jsx
import { useCurrentFrame, interpolate, spring, useVideoConfig } from "remotion";
const PunchIn = ({ at, children }) => {
const frame = useCurrentFrame(); const { fps } = useVideoConfig();
const s = spring({ frame: frame - at * fps, fps, config: { damping: 12, stiffness: 200 } });
const scale = interpolate(s, [0, 1], [1, 1.12]); // snap to 112% on the beat
return <div style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</div>;
};
```
## Captions
Most viewers watch muted, so on-screen captions are not optional — they carry the content and double as a pattern interrupt when each phrase pops in. Keep 4–7 words per line, high contrast, inside the safe area. **Do not re-implement caption animation here** — use the `caption-animation` skill for word-by-word timing, karaoke highlight, and styling; this skill only places captions on the retention timeline and inside the 9:16 safe zone.
## Loops & re-watch
A seamless loop counts each re-watch as fresh watch-time — a clean 8s loop watched 3× is 300% view-through, the strongest possible signal. Two techniques: match the **last frame to the first** (same composition, position, color) so the cut is invisible, and write a **sentence loop** — phrase the hook so the payoff feeds back into frame 0 ("…and that's why I never — " → loops to the start). Best length for re-watch is ~7–15s.
## 9:16 safe areas
Compose inside the universal safe zone so platform UI never covers text or faces.
| Zone | Pixels (1080×1920) | Avoid |
|---|---|---|
| Universal safe (all platforms) | center **900×1400** | anything critical outside it |
| Top | keep clear ~120px | profile / sound UI |
| Bottom | keep clear ~320px | captions, CTA, hashtags, audio bar |
| Right | keep clear ~120px | like / comment / share rail |
Put the hook text and key subject in the center 900×1400 box. Reserve the bottom ~320px even if it looks empty in the editor — that is where the platform paints its own captions and buttons.
## Output checklist
- Hook line is on-screen text by frame 1; strongest visual is the first frame (no fade-up).
- One open loop / promise set in the first ~4s.
- A visual pattern interrupt every ~2–4s; intervals uneven; no flat middle.
- Captions present (via `caption-animation`), 4–7 words, inside safe area.
- Loop closed — matched first/last frame or a sentence that feeds frame 0.
- Composed inside the 900×1400 center box; bottom 320px / top 120px / right 120px kept clear.
- Every animated value derived from `useCurrentFrame()` — no CSS/library timers.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
The short ships as a Remotion composition (`<Composition>` + zod `schema` + `defaultProps`) — every cut, punch-in, and interrupt driven by `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` + the project (re-render per topic). 9:16 vertical (1080×1920) is the default.
**Verify loop — render stills → inspect → encode.** Three frames test the three things that make or break retention: the hook, the pacing, and the loop seam.
```bash
# Stills at hook / mid-body / last frame — WITH SHIPPED PROPS (the real topic, not defaults)
npx remotion still Short out/f-hook.png --frame=10 --props='{"topic":"...","hook":"..."}' # ~first 3s reads instantly
npx remotion still Short out/f-mid.png --frame=N --props='{"topic":"...","hook":"..."}'
npx remotion still Short out/f-end.png --frame=L --props='{"topic":"...","hook":"..."}' # L = durationInFrames-1
# Inspect each PNG:
# - HOOK frame (~frame 0-ish / first 3s): hook line is on-screen text and reads instantly; strongest visual already up (no fade-up)
# - LOOP seam: compare f-hook(frame 0) vs f-end(last frame) — same composition/position/color for an invisible loop cut
# - 9:16 safe area: hook + key subject inside the center 900x1400 box; clear of top ~12%, bottom ~20-35% (captions/CTA/audio), right action rail
npx remotion render Short out/short.mp4 --props='{"topic":"...","hook":"..."}' # encode once stills verify
npx remotion render Short out/demo.gif --codec=gif # README proof clip
```
**Per topic / batch**: verify ONE topic via stills *before* rendering all of them. Use `npx remotion compositions` for `durationInFrames`/`fps` to pick the mid + last frames.
**Before you finish:**
1. Stills render cleanly at the hook, mid, and last frame — no errors.
2. The hook frame reads instantly (on-screen text + strongest visual up front, no fade-up); first vs last frame match for a clean loop seam.
3. Hook + subject + captions sit inside the 9:16 center 900×1400 safe box at every checked frame.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; cuts uneven, no flat middle.
5. A real topic's shipped props (not `defaultProps`) render correctly; MP4 encoded, GIF optional.
## Reference files
- `references/retention-pacing.md` — a complete runnable Remotion `<Short>` composition: a frame-driven shot scheduler with uneven cuts, the PunchIn pattern interrupt, an on-screen hook, a 9:16 safe-area overlay, and a seamless first/last-frame loop. Plus a hook-pattern library, a retention-curve debugging method, per-platform safe-zone maps, and the template×topic batch-render pattern.
@@ -0,0 +1,209 @@
# Retention Pacing — Short-Form Video Reference
Deep reference for the `short-form-video` skill: a full runnable Remotion short, a hook library, a retention-debugging method, per-platform safe zones, and batch output. All timing is frame-driven so renders are deterministic.
## 1. A complete `<Short>` composition
One self-contained vertical short: frame-driven shot scheduler with uneven cuts, a punch-in pattern interrupt, an on-screen hook, a 9:16 safe-area overlay (dev only), and a seamless loop. Topic content is a prop, so the same file renders many shorts.
```tsx
// Short.tsx — 1080×1920, 30fps. Register at 600 frames (20s) for a clean loop.
import {
AbsoluteFill, useCurrentFrame, useVideoConfig,
interpolate, spring, Sequence, Img,
} from "remotion";
type Beat = { at: number; text: string; src?: string }; // at = seconds
export type ShortProps = {
hook: string; // on-screen by frame 1
beats: Beat[]; // body, one idea each
loopText: string; // sentence that feeds back to frame 0
};
// --- frame-driven shot scheduler: uneven cuts read as momentum ---
const useShot = (cuts: number[]) => {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
let i = 0;
for (let k = 0; k < cuts.length; k++) if (frame >= cuts[k] * fps) i = k;
const startF = cuts[i] * fps;
const endF = (cuts[i + 1] ?? cuts[i] + 2) * fps;
const local = (frame - startF) / (endF - startF); // 0→1 within shot
return { index: i, local, startF };
};
// --- punch-in: the cheapest pattern interrupt, snaps on the cut ---
const PunchIn: React.FC<{ startF: number; to?: number; children: React.ReactNode }> = ({
startF, to = 1.1, children,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const s = spring({ frame: frame - startF, fps, config: { damping: 13, stiffness: 220 } });
const scale = interpolate(s, [0, 1], [1, to]);
return <AbsoluteFill style={{ transform: `scale(${scale})`, transformOrigin: "50% 45%" }}>{children}</AbsoluteFill>;
};
// --- hook: text present from frame 0, micro-overshoot, no fade-up ---
const Hook: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({ frame, fps, config: { damping: 9, stiffness: 260 } });
const scale = interpolate(pop, [0, 1], [0.9, 1]);
return (
<div style={{
position: "absolute", top: 360, left: 90, right: 90, // inside 900-wide safe box
transform: `scale(${scale})`, transformOrigin: "0 0",
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 76, lineHeight: 1.05,
color: "#fff", textShadow: "0 4px 24px rgba(0,0,0,.55)",
}}>{text}</div>
);
};
export const Short: React.FC<ShortProps> = ({ hook, beats, loopText }) => {
const { fps, durationInFrames } = useVideoConfig();
const frame = useCurrentFrame();
const cuts = [0, ...beats.map((b) => b.at)]; // hook shot + one per beat
const { index, startF } = useShot(cuts);
const current = index === 0 ? null : beats[index - 1];
// seamless loop: match the LAST 0.5s back toward the opening frame
const tailF = durationInFrames - frame;
const loopFade = interpolate(tailF, [0, fps * 0.5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill style={{ backgroundColor: "#0b0b0f" }}>
<PunchIn startF={startF}>
{current?.src && <Img src={current.src} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
</PunchIn>
{index === 0 && <Hook text={hook} />}
{/* body caption — placement only; styling/word-timing belongs to caption-animation */}
{current && (
<div style={{
position: "absolute", left: 90, right: 90, bottom: 380, // above the 320px bottom UI band
fontFamily: "Inter, sans-serif", fontWeight: 700, fontSize: 60, color: "#fff",
textAlign: "center", textShadow: "0 3px 18px rgba(0,0,0,.6)",
}}>{current.text}</div>
)}
{/* loop sentence rises as the clip ends, easing the eye back to frame 0 */}
<div style={{
position: "absolute", left: 90, right: 90, bottom: 380, opacity: 1 - loopFade,
fontFamily: "Inter, sans-serif", fontWeight: 800, fontSize: 64, color: "#fff", textAlign: "center",
}}>{loopText}</div>
<SafeZone />{/* remove for final render */}
</AbsoluteFill>
);
};
```
Register it with a duration that *is* the loop length:
```ts
// Root.tsx
import { Composition } from "remotion";
import { Short } from "./Short";
export const Root = () => (
<Composition
id="Short" component={Short}
durationInFrames={600} fps={30} width={1080} height={1920}
defaultProps={{
hook: "You're editing Shorts wrong —",
beats: [
{ at: 1.2, text: "Cut every 2–4 seconds", src: "b1.jpg" },
{ at: 4.0, text: "Never on a fixed grid", src: "b2.jpg" },
{ at: 7.5, text: "Kill the flat middle", src: "b3.jpg" },
{ at: 11.0, text: "Match last frame to first", src: "b4.jpg" },
],
loopText: "…and that's why mine loop forever",
}}
/>
);
```
## 2. The 9:16 safe-area overlay (dev-only)
Render this on top while editing; delete it before the final export.
```tsx
import { AbsoluteFill } from "remotion";
export const SafeZone: React.FC = () => (
<AbsoluteFill style={{ pointerEvents: "none" }}>
{/* universal safe box: center 900×1400 in 1080×1920 */}
<div style={{ position: "absolute", left: 90, top: 260, width: 900, height: 1400, outline: "2px dashed #39d", opacity: .6 }} />
{/* platform UI bands to keep clear */}
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 120, background: "rgba(255,0,0,.12)" }} />
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: 320, background: "rgba(255,0,0,.12)" }} />
<div style={{ position: "absolute", bottom: 320, top: 120, right: 0, width: 120, background: "rgba(255,128,0,.10)" }} />
</AbsoluteFill>
);
```
## 3. Hook pattern library
The hook is a sentence that opens a gap the brain must close, plus the most striking frame as frame 0. Copyable shapes:
| Pattern | Template | Note |
|---|---|---|
| Open loop | "The real reason your ___ keeps ___ …" | answer withheld until payoff |
| Direct promise | "___ in 30 seconds, no ___ needed" | name the payoff + the timebox |
| Negation | "Stop ___. You're doing it backwards." | strong-opinion content |
| Pattern interrupt | snap-zoom / whip / hard visual mismatch | entertainment, no text needed |
| Stakes | "This ___ cost me $___ — don't repeat it" | story / case-study |
| Listicle | "3 ___ that ___ (the 3rd one ___ )" | tees up a forward reference |
Rules that matter more than the wording: (1) the hook text is on-screen by frame 1 — most viewers are muted; (2) frame 0 is the best frame, never a fade-up from black; (3) one gap only — stacking two hooks closes neither.
## 4. Retention-curve debugging
Treat the per-second retention graph (TikTok Analytics, YT Studio "Audience retention", Reels) as a profiler.
| Symptom on the curve | Likely cause | Fix |
|---|---|---|
| Cliff at 0:00–0:02 | weak hook / slow first frame | new hook line; cut the ramp-in; lead with the best frame |
| Steady gentle decline | normal — leave it | — |
| Flat-then-drop in the middle | low-energy body, cuts too sparse | add interrupts; tighten cut spacing to 2–4s |
| Bump back up near the end | viewers are looping | lean in — tighten the loop, shorten total length |
| Spike at one timestamp | a re-watched moment | front-load that beat or make it the hook |
Target ≥70% average view-through. Iterate the hook first — it moves the curve more than any other edit because it gates everything after it.
## 5. Per-platform safe zones
All on a 1080×1920 canvas. The universal box (center 900×1400) is the intersection; design inside it and one master fits every platform.
| Platform | Top clear | Bottom clear | Right clear | Notes |
|---|---|---|---|---|
| Universal (use this) | ~120px | ~320px | ~120px | center 900×1400 holds on all |
| TikTok | ~108px | ~320px | ~120px | caption + button rail at bottom-right |
| Instagram Reels | ~120px | ~310px | ~84px | audio bar + caption bottom |
| YouTube Shorts | ~120px | ~300px | ~60px | channel + subscribe bottom band |
| TikTok Ads | ~108px | ~370px | ~120px | extra room for the CTA button |
When unsure, design to the *largest* margin (TikTok Ads bottom 370px) and nothing ever clips.
## 6. Template × topic — batch output
The payoff of code-driven shorts: lock the design once, swap only the props, render N videos. Make topic content a prop (already done above), keep one theme object, and loop over JSON files.
```jsx
// theme.ts — one source of truth so 50 shorts stay on-brand
export const theme = { font: "Inter", hookSize: 76, bodySize: 60, bg: "#0b0b0f", fg: "#fff", accent: "#39d" };
```
```bash
# render the same Short template for every topic file in /topics
for f in topics/*.json; do
name=$(basename "$f" .json)
npx remotion render Short "out/$name.mp4" --props="$f"
done
```
Each `topics/*.json` is one `ShortProps` object (`hook`, `beats`, `loopText`). Keep the hook line, cut points, and loop sentence per-topic; everything else stays fixed so the series reads as one brand.
---
## Built by the team behind iart.ai
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can batch-produce short-form videos from one template × data — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=short-form-video)
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/caption-animation
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/countdown-video
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/lower-thirds
+102
View File
@@ -0,0 +1,102 @@
---
name: remotion-video
description: Create videos from React code with Remotion. Use when the user asks to make, animate, preview, or render a video, motion graphic, intro, caption, or social clip using code.
---
# Remotion Video
Remotion makes videos out of React components. Each frame is a web page. Render them in order and you get an MP4.
## Core ideas
- **Composition**: one video. Has `width`, `height`, `fps`, `durationInFrames`.
- **Frame**: `useCurrentFrame()` returns the current frame number. All animation is a function of the frame.
- **`interpolate`**: maps a frame range to a value range (fade, slide).
- **`spring`**: natural bouncy motion.
- **`Sequence`**: shows children only during a frame range.
- **`AbsoluteFill`**: full-screen layer; stack them for backgrounds.
- **`Audio` / `OffthreadVideo` / `Img`**: media. Put files in `public/` and load with `staticFile("name.mp3")`.
## Workflow
1. **Plan first.** Ask or decide: length (seconds), size (1920x1080, or 1080x1920 for vertical), fps (30), and the scenes. Keep scenes to a short list.
2. **Set up** (skip if the project exists):
```
npx create-video@latest
cd <project> && npm install
```
3. **Write** compositions in `src/`, register them in `src/Root.tsx`.
4. **Preview**: `npm run dev` (opens Remotion Studio).
5. **Render**: `npx remotion render <CompositionId> out/video.mp4`
Still frame: `npx remotion still <CompositionId> out/frame.png --frame=30`
6. **Verify**: render one still first, check it looks right, then render the full video.
## Starter example
`src/Root.tsx`
```tsx
import { Composition } from "remotion";
import { Hello } from "./Hello";
export const RemotionRoot = () => (
<Composition
id="Hello"
component={Hello}
durationInFrames={150}
fps={30}
width={1920}
height={1080}
defaultProps={{ title: "Hello, world" }}
/>
);
```
`src/Hello.tsx`
```tsx
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
export const Hello = ({ title }: { title: string }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
const scale = spring({ frame, fps, config: { damping: 12 } });
return (
<AbsoluteFill style={{ backgroundColor: "#0b1020", justifyContent: "center", alignItems: "center" }}>
<h1 style={{ color: "white", fontSize: 120, opacity, transform: `scale(${scale})` }}>{title}</h1>
</AbsoluteFill>
);
};
```
## Rules of thumb
- Never animate with CSS transitions, `setTimeout`, or CSS animations. They don't render per frame. Drive everything from `useCurrentFrame()`.
- Always pass `extrapolateRight: "clamp"` to `interpolate` unless you want values to keep growing.
- Seconds to frames: `seconds * fps`.
- Use `<Sequence from={..} durationInFrames={..}>` to order scenes; `<Series>` to chain them.
- Load fonts with `@remotion/google-fonts` so they are ready before rendering.
- Keep props in `defaultProps` so the user can edit text in the Studio.
- Rendering needs Chromium and ffmpeg (Remotion installs/handles these); first render can be slow.
## Fonts
Google Fonts: `npm i @remotion/google-fonts`, then
```tsx
import { loadFont } from "@remotion/google-fonts/Inter";
const { fontFamily } = loadFont(); // use as style={{ fontFamily }}
```
It downloads the font while rendering, so the machine needs internet access to fonts.googleapis.com. If the render fails with "Failed to load font", the network is blocking it: use a local font file in `public/` with `@remotion/fonts` (`loadFont({ family, url: staticFile("x.woff2") })`) or a system font. (Not verified in the sandbox where this skill was tested, which had no access to Google.)
## Common problems
- **Can't download the browser (no internet / locked-down machine)**: point Remotion at an installed Chromium:
`npx remotion render <Id> out/video.mp4 --browser-executable=/path/to/chromium --chrome-mode=chrome-for-testing`
- **Blank/white frames**: an asset path is wrong; use `staticFile()`.
- **Flicker**: an image or font not loaded; use `<Img>` and `@remotion/google-fonts`.
- **Slow render**: lower resolution/fps while testing, or use `--concurrency`.
## Templates
Copy-paste starting points. Read the one that fits:
- `templates/captions.md` - words highlighted in time with speech
- `templates/charts.md` - animated bar chart
- `templates/intro-outro.md` - title card + chaining scenes with `Series`
- `templates/audio-voiceover.md` - background music, voiceover, auto video length
- `templates/vertical.md` - 1080x1920 clips for Reels/TikTok/Shorts
## Docs
https://www.remotion.dev/docs
@@ -0,0 +1,62 @@
# Template: Audio, music and voiceover
Put sound files in `public/` (mp3 or wav). Load them with `staticFile()`.
`src/Narrated.tsx`
```tsx
import { AbsoluteFill, Audio, Sequence, interpolate, staticFile, useVideoConfig } from "remotion";
import { getAudioDurationInSeconds } from "@remotion/media-utils";
import type { CalculateMetadataFunction } from "remotion";
type Props = { title: string; voice: string; music: string };
// Make the video exactly as long as the voiceover (+1 second of padding).
export const calcNarratedMetadata: CalculateMetadataFunction<Props> = async ({ props }) => {
const seconds = await getAudioDurationInSeconds(staticFile(props.voice));
return { durationInFrames: Math.ceil((seconds + 1) * 30) };
};
export const Narrated = ({ title, voice, music }: Props) => {
const { fps, durationInFrames } = useVideoConfig();
return (
<AbsoluteFill style={{ background: "#0b1020", justifyContent: "center", alignItems: "center" }}>
<h1 style={{ color: "white", fontSize: 110 }}>{title}</h1>
{/* Background music: quiet, fades out over the last second. */}
<Audio
src={staticFile(music)}
volume={(f) => interpolate(f, [durationInFrames - fps, durationInFrames], [0.15, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })}
/>
{/* Voiceover starts half a second in, full volume. */}
<Sequence from={Math.round(fps * 0.5)}>
<Audio src={staticFile(voice)} volume={1} />
</Sequence>
</AbsoluteFill>
);
};
```
Register it (note `calculateMetadata`):
```tsx
<Composition
id="Narrated"
component={Narrated}
calculateMetadata={calcNarratedMetadata}
durationInFrames={300} // placeholder, replaced by calculateMetadata
fps={30} width={1920} height={1080}
defaultProps={{ title: "My story", voice: "voice.mp3", music: "music.mp3" }}
/>
```
Install: `npm i @remotion/media-utils`
Getting a voiceover
- Record yourself, or generate speech with a text-to-speech service, and save as `public/voice.mp3`. Never commit API keys; keep them in environment variables.
- Pair with `templates/captions.md`: feed it word timings from a transcription of the same file.
Rules
- Music volume 0.1-0.2 so the voice stays clear.
- `<Sequence from={...}>` delays when a sound starts.
- Audio is only in the final render (mp4). Stills (`remotion still`) have no sound.
- Check the sound made it in: `ffprobe out/video.mp4` should list an `Audio:` stream.
@@ -0,0 +1,38 @@
# Template: Captions
Words appear in time with speech. Give each word a start time in seconds.
`src/Captions.tsx`
```tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
type Word = { text: string; start: number; end: number }; // seconds
export const Captions = ({ words, background = "#111" }: { words: Word[]; background?: string }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = frame / fps;
// Show a window of up to 5 words around the current one.
const current = words.findIndex((w) => t >= w.start && t < w.end);
const i = current === -1 ? 0 : current;
const visible = words.slice(Math.floor(i / 5) * 5, Math.floor(i / 5) * 5 + 5);
return (
<AbsoluteFill style={{ backgroundColor: background, justifyContent: "flex-end", alignItems: "center", paddingBottom: 200 }}>
<div style={{ fontSize: 72, fontWeight: 800, color: "white", textShadow: "0 4px 12px black", textAlign: "center", maxWidth: "80%" }}>
{visible.map((w, k) => (
<span key={k} style={{ color: t >= w.start && t < w.end ? "#ffd400" : "white", marginRight: 16 }}>
{w.text}
</span>
))}
</div>
</AbsoluteFill>
);
};
```
Tips
- Get word times from a transcription tool (e.g. `@remotion/captions` or Whisper) and save them as JSON in `public/`.
- `background` is the backdrop when there is no video. Pass `"transparent"` when layering over a video.
- Layer it over the video: `<AbsoluteFill><OffthreadVideo src={staticFile("clip.mp4")} /><Captions words={words} background="transparent" /></AbsoluteFill>`.
@@ -0,0 +1,37 @@
# Template: Animated bar chart
Bars grow one after another.
`src/BarChart.tsx`
```tsx
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
type Bar = { label: string; value: number };
export const BarChart = ({ data, color = "#4f8cff" }: { data: Bar[]; color?: string }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const max = Math.max(...data.map((d) => d.value));
return (
<AbsoluteFill style={{ backgroundColor: "#0b1020", padding: 120, justifyContent: "flex-end" }}>
<div style={{ display: "flex", alignItems: "flex-end", gap: 40, height: 700 }}>
{data.map((d, i) => {
const grow = spring({ frame: frame - i * 6, fps, config: { damping: 15 } });
return (
<div key={d.label} style={{ flex: 1, textAlign: "center", color: "white", fontSize: 36 }}>
<div style={{ height: (d.value / max) * 600 * grow, background: color, borderRadius: 8 }} />
<div style={{ marginTop: 16 }}>{d.label}</div>
</div>
);
})}
</div>
</AbsoluteFill>
);
};
```
Tips
- Delay each bar with `frame - i * 6` (6 frames apart).
- For lines, animate `strokeDashoffset` on an SVG path using `interpolate`.
- Keep numbers readable: show the value as text, rounded.
@@ -0,0 +1,34 @@
# Template: Title intro / outro card
A short card (2-3 seconds) with a title and subtitle. Use at the start or end.
`src/TitleCard.tsx`
```tsx
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
export const TitleCard = ({ title, subtitle }: { title: string; subtitle?: string }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const rise = spring({ frame, fps, config: { damping: 14 } });
const fadeOut = interpolate(frame, [durationInFrames - 15, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill style={{ background: "linear-gradient(135deg,#1b2a6b,#0b1020)", justifyContent: "center", alignItems: "center", opacity: fadeOut }}>
<h1 style={{ color: "white", fontSize: 130, margin: 0, transform: `translateY(${(1 - rise) * 60}px)`, opacity: rise }}>{title}</h1>
{subtitle && <p style={{ color: "#9fb3ff", fontSize: 48, opacity: rise }}>{subtitle}</p>}
</AbsoluteFill>
);
};
```
Chain with the main video using `<Series>`:
```tsx
<Series>
<Series.Sequence durationInFrames={90}><TitleCard title="My Video" /></Series.Sequence>
<Series.Sequence durationInFrames={600}><MainScene /></Series.Sequence>
<Series.Sequence durationInFrames={90}><TitleCard title="Thanks for watching" /></Series.Sequence>
</Series>
```
@@ -0,0 +1,23 @@
# Template: Vertical clips (Reels / TikTok / Shorts)
Same code, different size. Register a vertical composition:
```tsx
<Composition
id="Vertical"
component={MyScene}
width={1080}
height={1920}
fps={30}
durationInFrames={30 * 30} // 30 seconds
/>
```
Render: `npx remotion render Vertical out/vertical.mp4`
Rules
- Keep text inside the **safe zone**: about 150px from the top, 350px from the bottom (apps cover those parts with buttons and captions), and 60px from the sides.
- Use big text (60px+). Phone screens are small.
- Keep clips under 60 seconds (Shorts limit); 15-30 seconds does best.
- Use `useVideoConfig()` for width/height instead of hard-coding, so the same scene works wide and tall.
- Put captions in the lower-middle, above the bottom safe zone (see captions.md).
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/short-form-video
-12
View File
@@ -1,12 +0,0 @@
# Copy to .env style settings on your host. Never commit real values.
ADMIN_EMAIL=rana.krunal7558@gmail.com
ADMIN_PASSWORD=pick-a-long-password-here
# Where new tickets are emailed (this is already the default)
TICKET_TO=rana.krunal7558@gmail.com
# Gmail sending: use an "App Password", not your normal password
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_USER=rana.krunal7558@gmail.com
SMTP_PASS=your-16-letter-app-password
# Set to 1 when hosted behind a proxy (most hosts)
TRUST_PROXY=1
-4
View File
@@ -1,4 +0,0 @@
node_modules
dist
server/data
.env
-25
View File
@@ -1,25 +0,0 @@
# Deploy checklist (Hostinger)
## A. Before you start
- [ ] Plan is Business Web Hosting or a Cloud plan (Node.js Web App supported)
- [ ] Gmail app password created (myaccount.google.com > Security > 2-Step Verification > App passwords)
## B. In hPanel: Websites > Add website > Node.js Web App > Import Git repository
- [ ] Repo: keithrana/cycle-atlas
- [ ] Branch: claude/sleepy-goodall-rykli1 (NOT main)
- [ ] Root/app directory: it-support (if no such box, ask Claude to split into its own repo)
- [ ] Node version: 22.x or 24.x
- [ ] Build command: npm run build
- [ ] Start command: npm start (entry file, if asked: server/index.js)
## C. Environment variables (type them only into Hostinger, never into chat or Git)
ADMIN_EMAIL, ADMIN_PASSWORD (10+ chars), SMTP_HOST=smtp.gmail.com, SMTP_PORT=465,
SMTP_USER, SMTP_PASS (Gmail app password), TRUST_PROXY=1, DATA_DIR=/home/<your-username>/helpdesk-data
## D. Deploy, then test in order
1. [ ] Page loads over https:// and the video plays
2. [ ] Search "wifi" shows answers; send a test ticket
3. [ ] Email arrives at rana.krunal7558@gmail.com (check Spam)
4. [ ] /#/staff login works and the ticket is listed
5. [ ] Add a test FAQ, redeploy, confirm it is still there (proves data is kept)
6. [ ] Log out; /#/staff asks for login again
-18
View File
@@ -1,18 +0,0 @@
# IT Support Site: PRD (plan)
Restaurant analogy: the FAQ database is the **kitchen's recipe book**. The website is the **dining room**.
## Core features
1. Cinematic hero: looping video, glass buttons (exactly as specified)
2. Search bar in the hero: type a problem, get matching answers
3. Category chips: browse the 11 topics (Access, Network, Hardware...)
4. FAQ answer cards: question, answer, optional "official guide" link
5. Footer social icons
## Phased roadmap
- **Phase 1 – Food stand (TODAY):** hero + search + categories + 87 FAQs from the database, baked in as a JSON file. No server.
- **Phase 2 – Small restaurant (DONE, browser-only storage):** "Was this helpful?" votes, "Contact IT" ticket form, shareable links per question.
- **Phase 3 – Full restaurant (DONE):** small back-of-house server + real database, staff login, admin page to edit FAQs, ticket tracking.
## Test ladder
1. Page loads, no console errors 2. Buttons/search work 3. Real data/links work 4. Security (no raw HTML injection, safe links)
-11
View File
@@ -1,11 +0,0 @@
# HelpDesk IT Support
Run locally (needs Node 22+):
npm install
npm run build
ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='a-long-password' npm start
Open http://localhost:3001 (staff login: http://localhost:3001/#/staff)
For development with live reload: run `npm run server` and `npm run dev` in two terminals.
Settings are listed in `.env.example`. Data lives in `server/data/helpdesk.db`: back it up, and keep it on a persistent disk when hosting.
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HelpDesk | IT Support</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-3528
View File
File diff suppressed because it is too large. Load diff
-30
View File
@@ -1,30 +0,0 @@
{
"name": "it-support",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"server": "node server/index.js",
"start": "NODE_ENV=production node server/index.js"
},
"dependencies": {
"express": "^4.22.3",
"lucide-react": "^0.460.0",
"nodemailer": "^10.0.13",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "~5.6.2",
"vite": "^5.4.11"
}
}
-1
View File
@@ -1 +0,0 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
-48
View File
@@ -1,48 +0,0 @@
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto'
import { db } from './db.js'
export const SESSION_DAYS = 7
export const hashPassword = (pw) => {
const salt = randomBytes(16)
return `${salt.toString('hex')}:${scryptSync(pw, salt, 64).toString('hex')}`
}
export const verifyPassword = (pw, stored) => {
const [salt, hash] = stored.split(':')
const a = Buffer.from(hash, 'hex')
const b = scryptSync(pw, Buffer.from(salt, 'hex'), 64)
return a.length === b.length && timingSafeEqual(a, b)
}
const sha = (t) => createHash('sha256').update(t).digest('hex')
export function createSession(staffId) {
const token = randomBytes(32).toString('hex')
db.prepare('INSERT INTO sessions VALUES (?,?,?)').run(sha(token), staffId, Date.now() + SESSION_DAYS * 864e5)
return token
}
export const destroySession = (token) => db.prepare('DELETE FROM sessions WHERE token_hash=?').run(sha(token))
function cookieToken(req) {
const m = /(?:^|;\s*)session=([a-f0-9]{64})/.exec(req.headers.cookie || '')
return m ? m[1] : null
}
export { cookieToken }
export function requireStaff(req, res, next) {
const token = cookieToken(req)
const row = token && db.prepare('SELECT s.id, s.email, s.name, x.expires FROM sessions x JOIN staff s ON s.id=x.staff_id WHERE x.token_hash=?').get(sha(token))
if (!row || row.expires < Date.now()) return res.status(401).json({ error: 'Please log in.' })
req.staff = row
next()
}
// Tiny in-memory rate limiter: `max` hits per `windowMs` per IP+bucket.
const hits = new Map()
export const rateLimit = (bucket, max, windowMs) => (req, res, next) => {
const key = `${bucket}:${req.ip}`
const now = Date.now()
const recent = (hits.get(key) || []).filter((t) => now - t < windowMs)
if (recent.length >= max) return res.status(429).json({ error: 'Too many tries. Please wait a bit.' })
recent.push(now)
hits.set(key, recent)
next()
}
-24
View File
@@ -1,24 +0,0 @@
import { DatabaseSync } from 'node:sqlite'
import { readFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const dataDir = process.env.DATA_DIR || join(here, 'data')
mkdirSync(dataDir, { recursive: true })
export const db = new DatabaseSync(join(dataDir, 'helpdesk.db'))
db.exec(`
CREATE TABLE IF NOT EXISTS faqs (id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, question TEXT NOT NULL, answer TEXT NOT NULL, link TEXT NOT NULL DEFAULT '', link_label TEXT NOT NULL DEFAULT '');
CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, topic TEXT NOT NULL, message TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', emailed INTEGER NOT NULL DEFAULT 0, created TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS votes (id INTEGER PRIMARY KEY AUTOINCREMENT, faq_id INTEGER NOT NULL, vote TEXT NOT NULL, created TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS staff (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, created TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, staff_id INTEGER NOT NULL, expires INTEGER NOT NULL);
`)
// First run: copy the 87 FAQs from the bundled file into the real database.
if (db.prepare('SELECT COUNT(*) AS n FROM faqs').get().n === 0) {
const seed = JSON.parse(readFileSync(join(here, '..', 'src', 'data', 'faqs.json'), 'utf8'))
const ins = db.prepare('INSERT INTO faqs (id, category, question, answer, link, link_label) VALUES (?,?,?,?,?,?)')
for (const f of seed) ins.run(f.id, f.category, f.question, f.answer, f.link, f.linkLabel)
}
-151
View File
@@ -1,151 +0,0 @@
import express from 'express'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomBytes } from 'node:crypto'
import { db } from './db.js'
import { hashPassword, verifyPassword, createSession, destroySession, cookieToken, requireStaff, rateLimit, SESSION_DAYS } from './auth.js'
import { sendTicketEmail, emailConfigured } from './mail.js'
const app = express()
const prod = process.env.NODE_ENV === 'production'
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const clean = (s, max) => String(s ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, max)
const cleanBlock = (s, max) => String(s ?? '').trim().slice(0, max)
const isHttpUrl = (u) => { try { return ['http:', 'https:'].includes(new URL(u).protocol) } catch { return false } }
app.set('trust proxy', process.env.TRUST_PROXY ? 1 : false)
app.disable('x-powered-by')
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin',
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; media-src https://d8j0ntlcm91z4.cloudfront.net; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'",
})
next()
})
app.use(express.json({ limit: '20kb' }))
// Block cross-site writes: browsers always send Origin on POST/PUT/PATCH/DELETE from other sites.
app.use((req, res, next) => {
if (['GET', 'HEAD'].includes(req.method)) return next()
const origin = req.headers.origin
if (origin && new URL(origin).host !== req.headers.host) return res.status(403).json({ error: 'Bad origin.' })
next()
})
// ---------- Public ----------
const faqOut = (r) => ({ id: r.id, category: r.category, question: r.question, answer: r.answer, link: r.link, linkLabel: r.link_label })
app.get('/api/faqs', (_req, res) => res.json(db.prepare('SELECT * FROM faqs ORDER BY id').all().map(faqOut)))
app.post('/api/tickets', rateLimit('ticket', 5, 3600e3), async (req, res) => {
const t = {
id: `IT-${Date.now().toString(36).toUpperCase()}${randomBytes(2).toString('hex').toUpperCase()}`,
name: clean(req.body.name, 100), email: clean(req.body.email, 200),
topic: clean(req.body.topic, 100), message: cleanBlock(req.body.message, 2000),
}
if (!t.name) return res.status(400).json({ error: 'Please enter your name.' })
if (!EMAIL_RE.test(t.email)) return res.status(400).json({ error: 'Please enter a valid email.' })
if (t.message.length < 10) return res.status(400).json({ error: 'Please describe the problem (at least 10 characters).' })
db.prepare('INSERT INTO tickets (id,name,email,topic,message,created) VALUES (?,?,?,?,?,?)').run(t.id, t.name, t.email, t.topic, t.message, new Date().toISOString())
const emailed = await sendTicketEmail(t)
if (emailed) db.prepare('UPDATE tickets SET emailed=1 WHERE id=?').run(t.id)
res.status(201).json({ id: t.id })
})
app.post('/api/votes', rateLimit('vote', 30, 3600e3), (req, res) => {
const faqId = Number(req.body.faqId)
const vote = req.body.vote
if (!['up', 'down'].includes(vote) || !db.prepare('SELECT 1 FROM faqs WHERE id=?').get(faqId)) return res.status(400).json({ error: 'Bad vote.' })
db.prepare('INSERT INTO votes (faq_id,vote,created) VALUES (?,?,?)').run(faqId, vote, new Date().toISOString())
res.status(201).json({ ok: true })
})
// ---------- Staff login ----------
app.post('/api/login', rateLimit('login', 10, 15 * 60e3), (req, res) => {
const row = db.prepare('SELECT * FROM staff WHERE email=?').get(clean(req.body.email, 200).toLowerCase())
// Same message either way so attackers can't learn which emails exist.
if (!row || !verifyPassword(String(req.body.password ?? ''), row.password_hash)) return res.status(401).json({ error: 'Wrong email or password.' })
const token = createSession(row.id)
res.cookie('session', token, { httpOnly: true, sameSite: 'strict', secure: prod, maxAge: SESSION_DAYS * 864e5, path: '/' })
res.json({ name: row.name, email: row.email })
})
app.post('/api/logout', (req, res) => {
const t = cookieToken(req)
if (t) destroySession(t)
res.clearCookie('session', { path: '/' })
res.json({ ok: true })
})
app.get('/api/me', requireStaff, (req, res) => res.json({ name: req.staff.name, email: req.staff.email }))
// ---------- Staff only ----------
app.get('/api/staff/status', requireStaff, (_req, res) => res.json({ emailConfigured }))
app.get('/api/staff/tickets', requireStaff, (_req, res) => res.json(db.prepare('SELECT * FROM tickets ORDER BY created DESC').all()))
app.patch('/api/staff/tickets/:id', requireStaff, (req, res) => {
if (!['open', 'in_progress', 'closed'].includes(req.body.status)) return res.status(400).json({ error: 'Bad status.' })
db.prepare('UPDATE tickets SET status=? WHERE id=?').run(req.body.status, req.params.id)
res.json({ ok: true })
})
app.get('/api/staff/faqs', requireStaff, (_req, res) => {
const votes = db.prepare("SELECT faq_id, SUM(vote='up') AS up, SUM(vote='down') AS down FROM votes GROUP BY faq_id").all()
const byId = new Map(votes.map((v) => [v.faq_id, v]))
res.json(db.prepare('SELECT * FROM faqs ORDER BY id').all().map((r) => ({ ...faqOut(r), up: byId.get(r.id)?.up ?? 0, down: byId.get(r.id)?.down ?? 0 })))
})
function faqFields(body) {
const f = { category: clean(body.category, 100), question: clean(body.question, 300), answer: cleanBlock(body.answer, 3000), link: clean(body.link, 500), link_label: clean(body.linkLabel, 200) }
if (!f.category || !f.question || !f.answer) return { error: 'Category, question and answer are required.' }
if (f.link && !isHttpUrl(f.link)) return { error: 'Link must start with http:// or https://' }
return { f }
}
app.post('/api/staff/faqs', requireStaff, (req, res) => {
const { f, error } = faqFields(req.body)
if (error) return res.status(400).json({ error })
const r = db.prepare('INSERT INTO faqs (category,question,answer,link,link_label) VALUES (?,?,?,?,?)').run(f.category, f.question, f.answer, f.link, f.link_label)
res.status(201).json({ id: Number(r.lastInsertRowid) })
})
app.put('/api/staff/faqs/:id', requireStaff, (req, res) => {
const { f, error } = faqFields(req.body)
if (error) return res.status(400).json({ error })
db.prepare('UPDATE faqs SET category=?,question=?,answer=?,link=?,link_label=? WHERE id=?').run(f.category, f.question, f.answer, f.link, f.link_label, req.params.id)
res.json({ ok: true })
})
app.delete('/api/staff/faqs/:id', requireStaff, (req, res) => {
db.prepare('DELETE FROM faqs WHERE id=?').run(req.params.id)
res.json({ ok: true })
})
app.get('/api/staff/team', requireStaff, (_req, res) => res.json(db.prepare('SELECT id,email,name,created FROM staff ORDER BY id').all()))
app.post('/api/staff/team', requireStaff, (req, res) => {
const email = clean(req.body.email, 200).toLowerCase(), name = clean(req.body.name, 100), pw = String(req.body.password ?? '')
if (!EMAIL_RE.test(email) || !name) return res.status(400).json({ error: 'Name and a valid email are required.' })
if (pw.length < 10) return res.status(400).json({ error: 'Password must be at least 10 characters.' })
if (db.prepare('SELECT 1 FROM staff WHERE email=?').get(email)) return res.status(409).json({ error: 'That email is already on the team.' })
db.prepare('INSERT INTO staff (email,name,password_hash,created) VALUES (?,?,?,?)').run(email, name, hashPassword(pw), new Date().toISOString())
res.status(201).json({ ok: true })
})
app.delete('/api/staff/team/:id', requireStaff, (req, res) => {
if (Number(req.params.id) === req.staff.id) return res.status(400).json({ error: "You can't remove yourself." })
db.prepare('DELETE FROM sessions WHERE staff_id=?').run(req.params.id)
db.prepare('DELETE FROM staff WHERE id=?').run(req.params.id)
res.json({ ok: true })
})
app.use('/api', (_req, res) => res.status(404).json({ error: 'Not found.' }))
// ---------- First staff member ----------
if (db.prepare('SELECT COUNT(*) AS n FROM staff').get().n === 0) {
const { ADMIN_EMAIL, ADMIN_PASSWORD } = process.env
if (ADMIN_EMAIL && ADMIN_PASSWORD && ADMIN_PASSWORD.length >= 10) {
db.prepare('INSERT INTO staff (email,name,password_hash,created) VALUES (?,?,?,?)').run(ADMIN_EMAIL.toLowerCase(), 'Admin', hashPassword(ADMIN_PASSWORD), new Date().toISOString())
console.log(`Created first staff login: ${ADMIN_EMAIL}`)
} else console.warn('No staff yet. Set ADMIN_EMAIL and ADMIN_PASSWORD (10+ characters) and restart.')
}
if (!emailConfigured) console.warn('Email not configured (SMTP_HOST/SMTP_USER/SMTP_PASS). Tickets are saved but not emailed.')
// ---------- Serve the website (production) ----------
const dist = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist')
if (existsSync(dist)) app.use(express.static(dist))
const port = Number(process.env.PORT || 3001)
app.listen(port, () => console.log(`HelpDesk server on http://localhost:${port}`))
-25
View File
@@ -1,25 +0,0 @@
import nodemailer from 'nodemailer'
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, TICKET_TO = 'rana.krunal7558@gmail.com' } = process.env
const transporter = SMTP_HOST && SMTP_USER && SMTP_PASS
? nodemailer.createTransport({ host: SMTP_HOST, port: Number(SMTP_PORT || 465), secure: Number(SMTP_PORT || 465) === 465, auth: { user: SMTP_USER, pass: SMTP_PASS } })
: null
export const emailConfigured = !!transporter
export async function sendTicketEmail(t) {
if (!transporter) return false
try {
await transporter.sendMail({
from: SMTP_USER,
to: TICKET_TO,
replyTo: t.email,
subject: `[${t.id}] ${t.topic}: new IT request from ${t.name}`,
text: `Ticket: ${t.id}\nFrom: ${t.name} <${t.email}>\nTopic: ${t.topic}\n\n${t.message}\n`,
})
return true
} catch (e) {
console.error('Email failed:', e.message)
return false
}
}
-165
View File
@@ -1,165 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowRight, Globe, Instagram, Twitter } from 'lucide-react'
import VideoBackground from './VideoBackground'
import FaqList from './Faq'
import ContactForm from './ContactForm'
import type { Faq } from './types'
import Staff from './Staff'
import faqData from './data/faqs.json'
const bundledFaqs = faqData as Faq[]
export default function App() {
const [faqs, setFaqs] = useState<Faq[]>(bundledFaqs)
const [isStaff, setIsStaff] = useState(location.hash.startsWith('#/staff'))
const categories = useMemo(() => Array.from(new Set(faqs.map((f) => f.category))), [faqs])
useEffect(() => {
fetch('/api/faqs').then((r) => (r.ok ? r.json() : Promise.reject())).then(setFaqs).catch(() => {})
const onHash = () => setIsStaff(location.hash.startsWith('#/staff'))
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
}, [])
const [query, setQuery] = useState('')
const [category, setCategory] = useState<string | null>(null)
const answersRef = useRef<HTMLElement>(null)
const results = useMemo(() => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
return faqs.filter((f) => {
if (category && f.category !== category) return false
const text = `${f.question} ${f.answer} ${f.category}`.toLowerCase()
return words.every((w) => text.includes(w))
})
}, [query, category, faqs])
if (isStaff) return <Staff />
const scrollToAnswers = () => answersRef.current?.scrollIntoView({ behavior: 'smooth' })
return (
<div className="bg-black">
<div className="min-h-screen bg-black overflow-hidden relative flex flex-col">
<VideoBackground />
<nav className="relative z-20 pl-6 pr-6 py-6">
<div className="liquid-glass rounded-full px-6 py-3 flex items-center justify-between max-w-5xl mx-auto">
<div className="flex items-center gap-8">
<div className="flex items-center gap-2 text-white font-semibold text-lg">
<Globe size={24} />
<span>HelpDesk</span>
</div>
<div className="hidden md:flex items-center gap-8">
{[
['Topics', '#answers'],
['Search', '#top'],
['Contact', '#contact'],
].map(([label, href]) => (
<a key={label} href={href} className="text-white/80 hover:text-white transition-colors text-sm font-medium">
{label}
</a>
))}
</div>
</div>
<div className="flex items-center gap-4">
<a href="#contact" className="text-white text-sm font-medium">Contact IT</a>
<a href="#/staff" className="liquid-glass rounded-full px-6 py-2 text-white text-sm font-medium">Staff login</a>
</div>
</div>
</nav>
<div
id="top"
className="relative z-10 flex-1 flex flex-col items-center justify-center px-6 py-12 text-center -translate-y-[20%]"
>
<h1
className="text-5xl md:text-6xl lg:text-7xl text-white mb-8 tracking-tight whitespace-nowrap"
style={{ fontFamily: "'Instrument Serif', serif" }}
>
IT help, <em>made simple</em>
</h1>
<div className="max-w-xl w-full space-y-4">
<form
className="liquid-glass rounded-full pl-6 pr-2 py-2 flex items-center gap-3"
onSubmit={(e) => {
e.preventDefault()
scrollToAnswers()
}}
>
<input
type="text"
value={query}
maxLength={200}
onChange={(e) => setQuery(e.target.value)}
placeholder="Describe your problem"
aria-label="Search IT help"
className="bg-transparent outline-none flex-1 text-white placeholder:text-white/40 text-base"
/>
<button type="submit" aria-label="Search" className="bg-white rounded-full p-3 text-black">
<ArrowRight size={20} />
</button>
</form>
<p className="text-white text-sm leading-relaxed px-4">
Locked out? Wi-Fi down? Printer acting up? Search {faqs.length} plain-English answers to the most common IT problems.
</p>
<div className="flex justify-center">
<button
onClick={scrollToAnswers}
className="liquid-glass rounded-full px-8 py-3 text-white text-sm font-medium hover:bg-white/5 transition-colors"
>
Browse all topics
</button>
</div>
</div>
</div>
<div className="relative z-10 flex justify-center gap-4 pb-12">
{[
{ Icon: Instagram, label: 'Instagram' },
{ Icon: Twitter, label: 'Twitter' },
{ Icon: Globe, label: 'Website' },
].map(({ Icon, label }) => (
<button
key={label}
aria-label={label}
className="liquid-glass rounded-full p-4 text-white/80 hover:text-white hover:bg-white/5 transition-all"
>
<Icon size={20} />
</button>
))}
</div>
</div>
<section id="answers" ref={answersRef} className="max-w-3xl mx-auto px-6 py-20">
<h2 className="text-4xl text-white mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>
Answers
</h2>
<p className="text-white/60 text-sm mb-6" aria-live="polite">
Showing {results.length} of {faqs.length}
</p>
<div className="flex flex-wrap gap-2 mb-8">
{[null, ...categories].map((c) => (
<button
key={c ?? 'all'}
onClick={() => setCategory(c)}
aria-pressed={category === c}
className={`liquid-glass rounded-full px-4 py-2 text-sm transition-colors ${
category === c ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'
}`}
>
{c ?? 'All'}
</button>
))}
</div>
<FaqList items={results} />
</section>
<ContactForm topics={categories} />
<footer id="about" className="max-w-3xl mx-auto px-6 pb-16 text-center text-white/50 text-sm">
General guidance only. For account-specific problems, contact your organisation's IT service desk.
</footer>
</div>
)
}
-80
View File
@@ -1,80 +0,0 @@
import { useState } from 'react'
import { Send } from 'lucide-react'
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const inputCls = 'w-full bg-transparent outline-none text-white placeholder:text-white/40 text-base'
export default function ContactForm({ topics }: { topics: string[] }) {
const [form, setForm] = useState({ name: '', email: '', topic: topics[0] ?? '', message: '' })
const [errors, setErrors] = useState<Record<string, string>>({})
const [ticketId, setTicketId] = useState<string | null>(null)
const [sending, setSending] = useState(false)
const [serverError, setServerError] = useState('')
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setForm({ ...form, [k]: e.target.value })
const submit = async (e: React.FormEvent) => {
e.preventDefault()
const errs: Record<string, string> = {}
if (!form.name.trim()) errs.name = 'Please enter your name.'
if (!EMAIL_RE.test(form.email.trim())) errs.email = 'Please enter a valid email.'
if (form.message.trim().length < 10) errs.message = 'Please describe the problem (at least 10 characters).'
setErrors(errs)
setServerError('')
if (Object.keys(errs).length) return
setSending(true)
try {
const res = await fetch('/api/tickets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'Something went wrong. Please try again.')
setTicketId(data.id)
setForm({ name: '', email: '', topic: topics[0] ?? '', message: '' })
} catch (err) {
setServerError(err instanceof Error ? err.message : 'Could not reach the server.')
} finally {
setSending(false)
}
}
const field = 'liquid-glass rounded-3xl px-6 py-4'
const err = (k: string) => errors[k] && <p role="alert" className="text-red-300 text-sm mt-1 px-2">{errors[k]}</p>
return (
<section id="contact" className="max-w-3xl mx-auto px-6 pb-20">
<h2 className="text-4xl text-white mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>Contact IT</h2>
<p className="text-white/60 text-sm mb-6">Can't find your answer? Tell us what's wrong.</p>
{ticketId ? (
<div className="liquid-glass rounded-3xl p-8 text-white" role="status">
<p className="text-xl mb-2">Request sent. Your ticket number is <strong>{ticketId}</strong>.</p>
<p className="text-white/70 text-sm mb-4">Our IT team has been notified. Keep this number if you follow up.</p>
<button onClick={() => setTicketId(null)} className="liquid-glass rounded-full px-6 py-2 text-sm hover:bg-white/5">Send another</button>
</div>
) : (
<form onSubmit={submit} noValidate className="space-y-3">
<div>
<div className={field}><input className={inputCls} placeholder="Your name" aria-label="Your name" maxLength={100} value={form.name} onChange={set('name')} /></div>
{err('name')}
</div>
<div>
<div className={field}><input className={inputCls} type="email" placeholder="Your email" aria-label="Your email" maxLength={200} value={form.email} onChange={set('email')} /></div>
{err('email')}
</div>
<div className={field}>
<select className={`${inputCls} [&>option]:text-black`} aria-label="Topic" value={form.topic} onChange={set('topic')}>
{topics.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div>
<div className={field}><textarea className={`${inputCls} resize-none`} rows={5} placeholder="What's the problem?" aria-label="Problem description" maxLength={2000} value={form.message} onChange={set('message')} /></div>
{err('message')}
</div>
{serverError && <p role="alert" className="text-red-300 text-sm px-2">{serverError}</p>}
<button type="submit" disabled={sending} className="bg-white text-black rounded-full px-8 py-3 text-sm font-medium inline-flex items-center gap-2 disabled:opacity-60">
<Send size={16} /> {sending ? 'Sending...' : 'Send request'}
</button>
</form>
)}
</section>
)
}
-120
View File
@@ -1,120 +0,0 @@
import { useEffect, useState } from 'react'
import { Check, ExternalLink, Link2, SearchX, ThumbsDown, ThumbsUp } from 'lucide-react'
import type { Faq } from './types'
import { load, save } from './storage'
type Votes = Record<number, 'up' | 'down'>
function safeUrl(url: string): string | null {
try {
const u = new URL(url)
return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null
} catch {
return null
}
}
function FaqCard({ f, forceOpen, vote, onVote }: { f: Faq; forceOpen: boolean; vote?: 'up' | 'down'; onVote: (v: 'up' | 'down') => void }) {
const [copied, setCopied] = useState(false)
const href = f.link ? safeUrl(f.link) : null
const copyLink = async () => {
const url = `${location.origin}${location.pathname}#faq-${f.id}`
try {
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch {
location.hash = `faq-${f.id}`
}
}
const voteBtn = (v: 'up' | 'down', label: string, Icon: typeof ThumbsUp) => (
<button
onClick={() => onVote(v)}
aria-pressed={vote === v}
className={`liquid-glass rounded-full px-4 py-2 inline-flex items-center gap-2 text-sm transition-colors ${
vote === v ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/5'
}`}
>
<Icon size={14} /> {label}
</button>
)
return (
<li id={`faq-${f.id}`} className="scroll-mt-6">
<details className="liquid-glass rounded-3xl group" open={forceOpen || undefined}>
<summary className="cursor-pointer list-none px-6 py-5 flex items-start justify-between gap-4 text-white">
<span>
<span className="block text-xs uppercase tracking-wider text-white/50 mb-1">{f.category}</span>
<span className="text-lg">{f.question}</span>
</span>
<span className="text-white/60 transition-transform group-open:rotate-45 text-2xl leading-none">+</span>
</summary>
<div className="px-6 pb-6 text-white/80 text-sm leading-relaxed">
<p>{f.answer}</p>
{href && (
<a href={href} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 mt-4 text-white hover:underline">
{f.linkLabel || 'Official guide'} <ExternalLink size={14} />
</a>
)}
<div className="mt-5 flex flex-wrap items-center gap-2">
<span className="text-white/50 mr-1">Was this helpful?</span>
{voteBtn('up', 'Yes', ThumbsUp)}
{voteBtn('down', 'No', ThumbsDown)}
<button
onClick={copyLink}
className="liquid-glass rounded-full px-4 py-2 inline-flex items-center gap-2 text-sm text-white/70 hover:text-white hover:bg-white/5 transition-colors ml-auto"
>
{copied ? <Check size={14} /> : <Link2 size={14} />} {copied ? 'Link copied' : 'Copy link'}
</button>
</div>
{vote === 'down' && (
<p className="mt-3 text-white/60">
Sorry about that. <a href="#contact" className="text-white underline">Contact IT</a> and we'll help directly.
</p>
)}
</div>
</details>
</li>
)
}
export default function FaqList({ items }: { items: Faq[] }) {
const [votes, setVotes] = useState<Votes>(() => load<Votes>('faq-votes', {}))
const [hashId, setHashId] = useState<number | null>(null)
useEffect(() => {
const read = () => {
const m = /^#faq-(\d+)$/.exec(location.hash)
setHashId(m ? Number(m[1]) : null)
if (m) window.setTimeout(() => document.getElementById(`faq-${m[1]}`)?.scrollIntoView({ behavior: 'smooth' }), 50)
}
read()
window.addEventListener('hashchange', read)
return () => window.removeEventListener('hashchange', read)
}, [])
const onVote = (id: number, v: 'up' | 'down') => {
const next = { ...votes, [id]: v }
setVotes(next)
save('faq-votes', next)
fetch('/api/votes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faqId: id, vote: v }) }).catch(() => {})
}
if (items.length === 0) {
return (
<div className="liquid-glass rounded-3xl p-10 text-center text-white/70">
<SearchX className="mx-auto mb-3" size={28} />
<p>No answers found. Try different words, or <a href="#contact" className="text-white underline">contact IT</a>.</p>
</div>
)
}
return (
<ul className="space-y-3">
{items.map((f) => (
<FaqCard key={f.id} f={f} forceOpen={hashId === f.id} vote={votes[f.id]} onVote={(v) => onVote(f.id, v)} />
))}
</ul>
)
}
-175
View File
@@ -1,175 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { LogOut } from 'lucide-react'
interface Ticket { id: string; name: string; email: string; topic: string; message: string; status: string; emailed: number; created: string }
interface StaffFaq { id: number; category: string; question: string; answer: string; link: string; linkLabel: string; up: number; down: number }
interface Member { id: number; email: string; name: string }
type Tab = 'tickets' | 'faqs' | 'team'
async function api<T = unknown>(path: string, method = 'GET', body?: unknown): Promise<T> {
const res = await fetch(`/api${path}`, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status })
return data as T
}
const box = 'liquid-glass rounded-3xl p-6 text-white'
const input = 'w-full bg-white/5 rounded-2xl px-4 py-3 text-white placeholder:text-white/40 outline-none'
const btn = 'liquid-glass rounded-full px-5 py-2 text-sm text-white hover:bg-white/5 transition-colors'
function Login({ onDone }: { onDone: () => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const submit = async (e: React.FormEvent) => {
e.preventDefault()
try { await api('/login', 'POST', { email, password }); onDone() } catch (err) { setError((err as Error).message) }
}
return (
<form onSubmit={submit} className={`${box} max-w-sm mx-auto space-y-3`}>
<h1 className="text-3xl mb-2" style={{ fontFamily: "'Instrument Serif', serif" }}>Staff login</h1>
<input className={input} type="email" placeholder="Email" aria-label="Email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" />
<input className={input} type="password" placeholder="Password" aria-label="Password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
<button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Log in</button>
</form>
)
}
function Tickets() {
const [rows, setRows] = useState<Ticket[]>([])
const load = useCallback(() => api<Ticket[]>('/staff/tickets').then(setRows), [])
useEffect(() => { load() }, [load])
const setStatus = async (id: string, status: string) => { await api(`/staff/tickets/${id}`, 'PATCH', { status }); load() }
if (!rows.length) return <p className="text-white/60">No tickets yet.</p>
return (
<ul className="space-y-3">
{rows.map((t) => (
<li key={t.id} className={box}>
<div className="flex flex-wrap justify-between gap-2 text-sm text-white/60">
<span>{t.id} · {t.topic} · {new Date(t.created).toLocaleString()}</span>
<span>{t.emailed ? 'emailed' : 'not emailed'}</span>
</div>
<p className="mt-2 whitespace-pre-wrap">{t.message}</p>
<p className="mt-2 text-sm text-white/70">{t.name} · <a className="underline" href={`mailto:${encodeURIComponent(t.email)}`}>{t.email}</a></p>
<select aria-label="Status" value={t.status} onChange={(e) => setStatus(t.id, e.target.value)} className="mt-3 bg-white/10 rounded-full px-4 py-2 text-sm [&>option]:text-black">
<option value="open">Open</option><option value="in_progress">In progress</option><option value="closed">Closed</option>
</select>
</li>
))}
</ul>
)
}
const emptyFaq = { category: '', question: '', answer: '', link: '', linkLabel: '' }
function Faqs() {
const [rows, setRows] = useState<StaffFaq[]>([])
const [editing, setEditing] = useState<(typeof emptyFaq & { id?: number }) | null>(null)
const [error, setError] = useState('')
const load = useCallback(() => api<StaffFaq[]>('/staff/faqs').then(setRows), [])
useEffect(() => { load() }, [load])
const save = async (e: React.FormEvent) => {
e.preventDefault()
if (!editing) return
try {
if (editing.id) await api(`/staff/faqs/${editing.id}`, 'PUT', editing)
else await api('/staff/faqs', 'POST', editing)
setEditing(null); setError(''); load()
} catch (err) { setError((err as Error).message) }
}
const remove = async (f: StaffFaq) => { if (confirm(`Delete "${f.question}"?`)) { await api(`/staff/faqs/${f.id}`, 'DELETE'); load() } }
const set = (k: keyof typeof emptyFaq) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => editing && setEditing({ ...editing, [k]: e.target.value })
return (
<div className="space-y-4">
{editing ? (
<form onSubmit={save} className={`${box} space-y-3`}>
<input className={input} placeholder="Category" aria-label="Category" value={editing.category} onChange={set('category')} />
<input className={input} placeholder="Question" aria-label="Question" value={editing.question} onChange={set('question')} />
<textarea className={input} rows={5} placeholder="Answer" aria-label="Answer" value={editing.answer} onChange={set('answer')} />
<input className={input} placeholder="Link (optional, https://...)" aria-label="Link" value={editing.link} onChange={set('link')} />
<input className={input} placeholder="Link label (optional)" aria-label="Link label" value={editing.linkLabel} onChange={set('linkLabel')} />
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
<div className="flex gap-2"><button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Save</button><button type="button" className={btn} onClick={() => { setEditing(null); setError('') }}>Cancel</button></div>
</form>
) : <button className={btn} onClick={() => setEditing({ ...emptyFaq })}>+ Add FAQ</button>}
<ul className="space-y-2">
{rows.map((f) => (
<li key={f.id} className={`${box} flex flex-wrap items-center justify-between gap-3 !py-4`}>
<span><span className="block text-xs text-white/50">{f.category}</span>{f.question}</span>
<span className="flex items-center gap-3 text-sm text-white/70">
👍 {f.up} 👎 {f.down}
<button className={btn} onClick={() => setEditing(f)}>Edit</button>
<button className={btn} onClick={() => remove(f)}>Delete</button>
</span>
</li>
))}
</ul>
</div>
)
}
function Team({ me }: { me: string }) {
const [rows, setRows] = useState<Member[]>([])
const [f, setF] = useState({ name: '', email: '', password: '' })
const [error, setError] = useState('')
const load = useCallback(() => api<Member[]>('/staff/team').then(setRows), [])
useEffect(() => { load() }, [load])
const add = async (e: React.FormEvent) => {
e.preventDefault()
try { await api('/staff/team', 'POST', f); setF({ name: '', email: '', password: '' }); setError(''); load() } catch (err) { setError((err as Error).message) }
}
const remove = async (m: Member) => { if (confirm(`Remove ${m.name}?`)) { try { await api(`/staff/team/${m.id}`, 'DELETE'); load() } catch (err) { setError((err as Error).message) } } }
return (
<div className="space-y-4">
<ul className="space-y-2">
{rows.map((m) => (
<li key={m.id} className={`${box} flex items-center justify-between !py-4`}>
<span>{m.name} <span className="text-white/60 text-sm">· {m.email}</span></span>
{m.email !== me && <button className={btn} onClick={() => remove(m)}>Remove</button>}
</li>
))}
</ul>
<form onSubmit={add} className={`${box} space-y-3`}>
<h2 className="text-xl">Add a team member</h2>
<input className={input} placeholder="Name" aria-label="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} />
<input className={input} type="email" placeholder="Email" aria-label="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
<input className={input} type="password" placeholder="Temporary password (10+ characters)" aria-label="Password" value={f.password} onChange={(e) => setF({ ...f, password: e.target.value })} autoComplete="new-password" />
{error && <p role="alert" className="text-red-300 text-sm">{error}</p>}
<button type="submit" className="bg-white text-black rounded-full px-6 py-2 text-sm font-medium">Add</button>
</form>
</div>
)
}
export default function Staff() {
const [me, setMe] = useState<{ name: string; email: string } | null | undefined>(undefined)
const [emailOk, setEmailOk] = useState(true)
const [tab, setTab] = useState<Tab>('tickets')
const check = useCallback(() => {
api<{ name: string; email: string }>('/me').then((m) => { setMe(m); api<{ emailConfigured: boolean }>('/staff/status').then((s) => setEmailOk(s.emailConfigured)) }).catch(() => setMe(null))
}, [])
useEffect(() => { check() }, [check])
const logout = async () => { await api('/logout', 'POST'); setMe(null) }
return (
<div className="min-h-screen bg-black text-white px-6 py-10">
<div className="max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-8">
<a href="#" className="text-white/70 hover:text-white text-sm">← Back to site</a>
{me && <button onClick={logout} className={`${btn} inline-flex items-center gap-2`}><LogOut size={14} /> Log out ({me.name})</button>}
</div>
{me === undefined ? <p className="text-white/60">Loading...</p> : me === null ? <Login onDone={check} /> : (
<>
{!emailOk && <p className="mb-4 text-amber-300 text-sm">Email is not set up on the server yet, so new tickets are saved here but not emailed.</p>}
<div className="flex gap-2 mb-6">
{(['tickets', 'faqs', 'team'] as Tab[]).map((t) => (
<button key={t} onClick={() => setTab(t)} aria-pressed={tab === t} className={`${btn} capitalize ${tab === t ? 'bg-white/20' : ''}`}>{t === 'faqs' ? 'FAQs' : t}</button>
))}
</div>
{tab === 'tickets' && <Tickets />}{tab === 'faqs' && <Faqs />}{tab === 'team' && <Team me={me.email} />}
</>
)}
</div>
</div>
)
}
-66
View File
@@ -1,66 +0,0 @@
import { useCallback, useEffect, useRef } from 'react'
const VIDEO_URL =
'https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260328_115001_bcdaa3b4-03de-47e7-ad63-ae3e392c32d4.mp4'
const FADE_MS = 500
const FADE_OUT_REMAINING = 0.55
export default function VideoBackground() {
const videoRef = useRef<HTMLVideoElement>(null)
const rafRef = useRef<number | null>(null)
const fadingOutRef = useRef(false)
const fadeTo = useCallback((target: number) => {
const video = videoRef.current
if (!video) return
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
const startOpacity = parseFloat(video.style.opacity || '0')
const startTime = performance.now()
const step = (now: number) => {
const t = Math.min((now - startTime) / FADE_MS, 1)
video.style.opacity = String(startOpacity + (target - startOpacity) * t)
rafRef.current = t < 1 ? requestAnimationFrame(step) : null
}
rafRef.current = requestAnimationFrame(step)
}, [])
useEffect(() => () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
}, [])
const handleTimeUpdate = () => {
const video = videoRef.current
if (!video || !video.duration || fadingOutRef.current) return
if (video.duration - video.currentTime <= FADE_OUT_REMAINING) {
fadingOutRef.current = true
fadeTo(0)
}
}
const handleEnded = () => {
const video = videoRef.current
if (!video) return
video.style.opacity = '0'
window.setTimeout(() => {
video.currentTime = 0
void video.play().catch(() => {})
fadingOutRef.current = false
fadeTo(1)
}, 100)
}
return (
<video
ref={videoRef}
src={VIDEO_URL}
autoPlay
muted
playsInline
style={{ opacity: 0 }}
className="absolute inset-0 w-full h-full object-cover translate-y-[17%]"
onLoadedData={() => fadeTo(1)}
onTimeUpdate={handleTimeUpdate}
onEnded={handleEnded}
/>
)
}
-698
View File
@@ -1,698 +0,0 @@
[
{
"id": 1,
"category": "Access & logins",
"question": "I forgot my password",
"answer": "Most organisations have a self-service reset link on the sign-in page, usually 'Forgot password' or 'Can't access your account'. You'll be asked to verify your identity, normally with a code sent to a backup email or phone number you registered earlier. If there's no self-service option, or verification fails, your IT service desk can reset it after confirming your identity another way. Keep any backup codes you were given when MFA was set up somewhere safe, they're often the fastest way back in.",
"link": "https://support.microsoft.com/en-us/account-billing/how-to-reset-your-microsoft-account-password-eff4f067-5042-c1a3-fe72-b04d60556c37",
"linkLabel": "Microsoft: reset your password"
},
{
"id": 2,
"category": "Access & logins",
"question": "I'm locked out after too many failed attempts",
"answer": "This is a security feature, not a fault, it stops someone guessing your password repeatedly. Most systems clear the lockout automatically after a set time, often 15 to 30 minutes, so waiting is sometimes the fastest fix. If it's urgent, or the lockout doesn't clear itself, your IT service desk can unlock the account manually once they've verified it's really you.",
"link": "",
"linkLabel": ""
},
{
"id": 3,
"category": "Access & logins",
"question": "My multi-factor login (authenticator app) isn't working",
"answer": "If you've lost your phone or the authenticator app stopped generating valid codes, try a saved backup code first, most systems issue a set of one-time codes when MFA is first set up. If you don't have one, contact IT to re-register your MFA device, this normally requires an identity check (staff or student ID, or a manager confirming it's you) since it's effectively resetting your account security.",
"link": "",
"linkLabel": ""
},
{
"id": 4,
"category": "Access & logins",
"question": "My account is disabled or expired after returning from leave",
"answer": "Accounts are sometimes automatically disabled after a period of inactivity, or a leave period that wasn't flagged as 'returning' in the system. This is an account status issue in the directory (Active Directory or similar), not a password problem, ask IT to check whether the account itself is disabled or expired rather than resetting the password again.",
"link": "",
"linkLabel": ""
},
{
"id": 5,
"category": "Access & logins",
"question": "Single sign-on (SSO) keeps redirecting in a loop",
"answer": "An SSO loop, bouncing between your organisation's login page and the app without ever landing you in, is usually a cookie or session problem rather than your password. Try a private or incognito browser window first, if that works cleanly, clear cookies for your organisation's login domain. If it still loops, the connection between your identity provider and that specific app may need IT to check its configuration.",
"link": "",
"linkLabel": ""
},
{
"id": 6,
"category": "Access & logins",
"question": "A guest or contractor account isn't working",
"answer": "Guest and contractor accounts usually go through a separate approval process, often needing an internal sponsor, and typically expire automatically after a set period for security. If a guest account stops working, check with the person who sponsored the access first, then IT if they confirm it should still be active.",
"link": "",
"linkLabel": ""
},
{
"id": 7,
"category": "Access & logins",
"question": "My security questions aren't being accepted during recovery",
"answer": "Recovery answers need to match exactly what was originally entered, including capitalisation in some systems, so a common cause is simply misremembering the exact wording. If you've genuinely forgotten the answers, look for a 'try another way' option instead of repeatedly guessing, most recovery tools offer an alternative path such as an email or SMS code.",
"link": "",
"linkLabel": ""
},
{
"id": 8,
"category": "Access & logins",
"question": "A new password keeps being rejected as too weak or previously used",
"answer": "Organisations enforce password rules (minimum length, a mix of character types, and often a block on reusing recent passwords) to reduce the risk of a compromised account. A longer passphrase, three or four random unrelated words plus a number and symbol, is both stronger and easier to remember than a short complex one, and tends to pass these checks more easily. Avoid names, birthdays or the organisation's own name.",
"link": "",
"linkLabel": ""
},
{
"id": 9,
"category": "Network & wifi",
"question": "My wifi keeps dropping",
"answer": "Try moving closer to the access point, restarting your device's wifi, and checking if other devices nearby have the same problem, that tells you whether it's your device or the network. On a corporate or school network with certificate-based login (802.1x), an expired certificate is a common cause, IT can reissue it. A network adapter driver that's out of date after a Windows update is another very common cause worth ruling out.",
"link": "https://support.microsoft.com/en-us/windows/fix-wi-fi-connection-issues-in-windows-9424a1f7-6a3b-65a6-4d78-7f07eee84d2c",
"linkLabel": "Microsoft: fix Wi-Fi connection issues"
},
{
"id": 10,
"category": "Network & wifi",
"question": "The VPN won't connect",
"answer": "Check your normal internet connection works first. Then fully close and reopen the VPN app rather than just reconnecting, a half-open session is a common cause. Public wifi (cafes, hotels) sometimes blocks VPN traffic entirely until you've accepted its own login page in a browser first.",
"link": "",
"linkLabel": ""
},
{
"id": 11,
"category": "Network & wifi",
"question": "I can't reach a shared drive or folder",
"answer": "Confirm you're connected to the VPN or office network, shared drives usually don't work from the open internet. If it still fails, the folder permissions may have changed, ask IT to check your access rather than repeatedly trying to reconnect.",
"link": "",
"linkLabel": ""
},
{
"id": 12,
"category": "Network & wifi",
"question": "I'm getting an IP conflict or 'limited connectivity' message",
"answer": "This usually means your device didn't get a valid address from the network's DHCP server, often after being moved between networks or waking from sleep. Disconnecting and reconnecting to wifi, or running `ipconfig /release` then `ipconfig /renew` on Windows, fixes most cases. If it keeps happening on the same device, report it, it can indicate a DHCP address pool running low.",
"link": "",
"linkLabel": ""
},
{
"id": 13,
"category": "Network & wifi",
"question": "A captive portal (hotel or cafe wifi login page) won't load",
"answer": "If the login page doesn't appear automatically, try opening a plain, non-secure website, some networks only trigger the portal on unencrypted traffic. A corporate VPN client often blocks the portal from appearing at all, disconnect the VPN first, log in to the wifi, then reconnect the VPN.",
"link": "",
"linkLabel": ""
},
{
"id": 14,
"category": "Network & wifi",
"question": "Wifi shows connected but the internet is very slow",
"answer": "Being 'connected' only confirms a local network link, not that everything beyond it is fast. Run a speed test, then check whether it's isolated to one device (a device or driver issue) or affects everyone nearby, which usually points to the access point, the internet link itself, or a bandwidth-heavy device worth reporting to IT.",
"link": "",
"linkLabel": ""
},
{
"id": 15,
"category": "Network & wifi",
"question": "A mapped network drive keeps failing after a password change",
"answer": "A mapped drive often stores the old credentials separately, so it can keep failing silently even though you can log in to everything else fine. Disconnect the mapped drive and reconnect it fresh, entering the new password when prompted, rather than relying on the saved ones.",
"link": "",
"linkLabel": ""
},
{
"id": 16,
"category": "Network & wifi",
"question": "A Bluetooth device won't pair",
"answer": "Turn Bluetooth off and on again on both devices, and make sure the device is actually in pairing mode. If it was previously paired with a different computer, 'forget' or remove the old pairing on both ends first, most Bluetooth accessories only hold a limited number of paired connections at once.",
"link": "",
"linkLabel": ""
},
{
"id": 17,
"category": "Hardware",
"question": "The printer won't print",
"answer": "Check it's powered on, has paper and toner, and that you picked the right printer name in the print dialog. Network printers can show as 'offline' if they've lost wifi, turning them off and on again often fixes this.",
"link": "",
"linkLabel": ""
},
{
"id": 18,
"category": "Hardware",
"question": "An external monitor isn't detected",
"answer": "Check the cable is firmly in at both ends and try a different port or cable if you have one. Then open your display settings and click 'Detect'. A driver or graphics update can also help on older laptops.",
"link": "",
"linkLabel": ""
},
{
"id": 19,
"category": "Hardware",
"question": "A USB device isn't recognised",
"answer": "Try a different USB port, and a different cable if it's detachable. If it still doesn't show up, check Device Manager (Windows) or System Report (Mac) for a warning icon next to the device.",
"link": "",
"linkLabel": ""
},
{
"id": 20,
"category": "Hardware",
"question": "A laptop won't turn on at all",
"answer": "First rule out a flat battery, plug in the charger, check for a charging light, then leave it 15 to 30 minutes before trying again, some laptops won't start with a completely dead battery even while charging. If there's a charging light but still nothing on screen, try an external monitor, that tells you whether it's a screen problem or the laptop genuinely isn't starting.",
"link": "",
"linkLabel": ""
},
{
"id": 21,
"category": "Hardware",
"question": "The battery is draining fast or won't charge",
"answer": "Test the charger and cable on a different device or outlet first, to rule out a faulty charger rather than the laptop itself. Battery health does genuinely fade over 2 to 4 years of daily use, if the laptop is older and the drop was gradual, a battery replacement through IT asset management is often the real fix rather than a software setting.",
"link": "",
"linkLabel": ""
},
{
"id": 22,
"category": "Hardware",
"question": "The keyboard or trackpad isn't responding",
"answer": "Restart the device first, a stuck driver is a common and simple cause. If only specific keys are affected, check for anything physically stuck underneath, common after spills or crumbs, and try an external USB keyboard or mouse to confirm whether it's the built-in hardware or a wider fault.",
"link": "",
"linkLabel": ""
},
{
"id": 23,
"category": "Hardware",
"question": "A docking station isn't detecting monitors or peripherals",
"answer": "Confirm the dock itself has power, many need their own power cable, not just the laptop cable, and try unplugging the laptop from the dock and reconnecting it firmly. Docks that connect via USB-C or Thunderbolt can be picky about cable quality, swap the cable before assuming the dock has failed.",
"link": "",
"linkLabel": ""
},
{
"id": 24,
"category": "Hardware",
"question": "The webcam isn't detected by any app",
"answer": "Some laptops have a physical privacy shutter or a function-key toggle that disables the camera entirely, check for both before assuming it's broken. Checking Device Manager (Windows) or System Information (Mac) shows whether the operating system sees the camera at all, if it's not listed there, it's a driver or hardware fault rather than an app setting.",
"link": "",
"linkLabel": ""
},
{
"id": 25,
"category": "Software & apps",
"question": "My computer is running very slow",
"answer": "Restart it first, this clears a surprising number of slowdowns. After that, check how much storage is free and close programs you're not using that start automatically. If it's still slow, let IT know, it could be a background update or a bigger issue.",
"link": "",
"linkLabel": ""
},
{
"id": 26,
"category": "Software & apps",
"question": "An app keeps crashing or won't open",
"answer": "Close and reopen it, then restart your computer if that doesn't help. Check whether an update is available. If it's an approved work app that IT installed for you, they may need to reinstall it rather than you doing it yourself.",
"link": "",
"linkLabel": ""
},
{
"id": 27,
"category": "Software & apps",
"question": "My email isn't syncing or I'm missing messages",
"answer": "Check your internet connection and your junk or spam folder. Also check your mailbox isn't full, a full mailbox silently stops receiving mail in some systems. Outlook and Gmail both have a 'repair account' option worth trying.",
"link": "",
"linkLabel": ""
},
{
"id": 28,
"category": "Software & apps",
"question": "My camera isn't working in Microsoft Teams",
"answer": "Close other apps that might already be using the camera (like Zoom or FaceTime), unplug and reconnect it if it's external, and restart the device. Make a test call from Teams' device settings to check camera, mic and speaker together in one go.",
"link": "https://support.microsoft.com/en-US/teams/meetings/my-camera-isn-t-working-in-microsoft-teams",
"linkLabel": "Microsoft: Teams camera troubleshooting"
},
{
"id": 29,
"category": "Software & apps",
"question": "Zoom audio or video isn't working",
"answer": "Check the correct microphone, speaker and camera are selected inside Zoom's own settings, not just that your device isn't muted at the operating system level. If it's a browser-based meeting, the browser also needs camera and microphone permission granted specifically for that site.",
"link": "https://support.zoom.us/",
"linkLabel": "Zoom support"
},
{
"id": 30,
"category": "Software & apps",
"question": "Windows Update is stuck or won't install",
"answer": "Restart the device once and let it sit for at least 30 minutes, some updates genuinely take a long time on the 'working on updates' screen, and interrupting them can cause more problems than waiting. If it's still stuck well beyond that, or fails repeatedly, note the exact error code for IT, the built-in update troubleshooter resolves a lot of common stuck updates on its own.",
"link": "",
"linkLabel": ""
},
{
"id": 31,
"category": "Software & apps",
"question": "Office or Microsoft 365 shows 'unlicensed product' or won't activate",
"answer": "This usually means the app can't confirm your licence with Microsoft's servers, often because you're signed in with the wrong account, or you've been offline too long since the last successful check-in. Sign out and back in with your work or school account specifically, not a personal Microsoft account, and make sure you're online or on VPN when it tries to reactivate.",
"link": "",
"linkLabel": ""
},
{
"id": 32,
"category": "Software & apps",
"question": "'Access denied' or 'permission denied' opening a file",
"answer": "This is almost always a permissions setting on the file or folder itself, not a fault with your computer. Confirm you're opening it from the right location, a synced local copy can behave differently to the shared original, and if you believe you should have access, ask the file's owner or IT to check the permission list rather than trying to force it open.",
"link": "",
"linkLabel": ""
},
{
"id": 33,
"category": "Security",
"question": "I received a suspicious email, is it phishing?",
"answer": "Don't click links or open attachments. Check the sender's actual email address, not just the display name, and look for urgency or requests for passwords or gift cards, common phishing signs. Report it to your IT security team rather than deleting it, they may need to warn others across the organisation.",
"link": "https://www.cyber.gov.au/report-and-recover",
"linkLabel": "ACSC: report a cybercrime (ReportCyber)"
},
{
"id": 34,
"category": "Security",
"question": "My computer crashed or showed a blue screen",
"answer": "Note down any error code or message shown before it restarts, a photo on your phone is fine. Restart the computer. If it happens more than once, that error code is exactly what IT will need to diagnose it.",
"link": "",
"linkLabel": ""
},
{
"id": 35,
"category": "Security",
"question": "I think I've been hit by ransomware",
"answer": "If files suddenly become inaccessible with strange new extensions, or a ransom note appears, disconnect the device from the network immediately (unplug the cable or turn off wifi) without shutting it down, this can stop it spreading before you power off. Don't pay anything and don't try to remove it yourself, contact IT security immediately, this is treated as a priority incident.",
"link": "",
"linkLabel": ""
},
{
"id": 36,
"category": "Security",
"question": "I suspect my account has been compromised",
"answer": "Change your password immediately from a device you trust, and check for any MFA devices, mail forwarding rules, or app connections you don't recognise, attackers often quietly add a forwarding rule or a second MFA device to keep access even after the password changes. Report it to IT security even after you've changed the password, they may need to check for wider impact.",
"link": "",
"linkLabel": ""
},
{
"id": 37,
"category": "Security",
"question": "What actually makes a strong password or passphrase",
"answer": "Length matters more than complexity, three or four random unrelated words make a passphrase that's both stronger and easier to remember than a short complex one. Never reuse a password across personal and work accounts, and use a password manager rather than trying to remember unique passwords for everything, most organisations provide or approve one.",
"link": "",
"linkLabel": ""
},
{
"id": 38,
"category": "Security",
"question": "How to report a data breach or accidental data exposure",
"answer": "If personal or sensitive information was sent to the wrong person, uploaded somewhere public by mistake, or a device holding such data is lost or stolen, report it to IT and your privacy contact as soon as possible. Timing genuinely matters for breach response, and in government and other regulated sectors there can be mandatory notification obligations attached.",
"link": "https://www.cyber.gov.au/report-and-recover",
"linkLabel": "ACSC: report and recover"
},
{
"id": 39,
"category": "Security",
"question": "Windows is asking for a BitLocker recovery key",
"answer": "This usually appears after a hardware or firmware change, like a BIOS update, a new battery, or a changed boot order, that the encryption chip treats as suspicious, it's the protection working as intended, not a sign of a hack. The 48-digit recovery key is normally stored in your organisation's directory (Azure AD/Intune) or was saved separately when the device was set up, IT can retrieve it if you don't have it yourself. Microsoft itself cannot recover a lost key, so if neither you nor IT can locate it, the drive has to be reset.",
"link": "https://support.microsoft.com/en-us/help/4026181",
"linkLabel": "Microsoft: find your BitLocker recovery key"
},
{
"id": 40,
"category": "Security",
"question": "A Mac is asking for a FileVault recovery key",
"answer": "Similar to Windows BitLocker, this appears after certain hardware or firmware changes and is the encryption protecting your data working as designed. The recovery key is usually escrowed with your organisation's MDM (Jamf or similar), IT can retrieve it if it wasn't saved when the device was set up.",
"link": "",
"linkLabel": ""
},
{
"id": 41,
"category": "Security",
"question": "Antivirus or endpoint protection just quarantined a file or blocked something",
"answer": "Don't try to restore or override it yourself, even if you're confident the file is safe, false positives do happen but confirming that is IT security's job, not yours. Report exactly what you were doing and what the alert said, that detail is what determines whether it's a false positive or something needing wider action.",
"link": "",
"linkLabel": ""
},
{
"id": 42,
"category": "Security",
"question": "A USB drive or device is blocked by policy",
"answer": "Many organisations block USB storage devices entirely as a data-loss-prevention measure, this is deliberate policy rather than a fault, and it usually can't be worked around with a different port or drive. If you have a genuine business need to transfer files this way, request an exception through IT rather than trying another device.",
"link": "",
"linkLabel": ""
},
{
"id": 43,
"category": "Sector notes",
"question": "Why are some settings locked on my device?",
"answer": "Government, school and many corporate devices are deliberately locked down for security, this is policy, not a fault. Australian government agencies generally follow the ACSC's Essential Eight security controls, which is why things like installing your own software or changing certain settings are restricted to IT staff.",
"link": "https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight",
"linkLabel": "ACSC: Essential Eight explained"
},
{
"id": 44,
"category": "Sector notes",
"question": "School: why is a legitimate website or resource blocked?",
"answer": "Australian schools typically apply internet content filtering as required by state and territory education department policy, and manage student devices through a mobile device management (MDM) system. If a legitimate educational site is blocked, that's a filtering rule, not a fault, report it to the school's IT support so they can review or whitelist it rather than trying to work around the filter.",
"link": "",
"linkLabel": ""
},
{
"id": 45,
"category": "Sector notes",
"question": "Government: why can't I install software myself?",
"answer": "Restricting who can install software (application control) is one of the Essential Eight strategies Australian government agencies are expected to implement, it stops unapproved or malicious software running on government devices. If you need software for a work purpose, the usual path is a request to IT for review and installation through an approved, managed process.",
"link": "https://www.cyber.gov.au/resources-business-and-government/essential-cybersecurity/essential-eight",
"linkLabel": "ACSC: Essential Eight explained"
},
{
"id": 46,
"category": "Sector notes",
"question": "Corporate: why can't I do everything on my personal (BYOD) device?",
"answer": "A personal device enrolled for work access (BYOD) is usually only given a limited, contained work profile rather than full management of the whole device, protecting both your personal data and the organisation's. If a work app or file won't open, or copy-paste out of a work app is blocked, that's often a deliberate BYOD policy restriction rather than a technical fault.",
"link": "",
"linkLabel": ""
},
{
"id": 47,
"category": "How IT support works",
"question": "What do Level 1, 2, 3 (and 4) support actually mean?",
"answer": "Level 1 is the frontline help desk, handling common self-service issues like this page's Access, Wifi and Hardware topics, usually resolved in one call or ticket. Level 2 is more technical staff who handle things needing deeper diagnosis, like the Network Hardware topics here. Level 3 is specialist or senior engineers, often involved in design and complex incidents, and Level 4 is the vendor's own support (Cisco TAC, Fortinet support), brought in when the issue is inside the product itself, not the configuration.",
"link": "",
"linkLabel": ""
},
{
"id": 48,
"category": "How IT support works",
"question": "When should something be escalated to the next level?",
"answer": "As a general rule, escalate when the fix needs a permission, tool, or specialist knowledge the current level doesn't have, not simply because an issue is taking a long time. A good escalation includes what's already been tried, exact error messages, and which users or systems are affected, that context is what actually speeds up the next level's diagnosis.",
"link": "",
"linkLabel": ""
},
{
"id": 49,
"category": "How IT support works",
"question": "What's the difference between an incident, a request, and a problem?",
"answer": "An incident is something broken right now (the printer isn't printing, the VPN is down), the goal is to restore service quickly. A request is asking for something new (new software, more storage, account access), it follows an approval process rather than a fix. A problem is the underlying root cause behind repeated incidents, if the same printer fails every Monday, that pattern gets investigated as a problem so it stops recurring, rather than just fixed each time it happens.",
"link": "",
"linkLabel": ""
},
{
"id": 50,
"category": "How IT support works",
"question": "Where does a change request fit in?",
"answer": "A change request is a planned, deliberate modification to a system, like a firmware upgrade or a new firewall rule, done through an approval process because unplanned changes are one of the most common causes of new incidents. This is why network hardware changes especially are usually scheduled for a maintenance window rather than made on the spot.",
"link": "",
"linkLabel": ""
},
{
"id": 51,
"category": "Mobile & remote work",
"question": "A phone or tablet won't enrol in the company's mobile device management (MDM)",
"answer": "Confirm you're using the exact enrolment method IT specified (a QR code, an enrolment URL, or a specific app store link), a generic 'add work account' option in phone settings sometimes skips steps the proper enrolment method includes. Also check the device's operating system version meets what IT requires, MDM enrolment is commonly blocked on outdated OS versions for security reasons.",
"link": "",
"linkLabel": ""
},
{
"id": 52,
"category": "Mobile & remote work",
"question": "Work email or apps stopped working on a personal phone after an update",
"answer": "A phone OS update can reset app permissions or trigger a compliance re-check by the organisation's MDM, temporarily blocking access until the device is confirmed to still meet security requirements like having a passcode set. Check for a compliance or 'action needed' notification from the company portal app before assuming it's broken.",
"link": "",
"linkLabel": ""
},
{
"id": 53,
"category": "Mobile & remote work",
"question": "A mobile VPN drops every time the screen locks",
"answer": "Many phones aggressively kill background network connections to save battery, including VPN apps, unless the app is specifically excluded from battery optimisation. Check the phone's battery settings for an 'unrestricted' or 'no optimisation' option for that VPN app specifically.",
"link": "",
"linkLabel": ""
},
{
"id": 54,
"category": "Mobile & remote work",
"question": "A remote desktop (RDP) session freezes or won't connect",
"answer": "Confirm you're connected to the VPN first if the remote machine is inside a private network, RDP almost never works over the open internet directly, for good security reasons. If the VPN is connected but it still fails, check whether the remote machine is actually powered on and not asleep, a surprisingly common cause.",
"link": "",
"linkLabel": ""
},
{
"id": 55,
"category": "Mobile & remote work",
"question": "A VDI session (Citrix, Horizon) is slow or won't launch",
"answer": "Test your own internet connection's latency and stability first, VDI performance is very sensitive to a poor home connection in a way a locally installed app isn't. If your connection is fine, try a different VDI client version or a browser-based login as a workaround while reporting the failure to IT with the exact error shown.",
"link": "",
"linkLabel": ""
},
{
"id": 56,
"category": "Mobile & remote work",
"question": "Working from home, the VPN connects but internal apps still time out",
"answer": "Some VPN setups use 'split tunnelling', where only traffic for specific internal systems goes through the VPN and everything else uses your home internet directly, if a newer internal app isn't yet included in that list, it will time out even with the VPN connected. That's a configuration detail for IT to add, not something you can usually fix from your end.",
"link": "",
"linkLabel": ""
},
{
"id": 57,
"category": "Cloud storage & sync",
"question": "OneDrive is stuck on 'processing changes' or won't sync",
"answer": "Pause and resume syncing from the OneDrive icon in the system tray, this resets a stuck sync engine more often than you'd expect. A very long file path, a file open in another program, or special characters in a file name are the next most common causes of a stuck sync. Moving the affected file out of the synced folder and back in again can also clear a single stuck item without resetting the whole sync.",
"link": "https://support.microsoft.com/en-us/onedrive/fix-onedrive-sync-problems",
"linkLabel": "Microsoft: fix OneDrive sync problems"
},
{
"id": 58,
"category": "Cloud storage & sync",
"question": "Google Drive shows a red exclamation mark on a file",
"answer": "This means that specific file failed to sync, usually because it's open in another application, too large for your remaining storage quota, or has a file type or name Drive can't handle. Hover over the icon for the exact reason, then close the file elsewhere or check your storage quota before retrying.",
"link": "",
"linkLabel": ""
},
{
"id": 59,
"category": "Cloud storage & sync",
"question": "Cloud storage quota is full",
"answer": "Check what's actually taking up space first, large video files and old email attachments are common causes, rather than immediately requesting more storage. If you genuinely need more for legitimate work purposes, that's a request rather than an incident (see 'How IT support works'), so it goes through IT's normal approval process.",
"link": "",
"linkLabel": ""
},
{
"id": 60,
"category": "Cloud storage & sync",
"question": "A shared file link says 'access denied' for someone outside the organisation",
"answer": "Many organisations restrict external sharing by default for security, so a link that works fine internally can fail for an outside recipient. Check the specific sharing settings on that file or folder, external sharing is often something that needs to be deliberately enabled per-file or per-site, rather than being on by default.",
"link": "",
"linkLabel": ""
},
{
"id": 61,
"category": "Cloud storage & sync",
"question": "Files show as 'online-only' and won't open without internet",
"answer": "Cloud sync clients often keep infrequently used files as a placeholder to save local disk space, downloading the real content only when opened, which fails without an internet connection. Right-click the specific file or folder you need offline and choose 'always keep on this device' before you lose connectivity, before a flight or a site visit, for example.",
"link": "",
"linkLabel": ""
},
{
"id": 62,
"category": "macOS specific",
"question": "A Mac update is stuck or won't install",
"answer": "Confirm there's enough free disk space, macOS updates need significant free space to stage the install and fail silently without it. Restart once and try again from System Settings rather than clicking install repeatedly, which can leave the updater in a confused state.",
"link": "",
"linkLabel": ""
},
{
"id": 63,
"category": "macOS specific",
"question": "Time Machine backup keeps failing",
"answer": "Check the backup drive isn't full and is actually connected and mounted, an intermittent external drive connection is the most common cause. If backing up to a network location, confirm you can still reach that server or NAS at all, a backup failure is often really a network or permissions issue in disguise.",
"link": "",
"linkLabel": ""
},
{
"id": 64,
"category": "macOS specific",
"question": "A Mac keeps asking for the keychain password unexpectedly",
"answer": "This usually happens after a password change that the login keychain doesn't yet know about, since it's a separate password store from your account login. Go to Keychain Access and update or reset the login keychain's password to match your new account password, rather than repeatedly dismissing the prompt. Resetting the keychain does clear its saved passwords, so only do this if you can't recover the old one.",
"link": "https://support.apple.com/guide/keychain-access/kyca2429/mac",
"linkLabel": "Apple: update your keychain password"
},
{
"id": 65,
"category": "macOS specific",
"question": "A Mac won't join a corporate wifi network with certificate login",
"answer": "Confirm the required certificate is actually installed in the Mac's keychain, it's normally pushed automatically by MDM (Jamf or similar) but can fail to install if the Mac hasn't checked in with MDM recently. Manually forcing an MDM check-in (System Settings > general profile management) before retrying the wifi connection resolves this in many cases.",
"link": "",
"linkLabel": ""
},
{
"id": 66,
"category": "Network hardware (IT staff)",
"question": "A Cisco switch port shows no link light",
"answer": "Start at the physical layer: reseat the cable, try a different port, and check for a bad SFP. Then confirm the port isn't administratively shut down with `show interfaces status`, if it is, `no shutdown` brings it back up.",
"link": "",
"linkLabel": ""
},
{
"id": 67,
"category": "Network hardware (IT staff)",
"question": "A Cisco port went err-disabled",
"answer": "This means the switch shut the port down itself, usually after a security violation, a BPDU guard trip, or a duplex mismatch. Check the reason with `show interfaces status err-disabled`, fix the underlying cause, then cycle the port with `shutdown` then `no shutdown`.",
"link": "",
"linkLabel": ""
},
{
"id": 68,
"category": "Network hardware (IT staff)",
"question": "Devices can't reach each other across a Cisco VLAN or trunk",
"answer": "Confirm the VLAN exists on both switches with `show vlan brief`, then check the trunk with `show interfaces trunk`. A mismatched native VLAN or a VLAN missing from the allowed list is the most common cause.",
"link": "",
"linkLabel": ""
},
{
"id": 69,
"category": "Network hardware (IT staff)",
"question": "A Cisco router or Layer 3 switch can't reach a remote subnet",
"answer": "Check `show ip interface brief` to confirm the interface is up, then `show ip route` to confirm a route exists. `ping` and `traceroute` will show exactly where the path breaks.",
"link": "",
"linkLabel": ""
},
{
"id": 70,
"category": "Network hardware (IT staff)",
"question": "A Cisco device is running hot (high CPU)",
"answer": "Check `show processes cpu sorted` to see what's using it. A spanning-tree loop or broadcast storm is a common cause on switches, `show spanning-tree` will show repeated topology changes if that's happening.",
"link": "",
"linkLabel": ""
},
{
"id": 71,
"category": "Network hardware (IT staff)",
"question": "A FortiGate isn't passing traffic that looks like it should be allowed",
"answer": "Confirm a firewall policy actually matches the source, destination and service, then use `diagnose sniffer packet any 'host x.x.x.x' 4` to see if traffic is arriving, and `diagnose debug flow` to see how the FortiGate is treating it.",
"link": "",
"linkLabel": ""
},
{
"id": 72,
"category": "Network hardware (IT staff)",
"question": "A FortiGate site-to-site IPsec VPN won't come up",
"answer": "Phase 1 and Phase 2 settings (encryption, PSK or certificate, proposals) must match exactly on both ends. `diagnose vpn ike log-filter` with `diagnose debug application ike -1` shows exactly where negotiation is failing.",
"link": "",
"linkLabel": ""
},
{
"id": 73,
"category": "Network hardware (IT staff)",
"question": "A FortiGate is slow or unresponsive",
"answer": "Check `get system performance status` for CPU, memory and session count, and `diagnose sys top` to see which process is responsible. A nearly-full session table is a common cause on busy units.",
"link": "",
"linkLabel": ""
},
{
"id": 74,
"category": "Network hardware (IT staff)",
"question": "Can't reach the FortiGate admin GUI from a particular network",
"answer": "FortiGate can restrict admin access to trusted hosts and specific interfaces. Check the admin account's trusted host settings and confirm the interface you're connecting through has HTTPS/SSH admin access enabled.",
"link": "",
"linkLabel": ""
},
{
"id": 75,
"category": "Network hardware (IT staff)",
"question": "How to safely back up a Cisco device's configuration",
"answer": "Before any change, save the running config to the startup config with `copy running-config startup-config`, then copy it off the device via TFTP or SCP so there's an external copy too. Always confirm the backup completed before making changes.",
"link": "",
"linkLabel": ""
},
{
"id": 76,
"category": "Network hardware (IT staff)",
"question": "How to safely upgrade Cisco IOS firmware",
"answer": "Confirm the new image is supported on the exact hardware model and that there's enough flash and RAM. Copy the image to flash, verify its checksum, set the new boot variable, save the config, then reload in a maintenance window in case it needs rolling back.",
"link": "",
"linkLabel": ""
},
{
"id": 77,
"category": "Network hardware (IT staff)",
"question": "Lost the password to a Cisco device",
"answer": "Password recovery needs physical console access and a reboot into ROMMON mode to bypass the startup config temporarily. This is a standard, well-documented process, but the exact key combination differs by model, check the vendor documentation for your specific device first.",
"link": "https://www.cisco.com/c/en/us/support/index.html",
"linkLabel": "Cisco Support"
},
{
"id": 78,
"category": "Network hardware (IT staff)",
"question": "A wireless access point won't join the Cisco WLC",
"answer": "Check the AP can discover the controller (DHCP option 43, DNS, or same-subnet broadcast), and that the AP's firmware is compatible with the controller's software version, a mismatch is a common cause of a failed join.",
"link": "",
"linkLabel": ""
},
{
"id": 79,
"category": "Network hardware (IT staff)",
"question": "Traffic is blocked on a Cisco ASA despite a matching ACL",
"answer": "Check NAT rules aren't exempting or redirecting the traffic unexpectedly, and confirm the ACL is applied in the right direction on the right interface. `packet-tracer` simulates a specific flow and shows exactly which rule drops it.",
"link": "",
"linkLabel": ""
},
{
"id": 80,
"category": "Network hardware (IT staff)",
"question": "One FortiGate in an HA cluster won't sync",
"answer": "Check both units are running the identical firmware version first, a mismatch is the most common HA sync blocker. `diagnose sys ha status` shows the current sync state and any errors.",
"link": "",
"linkLabel": ""
},
{
"id": 81,
"category": "Network hardware (IT staff)",
"question": "A FortiSwitch isn't showing up under FortiGate management",
"answer": "FortiGate manages FortiSwitches over FortiLink, confirm the FortiLink interface is correctly configured and that the switch port used for FortiLink hasn't been repurposed for something else.",
"link": "",
"linkLabel": ""
},
{
"id": 82,
"category": "Network hardware (IT staff)",
"question": "A FortiAP won't join its FortiGate",
"answer": "Confirm the AP can discover the FortiGate (same subnet, or DHCP option 138/CAPWAP discovery across routed networks) and that the AP and FortiGate firmware are a supported combination.",
"link": "",
"linkLabel": ""
},
{
"id": 83,
"category": "Network hardware (IT staff)",
"question": "SD-WAN traffic is taking the wrong path on a FortiGate",
"answer": "Check the SD-WAN rule order, first match wins, and whether the SLA health checks for that link are actually passing, a failed health check silently removes a path from consideration. `diagnose sys sdwan` shows live link status.",
"link": "",
"linkLabel": ""
},
{
"id": 84,
"category": "Network hardware (IT staff)",
"question": "A FortiGate says its licence or support contract has expired",
"answer": "An expired contract stops signature and firmware updates but doesn't usually stop the firewall passing traffic. Check the exact status with `get system status`, then renew through your reseller or Fortinet, running without updates for long is a real security risk.",
"link": "",
"linkLabel": ""
},
{
"id": 85,
"category": "Network hardware (IT staff)",
"question": "Where to find full Cisco documentation and command syntax",
"answer": "Always confirm exact command syntax against Cisco's own docs, it changes between switch/router models and IOS versions.",
"link": "https://www.cisco.com/c/en/us/support/index.html",
"linkLabel": "Cisco Support"
},
{
"id": 86,
"category": "Network hardware (IT staff)",
"question": "Where to find full Fortinet documentation and command syntax",
"answer": "FortiOS CLI commands and defaults vary by firmware version, the official Fortinet Document Library is the source of truth.",
"link": "https://docs.fortinet.com/",
"linkLabel": "Fortinet Document Library"
},
{
"id": 87,
"category": "Network hardware (IT staff)",
"question": "An IPsec tunnel looks correctly configured but still won't come up",
"answer": "When the config looks right on paper, checking the live IKE negotiation logs tells you more than re-checking the configuration a third time. Fortinet's own support community has a detailed walkthrough of the exact `diagnose` commands and log filters their engineers use for exactly this scenario.",
"link": "https://community.fortinet.com/t5/FortiGate/Troubleshooting-Tip-IPsec-VPN-tunnels/ta-p/195955",
"linkLabel": "Fortinet Community: IPsec VPN troubleshooting"
}
]
-37
View File
@@ -1,37 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
.liquid-glass {
background: rgba(255, 255, 255, 0.01);
background-blend-mode: luminosity;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border: none;
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.1);
position: relative;
overflow: hidden;
}
.liquid-glass::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1.4px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.45) 0%,
rgba(255, 255, 255, 0.15) 20%,
rgba(255, 255, 255, 0) 40%,
rgba(255, 255, 255, 0) 60%,
rgba(255, 255, 255, 0.15) 80%,
rgba(255, 255, 255, 0.45) 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
-10
View File
@@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
-17
View File
@@ -1,17 +0,0 @@
// Safe wrappers: localStorage can be blocked (private mode), so never let it crash the page.
export function load<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch {
return fallback
}
}
export function save(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
/* ignore */
}
}
-8
View File
@@ -1,8 +0,0 @@
export interface Faq {
id: number
category: string
question: string
answer: string
link: string
linkLabel: string
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-6
View File
@@ -1,6 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { extend: {} },
plugins: [],
}
-15
View File
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src", "vite.config.ts"]
}
-1
View File
@@ -1 +0,0 @@
{"root":["./src/App.tsx","./src/ContactForm.tsx","./src/Faq.tsx","./src/Staff.tsx","./src/VideoBackground.tsx","./src/main.tsx","./src/storage.ts","./src/types.ts","./src/vite-env.d.ts","./vite.config.ts"],"version":"5.6.3"}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
server: { proxy: { '/api': 'http://localhost:3001' } },
})
-11
View File
@@ -1,11 +0,0 @@
{
"name": "cycle-atlas-root",
"private": true,
"version": "1.0.0",
"description": "Root wrapper so hosts can build and start the IT Support site in it-support/",
"engines": { "node": ">=22.13" },
"scripts": {
"build": "npm --prefix it-support install --include=dev && npm --prefix it-support run build",
"start": "NODE_ENV=production node it-support/server/index.js"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"version": 1,
"skills": {
"caption-animation": {
"source": "iart-ai/tiktok-video-skills",
"sourceType": "github",
"skillPath": "skills/caption-animation/SKILL.md",
"computedHash": "e42c4828d3c6115dfb4d8fd3c3fd485b8e6a89c35a8835eee6dae39fb83210a4"
},
"countdown-video": {
"source": "iart-ai/tiktok-video-skills",
"sourceType": "github",
"skillPath": "skills/countdown-video/SKILL.md",
"computedHash": "dafb9127ededa76d0df08be73adc46e18d6ec3955153f6ecfd0f928984ce95b2"
},
"lower-thirds": {
"source": "iart-ai/tiktok-video-skills",
"sourceType": "github",
"skillPath": "skills/lower-thirds/SKILL.md",
"computedHash": "502c1a8a491e477dd855eb1a45469c09f6ff46a72ea841838d358d0a6a9fd790"
},
"short-form-video": {
"source": "iart-ai/tiktok-video-skills",
"sourceType": "github",
"skillPath": "skills/short-form-video/SKILL.md",
"computedHash": "893ea19615f3199159b9fe94f05e37a5e538e7e0c3af7f77f9f9bed7ff933423"
}
}
}