Compare commits

..
Author SHA1 Message Date
Claude be5cbd8744 Add a relevant photo to each project card (network, server, SCCM, Teams calling)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 03:29:02 +00:00
Claude 9f2186b9f8 Contact Me opens an email pop-up (mail app, Gmail, Yahoo, copy address)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 03:25:26 +00:00
Claude c3def2c5c4 Add project-specific diagrams to each project card
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 03:22:35 +00:00
Claude 55f7434bf2 Turn template into Keith Rana's portfolio with real content, photos and bundled font
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 03:05:18 +00:00
Claude 1500ba1054 Add image-embedding preview builder, fix button wrapping on phones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 02:31:18 +00:00
Claude 6f3e3bc858 Add Jack 3D Creator portfolio template (Phase 1) with PRD
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
2026-10-02 02:09:03 +00:00
70 changed files with 4496 additions and 1475 deletions

No files matched your search

-102
View File
@@ -1,102 +0,0 @@
---
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
@@ -1,62 +0,0 @@
# 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.
@@ -1,38 +0,0 @@
# 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>`.
@@ -1,37 +0,0 @@
# 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.
@@ -1,34 +0,0 @@
# 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>
```
@@ -1,23 +0,0 @@
# 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).
-45
View File
@@ -1,45 +0,0 @@
/* CycleWise AU - scroll animations (Step 1).
Purely cosmetic: if GSAP fails to load or the visitor prefers reduced
motion, this file does nothing and the page looks exactly as before. */
(function(){
"use strict";
if (!window.gsap || !window.ScrollTrigger) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
gsap.registerPlugin(ScrollTrigger);
var done = new WeakSet();
function reveal(panel){
if (!panel || done.has(panel)) return;
done.add(panel);
// Top of the tab: hero text and picture fade up one after another.
gsap.from(panel.querySelectorAll(".hero > *"), {
opacity: 0, y: 24, duration: 0.7, stagger: 0.12, ease: "power2.out",
clearProps: "all"
});
// Everything below: fade up when it scrolls into view.
panel.querySelectorAll(
".ref-heading, .ref-grid, .timeline, .week-panel, .filmstrip, .cal-card, .detail-card"
).forEach(function(el){
gsap.from(el, {
opacity: 0, y: 32, duration: 0.7, ease: "power2.out",
clearProps: "all",
scrollTrigger: { trigger: el, start: "top 88%", once: true }
});
});
}
function activePanel(){ return document.querySelector(".mode-panel.active"); }
reveal(activePanel());
document.querySelectorAll(".mode-btn").forEach(function(btn){
btn.addEventListener("click", function(){
// Wait one tick so the page's own code switches the tab first.
setTimeout(function(){ reveal(activePanel()); ScrollTrigger.refresh(); }, 0);
});
});
})();
-539
View File
@@ -1,539 +0,0 @@
(function(){
"use strict";
/* ---------------- date helpers ---------------- */
function toDate(y,m,d){ return new Date(y,m,d); }
function fromInput(str){
if(!str) return null;
var p = str.split("-");
return new Date(+p[0], +p[1]-1, +p[2]);
}
function toInputVal(d){
return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");
}
function addDays(d,n){ var r=new Date(d); r.setDate(r.getDate()+n); return r; }
function daysBetween(a,b){ // b - a, in whole days, ignoring time
var A = new Date(a.getFullYear(),a.getMonth(),a.getDate());
var B = new Date(b.getFullYear(),b.getMonth(),b.getDate());
return Math.round((B-A)/86400000);
}
function sameDay(a,b){ return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); }
function mod(n,m){ return ((n % m) + m) % m; }
var MONTH_NAMES = ["January","February","March","April","May","June","July","August","September","October","November","December"];
var today = new Date(); today.setHours(0,0,0,0);
function loadLS(key, fallback){
try{ var v = localStorage.getItem(key); return v===null ? fallback : v; }catch(e){ return fallback; }
}
function saveLS(key, val){ try{ localStorage.setItem(key,val); }catch(e){} }
/* ---------------- mode switching ---------------- */
var modeBtns = document.querySelectorAll(".mode-btn");
var panels = { period: document.getElementById("mode-period"), ovulation: document.getElementById("mode-ovulation"), pregnancy: document.getElementById("mode-pregnancy") };
modeBtns.forEach(function(btn){
btn.addEventListener("click", function(){
modeBtns.forEach(function(b){ b.classList.remove("active"); b.setAttribute("aria-selected","false"); });
btn.classList.add("active"); btn.setAttribute("aria-selected","true");
Object.keys(panels).forEach(function(k){ panels[k].classList.remove("active"); });
panels[btn.dataset.mode].classList.add("active");
saveLS("ca-mode", btn.dataset.mode);
});
});
var savedMode = loadLS("ca-mode", null);
if(savedMode && panels[savedMode]){
modeBtns.forEach(function(b){ b.classList.toggle("active", b.dataset.mode===savedMode); b.setAttribute("aria-selected", b.dataset.mode===savedMode ? "true":"false"); });
Object.keys(panels).forEach(function(k){ panels[k].classList.toggle("active", k===savedMode); });
}
/* ================================================================
PHASE + OVULATION REFERENCE DATA
================================================================ */
var PHASES = {
menstrual: {
title:"Menstrual", color:"var(--period)", soft:"var(--period-soft)",
window:"Cycle day 1 through the end of bleeding",
desc:"The uterine lining sheds because no pregnancy occurred last cycle. Estrogen and progesterone are both at their lowest point of the month.",
hormone:"Estrogen & progesterone: low", energy:"Energy: lowest",
symptoms:"Cramping, fatigue, lower back ache, mood dips",
tip:"Iron-rich food, gentle movement, and heat on the lower abdomen ease cramping better than rest alone."
},
follicular: {
title:"Follicular", color:"var(--ovulation)", soft:"var(--ovulation-soft)",
window:"Cycle day 1 through ovulation (overlaps menstrual)",
desc:"The pituitary releases FSH, prompting several ovarian follicles to mature. Estrogen climbs steadily as one dominant follicle takes the lead.",
hormone:"Estrogen: rising", energy:"Energy: climbing",
symptoms:"Clearer skin, sharper focus, rising motivation",
tip:"This is the physiologically best window for starting new training blocks or demanding projects."
},
ovulatory: {
title:"Ovulatory", color:"var(--ovulation-strong)", soft:"var(--ovulation-soft)",
window:"Roughly one day, mid-cycle",
desc:"A surge in luteinizing hormone (LH) triggers the dominant follicle to release its egg. This is the single most fertile day of the cycle.",
hormone:"LH: sharp surge", energy:"Energy & libido: peak",
symptoms:"Egg-white cervical mucus, one-sided pelvic twinge (mittelschmerz)",
tip:"Ovulation predictor kits detect the LH surge roughly 24–36 hours before the egg is released."
},
luteal: {
title:"Luteal", color:"var(--pregnancy)", soft:"var(--pregnancy-soft)",
window:"Ovulation through the day before your next period",
desc:"The ruptured follicle becomes the corpus luteum and secretes progesterone to prepare the uterine lining. If no implantation occurs, hormone levels fall and the cycle restarts.",
hormone:"Progesterone: rises then falls", energy:"Energy: gradually declining",
symptoms:"Bloating, breast tenderness, irritability, food cravings (PMS)",
tip:"Basal body temperature stays elevated ~0.3–0.5°C through this phase — a useful way to confirm ovulation already happened."
}
};
// offset relative to ovulation day (0 = ovulation)
var OVULATION_DAYS = {
"-5": {fert:"Low", fertClass:"fert-low", bbt:"Baseline (pre-shift)", mucus:"Dry to sticky", note:"Sperm can survive up to 5 days, so conception is technically possible starting here."},
"-4": {fert:"Low–moderate", fertClass:"fert-low", bbt:"Baseline", mucus:"Sticky, cloudy", note:"Fertility begins climbing as estrogen rises toward its pre-ovulation peak."},
"-3": {fert:"Moderate", fertClass:"fert-mid", bbt:"Baseline", mucus:"Creamy, increasing in volume", note:"Cervical mucus is becoming more hospitable to sperm."},
"-2": {fert:"High", fertClass:"fert-high", bbt:"Baseline", mucus:"Thin, stretchy, egg-white-like", note:"One of the two most fertile days of the entire cycle."},
"-1": {fert:"Peak", fertClass:"fert-peak", bbt:"Baseline (often the day of the LH surge)", mucus:"Clear, slippery, maximum stretch", note:"Statistically the single highest-probability day for conception."},
"0": {fert:"Peak", fertClass:"fert-peak", bbt:"Still baseline — the rise hasn't happened yet", mucus:"Egg-white, abundant", note:"The egg is released and is viable for roughly 12–24 hours. Some feel a one-sided twinge (mittelschmerz)."},
"1": {fert:"Dropping fast", fertClass:"fert-mid", bbt:"Beginning to rise ~0.3–0.5°C", mucus:"Becoming tacky again", note:"The egg's fertile window is closing; BBT starts its post-ovulation climb."},
"2": {fert:"Very low", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry or minimal", note:"Progesterone from the corpus luteum keeps temperature elevated through the luteal phase."},
"3": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"If fertilization happened, the embryo is traveling toward the uterus."},
"4": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Too late in the cycle for a new ovulation; luteal phase symptoms may start appearing."},
"5": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Implantation, if it happens this cycle, typically occurs around days 6–10 after ovulation."}
};
var FERT_COLOR = {"fert-low":"var(--ink-soft)","fert-mid":"var(--fertile)","fert-high":"var(--fertile-strong)","fert-peak":"var(--ovulation-strong)"};
function phaseForDayInCycle(dic, periodLen, ovulationDic){
if(dic < periodLen) return "menstrual";
if(dic < ovulationDic) return "follicular";
if(dic === ovulationDic) return "ovulatory";
return "luteal";
}
function isFertile(dic, ovulationDic){
return dic >= ovulationDic - 5 && dic <= ovulationDic;
}
/* ================================================================
PREGNANCY WEEK DATA (1–40)
================================================================ */
var WEEKS = [
{c:"a poppy seed (not yet fertilized)", m:"n/a", dev:"Pregnancy is dated from the first day of this period, before conception happens — your body is shedding last cycle's lining and preparing a fresh egg.", body:"This week looks and feels like an ordinary period.", tip:"If you're trying to conceive, this is a good week to start a prenatal vitamin with folic acid."},
{c:"an egg cell", m:"~0.1 mm", dev:"Rising estrogen prompts an egg to mature; ovulation typically occurs near the end of this week, roughly two weeks before a missed period.", body:"Cervical mucus becomes clear and stretchy around ovulation — a natural fertility sign.", tip:"This is the fertile window if you're trying to conceive — see the Ovulation tab for day-by-day detail."},
{c:"a grain of sand", m:"~0.1–0.2 mm", dev:"If sperm meets egg this week, fertilization occurs in the fallopian tube and the resulting cell begins dividing as it travels toward the uterus.", body:"No symptoms are detectable yet — even a sensitive test won't turn positive for another week or two.", tip:"Implantation, if it happens, usually occurs 6–10 days after ovulation."},
{c:"a poppy seed", m:"~0.1 mm", dev:"The fertilized egg implants in the uterine lining and begins producing hCG, the hormone home pregnancy tests detect.", body:"Most people notice no symptoms yet; a sensitive test may already turn positive near the end of this week.", tip:"A missed period is usually the first sign — testing a few days after that gives the most reliable result."},
{c:"a sesame seed", m:"~2 mm", dev:"The neural tube — the earliest structure of the brain and spinal cord — begins to form, along with the first blood vessels.", body:"A missed period is usually the first clear sign; fatigue and tender breasts may begin.", tip:"Avoid alcohol and confirm any medications with a clinician — this window is critical for early development."},
{c:"a lentil", m:"~4 mm", dev:"A primitive heart tube starts beating, and the beginnings of the face, arms, and legs appear as small buds.", body:"Morning sickness and smell sensitivity often begin here, though timing varies widely.", tip:"Small, frequent, bland meals tend to sit better than three large ones if nausea has started."},
{c:"a blueberry", m:"~1.3 cm", dev:"The brain is dividing into distinct sections, and the arm and leg buds are elongating into recognizable limbs.", body:"Bloating and mild cramping are common as the uterus makes room to grow.", tip:"This is when most people schedule their first prenatal appointment — dating scans are most accurate now."},
{c:"a raspberry", m:"~1.6 cm", dev:"Fingers and toes are forming, still webbed; the heart now has four distinct chambers.", body:"Fatigue often peaks around this point as progesterone rises sharply.", tip:"Rest when you can — first-trimester exhaustion is real and temporary, not a sign anything is wrong."},
{c:"a grape", m:"~2.3 cm · 4 g", dev:"The embryo is officially called a fetus starting around now; essential organs are all present in early form and the tail has disappeared.", body:"Nausea, food aversions, and a heightened sense of smell are often at their strongest.", tip:"Ginger tea, vitamin B6, and staying hydrated are commonly recommended first-line options for nausea."},
{c:"a kumquat", m:"~3 cm · 7 g", dev:"Vital organs — kidneys, liver, brain — are functioning in a basic form, and tiny fingernail beds are forming.", body:"A visible baby bump is still unlikely, though clothes may feel tighter.", tip:"Ask your provider about first-trimester screening options available in this window."},
{c:"a fig", m:"~4.1 cm · 10 g", dev:"The fetus can now bend its arms and legs, and tooth buds are forming beneath the gums.", body:"Round ligament twinges may start as the uterus stretches.", tip:"Gentle stretching and changing positions slowly can ease early ligament discomfort."},
{c:"a lime", m:"~5.4 cm · 14 g", dev:"Reflexes are developing — fingers can curl, and toes can wiggle, though it can't be felt yet.", body:"The end of the first trimester is near, and miscarriage risk drops meaningfully from here.", tip:"Many people choose to share their pregnancy news around now, though the choice is entirely personal."},
{c:"a plum", m:"~6.7 cm · 23 g", dev:"Vocal cords are forming, and unique fingerprints are being etched onto tiny fingertips.", body:"Some early nausea may start easing as the placenta takes over hormone production.", tip:"The nuchal translucency scan, if chosen, is typically done between now and week 13."},
{c:"a passion fruit", m:"~10 cm · 45 g", dev:"Baby can open and close fists and may even suck a thumb; genitals are differentiating, though not yet visible on ultrasound.", body:"For many, first-trimester nausea and fatigue begin to lift.", tip:"Second-trimester energy often supports resuming or starting light-to-moderate exercise — check in with your provider first."},
{c:"a peach", m:"~11.6 cm · 58 g", dev:"Vocal cords are complete and the intestines, which developed in the umbilical cord, move into the abdomen.", body:"Welcome to the second trimester — often called the most comfortable stretch of pregnancy.", tip:"An anatomy scan is usually scheduled in the coming weeks; ask your clinic when it's booked."},
{c:"a lemon", m:"~13 cm · 70 g", dev:"Fine, downy hair called lanugo begins covering the body, and the kidneys are producing urine into the amniotic fluid.", body:"A small bump may finally become visible; appetite often improves noticeably.", tip:"Iron needs rise sharply now — leafy greens, legumes, and lean meat help meet increased demand."},
{c:"an apple", m:"~14.2 cm · 95 g", dev:"The skeleton continues hardening and baby can sense light through closed eyelids.", body:"Skin changes like a faint linea nigra or darkened areolas may appear as pigment increases.", tip:"Daily SPF helps limit pregnancy-related skin pigmentation (melasma) from sun exposure."},
{c:"an avocado", m:"~16 cm · 135 g", dev:"The placenta is now fully formed and functioning as baby's life-support system.", body:"First flutters of movement ('quickening') may be felt any time from now through week 20.", tip:"Movement timing varies a lot — first pregnancies and anterior placentas often mean feeling it later."},
{c:"a pomegranate", m:"~17.7 cm · 160 g", dev:"The skeleton is converting from cartilage to true bone, and fat stores are just beginning to accumulate.", body:"The uterus is now roughly at the level of the belly button.", tip:"A pregnancy support pillow can meaningfully improve sleep as side-sleeping becomes more comfortable."},
{c:"a bell pepper", m:"~19 cm · 190 g", dev:"Ears have reached their final position and baby may begin startling at loud external sounds.", body:"Quickening is commonly felt by now — often described as bubbles or light taps.", tip:"Try narrating your day out loud — hearing is developing and familiar voices become recognizable over time."},
{c:"a mango", m:"~20.3 cm · 240 g", dev:"A waxy coating called vernix caseosa begins protecting the skin, and taste buds are forming.", body:"Backaches can start as posture shifts to balance the growing bump.", tip:"A supportive, flatter shoe and a pillow between the knees at night both help low back strain."},
{c:"a banana", m:"~25.6 cm · 300 g", dev:"The detailed anatomy ultrasound is often done around now if it hasn't happened already, and sex can typically be identified on it.", body:"Movements are stronger and more frequent; the uterus is now level with the belly button.", tip:"If you want to know the sex and haven't had the anatomy scan yet, ask your clinic to schedule it soon — it's usually done weeks 18–22."},
{c:"a carrot", m:"~27 cm · 360 g", dev:"Baby is swallowing small amounts of amniotic fluid, giving the digestive system practice.", body:"Stretch marks may start appearing on the belly, hips, or chest as skin stretches.", tip:"Moisturizing won't prevent stretch marks, but it can ease the itchiness that comes with stretching skin."},
{c:"a papaya", m:"~28.9 cm · 430 g", dev:"Eyebrows and eyelids are fully formed, and the sense of touch is developing rapidly.", body:"Braxton Hicks — brief, painless practice contractions — may be felt occasionally from here on.", tip:"Braxton Hicks usually ease with a change of position or a glass of water; true labor contractions don't."},
{c:"an ear of corn", m:"~32.2 cm · 600 g", dev:"The lungs begin producing surfactant, a substance essential for breathing air after birth — a key viability milestone.", body:"The linea nigra (a dark vertical line on the belly) may become more visible.", tip:"This week is often cited as the threshold of viability with intensive neonatal care, should birth happen very early."},
{c:"a cauliflower head", m:"~33.7 cm · 660 g", dev:"Baby can now respond to your voice, and hand grip strength is developing.", body:"The glucose screening test for gestational diabetes is typically scheduled around now.", tip:"If asked to fast beforehand, follow your clinic's instructions closely for an accurate glucose result."},
{c:"a head of lettuce", m:"~35.6 cm · 760 g", dev:"Eyes begin to open after being fused shut since early development, and hair may be visibly growing.", body:"Round ligament pain and rib discomfort can increase as space gets tighter.", tip:"A maternity support band can ease rib and ligament pressure during long periods of standing or walking."},
{c:"a bunch of scallions", m:"~36.6 cm · 875 g", dev:"Brain tissue is developing rapidly, and baby is developing regular hiccups you may be able to feel.", body:"Third trimester begins soon — appointments typically become more frequent from here.", tip:"Hiccup-like twitches are completely normal and are the diaphragm practicing its rhythm."},
{c:"an eggplant", m:"~37.6 cm · 1.1 kg", dev:"Eyes can blink now, and baby may cycle through active REM sleep.", body:"Welcome to the third trimester — the home stretch begins.", tip:"This is a common time to start planning a hospital bag and reviewing birth preferences."},
{c:"a butternut squash", m:"~38.6 cm · 1.2 kg", dev:"Bones are fully formed but still soft and flexible, ready to move through the birth canal.", body:"Frequent urination often returns as the head engages lower in the pelvis.", tip:"Kick counts become a useful daily check-in — note baby's typical pattern of movement."},
{c:"a large cabbage", m:"~39.9 cm · 1.3 kg", dev:"The brain is developing at its fastest rate yet, adding folds and surface area.", body:"Sleep can become harder to find as the bump limits comfortable positions.", tip:"A full-body pregnancy pillow supports the belly, back, and knees simultaneously."},
{c:"a coconut", m:"~41.1 cm · 1.5 kg", dev:"All five senses are now functioning, and baby is gaining roughly half a pound a week.", body:"Colostrum, the first form of breast milk, may begin to leak slightly.", tip:"A few nursing pads in your bag can help if colostrum leaking becomes noticeable."},
{c:"a jicama", m:"~42.4 cm · 1.7 kg", dev:"Baby is practicing breathing movements against a closed airway, strengthening the lungs.", body:"Swelling and mild carpal tunnel tingling in the hands can appear from fluid retention.", tip:"Report sudden or severe swelling to your provider promptly — it can occasionally signal preeclampsia."},
{c:"a pineapple", m:"~43.7 cm · 1.9 kg", dev:"Bones are hardening overall, though the skull stays deliberately soft and flexible for delivery.", body:"Pelvic pressure increases as baby continues descending.", tip:"Prenatal massage or a warm bath can ease the deep pelvic ache many feel this week."},
{c:"a cantaloupe", m:"~45 cm · 2.1 kg", dev:"The central nervous system is maturing quickly, and baby's grip is now quite strong.", body:"Braxton Hicks contractions often become more noticeable and frequent.", tip:"Time any contractions that feel different — regular, intensifying, closer together warrants a call to your provider."},
{c:"a honeydew melon", m:"~46.2 cm · 2.4 kg", dev:"The kidneys are fully developed, and the liver is processing waste products on its own.", body:"You may feel baby 'drop' lower as it settles into position for birth.", tip:"Lightening (the baby dropping) can ease heartburn but often increases bathroom trips."},
{c:"a head of romaine", m:"~47.4 cm · 2.6 kg", dev:"Most physical development is complete; the focus now is steady weight gain.", body:"You've just reached 'early term' — babies born from here on tend to do very well.", tip:"Pack your hospital bag if you haven't yet — early-term babies can arrive with little warning."},
{c:"a leek", m:"~49.8 cm · 3.1 kg", dev:"Baby continues laying down fat for temperature regulation after birth, and the brain is still rapidly developing.", body:"Nesting instinct and a burst of energy are common right before labor.", tip:"Balance nesting energy with rest — labor's timing is unpredictable."},
{c:"a mini watermelon", m:"~50.7 cm · 3.3 kg", dev:"Full term. The lungs are completing final maturation in preparation for that first breath.", body:"Increased pelvic pressure and more frequent, intense Braxton Hicks are common.", tip:"Watch for your labor signs: regular contractions, water breaking, or a bloody show."},
{c:"a small pumpkin", m:"~51.2 cm · 3.5 kg", dev:"Baby is fully developed and simply continuing to grow, ready to meet you.", body:"If labor hasn't started, your provider will discuss monitoring or induction options.", tip:"Most babies arrive within two weeks of their due date in either direction — both are entirely normal."}
];
/* ================================================================
SVG helpers
================================================================ */
function polarToXY(cx,cy,r,angleDeg){
var rad = (angleDeg-90) * Math.PI/180;
return [cx + r*Math.cos(rad), cy + r*Math.sin(rad)];
}
function arcPath(cx,cy,r,startDeg,endDeg){
if(endDeg <= startDeg) endDeg = startDeg + 0.01;
var s = polarToXY(cx,cy,r,startDeg), e = polarToXY(cx,cy,r,endDeg);
var large = (endDeg-startDeg) > 180 ? 1 : 0;
return "M "+s[0].toFixed(2)+" "+s[1].toFixed(2)+" A "+r+" "+r+" 0 "+large+" 1 "+e[0].toFixed(2)+" "+e[1].toFixed(2);
}
/* ================================================================
SHARED STATE: period start / cycle length / period length
================================================================ */
var exampleStart = addDays(today, -10);
var startInput = document.getElementById("p-start");
var cycleLenInput = document.getElementById("p-cyclelen");
var periodLenInput = document.getElementById("p-periodlen");
var savedStart = loadLS("ca-start", null);
var savedCycleLen = loadLS("ca-cyclelen", null);
var savedPeriodLen = loadLS("ca-periodlen", null);
var usingExample = !savedStart;
startInput.value = savedStart || toInputVal(exampleStart);
cycleLenInput.value = savedCycleLen || 28;
periodLenInput.value = savedPeriodLen || 5;
function getCycleState(){
return {
start: fromInput(startInput.value) || exampleStart,
cycleLen: Math.max(21, Math.min(35, parseInt(cycleLenInput.value,10) || 28)),
periodLen: Math.max(2, Math.min(10, parseInt(periodLenInput.value,10) || 5))
};
}
var pCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
var oCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
var pSelectedDate = new Date(today);
var oSelectedDate = new Date(today);
[startInput,cycleLenInput,periodLenInput].forEach(function(inp){
inp.addEventListener("input", function(){
saveLS("ca-start", startInput.value);
saveLS("ca-cyclelen", cycleLenInput.value);
saveLS("ca-periodlen", periodLenInput.value);
usingExample = false;
document.getElementById("p-example-note").style.display = "none";
document.getElementById("pr-example-note").style.display = "none";
renderAll();
});
});
if(!usingExample) document.getElementById("p-example-note").style.display = "none";
/* ================================================================
CALENDAR RENDERING (shared builder)
================================================================ */
function buildCalendar(gridEl, weekdayHeaderIgnored, monthDate, dayClassifier, onSelect, selectedDate){
gridEl.innerHTML = "";
var year = monthDate.getFullYear(), month = monthDate.getMonth();
var firstDow = new Date(year,month,1).getDay();
var daysInMonth = new Date(year,month+1,0).getDate();
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
for(var i=0;i<totalCells;i++){
var dayNum = i - firstDow + 1;
var cell = document.createElement("button");
cell.className = "day-cell";
if(dayNum < 1 || dayNum > daysInMonth){
cell.classList.add("pad");
gridEl.appendChild(cell);
continue;
}
var d = new Date(year,month,dayNum);
var info = dayClassifier(d);
cell.innerHTML = "<span>"+dayNum+"</span>";
if(info.period) cell.classList.add("is-period");
if(info.fertile) cell.classList.add("is-fertile");
if(info.ovulation) cell.classList.add("is-ovulation");
if(sameDay(d,today)) cell.classList.add("today");
if(selectedDate && sameDay(d,selectedDate)) cell.classList.add("selected");
cell.setAttribute("aria-label", d.toDateString());
cell.addEventListener("click", function(dd){ return function(){ onSelect(dd); }; }(d));
gridEl.appendChild(cell);
}
}
function classifyForCycle(d, state){
var dic = mod(daysBetween(state.start, d), state.cycleLen);
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
return {
period: dic < state.periodLen,
fertile: isFertile(dic, ovDic),
ovulation: dic === ovDic,
dic: dic, ovDic: ovDic
};
}
/* ---------------- PERIOD tab render ---------------- */
function renderPeriodCalendar(){
var state = getCycleState();
document.getElementById("p-cal-title").textContent = MONTH_NAMES[pCalMonth.getMonth()] + " " + pCalMonth.getFullYear();
buildCalendar(document.getElementById("p-daygrid"), null, pCalMonth,
function(d){ return classifyForCycle(d, state); },
function(d){ pSelectedDate = d; renderPeriodDetail(); renderPeriodCalendar(); },
pSelectedDate
);
}
function renderPeriodDetail(){
var state = getCycleState();
var info = classifyForCycle(pSelectedDate, state);
var phaseKey = phaseForDayInCycle(info.dic, state.periodLen, info.ovDic);
var phase = PHASES[phaseKey];
var el = document.getElementById("p-detail");
el.innerHTML =
'<span class="d-date">'+pSelectedDate.toDateString()+'</span>'+
'<span class="pill" style="background:'+phase.soft+';color:'+phase.color+'">'+phase.title+' phase'+(info.fertile?' · fertile window':'')+'</span>'+
'<div class="d-phase">Cycle day '+(info.dic+1)+'</div>'+
'<p class="d-desc">'+phase.desc+'</p>'+
'<div class="d-grid">'+
'<div class="d-mini"><div class="k">Hormones</div><div class="v">'+phase.hormone+'</div></div>'+
'<div class="d-mini"><div class="k">Typical symptoms</div><div class="v" style="font-weight:500;font-size:0.82rem;">'+phase.symptoms+'</div></div>'+
'</div>'+
'<p class="d-desc"><strong style="color:var(--ink)">Care note — </strong>'+phase.tip+'</p>';
}
function renderPeriodStats(){
var state = getCycleState();
var dic = mod(daysBetween(state.start, today), state.cycleLen);
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
var daysToNextPeriod = mod(state.cycleLen - dic, state.cycleLen);
if(daysToNextPeriod === 0 && dic !== 0) daysToNextPeriod = state.cycleLen;
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
var phaseKey = phaseForDayInCycle(dic, state.periodLen, ovDic);
document.getElementById("period-stats").innerHTML =
statTile(dic+1, "Cycle day today") +
statTile(PHASES[phaseKey].title, "Current phase") +
statTile(dic===0 ? "Today" : (daysToNextPeriod+"d"), "Until next period") +
statTile(dic===ovDic ? "Today" : (daysToOvulation+"d"), "Until ovulation");
// hero ring
var cx=100, cy=100, r=88;
var perStart=0, perEnd = state.periodLen/state.cycleLen*360;
var follEnd = ovDic/state.cycleLen*360;
var fertStart = Math.max(0,(ovDic-5))/state.cycleLen*360;
var lutStart = (ovDic+1)/state.cycleLen*360;
document.getElementById("phase-arc-menstrual").setAttribute("d", arcPath(cx,cy,r,perStart,perEnd));
document.getElementById("phase-arc-follicular").setAttribute("d", arcPath(cx,cy,r,perEnd,fertStart));
document.getElementById("phase-arc-fertile").setAttribute("d", arcPath(cx,cy,r,fertStart,follEnd+ (360/state.cycleLen)));
document.getElementById("phase-arc-luteal").setAttribute("d", arcPath(cx,cy,r,lutStart,360));
var todayAngle = dic/state.cycleLen*360;
var tp = polarToXY(cx,cy,r,todayAngle);
document.getElementById("today-marker").setAttribute("cx", tp[0]);
document.getElementById("today-marker").setAttribute("cy", tp[1]);
document.getElementById("cycle-day-big").textContent = dic+1;
// hand the same numbers to the optional 3D ring (ring3d.js)
window.cycleRingData = {perEnd:perEnd, fertStart:fertStart, fertEnd:follEnd+(360/state.cycleLen),
lutStart:lutStart, todayAngle:todayAngle};
window.dispatchEvent(new CustomEvent("cycle:ring", {detail: window.cycleRingData}));
}
function statTile(num,label){
return '<div class="stat-tile"><div class="num">'+num+'</div><div class="lbl">'+label+'</div></div>';
}
document.getElementById("p-prev").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()-1); renderPeriodCalendar(); });
document.getElementById("p-next").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()+1); renderPeriodCalendar(); });
function renderPhaseRefGrid(){
var order = ["menstrual","follicular","ovulatory","luteal"];
document.getElementById("phase-ref-grid").innerHTML = order.map(function(key,i){
var p = PHASES[key];
return '<div class="ref-card"><span class="r-num">0'+(i+1)+'</span><h4 style="color:'+p.color+'">'+p.title+'</h4>'+
'<p>'+p.desc+'</p>'+
'<div class="tag-row"><span class="tagchip">'+p.window+'</span></div></div>';
}).join("");
}
/* ---------------- OVULATION tab render ---------------- */
var ovSelectedOffset = 0;
function renderOvTimeline(){
var wrap = document.getElementById("ov-timeline");
wrap.innerHTML = "";
for(var off=-5; off<=5; off++){
var d = OVULATION_DAYS[String(off)];
var btn = document.createElement("button");
btn.className = "tl-btn" + (off===ovSelectedOffset ? " active":"");
btn.innerHTML = '<div class="tl-day">'+(off===0?"OVUL.":(off>0?"+":"")+off)+'</div>'+
'<div class="tl-fert" style="background:'+FERT_COLOR[d.fertClass]+'"></div>';
btn.addEventListener("click", function(o){ return function(){ ovSelectedOffset=o; renderOvTimeline(); renderOvDetailFromOffset(); }; }(off));
wrap.appendChild(btn);
}
}
function renderOvDetailFromOffset(){
var d = OVULATION_DAYS[String(ovSelectedOffset)];
var el = document.getElementById("o-detail");
var label = ovSelectedOffset===0 ? "Ovulation day" : (ovSelectedOffset>0 ? ovSelectedOffset+" day"+(ovSelectedOffset>1?"s":"")+" after ovulation" : Math.abs(ovSelectedOffset)+" day"+(ovSelectedOffset<-1?"s":"")+" before ovulation");
el.innerHTML =
'<span class="d-date">'+label+'</span>'+
'<span class="pill" style="background:var(--ovulation-soft);color:'+FERT_COLOR[d.fertClass]+'">Fertility: '+d.fert+'</span>'+
'<div class="d-phase">'+(ovSelectedOffset===0?"Egg release":(ovSelectedOffset<0?"Building fertility":"Fertility fading"))+'</div>'+
'<div class="d-grid">'+
'<div class="d-mini"><div class="k">Basal body temp</div><div class="v" style="font-size:0.82rem;">'+d.bbt+'</div></div>'+
'<div class="d-mini"><div class="k">Cervical mucus</div><div class="v" style="font-size:0.82rem;">'+d.mucus+'</div></div>'+
'</div>'+
'<p class="d-desc">'+d.note+'</p>';
}
function renderOvulationCalendar(){
var state = getCycleState();
document.getElementById("o-cal-title").textContent = MONTH_NAMES[oCalMonth.getMonth()] + " " + oCalMonth.getFullYear();
buildCalendar(document.getElementById("o-daygrid"), null, oCalMonth,
function(d){ return classifyForCycle(d, state); },
function(d){
oSelectedDate = d;
var info = classifyForCycle(d, state);
var offset = info.dic - info.ovDic;
if(offset < -5) offset = -5; if(offset > 5) offset = 5;
ovSelectedOffset = offset;
renderOvTimeline(); renderOvDetailFromOffset(); renderOvulationCalendar();
},
oSelectedDate
);
}
function renderOvulationStats(){
var state = getCycleState();
var dic = mod(daysBetween(state.start, today), state.cycleLen);
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
var fertileStartDic = mod(ovDic-5, state.cycleLen);
var inFertile = isFertile(dic, ovDic);
var daysToFertile = inFertile ? 0 : mod(fertileStartDic - dic, state.cycleLen);
document.getElementById("ovulation-stats").innerHTML =
statTile(inFertile ? "Yes" : "No", "In fertile window") +
statTile(dic===ovDic ? "Today" : daysToOvulation+"d", "Until ovulation") +
statTile(inFertile ? "Now" : daysToFertile+"d", "Fertile window") +
statTile("6 days", "Window length");
}
document.getElementById("o-prev").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()-1); renderOvulationCalendar(); });
document.getElementById("o-next").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()+1); renderOvulationCalendar(); });
/* ================================================================
PREGNANCY tab
================================================================ */
var lmpInput = document.getElementById("pr-lmp");
var exampleLMP = addDays(today, -20*7 - 3);
var savedLMP = loadLS("ca-lmp", null);
lmpInput.value = savedLMP || toInputVal(exampleLMP);
if(savedLMP) document.getElementById("pr-example-note").style.display = "none";
var slider = document.getElementById("week-slider");
var prCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
lmpInput.addEventListener("input", function(){
saveLS("ca-lmp", lmpInput.value);
document.getElementById("pr-example-note").style.display = "none";
document.getElementById("p-example-note").style.display = "none";
syncSliderToLMP();
renderPregnancy();
});
function getLMP(){ return fromInput(lmpInput.value) || exampleLMP; }
function currentGestWeek(){
var lmp = getLMP();
var w = Math.floor(daysBetween(lmp,today)/7) + 1;
return Math.max(1, Math.min(40, w));
}
function syncSliderToLMP(){ slider.value = currentGestWeek(); }
syncSliderToLMP();
function renderPregnancyStats(){
var lmp = getLMP();
var due = addDays(lmp, 280);
var week = currentGestWeek();
var daysLeft = Math.max(0, daysBetween(today, due));
document.getElementById("pregnancy-stats").innerHTML =
statTile(week, "Weeks along") +
statTile(Math.floor((week-1)/13)+1 <= 3 ? ["First","Second","Third"][Math.min(2,Math.floor((week-1)/13))] : "Third", "Trimester") +
statTile(daysLeft, "Days to due date") +
statTile(due.toLocaleDateString(undefined,{month:"short",day:"numeric"}), "Estimated due date");
var pct = (week-1)/39;
var pathLen = 230;
var offset = pathLen - pct*pathLen;
var path = document.getElementById("pregnancy-progress-path");
path.setAttribute("stroke-dashoffset", offset.toFixed(1));
try{
var totalLen = path.getTotalLength ? path.getTotalLength() : pathLen;
var pt = path.getPointAtLength ? path.getPointAtLength(pct*totalLen) : null;
if(pt) { document.getElementById("pregnancy-progress-dot").setAttribute("cx",pt.x); document.getElementById("pregnancy-progress-dot").setAttribute("cy",pt.y); }
}catch(e){}
}
function renderWeekPanel(week){
var w = WEEKS[week-1];
document.getElementById("wk-badge").textContent = "WEEK " + week;
document.getElementById("wk-compare").textContent = "size of " + w.c;
document.getElementById("wk-metric").textContent = w.m === "n/a" ? "not yet fertilized" : "≈ " + w.m;
document.getElementById("wk-dev").textContent = w.dev;
document.getElementById("wk-body").textContent = w.body;
document.getElementById("wk-tip").textContent = w.tip;
var tri = week<=13?1:(week<=27?2:3);
var triColor = "var(--pregnancy)";
document.getElementById("wk-blob").innerHTML =
'<svg width="96" height="96" viewBox="0 0 96 96"><circle cx="48" cy="48" r="'+(14+week*0.9)+'" fill="'+ (tri===1?"var(--pregnancy-soft)":tri===2?"var(--pregnancy)":"var(--pregnancy-strong)") +'" opacity="0.85"/></svg>';
document.querySelectorAll(".film-item").forEach(function(f){ f.classList.toggle("active", +f.dataset.week===week); });
var active = document.querySelector('.film-item[data-week="'+week+'"]');
if(active) active.scrollIntoView({inline:"center", block:"nearest", behavior:"smooth"});
}
slider.addEventListener("input", function(){ renderWeekPanel(+slider.value); });
function buildFilmstrip(){
var wrap = document.getElementById("week-filmstrip");
wrap.innerHTML = "";
for(var w=1; w<=40; w++){
var item = document.createElement("button");
item.className = "film-item";
item.dataset.week = w;
item.innerHTML = '<div class="fw">WK</div><div class="fn">'+w+'</div>';
item.addEventListener("click", function(ww){ return function(){ slider.value=ww; renderWeekPanel(ww); }; }(w));
wrap.appendChild(item);
}
}
function renderPregnancyCalendar(){
var lmp = getLMP();
document.getElementById("pr-cal-title").textContent = MONTH_NAMES[prCalMonth.getMonth()] + " " + prCalMonth.getFullYear();
var grid = document.getElementById("pr-daygrid");
grid.innerHTML = "";
var year = prCalMonth.getFullYear(), month = prCalMonth.getMonth();
var firstDow = new Date(year,month,1).getDay();
var daysInMonth = new Date(year,month+1,0).getDate();
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
for(var i=0;i<totalCells;i++){
var dayNum = i - firstDow + 1;
var cell = document.createElement("button");
cell.className = "day-cell";
if(dayNum<1 || dayNum>daysInMonth){ cell.classList.add("pad"); grid.appendChild(cell); continue; }
var d = new Date(year,month,dayNum);
var wk = Math.floor(daysBetween(lmp,d)/7)+1;
var inRange = wk>=1 && wk<=42;
cell.innerHTML = "<span>"+dayNum+"</span>" + (inRange ? '<span class="dot-row" style="position:static;margin-top:2px;font-size:0.55rem;color:var(--pregnancy-strong)">W'+wk+'</span>' : "");
if(inRange && wk<=40) cell.classList.add("is-fertile");
if(sameDay(d,today)) cell.classList.add("today");
cell.addEventListener("click", function(ww){ return function(){ if(ww>=1&&ww<=40){ slider.value=ww; renderWeekPanel(ww); } }; }(wk));
grid.appendChild(cell);
}
}
document.getElementById("pr-prev").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()-1); renderPregnancyCalendar(); });
document.getElementById("pr-next").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()+1); renderPregnancyCalendar(); });
function renderPregnancy(){
renderPregnancyStats();
renderWeekPanel(currentGestWeek());
renderPregnancyCalendar();
}
/* ================================================================
INIT
================================================================ */
function renderAll(){
renderPeriodStats();
renderPeriodCalendar();
renderPeriodDetail();
renderOvulationStats();
renderOvTimeline();
renderOvDetailFromOffset();
renderOvulationCalendar();
renderPregnancy();
}
renderPhaseRefGrid();
buildFilmstrip();
slider.value = currentGestWeek();
renderAll();
})();
-93
View File
@@ -1,93 +0,0 @@
Copyright 2020 The Fraunces Project Authors (github.com/undercasetype/Fraunces) Fraunces-Italic[SOFT,WONK,opsz,wght].ttf: Copyright 2020 The Fraunces Project Authors (github.com/undercasetype/Fraunces)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-93
View File
@@ -1,93 +0,0 @@
Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-93
View File
@@ -1,93 +0,0 @@
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans) WorkSans-Italic[wght].ttf: Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-15
View File
@@ -1,15 +0,0 @@
/* Self-hosted fonts (Latin subset), SIL Open Font License 1.1 - see OFL-*.txt */
@font-face{font-family:"Fraunces";font-style:normal;font-weight:400;font-display:swap;src:url("fraunces-latin-400-normal.woff2") format("woff2");}
@font-face{font-family:"Fraunces";font-style:normal;font-weight:500;font-display:swap;src:url("fraunces-latin-500-normal.woff2") format("woff2");}
@font-face{font-family:"Fraunces";font-style:normal;font-weight:600;font-display:swap;src:url("fraunces-latin-600-normal.woff2") format("woff2");}
@font-face{font-family:"Fraunces";font-style:normal;font-weight:700;font-display:swap;src:url("fraunces-latin-700-normal.woff2") format("woff2");}
@font-face{font-family:"Fraunces";font-style:normal;font-weight:900;font-display:swap;src:url("fraunces-latin-900-normal.woff2") format("woff2");}
@font-face{font-family:"Fraunces";font-style:italic;font-weight:500;font-display:swap;src:url("fraunces-latin-500-italic.woff2") format("woff2");}
@font-face{font-family:"Fraunces";font-style:italic;font-weight:600;font-display:swap;src:url("fraunces-latin-600-italic.woff2") format("woff2");}
@font-face{font-family:"Work Sans";font-style:normal;font-weight:400;font-display:swap;src:url("work-sans-latin-400-normal.woff2") format("woff2");}
@font-face{font-family:"Work Sans";font-style:normal;font-weight:500;font-display:swap;src:url("work-sans-latin-500-normal.woff2") format("woff2");}
@font-face{font-family:"Work Sans";font-style:normal;font-weight:600;font-display:swap;src:url("work-sans-latin-600-normal.woff2") format("woff2");}
@font-face{font-family:"Work Sans";font-style:normal;font-weight:700;font-display:swap;src:url("work-sans-latin-700-normal.woff2") format("woff2");}
@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:400;font-display:swap;src:url("ibm-plex-mono-latin-400-normal.woff2") format("woff2");}
@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:500;font-display:swap;src:url("ibm-plex-mono-latin-500-normal.woff2") format("woff2");}
@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:600;font-display:swap;src:url("ibm-plex-mono-latin-600-normal.woff2") format("woff2");}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+550 -32
View File
@@ -1,11 +1,7 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>CycleWise AU</title> <title>CycleWise AU</title>
<link rel="stylesheet" href="fonts/fonts.css"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,400;0,500;0,600;0,700;0,900;1,500;1,600&family=Work+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style> <style>
:root{ :root{
--paper:#FBF7F2; --paper:#FBF7F2;
@@ -135,7 +131,6 @@ button{font-family:inherit;}
.mode-panel.active{display:block;animation:rise .4s ease both;} .mode-panel.active{display:block;animation:rise .4s ease both;}
@keyframes rise{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}} @keyframes rise{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
@media (prefers-reduced-motion: reduce){.mode-panel.active{animation:none;}} @media (prefers-reduced-motion: reduce){.mode-panel.active{animation:none;}}
@media (prefers-color-scheme: dark){.mode-btn[data-mode="pregnancy"].active{color:#1B1417;}}
.hero{ .hero{
display:grid;grid-template-columns:1.3fr 1fr;gap:40px; display:grid;grid-template-columns:1.3fr 1fr;gap:40px;
@@ -149,11 +144,7 @@ button{font-family:inherit;}
} }
.hero h1{font-size:clamp(1.9rem,3.4vw,2.7rem);font-weight:600;line-height:1.08;} .hero h1{font-size:clamp(1.9rem,3.4vw,2.7rem);font-weight:600;line-height:1.08;}
.hero p.lede{color:var(--ink-soft);font-size:1.02rem;line-height:1.6;max-width:52ch;margin-top:14px;} .hero p.lede{color:var(--ink-soft);font-size:1.02rem;line-height:1.6;max-width:52ch;margin-top:14px;}
.hero-art{display:flex;justify-content:center;position:relative;} .hero-art{display:flex;justify-content:center;}
.hero-art.has-3d{align-items:center;}
.hero-art.has-3d .ring3d{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);}
.hero-art.has-3d svg{position:relative;max-width:300px;}
.hero-art.has-3d svg path,.hero-art.has-3d svg circle{display:none;}
.hero-art svg{width:100%;max-width:220px;height:auto;} .hero-art svg{width:100%;max-width:220px;height:auto;}
.stat-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;} .stat-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}
@@ -271,7 +262,7 @@ button{font-family:inherit;}
text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;font-weight:600;} text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;font-weight:600;}
input[type="range"].week-slider{ input[type="range"].week-slider{
width:100%;-webkit-appearance:none;appearance:none;height:8px;border-radius:999px; width:100%;-webkit-appearance:none;appearance:none;height:8px;border-radius:999px;
background:linear-gradient(to right, var(--pregnancy) 0, var(--pregnancy) calc(11px + (100% - 22px) * var(--f,0)), var(--pregnancy-soft) calc(11px + (100% - 22px) * var(--f,0)), var(--pregnancy-soft) 100%); background:linear-gradient(to right, var(--pregnancy-soft) 0%, var(--pregnancy-soft) 100%);
outline:none;cursor:pointer; outline:none;cursor:pointer;
} }
input[type="range"].week-slider::-webkit-slider-thumb{ input[type="range"].week-slider::-webkit-slider-thumb{
@@ -306,7 +297,6 @@ input[type="range"].week-slider::-moz-range-thumb{
.filmstrip{display:flex;gap:8px;overflow-x:auto;padding:4px 2px 20px;margin-top:8px;} .filmstrip{display:flex;gap:8px;overflow-x:auto;padding:4px 2px 20px;margin-top:8px;}
.film-item{ .film-item{
color:var(--ink);
flex:none;width:64px;height:64px;border-radius:12px;border:1px solid var(--line); flex:none;width:64px;height:64px;border-radius:12px;border:1px solid var(--line);
background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center; background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center;
cursor:pointer;font-family:"IBM Plex Mono",monospace; cursor:pointer;font-family:"IBM Plex Mono",monospace;
@@ -320,8 +310,7 @@ footer.note{
font-size:0.78rem;color:var(--ink-soft);line-height:1.6; font-size:0.78rem;color:var(--ink-soft);line-height:1.6;
} }
</style> </style>
</head>
<body>
<div class="app"> <div class="app">
<header class="topbar"> <header class="topbar">
<div class="wordmark"> <div class="wordmark">
@@ -335,15 +324,15 @@ footer.note{
<span class="tag">Menstrual &amp; Pregnancy Charting</span> <span class="tag">Menstrual &amp; Pregnancy Charting</span>
</div> </div>
<nav class="mode-switch" role="tablist" aria-label="Tracking mode"> <nav class="mode-switch" role="tablist" aria-label="Tracking mode">
<button class="mode-btn active" id="tab-period" aria-controls="mode-period" data-mode="period" role="tab" aria-selected="true">Period</button> <button class="mode-btn active" data-mode="period" role="tab" aria-selected="true">Period</button>
<button class="mode-btn" id="tab-ovulation" aria-controls="mode-ovulation" data-mode="ovulation" role="tab" aria-selected="false">Ovulation</button> <button class="mode-btn" data-mode="ovulation" role="tab" aria-selected="false">Ovulation</button>
<button class="mode-btn" id="tab-pregnancy" aria-controls="mode-pregnancy" data-mode="pregnancy" role="tab" aria-selected="false">Pregnancy</button> <button class="mode-btn" data-mode="pregnancy" role="tab" aria-selected="false">Pregnancy</button>
</nav> </nav>
</header> </header>
<main> <main>
<!-- ================= PERIOD ================= --> <!-- ================= PERIOD ================= -->
<section id="mode-period" class="mode-panel active" role="tabpanel" aria-labelledby="tab-period"> <section id="mode-period" class="mode-panel active">
<div class="hero"> <div class="hero">
<div> <div>
<span class="eyebrow" style="color:var(--period)">01 — Menstrual Cycle</span> <span class="eyebrow" style="color:var(--period)">01 — Menstrual Cycle</span>
@@ -409,7 +398,7 @@ footer.note{
</section> </section>
<!-- ================= OVULATION ================= --> <!-- ================= OVULATION ================= -->
<section id="mode-ovulation" class="mode-panel" role="tabpanel" aria-labelledby="tab-ovulation"> <section id="mode-ovulation" class="mode-panel">
<div class="hero"> <div class="hero">
<div> <div>
<span class="eyebrow" style="color:var(--ovulation)">02 — Fertile Window</span> <span class="eyebrow" style="color:var(--ovulation)">02 — Fertile Window</span>
@@ -457,7 +446,7 @@ footer.note{
</section> </section>
<!-- ================= PREGNANCY ================= --> <!-- ================= PREGNANCY ================= -->
<section id="mode-pregnancy" class="mode-panel" role="tabpanel" aria-labelledby="tab-pregnancy"> <section id="mode-pregnancy" class="mode-panel">
<div class="hero"> <div class="hero">
<div> <div>
<span class="eyebrow" style="color:var(--pregnancy)">03 — Forty Weeks</span> <span class="eyebrow" style="color:var(--pregnancy)">03 — Forty Weeks</span>
@@ -487,7 +476,7 @@ footer.note{
<span style="color:var(--pregnancy)">Trimester 2 · Weeks 14–27</span> <span style="color:var(--pregnancy)">Trimester 2 · Weeks 14–27</span>
<span style="color:var(--pregnancy)">Trimester 3 · Weeks 28–40</span> <span style="color:var(--pregnancy)">Trimester 3 · Weeks 28–40</span>
</div> </div>
<input type="range" class="week-slider" id="week-slider" aria-label="Pregnancy week, 1 to 40" min="1" max="40" value="20"> <input type="range" class="week-slider" id="week-slider" min="1" max="40" value="20">
<div class="week-ticks"><span>Wk 1</span><span>Wk 13</span><span>Wk 27</span><span>Wk 40</span></div> <div class="week-ticks"><span>Wk 1</span><span>Wk 13</span><span>Wk 27</span><span>Wk 40</span></div>
</div> </div>
@@ -535,11 +524,540 @@ footer.note{
</footer> </footer>
</div> </div>
<script src="app.js"></script> <script>
<script src="vendor/gsap.min.js"></script> (function(){
<script src="vendor/ScrollTrigger.min.js"></script> "use strict";
<script src="ring3d.js"></script>
<script src="animations.js"></script> /* ---------------- date helpers ---------------- */
<script src="timeline.js"></script> function toDate(y,m,d){ return new Date(y,m,d); }
</body> function fromInput(str){
</html> if(!str) return null;
var p = str.split("-");
return new Date(+p[0], +p[1]-1, +p[2]);
}
function toInputVal(d){
return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");
}
function addDays(d,n){ var r=new Date(d); r.setDate(r.getDate()+n); return r; }
function daysBetween(a,b){ // b - a, in whole days, ignoring time
var A = new Date(a.getFullYear(),a.getMonth(),a.getDate());
var B = new Date(b.getFullYear(),b.getMonth(),b.getDate());
return Math.round((B-A)/86400000);
}
function sameDay(a,b){ return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); }
function mod(n,m){ return ((n % m) + m) % m; }
var MONTH_NAMES = ["January","February","March","April","May","June","July","August","September","October","November","December"];
var today = new Date(); today.setHours(0,0,0,0);
function loadLS(key, fallback){
try{ var v = localStorage.getItem(key); return v===null ? fallback : v; }catch(e){ return fallback; }
}
function saveLS(key, val){ try{ localStorage.setItem(key,val); }catch(e){} }
/* ---------------- mode switching ---------------- */
var modeBtns = document.querySelectorAll(".mode-btn");
var panels = { period: document.getElementById("mode-period"), ovulation: document.getElementById("mode-ovulation"), pregnancy: document.getElementById("mode-pregnancy") };
modeBtns.forEach(function(btn){
btn.addEventListener("click", function(){
modeBtns.forEach(function(b){ b.classList.remove("active"); b.setAttribute("aria-selected","false"); });
btn.classList.add("active"); btn.setAttribute("aria-selected","true");
Object.keys(panels).forEach(function(k){ panels[k].classList.remove("active"); });
panels[btn.dataset.mode].classList.add("active");
saveLS("ca-mode", btn.dataset.mode);
});
});
var savedMode = loadLS("ca-mode", null);
if(savedMode && panels[savedMode]){
modeBtns.forEach(function(b){ b.classList.toggle("active", b.dataset.mode===savedMode); b.setAttribute("aria-selected", b.dataset.mode===savedMode ? "true":"false"); });
Object.keys(panels).forEach(function(k){ panels[k].classList.toggle("active", k===savedMode); });
}
/* ================================================================
PHASE + OVULATION REFERENCE DATA
================================================================ */
var PHASES = {
menstrual: {
title:"Menstrual", color:"var(--period)", soft:"var(--period-soft)",
window:"Cycle day 1 through the end of bleeding",
desc:"The uterine lining sheds because no pregnancy occurred last cycle. Estrogen and progesterone are both at their lowest point of the month.",
hormone:"Estrogen & progesterone: low", energy:"Energy: lowest",
symptoms:"Cramping, fatigue, lower back ache, mood dips",
tip:"Iron-rich food, gentle movement, and heat on the lower abdomen ease cramping better than rest alone."
},
follicular: {
title:"Follicular", color:"var(--ovulation)", soft:"var(--ovulation-soft)",
window:"Cycle day 1 through ovulation (overlaps menstrual)",
desc:"The pituitary releases FSH, prompting several ovarian follicles to mature. Estrogen climbs steadily as one dominant follicle takes the lead.",
hormone:"Estrogen: rising", energy:"Energy: climbing",
symptoms:"Clearer skin, sharper focus, rising motivation",
tip:"This is the physiologically best window for starting new training blocks or demanding projects."
},
ovulatory: {
title:"Ovulatory", color:"var(--ovulation-strong)", soft:"var(--ovulation-soft)",
window:"Roughly one day, mid-cycle",
desc:"A surge in luteinizing hormone (LH) triggers the dominant follicle to release its egg. This is the single most fertile day of the cycle.",
hormone:"LH: sharp surge", energy:"Energy & libido: peak",
symptoms:"Egg-white cervical mucus, one-sided pelvic twinge (mittelschmerz)",
tip:"Ovulation predictor kits detect the LH surge roughly 24–36 hours before the egg is released."
},
luteal: {
title:"Luteal", color:"var(--pregnancy)", soft:"var(--pregnancy-soft)",
window:"Ovulation through the day before your next period",
desc:"The ruptured follicle becomes the corpus luteum and secretes progesterone to prepare the uterine lining. If no implantation occurs, hormone levels fall and the cycle restarts.",
hormone:"Progesterone: rises then falls", energy:"Energy: gradually declining",
symptoms:"Bloating, breast tenderness, irritability, food cravings (PMS)",
tip:"Basal body temperature stays elevated ~0.3–0.5°C through this phase — a useful way to confirm ovulation already happened."
}
};
// offset relative to ovulation day (0 = ovulation)
var OVULATION_DAYS = {
"-5": {fert:"Low", fertClass:"fert-low", bbt:"Baseline (pre-shift)", mucus:"Dry to sticky", note:"Sperm can survive up to 5 days, so conception is technically possible starting here."},
"-4": {fert:"Low–moderate", fertClass:"fert-low", bbt:"Baseline", mucus:"Sticky, cloudy", note:"Fertility begins climbing as estrogen rises toward its pre-ovulation peak."},
"-3": {fert:"Moderate", fertClass:"fert-mid", bbt:"Baseline", mucus:"Creamy, increasing in volume", note:"Cervical mucus is becoming more hospitable to sperm."},
"-2": {fert:"High", fertClass:"fert-high", bbt:"Baseline", mucus:"Thin, stretchy, egg-white-like", note:"One of the two most fertile days of the entire cycle."},
"-1": {fert:"Peak", fertClass:"fert-peak", bbt:"Baseline (often the day of the LH surge)", mucus:"Clear, slippery, maximum stretch", note:"Statistically the single highest-probability day for conception."},
"0": {fert:"Peak", fertClass:"fert-peak", bbt:"Still baseline — the rise hasn't happened yet", mucus:"Egg-white, abundant", note:"The egg is released and is viable for roughly 12–24 hours. Some feel a one-sided twinge (mittelschmerz)."},
"1": {fert:"Dropping fast", fertClass:"fert-mid", bbt:"Beginning to rise ~0.3–0.5°C", mucus:"Becoming tacky again", note:"The egg's fertile window is closing; BBT starts its post-ovulation climb."},
"2": {fert:"Very low", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry or minimal", note:"Progesterone from the corpus luteum keeps temperature elevated through the luteal phase."},
"3": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"If fertilization happened, the embryo is traveling toward the uterus."},
"4": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Too late in the cycle for a new ovulation; luteal phase symptoms may start appearing."},
"5": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Implantation, if it happens this cycle, typically occurs around days 6–10 after ovulation."}
};
var FERT_COLOR = {"fert-low":"var(--ink-soft)","fert-mid":"var(--fertile)","fert-high":"var(--fertile-strong)","fert-peak":"var(--ovulation-strong)"};
function phaseForDayInCycle(dic, periodLen, ovulationDic){
if(dic < periodLen) return "menstrual";
if(dic < ovulationDic) return "follicular";
if(dic === ovulationDic) return "ovulatory";
return "luteal";
}
function isFertile(dic, ovulationDic){
return dic >= ovulationDic - 5 && dic <= ovulationDic;
}
/* ================================================================
PREGNANCY WEEK DATA (1–40)
================================================================ */
var WEEKS = [
{c:"a poppy seed (not yet fertilized)", m:"n/a", dev:"Pregnancy is dated from the first day of this period, before conception happens — your body is shedding last cycle's lining and preparing a fresh egg.", body:"This week looks and feels like an ordinary period.", tip:"If you're trying to conceive, this is a good week to start a prenatal vitamin with folic acid."},
{c:"an egg cell", m:"~0.1 mm", dev:"Rising estrogen prompts an egg to mature; ovulation typically occurs near the end of this week, roughly two weeks before a missed period.", body:"Cervical mucus becomes clear and stretchy around ovulation — a natural fertility sign.", tip:"This is the fertile window if you're trying to conceive — see the Ovulation tab for day-by-day detail."},
{c:"a grain of sand", m:"~0.1–0.2 mm", dev:"If sperm meets egg this week, fertilization occurs in the fallopian tube and the resulting cell begins dividing as it travels toward the uterus.", body:"No symptoms are detectable yet — even a sensitive test won't turn positive for another week or two.", tip:"Implantation, if it happens, usually occurs 6–10 days after ovulation."},
{c:"a poppy seed", m:"~0.1 mm", dev:"The fertilized egg implants in the uterine lining and begins producing hCG, the hormone home pregnancy tests detect.", body:"Most people notice no symptoms yet; a sensitive test may already turn positive near the end of this week.", tip:"A missed period is usually the first sign — testing a few days after that gives the most reliable result."},
{c:"a sesame seed", m:"~2 mm", dev:"The neural tube — the earliest structure of the brain and spinal cord — begins to form, along with the first blood vessels.", body:"A missed period is usually the first clear sign; fatigue and tender breasts may begin.", tip:"Avoid alcohol and confirm any medications with a clinician — this window is critical for early development."},
{c:"a lentil", m:"~4 mm", dev:"A primitive heart tube starts beating, and the beginnings of the face, arms, and legs appear as small buds.", body:"Morning sickness and smell sensitivity often begin here, though timing varies widely.", tip:"Small, frequent, bland meals tend to sit better than three large ones if nausea has started."},
{c:"a blueberry", m:"~1.3 cm", dev:"The brain is dividing into distinct sections, and the arm and leg buds are elongating into recognizable limbs.", body:"Bloating and mild cramping are common as the uterus makes room to grow.", tip:"This is when most people schedule their first prenatal appointment — dating scans are most accurate now."},
{c:"a raspberry", m:"~1.6 cm", dev:"Fingers and toes are forming, still webbed; the heart now has four distinct chambers.", body:"Fatigue often peaks around this point as progesterone rises sharply.", tip:"Rest when you can — first-trimester exhaustion is real and temporary, not a sign anything is wrong."},
{c:"a grape", m:"~2.3 cm · 4 g", dev:"The embryo is officially called a fetus starting around now; essential organs are all present in early form and the tail has disappeared.", body:"Nausea, food aversions, and a heightened sense of smell are often at their strongest.", tip:"Ginger tea, vitamin B6, and staying hydrated are commonly recommended first-line options for nausea."},
{c:"a kumquat", m:"~3 cm · 7 g", dev:"Vital organs — kidneys, liver, brain — are functioning in a basic form, and tiny fingernail beds are forming.", body:"A visible baby bump is still unlikely, though clothes may feel tighter.", tip:"Ask your provider about first-trimester screening options available in this window."},
{c:"a fig", m:"~4.1 cm · 10 g", dev:"The fetus can now bend its arms and legs, and tooth buds are forming beneath the gums.", body:"Round ligament twinges may start as the uterus stretches.", tip:"Gentle stretching and changing positions slowly can ease early ligament discomfort."},
{c:"a lime", m:"~5.4 cm · 14 g", dev:"Reflexes are developing — fingers can curl, and toes can wiggle, though it can't be felt yet.", body:"The end of the first trimester is near, and miscarriage risk drops meaningfully from here.", tip:"Many people choose to share their pregnancy news around now, though the choice is entirely personal."},
{c:"a plum", m:"~6.7 cm · 23 g", dev:"Vocal cords are forming, and unique fingerprints are being etched onto tiny fingertips.", body:"Some early nausea may start easing as the placenta takes over hormone production.", tip:"The nuchal translucency scan, if chosen, is typically done between now and week 13."},
{c:"a passion fruit", m:"~10 cm · 45 g", dev:"Baby can open and close fists and may even suck a thumb; genitals are differentiating, though not yet visible on ultrasound.", body:"For many, first-trimester nausea and fatigue begin to lift.", tip:"Second-trimester energy often supports resuming or starting light-to-moderate exercise — check in with your provider first."},
{c:"a peach", m:"~11.6 cm · 58 g", dev:"Vocal cords are complete and the intestines, which developed in the umbilical cord, move into the abdomen.", body:"Welcome to the second trimester — often called the most comfortable stretch of pregnancy.", tip:"An anatomy scan is usually scheduled in the coming weeks; ask your clinic when it's booked."},
{c:"a lemon", m:"~13 cm · 70 g", dev:"Fine, downy hair called lanugo begins covering the body, and the kidneys are producing urine into the amniotic fluid.", body:"A small bump may finally become visible; appetite often improves noticeably.", tip:"Iron needs rise sharply now — leafy greens, legumes, and lean meat help meet increased demand."},
{c:"an apple", m:"~14.2 cm · 95 g", dev:"The skeleton continues hardening and baby can sense light through closed eyelids.", body:"Skin changes like a faint linea nigra or darkened areolas may appear as pigment increases.", tip:"Daily SPF helps limit pregnancy-related skin pigmentation (melasma) from sun exposure."},
{c:"an avocado", m:"~16 cm · 135 g", dev:"The placenta is now fully formed and functioning as baby's life-support system.", body:"First flutters of movement ('quickening') may be felt any time from now through week 20.", tip:"Movement timing varies a lot — first pregnancies and anterior placentas often mean feeling it later."},
{c:"a pomegranate", m:"~17.7 cm · 160 g", dev:"The skeleton is converting from cartilage to true bone, and fat stores are just beginning to accumulate.", body:"The uterus is now roughly at the level of the belly button.", tip:"A pregnancy support pillow can meaningfully improve sleep as side-sleeping becomes more comfortable."},
{c:"a bell pepper", m:"~19 cm · 190 g", dev:"Ears have reached their final position and baby may begin startling at loud external sounds.", body:"Quickening is commonly felt by now — often described as bubbles or light taps.", tip:"Try narrating your day out loud — hearing is developing and familiar voices become recognizable over time."},
{c:"a mango", m:"~20.3 cm · 240 g", dev:"A waxy coating called vernix caseosa begins protecting the skin, and taste buds are forming.", body:"Backaches can start as posture shifts to balance the growing bump.", tip:"A supportive, flatter shoe and a pillow between the knees at night both help low back strain."},
{c:"a banana", m:"~25.6 cm · 300 g", dev:"The detailed anatomy ultrasound is often done around now if it hasn't happened already, and sex can typically be identified on it.", body:"Movements are stronger and more frequent; the uterus is now level with the belly button.", tip:"If you want to know the sex and haven't had the anatomy scan yet, ask your clinic to schedule it soon — it's usually done weeks 18–22."},
{c:"a carrot", m:"~27 cm · 360 g", dev:"Baby is swallowing small amounts of amniotic fluid, giving the digestive system practice.", body:"Stretch marks may start appearing on the belly, hips, or chest as skin stretches.", tip:"Moisturizing won't prevent stretch marks, but it can ease the itchiness that comes with stretching skin."},
{c:"a papaya", m:"~28.9 cm · 430 g", dev:"Eyebrows and eyelids are fully formed, and the sense of touch is developing rapidly.", body:"Braxton Hicks — brief, painless practice contractions — may be felt occasionally from here on.", tip:"Braxton Hicks usually ease with a change of position or a glass of water; true labor contractions don't."},
{c:"an ear of corn", m:"~32.2 cm · 600 g", dev:"The lungs begin producing surfactant, a substance essential for breathing air after birth — a key viability milestone.", body:"The linea nigra (a dark vertical line on the belly) may become more visible.", tip:"This week is often cited as the threshold of viability with intensive neonatal care, should birth happen very early."},
{c:"a cauliflower head", m:"~33.7 cm · 660 g", dev:"Baby can now respond to your voice, and hand grip strength is developing.", body:"The glucose screening test for gestational diabetes is typically scheduled around now.", tip:"If asked to fast beforehand, follow your clinic's instructions closely for an accurate glucose result."},
{c:"a head of lettuce", m:"~35.6 cm · 760 g", dev:"Eyes begin to open after being fused shut since early development, and hair may be visibly growing.", body:"Round ligament pain and rib discomfort can increase as space gets tighter.", tip:"A maternity support band can ease rib and ligament pressure during long periods of standing or walking."},
{c:"a bunch of scallions", m:"~36.6 cm · 875 g", dev:"Brain tissue is developing rapidly, and baby is developing regular hiccups you may be able to feel.", body:"Third trimester begins soon — appointments typically become more frequent from here.", tip:"Hiccup-like twitches are completely normal and are the diaphragm practicing its rhythm."},
{c:"an eggplant", m:"~37.6 cm · 1.1 kg", dev:"Eyes can blink now, and baby may cycle through active REM sleep.", body:"Welcome to the third trimester — the home stretch begins.", tip:"This is a common time to start planning a hospital bag and reviewing birth preferences."},
{c:"a butternut squash", m:"~38.6 cm · 1.2 kg", dev:"Bones are fully formed but still soft and flexible, ready to move through the birth canal.", body:"Frequent urination often returns as the head engages lower in the pelvis.", tip:"Kick counts become a useful daily check-in — note baby's typical pattern of movement."},
{c:"a large cabbage", m:"~39.9 cm · 1.3 kg", dev:"The brain is developing at its fastest rate yet, adding folds and surface area.", body:"Sleep can become harder to find as the bump limits comfortable positions.", tip:"A full-body pregnancy pillow supports the belly, back, and knees simultaneously."},
{c:"a coconut", m:"~41.1 cm · 1.5 kg", dev:"All five senses are now functioning, and baby is gaining roughly half a pound a week.", body:"Colostrum, the first form of breast milk, may begin to leak slightly.", tip:"A few nursing pads in your bag can help if colostrum leaking becomes noticeable."},
{c:"a jicama", m:"~42.4 cm · 1.7 kg", dev:"Baby is practicing breathing movements against a closed airway, strengthening the lungs.", body:"Swelling and mild carpal tunnel tingling in the hands can appear from fluid retention.", tip:"Report sudden or severe swelling to your provider promptly — it can occasionally signal preeclampsia."},
{c:"a pineapple", m:"~43.7 cm · 1.9 kg", dev:"Bones are hardening overall, though the skull stays deliberately soft and flexible for delivery.", body:"Pelvic pressure increases as baby continues descending.", tip:"Prenatal massage or a warm bath can ease the deep pelvic ache many feel this week."},
{c:"a cantaloupe", m:"~45 cm · 2.1 kg", dev:"The central nervous system is maturing quickly, and baby's grip is now quite strong.", body:"Braxton Hicks contractions often become more noticeable and frequent.", tip:"Time any contractions that feel different — regular, intensifying, closer together warrants a call to your provider."},
{c:"a honeydew melon", m:"~46.2 cm · 2.4 kg", dev:"The kidneys are fully developed, and the liver is processing waste products on its own.", body:"You may feel baby 'drop' lower as it settles into position for birth.", tip:"Lightening (the baby dropping) can ease heartburn but often increases bathroom trips."},
{c:"a head of romaine", m:"~47.4 cm · 2.6 kg", dev:"Most physical development is complete; the focus now is steady weight gain.", body:"You've just reached 'early term' — babies born from here on tend to do very well.", tip:"Pack your hospital bag if you haven't yet — early-term babies can arrive with little warning."},
{c:"a leek", m:"~49.8 cm · 3.1 kg", dev:"Baby continues laying down fat for temperature regulation after birth, and the brain is still rapidly developing.", body:"Nesting instinct and a burst of energy are common right before labor.", tip:"Balance nesting energy with rest — labor's timing is unpredictable."},
{c:"a mini watermelon", m:"~50.7 cm · 3.3 kg", dev:"Full term. The lungs are completing final maturation in preparation for that first breath.", body:"Increased pelvic pressure and more frequent, intense Braxton Hicks are common.", tip:"Watch for your labor signs: regular contractions, water breaking, or a bloody show."},
{c:"a small pumpkin", m:"~51.2 cm · 3.5 kg", dev:"Baby is fully developed and simply continuing to grow, ready to meet you.", body:"If labor hasn't started, your provider will discuss monitoring or induction options.", tip:"Most babies arrive within two weeks of their due date in either direction — both are entirely normal."}
];
/* ================================================================
SVG helpers
================================================================ */
function polarToXY(cx,cy,r,angleDeg){
var rad = (angleDeg-90) * Math.PI/180;
return [cx + r*Math.cos(rad), cy + r*Math.sin(rad)];
}
function arcPath(cx,cy,r,startDeg,endDeg){
if(endDeg <= startDeg) endDeg = startDeg + 0.01;
var s = polarToXY(cx,cy,r,startDeg), e = polarToXY(cx,cy,r,endDeg);
var large = (endDeg-startDeg) > 180 ? 1 : 0;
return "M "+s[0].toFixed(2)+" "+s[1].toFixed(2)+" A "+r+" "+r+" 0 "+large+" 1 "+e[0].toFixed(2)+" "+e[1].toFixed(2);
}
/* ================================================================
SHARED STATE: period start / cycle length / period length
================================================================ */
var exampleStart = addDays(today, -10);
var startInput = document.getElementById("p-start");
var cycleLenInput = document.getElementById("p-cyclelen");
var periodLenInput = document.getElementById("p-periodlen");
var savedStart = loadLS("ca-start", null);
var savedCycleLen = loadLS("ca-cyclelen", null);
var savedPeriodLen = loadLS("ca-periodlen", null);
var usingExample = !savedStart;
startInput.value = savedStart || toInputVal(exampleStart);
cycleLenInput.value = savedCycleLen || 28;
periodLenInput.value = savedPeriodLen || 5;
function getCycleState(){
return {
start: fromInput(startInput.value) || exampleStart,
cycleLen: Math.max(21, Math.min(35, parseInt(cycleLenInput.value,10) || 28)),
periodLen: Math.max(2, Math.min(10, parseInt(periodLenInput.value,10) || 5))
};
}
var pCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
var oCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
var pSelectedDate = new Date(today);
var oSelectedDate = new Date(today);
[startInput,cycleLenInput,periodLenInput].forEach(function(inp){
inp.addEventListener("input", function(){
saveLS("ca-start", startInput.value);
saveLS("ca-cyclelen", cycleLenInput.value);
saveLS("ca-periodlen", periodLenInput.value);
usingExample = false;
document.getElementById("p-example-note").style.display = "none";
document.getElementById("pr-example-note").style.display = "none";
renderAll();
});
});
if(!usingExample) document.getElementById("p-example-note").style.display = "none";
/* ================================================================
CALENDAR RENDERING (shared builder)
================================================================ */
function buildCalendar(gridEl, weekdayHeaderIgnored, monthDate, dayClassifier, onSelect, selectedDate){
gridEl.innerHTML = "";
var year = monthDate.getFullYear(), month = monthDate.getMonth();
var firstDow = new Date(year,month,1).getDay();
var daysInMonth = new Date(year,month+1,0).getDate();
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
for(var i=0;i<totalCells;i++){
var dayNum = i - firstDow + 1;
var cell = document.createElement("button");
cell.className = "day-cell";
if(dayNum < 1 || dayNum > daysInMonth){
cell.classList.add("pad");
gridEl.appendChild(cell);
continue;
}
var d = new Date(year,month,dayNum);
var info = dayClassifier(d);
cell.innerHTML = "<span>"+dayNum+"</span>";
if(info.period) cell.classList.add("is-period");
if(info.fertile) cell.classList.add("is-fertile");
if(info.ovulation) cell.classList.add("is-ovulation");
if(sameDay(d,today)) cell.classList.add("today");
if(selectedDate && sameDay(d,selectedDate)) cell.classList.add("selected");
cell.setAttribute("aria-label", d.toDateString());
cell.addEventListener("click", function(dd){ return function(){ onSelect(dd); }; }(d));
gridEl.appendChild(cell);
}
}
function classifyForCycle(d, state){
var dic = mod(daysBetween(state.start, d), state.cycleLen);
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
return {
period: dic < state.periodLen,
fertile: isFertile(dic, ovDic),
ovulation: dic === ovDic,
dic: dic, ovDic: ovDic
};
}
/* ---------------- PERIOD tab render ---------------- */
function renderPeriodCalendar(){
var state = getCycleState();
document.getElementById("p-cal-title").textContent = MONTH_NAMES[pCalMonth.getMonth()] + " " + pCalMonth.getFullYear();
buildCalendar(document.getElementById("p-daygrid"), null, pCalMonth,
function(d){ return classifyForCycle(d, state); },
function(d){ pSelectedDate = d; renderPeriodDetail(); renderPeriodCalendar(); },
pSelectedDate
);
}
function renderPeriodDetail(){
var state = getCycleState();
var info = classifyForCycle(pSelectedDate, state);
var phaseKey = phaseForDayInCycle(info.dic, state.periodLen, info.ovDic);
var phase = PHASES[phaseKey];
var el = document.getElementById("p-detail");
el.innerHTML =
'<span class="d-date">'+pSelectedDate.toDateString()+'</span>'+
'<span class="pill" style="background:'+phase.soft+';color:'+phase.color+'">'+phase.title+' phase'+(info.fertile?' · fertile window':'')+'</span>'+
'<div class="d-phase">Cycle day '+(info.dic+1)+'</div>'+
'<p class="d-desc">'+phase.desc+'</p>'+
'<div class="d-grid">'+
'<div class="d-mini"><div class="k">Hormones</div><div class="v">'+phase.hormone+'</div></div>'+
'<div class="d-mini"><div class="k">Typical symptoms</div><div class="v" style="font-weight:500;font-size:0.82rem;">'+phase.symptoms+'</div></div>'+
'</div>'+
'<p class="d-desc"><strong style="color:var(--ink)">Care note — </strong>'+phase.tip+'</p>';
}
function renderPeriodStats(){
var state = getCycleState();
var dic = mod(daysBetween(state.start, today), state.cycleLen);
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
var daysToNextPeriod = mod(state.cycleLen - dic, state.cycleLen);
if(daysToNextPeriod === 0 && dic !== 0) daysToNextPeriod = state.cycleLen;
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
var phaseKey = phaseForDayInCycle(dic, state.periodLen, ovDic);
document.getElementById("period-stats").innerHTML =
statTile(dic+1, "Cycle day today") +
statTile(PHASES[phaseKey].title, "Current phase") +
statTile(dic===0 ? "Today" : (daysToNextPeriod+"d"), "Until next period") +
statTile(dic===ovDic ? "Today" : (daysToOvulation+"d"), "Until ovulation");
// hero ring
var cx=100, cy=100, r=88;
var perStart=0, perEnd = state.periodLen/state.cycleLen*360;
var follEnd = ovDic/state.cycleLen*360;
var fertStart = Math.max(0,(ovDic-5))/state.cycleLen*360;
var lutStart = (ovDic+1)/state.cycleLen*360;
document.getElementById("phase-arc-menstrual").setAttribute("d", arcPath(cx,cy,r,perStart,perEnd));
document.getElementById("phase-arc-follicular").setAttribute("d", arcPath(cx,cy,r,perEnd,fertStart));
document.getElementById("phase-arc-fertile").setAttribute("d", arcPath(cx,cy,r,fertStart,follEnd+ (360/state.cycleLen)));
document.getElementById("phase-arc-luteal").setAttribute("d", arcPath(cx,cy,r,lutStart,360));
var todayAngle = dic/state.cycleLen*360;
var tp = polarToXY(cx,cy,r,todayAngle);
document.getElementById("today-marker").setAttribute("cx", tp[0]);
document.getElementById("today-marker").setAttribute("cy", tp[1]);
document.getElementById("cycle-day-big").textContent = dic+1;
}
function statTile(num,label){
return '<div class="stat-tile"><div class="num">'+num+'</div><div class="lbl">'+label+'</div></div>';
}
document.getElementById("p-prev").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()-1); renderPeriodCalendar(); });
document.getElementById("p-next").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()+1); renderPeriodCalendar(); });
function renderPhaseRefGrid(){
var order = ["menstrual","follicular","ovulatory","luteal"];
document.getElementById("phase-ref-grid").innerHTML = order.map(function(key,i){
var p = PHASES[key];
return '<div class="ref-card"><span class="r-num">0'+(i+1)+'</span><h4 style="color:'+p.color+'">'+p.title+'</h4>'+
'<p>'+p.desc+'</p>'+
'<div class="tag-row"><span class="tagchip">'+p.window+'</span></div></div>';
}).join("");
}
/* ---------------- OVULATION tab render ---------------- */
var ovSelectedOffset = 0;
function renderOvTimeline(){
var wrap = document.getElementById("ov-timeline");
wrap.innerHTML = "";
for(var off=-5; off<=5; off++){
var d = OVULATION_DAYS[String(off)];
var btn = document.createElement("button");
btn.className = "tl-btn" + (off===ovSelectedOffset ? " active":"");
btn.innerHTML = '<div class="tl-day">'+(off===0?"OVUL.":(off>0?"+":"")+off)+'</div>'+
'<div class="tl-fert" style="background:'+FERT_COLOR[d.fertClass]+'"></div>';
btn.addEventListener("click", function(o){ return function(){ ovSelectedOffset=o; renderOvTimeline(); renderOvDetailFromOffset(); }; }(off));
wrap.appendChild(btn);
}
}
function renderOvDetailFromOffset(){
var d = OVULATION_DAYS[String(ovSelectedOffset)];
var el = document.getElementById("o-detail");
var label = ovSelectedOffset===0 ? "Ovulation day" : (ovSelectedOffset>0 ? ovSelectedOffset+" day"+(ovSelectedOffset>1?"s":"")+" after ovulation" : Math.abs(ovSelectedOffset)+" day"+(ovSelectedOffset<-1?"s":"")+" before ovulation");
el.innerHTML =
'<span class="d-date">'+label+'</span>'+
'<span class="pill" style="background:var(--ovulation-soft);color:'+FERT_COLOR[d.fertClass]+'">Fertility: '+d.fert+'</span>'+
'<div class="d-phase">'+(ovSelectedOffset===0?"Egg release":(ovSelectedOffset<0?"Building fertility":"Fertility fading"))+'</div>'+
'<div class="d-grid">'+
'<div class="d-mini"><div class="k">Basal body temp</div><div class="v" style="font-size:0.82rem;">'+d.bbt+'</div></div>'+
'<div class="d-mini"><div class="k">Cervical mucus</div><div class="v" style="font-size:0.82rem;">'+d.mucus+'</div></div>'+
'</div>'+
'<p class="d-desc">'+d.note+'</p>';
}
function renderOvulationCalendar(){
var state = getCycleState();
document.getElementById("o-cal-title").textContent = MONTH_NAMES[oCalMonth.getMonth()] + " " + oCalMonth.getFullYear();
buildCalendar(document.getElementById("o-daygrid"), null, oCalMonth,
function(d){ return classifyForCycle(d, state); },
function(d){
oSelectedDate = d;
var info = classifyForCycle(d, state);
var offset = info.dic - info.ovDic;
if(offset < -5) offset = -5; if(offset > 5) offset = 5;
ovSelectedOffset = offset;
renderOvTimeline(); renderOvDetailFromOffset(); renderOvulationCalendar();
},
oSelectedDate
);
}
function renderOvulationStats(){
var state = getCycleState();
var dic = mod(daysBetween(state.start, today), state.cycleLen);
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
var fertileStartDic = mod(ovDic-5, state.cycleLen);
var inFertile = isFertile(dic, ovDic);
var daysToFertile = inFertile ? 0 : mod(fertileStartDic - dic, state.cycleLen);
document.getElementById("ovulation-stats").innerHTML =
statTile(inFertile ? "Yes" : "No", "In fertile window") +
statTile(dic===ovDic ? "Today" : daysToOvulation+"d", "Until ovulation") +
statTile(inFertile ? "Now" : daysToFertile+"d", "Fertile window") +
statTile("6 days", "Window length");
}
document.getElementById("o-prev").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()-1); renderOvulationCalendar(); });
document.getElementById("o-next").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()+1); renderOvulationCalendar(); });
/* ================================================================
PREGNANCY tab
================================================================ */
var lmpInput = document.getElementById("pr-lmp");
var exampleLMP = addDays(today, -20*7 - 3);
var savedLMP = loadLS("ca-lmp", null);
lmpInput.value = savedLMP || toInputVal(exampleLMP);
if(savedLMP) document.getElementById("pr-example-note").style.display = "none";
var slider = document.getElementById("week-slider");
var prCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
lmpInput.addEventListener("input", function(){
saveLS("ca-lmp", lmpInput.value);
document.getElementById("pr-example-note").style.display = "none";
document.getElementById("p-example-note").style.display = "none";
syncSliderToLMP();
renderPregnancy();
});
function getLMP(){ return fromInput(lmpInput.value) || exampleLMP; }
function currentGestWeek(){
var lmp = getLMP();
var w = Math.floor(daysBetween(lmp,today)/7) + 1;
return Math.max(1, Math.min(40, w));
}
function syncSliderToLMP(){ slider.value = currentGestWeek(); }
syncSliderToLMP();
function renderPregnancyStats(){
var lmp = getLMP();
var due = addDays(lmp, 280);
var week = currentGestWeek();
var daysLeft = Math.max(0, daysBetween(today, due));
document.getElementById("pregnancy-stats").innerHTML =
statTile(week, "Weeks along") +
statTile(Math.floor((week-1)/13)+1 <= 3 ? ["First","Second","Third"][Math.min(2,Math.floor((week-1)/13))] : "Third", "Trimester") +
statTile(daysLeft, "Days to due date") +
statTile(due.toLocaleDateString(undefined,{month:"short",day:"numeric"}), "Estimated due date");
var pct = (week-1)/39;
var pathLen = 230;
var offset = pathLen - pct*pathLen;
var path = document.getElementById("pregnancy-progress-path");
path.setAttribute("stroke-dashoffset", offset.toFixed(1));
try{
var totalLen = path.getTotalLength ? path.getTotalLength() : pathLen;
var pt = path.getPointAtLength ? path.getPointAtLength(pct*totalLen) : null;
if(pt) { document.getElementById("pregnancy-progress-dot").setAttribute("cx",pt.x); document.getElementById("pregnancy-progress-dot").setAttribute("cy",pt.y); }
}catch(e){}
}
function renderWeekPanel(week){
var w = WEEKS[week-1];
document.getElementById("wk-badge").textContent = "WEEK " + week;
document.getElementById("wk-compare").textContent = "size of " + w.c;
document.getElementById("wk-metric").textContent = w.m === "n/a" ? "not yet fertilized" : "≈ " + w.m;
document.getElementById("wk-dev").textContent = w.dev;
document.getElementById("wk-body").textContent = w.body;
document.getElementById("wk-tip").textContent = w.tip;
var tri = week<=13?1:(week<=27?2:3);
var triColor = "var(--pregnancy)";
document.getElementById("wk-blob").innerHTML =
'<svg width="96" height="96" viewBox="0 0 96 96"><circle cx="48" cy="48" r="'+(14+week*0.9)+'" fill="'+ (tri===1?"var(--pregnancy-soft)":tri===2?"var(--pregnancy)":"var(--pregnancy-strong)") +'" opacity="0.85"/></svg>';
document.querySelectorAll(".film-item").forEach(function(f){ f.classList.toggle("active", +f.dataset.week===week); });
var active = document.querySelector('.film-item[data-week="'+week+'"]');
if(active) active.scrollIntoView({inline:"center", block:"nearest", behavior:"smooth"});
}
slider.addEventListener("input", function(){ renderWeekPanel(+slider.value); });
function buildFilmstrip(){
var wrap = document.getElementById("week-filmstrip");
wrap.innerHTML = "";
for(var w=1; w<=40; w++){
var item = document.createElement("button");
item.className = "film-item";
item.dataset.week = w;
item.innerHTML = '<div class="fw">WK</div><div class="fn">'+w+'</div>';
item.addEventListener("click", function(ww){ return function(){ slider.value=ww; renderWeekPanel(ww); }; }(w));
wrap.appendChild(item);
}
}
function renderPregnancyCalendar(){
var lmp = getLMP();
document.getElementById("pr-cal-title").textContent = MONTH_NAMES[prCalMonth.getMonth()] + " " + prCalMonth.getFullYear();
var grid = document.getElementById("pr-daygrid");
grid.innerHTML = "";
var year = prCalMonth.getFullYear(), month = prCalMonth.getMonth();
var firstDow = new Date(year,month,1).getDay();
var daysInMonth = new Date(year,month+1,0).getDate();
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
for(var i=0;i<totalCells;i++){
var dayNum = i - firstDow + 1;
var cell = document.createElement("button");
cell.className = "day-cell";
if(dayNum<1 || dayNum>daysInMonth){ cell.classList.add("pad"); grid.appendChild(cell); continue; }
var d = new Date(year,month,dayNum);
var wk = Math.floor(daysBetween(lmp,d)/7)+1;
var inRange = wk>=1 && wk<=42;
cell.innerHTML = "<span>"+dayNum+"</span>" + (inRange ? '<span class="dot-row" style="position:static;margin-top:2px;font-size:0.55rem;color:var(--pregnancy-strong)">W'+wk+'</span>' : "");
if(inRange && wk<=40) cell.classList.add("is-fertile");
if(sameDay(d,today)) cell.classList.add("today");
cell.addEventListener("click", function(ww){ return function(){ if(ww>=1&&ww<=40){ slider.value=ww; renderWeekPanel(ww); } }; }(wk));
grid.appendChild(cell);
}
}
document.getElementById("pr-prev").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()-1); renderPregnancyCalendar(); });
document.getElementById("pr-next").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()+1); renderPregnancyCalendar(); });
function renderPregnancy(){
renderPregnancyStats();
renderWeekPanel(currentGestWeek());
renderPregnancyCalendar();
}
/* ================================================================
INIT
================================================================ */
function renderAll(){
renderPeriodStats();
renderPeriodCalendar();
renderPeriodDetail();
renderOvulationStats();
renderOvTimeline();
renderOvDetailFromOffset();
renderOvulationCalendar();
renderPregnancy();
}
renderPhaseRefGrid();
buildFilmstrip();
slider.value = currentGestWeek();
renderAll();
})();
</script>
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+46
View File
@@ -0,0 +1,46 @@
# PRD: Keith Rana Portfolio (built from the "Jack 3D Creator" template)
## The restaurant analogy
| Stage | Restaurant | This project |
|---|---|---|
| Phase 1 (done) | Food stand: one great dish, open today | The full single-page site with Keith's real content, photos and animations |
| Phase 2 | Small restaurant: a few tables, a real menu | Make it easy to edit (content in one file), better mobile + accessibility |
| Phase 3 | Full restaurant: front of house + back of house | Real contact form, email, project pages, CMS, analytics |
Front of house = what visitors see. Back of house = forms, email, storage, security.
## Core features
1. Hero: giant "Hi, i'm jack" title, nav, magnetic portrait, Contact button
2. Marquee: two rows of GIFs sliding left/right as you scroll
3. About: floating 3D icons + words that light up as you scroll
4. Services: white panel, 5 numbered services
5. Projects: 3 cards that stack and shrink as you scroll
6. Reusable parts: ContactButton, LiveProjectButton, FadeIn, Magnet, AnimatedText
## Phased roadmap
**Phase 1 — Food stand (today).** Static site, all 5 sections, all animations, responsive.
Done when: builds with no errors; page loads with no console errors; every button/link works; all image URLs load.
**Phase 2 — Small restaurant.** Nav links smooth-scroll to sections, Contact Me opens a mailto, content moved into editable data files, reduced-motion support, alt text, SEO tags, image fallbacks.
**Phase 3 — Full restaurant.** Contact form with a backend (server-side validation, spam protection, rate limits), project detail pages, CMS, analytics, hosting.
## Test ladder (each step must pass before the next)
1. Page loads with no errors
2. Buttons / links / forms work
3. Real external APIs/assets work (images load)
4. Security check (no secrets in code, safe external links, dependency audit)
## Security notes
Phase 1 has no forms, no logins, no secrets, so risk is low. External links use rel="noopener noreferrer". When Phase 3 adds a form, validation and spam protection must live on the server, and API keys must never be in front-end code.
## Phase 1 status (done)
- Content comes from Keith's existing portfolio: profile, 8 capabilities, 3 roles + 5 earlier roles, 4 projects, 10 certifications, contact details (all in `src/data/content.ts`).
- Each project card has its own illustrative diagram (drawn from the deliverables in Keith's portfolio text; swap for real screenshots when available).
- Sections: Hero (portrait), scrolling photo/skills rows, About, Capability, Experience, Projects (stacking cards), Credentials, Contact.
- Photos and the Kanit font ship inside the site (no outside image hosts needed).
- Tested at 360, 390, 768, 1024, 1440 and 1920 px wide: no sideways scroll, no console errors, all images load.
- Cloud preview: `node scripts/build-preview.mjs out.html` builds one self-contained page.
## Not used yet
- `it_helpdesk.db` (87 IT help-desk FAQs) was supplied but is not portfolio content. Candidate for a Phase 2 FAQ section if wanted.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Keith Rana, IT Service Delivery Manager in Melbourne, Australia." />
<title>Keith Rana -- IT Service Delivery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2754
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "keith-portfolio",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/kanit": "^5.3.0",
"framer-motion": "^12.38.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"vite": "^5.4.2"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};
+24
View File
@@ -0,0 +1,24 @@
// Builds ONE self-contained HTML page for the Claude cloud preview.
// Claude's cloud pages block outside images and files, so photos and fonts are baked in.
// Usage: node scripts/build-preview.mjs <output.html>
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
const out = process.argv[2] ?? 'preview.html';
execFileSync('npm', ['run', 'build'], { stdio: 'inherit', env: { ...process.env, INLINE: '1' } });
const dir = 'dist/assets/';
const files = readdirSync(dir);
const js = readFileSync(dir + files.find((f) => f.endsWith('.js')), 'utf8').replace(/<\/script/g, '<\\/script');
const css = readFileSync(dir + files.find((f) => f.endsWith('.css')), 'utf8');
const html = `<meta charset="utf-8">
<title>Keith Rana Portfolio</title>
<style>${css}
html,body{background:#0C0C0C}</style>
<div id="root"></div>
<script type="module">${js}</script>
`;
writeFileSync(out, html);
const mb = Buffer.byteLength(html) / 1048576;
console.log(`wrote ${out} (${mb.toFixed(1)} MB)${mb > 16 ? ' WARNING: over the 16 MB page limit' : ''}`);
+26
View File
@@ -0,0 +1,26 @@
import HeroSection from './sections/HeroSection';
import MarqueeSection from './sections/MarqueeSection';
import AboutSection from './sections/AboutSection';
import ServicesSection from './sections/ServicesSection';
import ExperienceSection from './sections/ExperienceSection';
import ProjectsSection from './sections/ProjectsSection';
import CredentialsSection from './sections/CredentialsSection';
import ContactSection from './sections/ContactSection';
import { ContactProvider } from './components/ContactDialog';
export default function App() {
return (
<ContactProvider>
<main style={{ background: '#0C0C0C', overflowX: 'clip' }}>
<HeroSection />
<MarqueeSection />
<AboutSection />
<ServicesSection />
<ExperienceSection />
<ProjectsSection />
<CredentialsSection />
<ContactSection />
</main>
</ContactProvider>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

@@ -0,0 +1,30 @@
import { useRef } from 'react';
import { motion, useScroll, useTransform } from 'framer-motion';
import type { MotionValue } from 'framer-motion';
function Char({ char, index, total, progress }: { char: string; index: number; total: number; progress: MotionValue<number> }) {
const start = index / total;
const end = start + 1 / total;
const opacity = useTransform(progress, [start, end], [0.2, 1]);
return (
<span className="relative">
<span className="invisible">{char}</span>
<motion.span className="absolute left-0 top-0" style={{ opacity }}>
{char}
</motion.span>
</span>
);
}
export default function AnimatedText({ text, className, style }: { text: string; className?: string; style?: React.CSSProperties }) {
const ref = useRef<HTMLParagraphElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ['start 0.8', 'end 0.2'] });
const chars = Array.from(text);
return (
<p ref={ref} className={className} style={style} aria-label={text}>
{chars.map((c, i) =>
c === ' ' ? ' ' : <Char key={i} char={c} index={i} total={chars.length} progress={scrollYProgress} />
)}
</p>
);
}
@@ -0,0 +1,21 @@
import { useOpenContact } from './ContactDialog';
export default function ContactButton() {
const openContact = useOpenContact();
return (
<button
type="button"
onClick={openContact}
className="inline-block whitespace-nowrap rounded-full px-8 py-3 text-xs font-medium uppercase tracking-widest text-white sm:px-10 sm:py-3.5 sm:text-sm md:px-12 md:py-4 md:text-base"
style={{
background: 'linear-gradient(123deg, #18011F 7%, #B600A8 37%, #7621B0 72%, #BE4C00 100%)',
boxShadow: '0px 4px 4px rgba(181, 1, 167, 0.25), 4px 4px 12px #7721B1 inset',
outline: '2px solid white',
outlineOffset: '-3px',
cursor: 'pointer',
}}
>
Contact Me
</button>
);
}
@@ -0,0 +1,101 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { Check, Copy, Mail, X } from 'lucide-react';
import { profile } from '../data/content';
const OpenContext = createContext<() => void>(() => {});
export const useOpenContact = () => useContext(OpenContext);
const subject = encodeURIComponent('Portfolio enquiry');
const to = encodeURIComponent(profile.email);
const options = [
{ label: 'Open my email app', href: `mailto:${profile.email}?subject=${subject}`, newTab: false },
{ label: 'Write in Gmail', href: `https://mail.google.com/mail/?view=cm&fs=1&to=${to}&su=${subject}`, newTab: true },
{ label: 'Write in Yahoo Mail', href: `https://compose.mail.yahoo.com/?to=${to}&subject=${subject}`, newTab: true },
];
const optionClass =
'flex items-center justify-between gap-3 rounded-full border-2 border-[#D7E2EA] px-6 py-3 text-sm font-medium uppercase tracking-widest text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10 sm:text-base';
export function ContactProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const opener = useRef<HTMLElement | null>(null);
const show = useCallback(() => {
opener.current = document.activeElement as HTMLElement | null;
setCopied(false);
setOpen(true);
}, []);
const hide = useCallback(() => {
setOpen(false);
opener.current?.focus?.();
}, []);
useEffect(() => {
if (!open) return;
closeRef.current?.focus();
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
document.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [open, hide]);
const copy = async () => {
try {
await navigator.clipboard.writeText(profile.email);
setCopied(true);
} catch {
inputRef.current?.select();
}
};
return (
<OpenContext.Provider value={show}>
{children}
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
onMouseDown={(e) => e.target === e.currentTarget && hide()}
>
<div role="dialog" aria-modal="true" aria-labelledby="contact-title" className="relative flex w-full max-w-md flex-col gap-5 rounded-[32px] border-2 border-[#D7E2EA] bg-[#0C0C0C] p-6 sm:p-8">
<button ref={closeRef} onClick={hide} aria-label="Close" className="absolute right-4 top-4 rounded-full p-2 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
<X size={22} />
</button>
<h2 id="contact-title" className="hero-heading pr-10 text-4xl font-black uppercase leading-none sm:text-5xl">Email Keith</h2>
<p className="font-light leading-relaxed text-[#D7E2EA]/80">Pick how you would like to write. Your message goes to the address below.</p>
<div className="flex items-center gap-2">
<input
ref={inputRef}
id="contact-email"
readOnly
value={profile.email}
aria-label="Email address"
onFocus={(e) => e.currentTarget.select()}
className="min-w-0 flex-1 rounded-full border border-[#D7E2EA]/40 bg-transparent px-5 py-3 text-[#D7E2EA]"
/>
<button onClick={copy} className="flex shrink-0 items-center gap-2 rounded-full border border-[#D7E2EA]/40 px-4 py-3 text-sm text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<div className="flex flex-col gap-3">
{options.map((o) => (
<a key={o.label} href={o.href} className={optionClass} {...(o.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
{o.label}
<Mail size={18} />
</a>
))}
</div>
</div>
</div>
)}
</OpenContext.Provider>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { motion } from 'framer-motion';
import type { MotionProps } from 'framer-motion';
import type { ComponentType, ElementType, ReactNode } from 'react';
type FadeInProps = {
children: ReactNode;
delay?: number;
duration?: number;
x?: number;
y?: number;
className?: string;
as?: ElementType;
};
const cache = new Map<ElementType, ComponentType<MotionProps & { className?: string }>>();
const getMotion = (el: ElementType) => {
if (!cache.has(el)) cache.set(el, motion.create(el as 'div') as ComponentType<MotionProps & { className?: string }>);
return cache.get(el)!;
};
export default function FadeIn({
children,
delay = 0,
duration = 0.7,
x = 0,
y = 30,
className,
as = 'div',
}: FadeInProps) {
const Component = getMotion(as);
return (
<Component
className={className}
initial={{ opacity: 0, x, y }}
whileInView={{ opacity: 1, x: 0, y: 0 }}
viewport={{ once: true, margin: '50px', amount: 0 }}
transition={{ duration, delay, ease: [0.25, 0.1, 0.25, 1] }}
>
{children}
</Component>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
type MagnetProps = {
children: ReactNode;
padding?: number;
strength?: number;
activeTransition?: string;
inactiveTransition?: string;
className?: string;
};
export default function Magnet({
children,
padding = 100,
strength = 2,
activeTransition = 'transform 0.3s ease-out',
inactiveTransition = 'transform 0.6s ease-in-out',
className,
}: MagnetProps) {
const [active, setActive] = useState(false);
const [pos, setPos] = useState({ x: 0, y: 0 });
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!ref.current) return;
const { left, top, width, height } = ref.current.getBoundingClientRect();
const cx = left + width / 2;
const cy = top + height / 2;
const dx = Math.abs(cx - e.clientX);
const dy = Math.abs(cy - e.clientY);
if (dx < width / 2 + padding && dy < height / 2 + padding) {
setActive(true);
setPos({ x: (e.clientX - cx) / strength, y: (e.clientY - cy) / strength });
} else {
setActive(false);
setPos({ x: 0, y: 0 });
}
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, [padding, strength]);
return (
<div ref={ref} className={className} style={{ position: 'relative', display: 'inline-block' }}>
<div
style={{
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
transition: active ? activeTransition : inactiveTransition,
willChange: 'transform',
}}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,149 @@
import type { CSSProperties, ReactNode } from 'react';
export type DiagramKind = 'network' | 'server' | 'sccm' | 'voice';
const INK = '#D7E2EA';
const TEAL = '#2BB3A6';
const MUTED = 'rgba(215,226,234,0.55)';
const FILL = '#14161a';
function Box({ x, y, w, h, label, sub, accent }: { x: number; y: number; w: number; h: number; label: string; sub?: string; accent?: boolean }) {
return (
<g>
<rect x={x} y={y} width={w} height={h} rx={14} fill={FILL} stroke={accent ? TEAL : INK} strokeWidth={accent ? 2.5 : 1.5} />
<text x={x + w / 2} y={y + h / 2 + (sub ? -4 : 8)} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>{label}</text>
{sub && <text x={x + w / 2} y={y + h / 2 + 22} textAnchor="middle" fontSize={18} fill={MUTED}>{sub}</text>}
</g>
);
}
function Line({ x1, y1, x2, y2, dashed }: { x1: number; y1: number; x2: number; y2: number; dashed?: boolean }) {
return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke={MUTED} strokeWidth={2} strokeDasharray={dashed ? '6 6' : undefined} />;
}
function Arrow({ x1, y, x2, label }: { x1: number; y: number; x2: number; label?: string }) {
return (
<g>
<line x1={x1} y1={y} x2={x2 - 10} y2={y} stroke={TEAL} strokeWidth={3} />
<path d={`M${x2 - 14} ${y - 8} L${x2} ${y} L${x2 - 14} ${y + 8} Z`} fill={TEAL} />
{label && <text x={(x1 + x2) / 2} y={y - 14} textAnchor="middle" fontSize={20} fill={TEAL}>{label}</text>}
</g>
);
}
function Dot({ cx, cy, r = 14 }: { cx: number; cy: number; r?: number }) {
return <circle cx={cx} cy={cy} r={r} fill={FILL} stroke={TEAL} strokeWidth={2.5} />;
}
function Wifi({ cx, cy }: { cx: number; cy: number }) {
return (
<g fill="none" stroke={TEAL} strokeWidth={2.5} strokeLinecap="round">
<path d={`M${cx - 18} ${cy - 4} Q${cx} ${cy - 22} ${cx + 18} ${cy - 4}`} />
<path d={`M${cx - 10} ${cy + 4} Q${cx} ${cy - 6} ${cx + 10} ${cy + 4}`} />
<circle cx={cx} cy={cy + 12} r={2.5} fill={TEAL} />
</g>
);
}
const diagrams: Record<DiagramKind, { label: string; body: ReactNode }> = {
network: {
label: 'Network topology: Fortinet firewall connects to Fortinet 48P switches, which connect to access points.',
body: (
<>
<Box x={190} y={30} w={220} h={64} label="Fortinet firewall" accent />
<Line x1={300} y1={94} x2={300} y2={130} />
<Line x1={150} y1={130} x2={450} y2={130} />
<Line x1={150} y1={130} x2={150} y2={160} />
<Line x1={450} y1={130} x2={450} y2={160} />
<Box x={50} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
<Box x={350} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
{[90, 210, 390, 510].map((x, i) => (
<g key={x}>
<Line x1={i < 2 ? 150 : 450} y1={224} x2={x} y2={274} dashed />
<Dot cx={x} cy={296} r={22} />
<Wifi cx={x} cy={292} />
</g>
))}
<text x={300} y={372} textAnchor="middle" fontSize={20} fill={MUTED}>Access point uplift</text>
<text x={300} y={400} textAnchor="middle" fontSize={20} fill={MUTED}>Network topology upgrade</text>
</>
),
},
server: {
label: 'Server upgrade: virtual machines and services move from the existing server to the upgraded server, with updated network settings.',
body: (
<>
<Box x={20} y={70} w={190} h={200} label="" />
<text x={115} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Existing server</text>
<Box x={40} y={130} w={150} h={44} label="VMs" />
<Box x={40} y={192} w={150} h={44} label="Services" />
<Arrow x1={222} y={170} x2={378} label="Data migrated" />
<Box x={390} y={70} w={190} h={200} label="" accent />
<text x={485} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Upgraded server</text>
<Box x={410} y={130} w={150} h={44} label="VMs" />
<Box x={410} y={192} w={150} h={44} label="Services" />
<Line x1={485} y1={270} x2={485} y2={320} dashed />
<Box x={150} y={320} w={320} h={56} label="Updated network settings" />
<Line x1={470} y1={348} x2={485} y2={348} dashed />
</>
),
},
sccm: {
label: 'SCCM implementation: install and configure SCCM, prepare settings, then establish the task-sequence process for endpoints.',
body: (
<>
<Box x={200} y={26} w={200} h={64} label="SCCM" accent />
<Line x1={300} y1={90} x2={300} y2={122} />
<Box x={40} y={122} w={150} h={92} label="Install" sub="& configure" />
<Box x={225} y={122} w={150} h={92} label="Prepare" sub="settings" />
<Box x={410} y={122} w={150} h={92} label="Task" sub="sequence" accent />
<Arrow x1={190} y={168} x2={225} />
<Arrow x1={375} y={168} x2={410} />
<Line x1={485} y1={214} x2={485} y2={262} dashed />
<Line x1={150} y1={262} x2={450} y2={262} dashed />
{[150, 250, 350, 450].map((x) => (
<g key={x}>
<Line x1={x} y1={262} x2={x} y2={292} dashed />
<rect x={x - 30} y={292} width={60} height={40} rx={6} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
<line x1={x - 40} y1={344} x2={x + 40} y2={344} stroke={TEAL} strokeWidth={3} strokeLinecap="round" />
</g>
))}
<text x={300} y={392} textAnchor="middle" fontSize={20} fill={MUTED}>Endpoints built through the task sequence</text>
</>
),
},
voice: {
label: 'Microsoft Teams calling: number porting, dial-plan and call-flow design, then users supported by training, documentation and adoption monitoring.',
body: (
<>
<Box x={20} y={50} w={160} h={70} label="Number" sub="porting" />
<Box x={220} y={50} w={160} h={70} label="Dial plan" accent />
<Box x={420} y={50} w={160} h={70} label="Call flow" />
<Arrow x1={180} y={85} x2={220} />
<Arrow x1={380} y={85} x2={420} />
<Line x1={300} y1={120} x2={300} y2={168} dashed />
<Line x1={100} y1={168} x2={500} y2={168} dashed />
{[100, 300, 500].map((x) => (
<g key={x}>
<Line x1={x} y1={168} x2={x} y2={196} dashed />
<circle cx={x} cy={214} r={16} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
<path d={`M${x - 28} ${264} Q${x} ${226} ${x + 28} ${264}`} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
</g>
))}
<text x={100} y={310} textAnchor="middle" fontSize={20} fill={INK}>User training</text>
<text x={300} y={310} textAnchor="middle" fontSize={20} fill={INK}>Documentation</text>
<text x={500} y={310} textAnchor="middle" fontSize={20} fill={INK}>Adoption monitoring</text>
<text x={300} y={380} textAnchor="middle" fontSize={20} fill={MUTED}>Microsoft Teams calling</text>
</>
),
},
};
export default function ProjectDiagram({ kind, className, style }: { kind: DiagramKind; className?: string; style?: CSSProperties }) {
const d = diagrams[kind];
return (
<svg viewBox="0 0 600 420" role="img" aria-label={`Illustrative diagram. ${d.label}`} className={className} style={{ fontFamily: 'Kanit, sans-serif', ...style }}>
{d.body}
</svg>
);
}
+140
View File
@@ -0,0 +1,140 @@
import portrait from '../assets/portrait.jpg';
import leadership from '../assets/leadership.webp';
import systems from '../assets/systems.webp';
import signalMark from '../assets/signal-mark.webp';
import networkPhoto from '../assets/projects/network.jpg';
import serverPhoto from '../assets/projects/server.jpg';
import sccmPhoto from '../assets/projects/sccm.jpg';
import voicePhoto from '../assets/projects/voice.jpg';
import type { DiagramKind } from '../components/ProjectDiagram';
export const images = { portrait, leadership, systems, signalMark };
export const profile = {
name: 'Keith Rana',
firstName: 'keith',
role: 'IT Service Delivery Manager',
location: 'Melbourne, Australia',
email: 'keith_rana@yahoo.com',
phone: '04 1668 7558',
phoneHref: 'tel:+61416687558',
tagline: 'an it service delivery manager keeping critical services steady while the work keeps moving',
};
export const aboutText =
'A results-driven IT professional with over 16 years of experience across telecommunications, education, and managed services, including more than 8 years leading technical teams. I make technology service delivery visible, stable, and continually better. Let\'s make delivery more dependable.';
export const stats = [
{ value: '16+', label: 'Years in IT' },
{ value: '8+', label: 'Years leading' },
];
export const expertise = [
{ name: 'IT Service Delivery', description: 'End-to-end accountability for service delivery against agreed SLAs, with reporting to senior stakeholders.' },
{ name: 'Enterprise Infrastructure', description: 'Enterprise network, server and endpoint environments, including Fortinet, Zscaler and SCCM.' },
{ name: 'ITIL & Change Control', description: 'ITIL process, measured change and service reporting that improve compliance and service quality.' },
{ name: 'Incident & Escalation Leadership', description: 'Major incident management, workload prioritisation and escalations led with a calm, evidence-based approach.' },
{ name: 'Vendor & Stakeholder Management', description: 'Clear ownership of the vendor relationships that sit behind every service the business relies on.' },
{ name: 'Microsoft 365 & Intune', description: 'Hands-on depth in Microsoft 365, Azure and Intune across cloud applications and end-user devices.' },
{ name: 'Cybersecurity & Access', description: 'Security, identity and access practices built into daily operations, backed by Microsoft security certifications.' },
{ name: 'Team Development', description: 'Coaching, performance conversations, training and mentoring that grow capable, confident support teams.' },
];
export const roles = [
{
dates: 'Oct 2023 — Present',
role: 'IT Service Delivery Manager',
organisation: 'AFL Telecommunications',
summary: 'Leading the daily delivery of business-critical IT services, balancing prompt resolution, availability, security, and a dependable experience for end users.',
points: [
'Own service operations across cloud applications, Windows and Linux environments, desktops, virtual apps, networks, and end-user support.',
'Lead a support and service-desk team through coaching, performance conversations, workload prioritisation, and escalations.',
'Use incident patterns, service-performance reporting, change controls, and feedback loops to improve ITIL compliance and service quality.',
],
},
{
dates: 'Dec 2021 — Sep 2023',
role: 'Information Technology Manager',
organisation: 'Suzanne Cory High School',
summary: 'Managed technology services, documentation, knowledge systems, infrastructure capacity, and Microsoft 365 operations in a fast-moving education environment.',
points: [
'Oversaw Microsoft 365 services, endpoint operations, security, quality, and the administration and maintenance of core IT infrastructure.',
'Led a skilled IT specialist team with a focus on strategic direction, mentoring, service effectiveness, and superior customer outcomes.',
'Coordinated systems migrations, upgrades, and deployments to defined scope and schedule constraints.',
],
},
{
dates: 'May 2018 — Nov 2021',
role: 'IT Support Specialist — Team Lead',
organisation: 'Wesley College Melbourne',
summary: 'Directed a six-person IT support team and helped translate organisational priorities into reliable, well-documented technical service.',
points: [
'Established project scope, supported IT initiatives, and aligned delivery activity with strategic objectives.',
'Worked across Casper Management, AirWatch MDM, Microsoft Intune, and operational processes informed by ITIL principles.',
'Identified skills gaps, organised training, coordinated with project managers, and maintained clear project documentation.',
],
},
];
export const foundations = [
{ role: 'IT Support Officer', organisation: 'Mentone Grammar School', dates: 'Apr 2017 — Dec 2017' },
{ role: 'IT Project Manager', organisation: 'Archirayan Infotech', dates: 'Aug 2015 — Jul 2016' },
{ role: 'IT Technical Support', organisation: 'Has Tech Solutions', dates: 'Aug 2014 — Jul 2015' },
{ role: 'IT Technical Engineer', organisation: 'Hindustan Cyber Services', dates: 'Oct 2012 — Aug 2014' },
{ role: 'Assistant Systems Analyst', organisation: 'Prutha Technologies', dates: 'Oct 2009 — Sep 2010' },
];
export const projects = [
{
category: 'Network',
name: 'Network project',
body: 'Fortinet Firewall upgrade, Fortinet 48P Switches upgrade, Fortinet access point uplift, and network topology upgrade.',
delivered: ['Fortinet firewall upgrade', 'Fortinet 48P switches upgrade', 'Fortinet access point uplift', 'Network topology upgrade'],
diagram: 'network' as DiagramKind,
photo: networkPhoto,
photoPosition: 'center',
},
{
category: 'Infrastructure',
name: 'Server upgrade',
body: 'Upgraded the server with existing VMs and services, migrated data, and configured updated network settings.',
delivered: ['Server upgraded with existing VMs and services', 'Data migrated', 'Updated network settings configured'],
diagram: 'server' as DiagramKind,
photo: serverPhoto,
photoPosition: 'center',
},
{
category: 'Endpoint',
name: 'SCCM implementation',
body: 'Installed and configured SCCM, prepared the required settings, and established the task-sequence process.',
delivered: ['SCCM installed and configured', 'Required settings prepared', 'Task-sequence process established'],
diagram: 'sccm' as DiagramKind,
photo: sccmPhoto,
photoPosition: 'center',
},
{
category: 'Voice',
name: 'Microsoft Teams calling',
body: 'Delivered number-porting support, call-flow and dial-plan design, user training, documentation, and adoption monitoring.',
delivered: ['Number-porting support', 'Call-flow and dial-plan design', 'User training and documentation', 'Adoption monitoring'],
diagram: 'voice' as DiagramKind,
photo: voicePhoto,
photoPosition: 'center',
},
];
export const certifications = [
'Introduction to Cyber Security',
'Windows Client',
'Microsoft Teams Voice Engineer',
'M365 Teams Administrator Associate',
'Microsoft Azure Administrator Associate',
'Microsoft Azure Security Engineer Associate',
'M365 Security Administrator Associate',
'Microsoft Information Protection Administrator Associate',
'Project Management Essentials Certified',
'Cloud Computing',
];
export const education = { degree: 'Bachelor of Information Technology', school: 'Federation University', year: '2009' };
+25
View File
@@ -0,0 +1,25 @@
@import '@fontsource/kanit/latin-300.css';
@import '@fontsource/kanit/latin-400.css';
@import '@fontsource/kanit/latin-500.css';
@import '@fontsource/kanit/latin-700.css';
@import '@fontsource/kanit/latin-900.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { background: #0C0C0C; }
html { scroll-behavior: smooth; }
body { font-family: 'Kanit', sans-serif; }
.hero-heading {
background: linear-gradient(180deg, #646973 0%, #BBCCD7 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
@@ -0,0 +1,46 @@
import FadeIn from '../components/FadeIn';
import AnimatedText from '../components/AnimatedText';
import ContactButton from '../components/ContactButton';
import { aboutText, images, stats } from '../data/content';
export default function AboutSection() {
return (
<section id="about" className="relative flex min-h-screen flex-col items-center justify-center gap-16 px-5 py-20 sm:gap-20 sm:px-8 md:gap-24 md:px-10">
<FadeIn delay={0.1} x={-80} y={0} duration={0.9} className="absolute left-[1%] top-[3%] w-[90px] sm:left-[2%] sm:w-[130px] md:left-[4%] md:w-[170px]">
<img src={images.signalMark} alt="" className="aspect-square w-full rounded-2xl object-cover" />
</FadeIn>
<FadeIn delay={0.25} x={-80} y={0} duration={0.9} className="absolute bottom-[4%] left-[3%] w-[100px] sm:left-[6%] sm:w-[150px] md:left-[10%] md:w-[200px]">
<img src={images.systems} alt="" className="aspect-[4/3] w-full -rotate-6 rounded-2xl object-cover" />
</FadeIn>
<FadeIn delay={0.15} x={80} y={0} duration={0.9} className="absolute right-[1%] top-[3%] w-[100px] sm:right-[2%] sm:w-[150px] md:right-[4%] md:w-[210px]">
<img src={images.leadership} alt="" className="aspect-[3/2] w-full rotate-6 rounded-2xl object-cover" />
</FadeIn>
<FadeIn delay={0.3} x={80} y={0} duration={0.9} className="absolute bottom-[4%] right-[3%] sm:right-[6%] md:right-[10%]">
<div className="flex gap-2 sm:gap-3">
{stats.map((s) => (
<div key={s.label} className="rounded-2xl border border-[#D7E2EA]/30 px-2.5 py-2 text-center sm:px-5 sm:py-3">
<div className="hero-heading text-2xl font-black leading-none sm:text-5xl md:text-6xl">{s.value}</div>
<div className="mt-1 whitespace-nowrap text-[9px] font-light uppercase tracking-wide text-[#D7E2EA] sm:text-xs">{s.label}</div>
</div>
))}
</div>
</FadeIn>
<div className="relative z-10 flex flex-col items-center gap-10 sm:gap-14 md:gap-16">
<FadeIn delay={0} y={40}>
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
About me
</h2>
</FadeIn>
<AnimatedText
text={aboutText}
className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]"
style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}
/>
</div>
<div className="relative z-10 mb-24 sm:mb-28 md:mb-0">
<ContactButton />
</div>
</section>
);
}
@@ -0,0 +1,47 @@
import { ArrowUpRight, Phone } from 'lucide-react';
import FadeIn from '../components/FadeIn';
import ContactButton from '../components/ContactButton';
import { profile } from '../data/content';
export default function ContactSection() {
return (
<section id="contact" className="flex flex-col items-center gap-10 px-5 pb-10 pt-20 sm:gap-14 sm:px-8 md:px-10 md:pt-28">
<FadeIn>
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(2.6rem, 10vw, 140px)' }}>
Let’s talk
</h2>
</FadeIn>
<FadeIn>
<p className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]" style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}>
Based in {profile.location}. Available for the right conversation.
</p>
</FadeIn>
<FadeIn>
<ContactButton />
</FadeIn>
<FadeIn className="w-full max-w-3xl">
<div className="grid gap-3 sm:grid-cols-2">
<a href={`mailto:${profile.email}`} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
<span className="min-w-0">
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Email</span>
<strong className="block break-words font-medium">{profile.email}</strong>
</span>
<ArrowUpRight size={20} className="shrink-0" />
</a>
<a href={profile.phoneHref} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
<span className="min-w-0">
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Phone</span>
<strong className="block font-medium">{profile.phone}</strong>
</span>
<Phone size={18} className="shrink-0" />
</a>
</div>
</FadeIn>
<footer className="mt-10 flex w-full flex-wrap justify-between gap-2 border-t border-[#D7E2EA]/20 pt-6 text-xs font-light uppercase tracking-wider text-[#D7E2EA]/70">
<span>{profile.name}</span>
<span>{profile.role}</span>
<span>{profile.location}</span>
</footer>
</section>
);
}
@@ -0,0 +1,31 @@
import FadeIn from '../components/FadeIn';
import { certifications, education } from '../data/content';
export default function CredentialsSection() {
return (
<section id="credentials" className="px-5 py-20 sm:px-8 sm:py-24 md:px-10 md:py-32">
<FadeIn>
<h2 className="hero-heading mb-10 text-center font-black uppercase leading-none tracking-tight sm:mb-14" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
Credentials
</h2>
</FadeIn>
<FadeIn>
<div className="mx-auto mb-12 flex max-w-5xl flex-col items-center gap-1 text-center text-[#D7E2EA] sm:mb-16">
<span className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">Education</span>
<strong className="font-medium uppercase" style={{ fontSize: 'clamp(1.1rem, 2.2vw, 2.1rem)' }}>{education.degree}</strong>
<span className="font-light opacity-70">{education.school}, {education.year}</span>
</div>
</FadeIn>
<div className="mx-auto grid max-w-5xl gap-3 sm:grid-cols-2">
{certifications.map((c, i) => (
<FadeIn key={c} delay={(i % 2) * 0.08}>
<div className="flex h-full items-center gap-4 rounded-3xl border border-[#D7E2EA]/25 p-5 sm:p-6">
<span className="hero-heading text-3xl font-black leading-none sm:text-4xl">{String(i + 1).padStart(2, '0')}</span>
<p className="min-w-0 font-medium uppercase leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.1rem)' }}>{c}</p>
</div>
</FadeIn>
))}
</div>
</section>
);
}
@@ -0,0 +1,55 @@
import { Check } from 'lucide-react';
import FadeIn from '../components/FadeIn';
import { foundations, roles } from '../data/content';
export default function ExperienceSection() {
return (
<section id="experience" className="bg-white px-5 pb-28 pt-20 sm:px-8 sm:pb-32 sm:pt-24 md:px-10 md:pb-40 md:pt-32">
<FadeIn>
<h2 className="mb-16 text-center font-black uppercase leading-none text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
Experience
</h2>
</FadeIn>
<div className="mx-auto max-w-5xl">
{roles.map((r, i) => (
<FadeIn key={r.role + r.organisation}>
<article className="grid gap-4 py-8 sm:py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
<div className="flex flex-col gap-1 text-[#0C0C0C]">
<span className="text-sm font-medium uppercase tracking-wider opacity-60">{r.dates}</span>
<span className="font-black uppercase leading-tight" style={{ fontSize: 'clamp(1.1rem, 1.8vw, 1.5rem)' }}>{r.organisation}</span>
<span className="text-sm font-light opacity-60">{String(i + 1).padStart(2, '0')}</span>
</div>
<div className="flex min-w-0 flex-col gap-4 text-[#0C0C0C]">
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{r.role}</h3>
<p className="max-w-2xl font-light leading-relaxed opacity-70" style={{ fontSize: 'clamp(0.9rem, 1.6vw, 1.2rem)' }}>{r.summary}</p>
<ul className="flex max-w-2xl flex-col gap-3">
{r.points.map((p) => (
<li key={p} className="flex gap-3 font-light leading-relaxed" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.05rem)' }}>
<Check size={18} className="mt-1 shrink-0" />
<span>{p}</span>
</li>
))}
</ul>
</div>
</article>
</FadeIn>
))}
<FadeIn>
<div className="grid gap-6 py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
<h3 className="text-sm font-medium uppercase tracking-wider text-[#0C0C0C] opacity-60">Earlier foundations</h3>
<div className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
{foundations.map((f) => (
<div key={f.role + f.organisation} className="flex min-w-0 flex-col text-[#0C0C0C]">
<span className="text-xs font-medium uppercase tracking-wider opacity-60">{f.dates}</span>
<strong className="font-medium uppercase">{f.role}</strong>
<span className="font-light opacity-70">{f.organisation}</span>
</div>
))}
</div>
</div>
</FadeIn>
</div>
</section>
);
}
@@ -0,0 +1,66 @@
import FadeIn from '../components/FadeIn';
import Magnet from '../components/Magnet';
import ContactButton from '../components/ContactButton';
import { images, profile } from '../data/content';
const links = [
{ label: 'About', href: '#about' },
{ label: 'Skills', href: '#skills' },
{ label: 'Projects', href: '#projects' },
{ label: 'Contact', href: '#contact' },
];
export default function HeroSection() {
return (
<section className="relative flex h-screen min-h-[600px] flex-col" style={{ overflowX: 'clip' }}>
<FadeIn delay={0} y={-20}>
<nav className="flex justify-between px-6 pt-6 md:px-10 md:pt-8" aria-label="Section navigation">
{links.map((l) => (
<a
key={l.label}
href={l.href}
className="text-sm font-medium uppercase tracking-wider text-[#D7E2EA] transition-opacity duration-200 hover:opacity-70 md:text-lg lg:text-[1.4rem]"
>
{l.label}
</a>
))}
</nav>
</FadeIn>
<div className="overflow-hidden">
<FadeIn delay={0.15} y={40}>
<h1 className="hero-heading mt-6 w-full whitespace-nowrap text-center text-[16.5vw] font-black uppercase leading-none tracking-tight sm:mt-4 md:-mt-5">
Hi, i’m {profile.firstName}
</h1>
</FadeIn>
</div>
<div className="absolute left-1/2 top-1/2 z-10 w-[min(280px,52vh)] -translate-x-1/2 -translate-y-1/2 sm:bottom-0 sm:top-auto sm:w-[min(360px,56vh)] sm:translate-y-0 md:w-[min(440px,58vh)] lg:w-[min(520px,58vh)]">
<FadeIn delay={0.6} y={30}>
<Magnet padding={150} strength={3} activeTransition="transform 0.3s ease-out" inactiveTransition="transform 0.6s ease-in-out" className="!block">
<img
src={images.portrait}
alt="Keith Rana at his work desk"
className="aspect-square w-full border border-[#D7E2EA]/30 object-cover object-top"
style={{ borderRadius: '999px 999px 32px 32px' }}
/>
</Magnet>
</FadeIn>
</div>
<div className="mt-auto flex items-end justify-between gap-4 px-6 pb-7 sm:pb-8 md:px-10 md:pb-10">
<FadeIn delay={0.35} y={20}>
<p
className="max-w-[160px] font-light uppercase leading-snug tracking-wide text-[#D7E2EA] sm:max-w-[220px] md:max-w-[260px]"
style={{ fontSize: 'clamp(0.7rem, 1.4vw, 1.5rem)' }}
>
{profile.tagline}
</p>
</FadeIn>
<FadeIn delay={0.5} y={20} className="relative z-20">
<ContactButton />
</FadeIn>
</div>
</section>
);
}
@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { expertise, images } from '../data/content';
const TILE_W = 420;
const GAP = 12;
const photoTiles: { src: string; alt: string; position: string }[] = [
{ src: images.portrait, alt: 'Keith Rana at his work desk', position: 'center 20%' },
{ src: images.leadership, alt: 'Hands organising a service-delivery briefing', position: 'center' },
{ src: images.systems, alt: 'Abstract composition of connected service pathways', position: 'center' },
];
const photos = photoTiles.map((t) => (
<img
key={t.alt}
src={t.src}
alt={t.alt}
loading="lazy"
className="h-[270px] w-[420px] shrink-0 rounded-2xl object-cover"
style={{ objectPosition: t.position }}
/>
));
const skills = expertise.map((e, i) => (
<div
key={e.name}
className="flex h-[270px] w-[420px] shrink-0 flex-col justify-between rounded-2xl border border-[#D7E2EA]/25 p-6"
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
>
<span className="hero-heading text-5xl font-black leading-none">{String(i + 1).padStart(2, '0')}</span>
<span className="text-2xl font-medium uppercase leading-tight text-[#D7E2EA]">{e.name}</span>
</div>
));
function Row({ tiles, setWidth, transform }: { tiles: ReactNode[]; setWidth: number; transform: string }) {
return (
<div className="flex" style={{ gap: GAP, marginLeft: -setWidth, transform, willChange: 'transform' }} aria-hidden={tiles === skills}>
{[0, 1, 2].map((copy) => (
<div key={copy} className="flex" style={{ gap: GAP }}>
{tiles}
</div>
))}
</div>
);
}
export default function MarqueeSection() {
const ref = useRef<HTMLElement>(null);
const [offset, setOffset] = useState(0);
useEffect(() => {
const onScroll = () => {
if (!ref.current) return;
const top = ref.current.getBoundingClientRect().top + window.scrollY;
setOffset((window.scrollY - top + window.innerHeight) * 0.3);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
}, []);
const width = (n: number) => n * (TILE_W + GAP) + GAP;
return (
<section ref={ref} className="flex flex-col gap-3 bg-[#0C0C0C] pb-10 pt-24 sm:pt-32 md:pt-40" style={{ overflow: 'clip' }}>
<Row tiles={photos} setWidth={width(photos.length)} transform={`translateX(${offset - 200}px)`} />
<Row tiles={skills} setWidth={width(skills.length)} transform={`translateX(${-(offset - 200)}px)`} />
</section>
);
}
@@ -0,0 +1,87 @@
import { useRef } from 'react';
import { motion, useScroll, useTransform } from 'framer-motion';
import { Check } from 'lucide-react';
import FadeIn from '../components/FadeIn';
import ProjectDiagram from '../components/ProjectDiagram';
import { projects } from '../data/content';
type Project = (typeof projects)[number];
const radius = 'rounded-[40px] sm:rounded-[50px] md:rounded-[60px]';
function ProjectCard({ project, index, total }: { project: Project; index: number; total: number }) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'start start'] });
const targetScale = 1 - (total - 1 - index) * 0.03;
const scale = useTransform(scrollYProgress, [0, 1], [1, targetScale]);
return (
<div ref={ref} className="sticky top-24 min-h-[85vh]">
<motion.div
className={`border-2 border-[#D7E2EA] bg-[#0C0C0C] p-4 sm:p-6 md:p-8 ${radius}`}
style={{ scale, top: `${index * 28}px`, position: 'relative', transformOrigin: 'top center' }}
>
<div className="mb-4 flex flex-col gap-3 sm:mb-6 md:flex-row md:items-center md:justify-between md:gap-10">
<div className="flex items-center gap-4 sm:gap-8">
<span className="hero-heading font-black leading-none" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
{String(index + 1).padStart(2, '0')}
</span>
<div className="min-w-0 text-[#D7E2EA]">
<p className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">{project.category}</p>
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{project.name}</h3>
</div>
</div>
<p className="max-w-xl px-1 font-light leading-relaxed text-[#D7E2EA]/70" style={{ fontSize: 'clamp(0.85rem, 1.5vw, 1.15rem)' }}>
{project.body}
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
<div className="flex flex-col gap-3 sm:w-[40%] sm:gap-4">
<img
src={project.photo}
alt={`${project.name}: illustrative photo`}
loading="lazy"
className={`w-full object-cover ${radius}`}
style={{ height: 'clamp(150px, 16vw, 230px)', objectPosition: project.photoPosition }}
/>
<div
className={`flex flex-1 flex-col justify-center gap-3 border border-[#D7E2EA]/25 p-5 sm:gap-4 sm:p-6 md:p-8 ${radius}`}
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)', minHeight: 'clamp(160px, 22vw, 340px)' }}
>
<span className="text-xs font-medium uppercase tracking-widest text-[#D7E2EA]/60 sm:text-sm">Delivered</span>
<ul className="flex flex-col gap-2 sm:gap-3">
{project.delivered.map((d) => (
<li key={d} className="flex gap-2 font-light leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.8rem, 1.3vw, 1.1rem)' }}>
<Check className="mt-0.5 h-4 w-4 shrink-0" />
<span>{d}</span>
</li>
))}
</ul>
</div>
</div>
<div
className={`order-first flex items-center justify-center border border-[#D7E2EA]/25 p-3 sm:order-none sm:w-[60%] sm:p-5 md:p-8 ${radius}`}
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
>
<ProjectDiagram kind={project.diagram} className="h-auto w-full" style={{ maxHeight: "clamp(240px, 30vw, 430px)" }} />
</div>
</div>
</motion.div>
</div>
);
}
export default function ProjectsSection() {
return (
<section id="projects" className="relative z-10 -mt-10 rounded-t-[40px] bg-[#0C0C0C] px-5 py-20 sm:-mt-12 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:-mt-14 md:rounded-t-[60px] md:px-10 md:py-32">
<FadeIn>
<h2 className="hero-heading mb-16 text-center font-black uppercase leading-none tracking-tight sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
Projects
</h2>
</FadeIn>
{projects.map((p, i) => (
<ProjectCard key={p.name} project={p} index={i} total={projects.length} />
))}
</section>
);
}
@@ -0,0 +1,32 @@
import FadeIn from '../components/FadeIn';
import { expertise } from '../data/content';
export default function ServicesSection() {
return (
<section id="skills" className="rounded-t-[40px] bg-white px-5 pt-20 sm:rounded-t-[50px] sm:px-8 sm:pt-24 md:rounded-t-[60px] md:px-10 md:pt-32">
<h2 className="mb-16 text-center font-black uppercase text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
Capability
</h2>
<div className="mx-auto max-w-5xl">
{expertise.map((s, i) => (
<FadeIn key={s.name} delay={(i % 4) * 0.1}>
<div
className="flex items-start gap-4 py-8 sm:gap-8 sm:py-10 md:gap-12 md:py-12"
style={{ borderTop: i === 0 ? undefined : '1px solid rgba(12, 12, 12, 0.15)' }}
>
<span className="min-w-[1.6em] font-black leading-none text-[#0C0C0C]" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
{String(i + 1).padStart(2, '0')}
</span>
<div className="flex min-w-0 flex-col gap-3 text-[#0C0C0C]">
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{s.name}</h3>
<p className="max-w-2xl font-light leading-relaxed opacity-60" style={{ fontSize: 'clamp(0.85rem, 1.6vw, 1.25rem)' }}>
{s.description}
</p>
</div>
</div>
</FadeIn>
))}
</div>
</section>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { extend: { fontFamily: { sans: ['Kanit', 'sans-serif'] } } },
plugins: [],
};
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// INLINE=1 bakes every image and font into the JS/CSS (used for the one-file cloud preview).
export default defineConfig({
plugins: [react()],
base: './',
build: { assetsInlineLimit: process.env.INLINE ? 100_000_000 : 4096 },
});
-151
View File
@@ -1,151 +0,0 @@
/* CycleWise AU - 3D cycle ring (Step 2).
Draws the same four phase arcs as the flat SVG ring, in 3D.
The page code sends the numbers via the "cycle:ring" event.
If WebGL is missing or anything fails, the flat ring stays visible. */
(function(){
"use strict";
function start(){
try {
if (!window.THREE) return;
var art = document.getElementById("phase-arc-menstrual");
art = art && art.closest(".hero-art");
if (!art) return;
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var svg = art.querySelector("svg");
var canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.className = "ring3d";
var renderer;
try { renderer = new THREE.WebGLRenderer({canvas: canvas, antialias: true, alpha: true}); }
catch(e){ return; } // no WebGL: keep the flat ring
var R = 1, TUBE = 0.085;
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(30, 1, 0.1, 20);
camera.position.set(0, 0, 5.2);
scene.add(new THREE.AmbientLight(0xffffff, 0.75));
var sun = new THREE.DirectionalLight(0xffffff, 1.1);
sun.position.set(2, 3, 4);
scene.add(sun);
var group = new THREE.Group();
scene.add(group);
function cssColor(name){
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return new THREE.Color(v || "#888888");
}
function point(deg, r){
var a = deg * Math.PI / 180; // 0 deg = top, clockwise
return new THREE.Vector3(Math.sin(a) * r, Math.cos(a) * r, 0);
}
class ArcCurve extends THREE.Curve {
constructor(a0, a1){ super(); this.a0 = a0; this.a1 = a1; }
getPoint(t, target){
return (target || new THREE.Vector3()).copy(point(this.a0 + (this.a1 - this.a0) * t, R));
}
}
var data = window.cycleRingData || null;
var meshes = [];
function clear(){
meshes.forEach(function(m){
group.remove(m); m.geometry.dispose(); m.material.dispose();
});
meshes = [];
}
function add(mesh){ group.add(mesh); meshes.push(mesh); }
function mat(c){ return new THREE.MeshStandardMaterial({color: c, roughness: 0.45, metalness: 0.05}); }
function build(){
clear();
if (!data) return;
var cols = {
menstrual: cssColor("--period"),
follicular: cssColor("--ovulation-soft"),
fertile: cssColor("--fertile"),
luteal: cssColor("--pregnancy-soft"),
ink: cssColor("--ink")
};
var arcs = [
[0, data.perEnd, cols.menstrual],
[data.perEnd, data.fertStart, cols.follicular],
[data.fertStart, data.fertEnd, cols.fertile],
[data.lutStart, 360, cols.luteal]
];
arcs.forEach(function(a){
var end = Math.max(a[1], a[0] + 0.5);
var tube = new THREE.Mesh(new THREE.TubeGeometry(new ArcCurve(a[0], end), 48, TUBE, 16, false), mat(a[2]));
add(tube);
[a[0], end].forEach(function(deg){ // round end caps
var cap = new THREE.Mesh(new THREE.SphereGeometry(TUBE, 16, 12), mat(a[2]));
cap.position.copy(point(deg, R));
add(cap);
});
});
var marker = new THREE.Mesh(new THREE.SphereGeometry(TUBE * 1.9, 24, 16), mat(cols.ink));
marker.position.copy(point(data.todayAngle, R));
marker.position.z = TUBE * 0.6;
add(marker);
}
function resize(){
var w = svg.getBoundingClientRect().width || 220;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(w, w, false);
canvas.style.width = canvas.style.height = w + "px";
}
var visible = false, raf = 0;
function frame(t){
raf = 0;
var s = t / 1000;
group.rotation.x = -0.55 + (reduce ? 0 : Math.sin(s * 0.6) * 0.12);
group.rotation.y = reduce ? 0.25 : Math.sin(s * 0.4) * 0.45;
renderer.render(scene, camera);
if (visible && !reduce) raf = requestAnimationFrame(frame);
}
function kick(){ if (!raf) raf = requestAnimationFrame(frame); }
// Show the 3D ring; hide the flat arcs but keep the "CYCLE DAY" text.
art.classList.add("has-3d");
art.insertBefore(canvas, svg);
resize();
build();
kick();
window.addEventListener("cycle:ring", function(e){ data = e.detail; build(); kick(); });
window.addEventListener("resize", function(){ resize(); kick(); });
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function(){ build(); kick(); });
// Only animate while the ring is actually on screen (saves battery).
new IntersectionObserver(function(entries){
visible = entries[0].isIntersecting;
if (visible) { resize(); kick(); }
}).observe(canvas);
} catch(err) {
// Anything went wrong: leave the flat ring alone.
var c = document.querySelector(".ring3d"); if (c) c.remove();
var h = document.querySelector(".hero-art.has-3d"); if (h) h.classList.remove("has-3d");
if (window.console) console.warn("3D ring disabled:", err && err.message);
}
}
// three.js is big: fetch it only after the page is up, so the page shows first.
function loadThree(){
var tag = document.createElement("script");
tag.src = "vendor/three.min.js";
tag.onload = start;
document.head.appendChild(tag);
}
function schedule(){
if (window.THREE) { start(); }
else if (window.requestIdleCallback) { requestIdleCallback(loadThree, {timeout: 2000}); }
else { setTimeout(loadThree, 300); }
}
if (document.readyState === "complete") schedule();
else window.addEventListener("load", schedule);
})();
-68
View File
@@ -1,68 +0,0 @@
/* CycleWise AU - pregnancy timeline motion (Step 3).
- The week slider's track fills with colour up to the current week.
It grows the first time you scroll to it, then follows every week change.
- The baby-size picture pops a little when the week changes.
- The 40 week buttons wave in the first time they appear.
Cosmetic only: without GSAP the slider simply shows its normal look. */
(function(){
"use strict";
var slider = document.getElementById("week-slider");
var badge = document.getElementById("wk-badge");
if (!slider || !badge) return;
var hasGsap = !!window.gsap;
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var animate = hasGsap && !reduce;
var fill = { f: 0 };
var target = 0;
var introDone = false;
function paint(){ slider.style.setProperty("--f", fill.f.toFixed(4)); }
function currentWeek(){
var n = parseInt((badge.textContent || "").replace(/\D/g, ""), 10);
return isNaN(n) ? (parseInt(slider.value, 10) || 1) : Math.max(1, Math.min(40, n));
}
function goTo(f){
target = f;
if (!introDone) return; // wait until the slider is on screen
if (!animate) { fill.f = f; paint(); return; }
gsap.to(fill, { f: f, duration: 0.6, ease: "power2.out", overwrite: true, onUpdate: paint });
}
// Follow every week change, whether from the slider, a week button or the calendar.
var lastWeek = currentWeek();
target = (lastWeek - 1) / 39;
new MutationObserver(function(){
var w = currentWeek();
if (w === lastWeek) return;
lastWeek = w;
goTo((w - 1) / 39);
var blob = document.querySelector("#wk-blob svg");
if (animate && blob) {
gsap.fromTo(blob, { scale: 0.8, transformOrigin: "50% 50%" },
{ scale: 1, duration: 0.5, ease: "back.out(2)", clearProps: "transform" });
}
}).observe(badge, { childList: true, characterData: true, subtree: true });
// First time the slider is visible: grow the fill from empty up to this week.
new IntersectionObserver(function(entries, obs){
if (!entries[0].isIntersecting) return;
obs.disconnect();
introDone = true;
if (!animate) { fill.f = target; paint(); return; }
gsap.to(fill, { f: target, duration: 1.2, ease: "power2.out", onUpdate: paint });
}).observe(slider);
// First time the week buttons are visible: wave them in.
var strip = document.getElementById("week-filmstrip");
if (strip && animate) {
new IntersectionObserver(function(entries, obs){
if (!entries[0].isIntersecting) return;
obs.disconnect();
gsap.from(strip.querySelectorAll(".film-item"), {
opacity: 0, scale: 0.8, duration: 0.4, stagger: 0.02, ease: "power2.out", clearProps: "all"
});
}).observe(strip);
}
})();
-11
View File
@@ -1,11 +0,0 @@
/*!
* ScrollTrigger 3.12.5
* https://gsap.com
*
* @license Copyright 2024, GreenSock. All rights reserved.
* Subject to the terms at https://gsap.com/standard-license or for Club GSAP members, the agreement issued with that membership.
* @author: Jack Doyle, jack@greensock.com
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e=e||self).window=e.window||{})}(this,function(e){"use strict";function _defineProperties(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}function r(){return Ce||"undefined"!=typeof window&&(Ce=window.gsap)&&Ce.registerPlugin&&Ce}function z(e,t){return~Le.indexOf(e)&&Le[Le.indexOf(e)+1][t]}function A(e){return!!~t.indexOf(e)}function B(e,t,r,n,o){return e.addEventListener(t,r,{passive:!1!==n,capture:!!o})}function C(e,t,r,n){return e.removeEventListener(t,r,!!n)}function F(){return De&&De.isPressed||Ie.cache++}function G(r,n){function dd(e){if(e||0===e){o&&(Se.history.scrollRestoration="manual");var t=De&&De.isPressed;e=dd.v=Math.round(e)||(De&&De.iOS?1:0),r(e),dd.cacheID=Ie.cache,t&&i("ss",e)}else(n||Ie.cache!==dd.cacheID||i("ref"))&&(dd.cacheID=Ie.cache,dd.v=r());return dd.v+dd.offset}return dd.offset=0,r&&dd}function J(e,t){return(t&&t._ctx&&t._ctx.selector||Ce.utils.toArray)(e)[0]||("string"==typeof e&&!1!==Ce.config().nullTargetWarn?console.warn("Element not found:",e):null)}function K(t,e){var r=e.s,n=e.sc;A(t)&&(t=ke.scrollingElement||Pe);var o=Ie.indexOf(t),i=n===Fe.sc?1:2;~o||(o=Ie.push(t)-1),Ie[o+i]||B(t,"scroll",F);var a=Ie[o+i],s=a||(Ie[o+i]=G(z(t,r),!0)||(A(t)?n:G(function(e){return arguments.length?t[r]=e:t[r]})));return s.target=t,a||(s.smooth="smooth"===Ce.getProperty(t,"scrollBehavior")),s}function L(e,t,o){function Cd(e,t){var r=ze();t||n<r-s?(a=i,i=e,l=s,s=r):o?i+=e:i=a+(e-a)/(r-l)*(s-l)}var i=e,a=e,s=ze(),l=s,n=t||50,c=Math.max(500,3*n);return{update:Cd,reset:function reset(){a=i=o?0:i,l=s=0},getVelocity:function getVelocity(e){var t=l,r=a,n=ze();return!e&&0!==e||e===i||Cd(e),s===l||c<n-l?0:(i+(o?r:-r))/((o?n:s)-t)*1e3}}}function M(e,t){return t&&!e._gsapAllow&&e.preventDefault(),e.changedTouches?e.changedTouches[0]:e}function N(e){var t=Math.max.apply(Math,e),r=Math.min.apply(Math,e);return Math.abs(t)>=Math.abs(r)?t:r}function O(){(Ae=Ce.core.globals().ScrollTrigger)&&Ae.core&&function _integrate(){var e=Ae.core,r=e.bridge||{},t=e._scrollers,n=e._proxies;t.push.apply(t,Ie),n.push.apply(n,Le),Ie=t,Le=n,i=function _bridge(e,t){return r[e](t)}}()}function P(e){return Ce=e||r(),!Te&&Ce&&"undefined"!=typeof document&&document.body&&(Se=window,Pe=(ke=document).documentElement,Me=ke.body,t=[Se,ke,Pe,Me],Ce.utils.clamp,Be=Ce.core.context||function(){},Oe="onpointerenter"in Me?"pointer":"mouse",Ee=k.isTouch=Se.matchMedia&&Se.matchMedia("(hover: none), (pointer: coarse)").matches?1:"ontouchstart"in Se||0<navigator.maxTouchPoints||0<navigator.msMaxTouchPoints?2:0,Re=k.eventTypes=("ontouchstart"in Pe?"touchstart,touchmove,touchcancel,touchend":"onpointerdown"in Pe?"pointerdown,pointermove,pointercancel,pointerup":"mousedown,mousemove,mouseup,mouseup").split(","),setTimeout(function(){return o=0},500),O(),Te=1),Te}var Ce,Te,Se,ke,Pe,Me,Ee,Oe,Ae,t,De,Re,Be,o=1,qe=[],Ie=[],Le=[],ze=Date.now,i=function _bridge(e,t){return t},n="scrollLeft",a="scrollTop",Ye={s:n,p:"left",p2:"Left",os:"right",os2:"Right",d:"width",d2:"Width",a:"x",sc:G(function(e){return arguments.length?Se.scrollTo(e,Fe.sc()):Se.pageXOffset||ke[n]||Pe[n]||Me[n]||0})},Fe={s:a,p:"top",p2:"Top",os:"bottom",os2:"Bottom",d:"height",d2:"Height",a:"y",op:Ye,sc:G(function(e){return arguments.length?Se.scrollTo(Ye.sc(),e):Se.pageYOffset||ke[a]||Pe[a]||Me[a]||0})};Ye.op=Fe,Ie.cache=0;var k=(Observer.prototype.init=function init(e){Te||P(Ce)||console.warn("Please gsap.registerPlugin(Observer)"),Ae||O();var o=e.tolerance,a=e.dragMinimum,t=e.type,i=e.target,r=e.lineHeight,n=e.debounce,s=e.preventDefault,l=e.onStop,c=e.onStopDelay,u=e.ignore,f=e.wheelSpeed,d=e.event,p=e.onDragStart,g=e.onDragEnd,h=e.onDrag,v=e.onPress,b=e.onRelease,m=e.onRight,y=e.onLeft,x=e.onUp,w=e.onDown,_=e.onChangeX,T=e.onChangeY,S=e.onChange,k=e.onToggleX,E=e.onToggleY,D=e.onHover,R=e.onHoverEnd,q=e.onMove,I=e.ignoreCheck,z=e.isNormalizer,Y=e.onGestureStart,H=e.onGestureEnd,X=e.onWheel,W=e.onEnable,V=e.onDisable,U=e.onClick,j=e.scrollSpeed,G=e.capture,Q=e.allowClicks,Z=e.lockAxis,$=e.onLockAxis;function cf(){return xe=ze()}function df(e,t){return(se.event=e)&&u&&~u.indexOf(e.target)||t&&he&&"touch"!==e.pointerType||I&&I(e,t)}function ff(){var e=se.deltaX=N(me),t=se.deltaY=N(ye),r=Math.abs(e)>=o,n=Math.abs(t)>=o;S&&(r||n)&&S(se,e,t,me,ye),r&&(m&&0<se.deltaX&&m(se),y&&se.deltaX<0&&y(se),_&&_(se),k&&se.deltaX<0!=le<0&&k(se),le=se.deltaX,me[0]=me[1]=me[2]=0),n&&(w&&0<se.deltaY&&w(se),x&&se.deltaY<0&&x(se),T&&T(se),E&&se.deltaY<0!=ce<0&&E(se),ce=se.deltaY,ye[0]=ye[1]=ye[2]=0),(ne||re)&&(q&&q(se),re&&(h(se),re=!1),ne=!1),ie&&!(ie=!1)&&$&&$(se),oe&&(X(se),oe=!1),ee=0}function gf(e,t,r){me[r]+=e,ye[r]+=t,se._vx.update(e),se._vy.update(t),n?ee=ee||requestAnimationFrame(ff):ff()}function hf(e,t){Z&&!ae&&(se.axis=ae=Math.abs(e)>Math.abs(t)?"xLine truncated
-21
View File
@@ -1,21 +0,0 @@
The MIT License
Copyright © 2010-2023 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-11
View File
@@ -1,11 +0,0 @@
/*!
* GSAP 3.12.5
* https://gsap.com
*
* @license Copyright 2024, GreenSock. All rights reserved.
* Subject to the terms at https://gsap.com/standard-license or for Club GSAP members, the agreement issued with that membership.
* @author: Jack Doyle, jack@greensock.com
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).window=t.window||{})}(this,function(e){"use strict";function _inheritsLoose(t,e){t.prototype=Object.create(e.prototype),(t.prototype.constructor=t).__proto__=e}function _assertThisInitialized(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}function r(t){return"string"==typeof t}function s(t){return"function"==typeof t}function t(t){return"number"==typeof t}function u(t){return void 0===t}function v(t){return"object"==typeof t}function w(t){return!1!==t}function x(){return"undefined"!=typeof window}function y(t){return s(t)||r(t)}function P(t){return(i=yt(t,ot))&&ze}function Q(t,e){return console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}function R(t,e){return!e&&console.warn(t)}function S(t,e){return t&&(ot[t]=e)&&i&&(i[t]=e)||ot}function T(){return 0}function ea(t){var e,r,i=t[0];if(v(i)||s(i)||(t=[t]),!(e=(i._gsap||{}).harness)){for(r=gt.length;r--&&!gt[r].targetTest(i););e=gt[r]}for(r=t.length;r--;)t[r]&&(t[r]._gsap||(t[r]._gsap=new Vt(t[r],e)))||t.splice(r,1);return t}function fa(t){return t._gsap||ea(Mt(t))[0]._gsap}function ga(t,e,r){return(r=t[e])&&s(r)?t[e]():u(r)&&t.getAttribute&&t.getAttribute(e)||r}function ha(t,e){return(t=t.split(",")).forEach(e)||t}function ia(t){return Math.round(1e5*t)/1e5||0}function ja(t){return Math.round(1e7*t)/1e7||0}function ka(t,e){var r=e.charAt(0),i=parseFloat(e.substr(2));return t=parseFloat(t),"+"===r?t+i:"-"===r?t-i:"*"===r?t*i:t/i}function la(t,e){for(var r=e.length,i=0;t.indexOf(e[i])<0&&++i<r;);return i<r}function ma(){var t,e,r=dt.length,i=dt.slice(0);for(ct={},t=dt.length=0;t<r;t++)(e=i[t])&&e._lazy&&(e.render(e._lazy[0],e._lazy[1],!0)._lazy=0)}function na(t,e,r,i){dt.length&&!L&&ma(),t.render(e,r,i||L&&e<0&&(t._initted||t._startAt)),dt.length&&!L&&ma()}function oa(t){var e=parseFloat(t);return(e||0===e)&&(t+"").match(at).length<2?e:r(t)?t.trim():t}function pa(t){return t}function qa(t,e){for(var r in e)r in t||(t[r]=e[r]);return t}function ta(t,e){for(var r in e)"__proto__"!==r&&"constructor"!==r&&"prototype"!==r&&(t[r]=v(e[r])?ta(t[r]||(t[r]={}),e[r]):e[r]);return t}function ua(t,e){var r,i={};for(r in t)r in e||(i[r]=t[r]);return i}function va(t){var e=t.parent||I,r=t.keyframes?function _setKeyframeDefaults(i){return function(t,e){for(var r in e)r in t||"duration"===r&&i||"ease"===r||(t[r]=e[r])}}(Z(t.keyframes)):qa;if(w(t.inherit))for(;e;)r(t,e.vars.defaults),e=e.parent||e._dp;return t}function xa(t,e,r,i,n){void 0===r&&(r="_first"),void 0===i&&(i="_last");var a,s=t[i];if(n)for(a=e[n];s&&s[n]>a;)s=s._prev;return s?(e._next=s._next,s._next=e):(e._next=t[r],t[r]=e),e._next?e._next._prev=e:t[i]=e,e._prev=s,e.parent=e._dp=t,e}function ya(t,e,r,i){void 0===r&&(r="_first"),void 0===i&&(i="_last");var n=e._prev,a=e._next;n?n._next=a:t[r]===e&&(t[r]=a),a?a._prev=n:t[i]===e&&(t[i]=n),e._next=e._prev=e.parent=null}function za(t,e){t.parent&&(!e||t.parent.autoRemoveChildren)&&t.parent.remove&&t.parent.remove(t),t._act=0}function Aa(t,e){if(t&&(!e||e._end>t._dur||e._start<0))for(var r=t;r;)r._dirty=1,r=r.parent;return t}function Ca(t,e,r,i){return t._startAt&&(L?t._startAt.revert(ht):t.vars.immediateRender&&!t.vars.autoRevert||t._startAt.render(e,!0,i))}function Ea(t){return t._repeat?Tt(t._tTime,t=t.duration()+t._rDelay)*t:0}function Ga(t,e){return(t-e._start)*e._ts+(0<=e._ts?0:e._dirty?e.totalDuration():e._tDur)}function Ha(t){return t._end=ja(t._start+(t._tDur/Math.abs(t._ts||t._rts||X)||0))}function Ia(t,e){var r=t._dp;return r&&r.smoothChildTiming&&t._ts&&(t._start=ja(r._time-(0<t._ts?e/t._ts:((t._dirty?t.totalDuration():t._tDur)-e)/-t._ts)),Ha(t),r._dirty||Aa(r,t)),t}function Ja(t,e){var r;if((e._time||!e._dur&&e._initted||e._start<t._time&&(e._dur||!e.add))&&(r=Ga(t.rawTime(),e),(!e._dur||Ot(0,e.totalDuration(),r)-e._tTime>X)&&e.render(r,!0)),Aa(t,e)._dp&&t._initted&&t._time>=t._dur&&t._ts){if(t._dur<t.duration())for(r=t;r._dp;)0<=r.rawTime()&&r.totalTime(r._tTime),r=r._dp;t._zTime=-X}}function Ka(e,r,i,n){return r.parent&&za(r),r._start=ja((t(i)?i:i||e!==I?xt(e,i,r):e._time)+r._delay),r._end=ja(r._start+(r.totalDuration()/Math.abs(r.timeScale())||0)),xa(e,r,"_first","_last",e._sort?"_start":0),bt(r)||(e._recent=r),n||Ja(e,r),e._ts<0&&Ia(e,e._tTime),e}function La(t,e){return(ot.ScrollTrigger||Q("scrollTrigger",e))&&ot.ScrollTrigger.create(e,t)}function Ma(t,e,r,i,n){return Qt(t,e,n),t._initted?!r&&t._pt&&!L&&(t._dur&&!1!==t.vars.lazy||!t._dur&&t.vars.lazy)&&f!==Rt.frame?(dt.push(t),t._lazy=[n,i],1):void 0:1}function Ra(t,e,r,i){var n=t._repeat,a=ja(e)||0,s=t._tTime/t._tDur;return s&&!i&&(t._time*=a/t._dur),t._dur=a,t._tDur=n?n<0?1e10:ja(a*(n+1)+t._rDelay*n):a,0<s&&!i&&Ia(t,t._tTime=t._tDur*s),t.parent&&Ha(t),r||Aa(t.parent,t),t}function Sa(t){return t instanceof Xt?Aa(t):Ra(t,t._dur)}function Va(e,r,i){var n,aLine truncated
-7
View File
@@ -1,7 +0,0 @@
console.warn('Scripts "build/three.js" and "build/three.min.js" are deprecated with r150+, and will be removed with r160. Please use ES Modules or alternatives: https://threejs.org/docs/index.html#manual/en/introduction/Installation'),
/**
* @license
* Copyright 2010-2023 Three.js Authors
* SPDX-License-Identifier: MIT
*/
function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="160",n=1,i=2,r=3,s=0,a=1,o=100,l=204,c=205,h=0,u=1,d=2,p=0,m=1,f=2,g=3,_=4,v=5,x=6,y="attached",M="detached",S=300,b=301,T=302,E=303,w=304,A=306,R=1e3,C=1001,L=1002,P=1003,I=1004,U=1005,D=1006,N=1007,O=1008,F=1009,B=1012,z=1013,G=1014,H=1015,V=1016,k=1017,W=1018,X=1020,j=1023,q=1026,Y=1027,Z=1029,J=1031,K=1033,$=33776,Q=33777,tt=33778,et=33779,nt=35840,it=35841,rt=35842,st=35843,at=36196,ot=37492,lt=37496,ct=37808,ht=37809,ut=37810,dt=37811,pt=37812,mt=37813,ft=37814,gt=37815,_t=37816,vt=37817,xt=37818,yt=37819,Mt=37820,St=37821,bt=36492,Tt=36494,Et=36495,wt=36284,At=36285,Rt=36286,Ct=2300,Lt=2301,Pt=2302,It=2400,Ut=2401,Dt=2402,Nt=2500,Ot=2501,Ft=3e3,Bt=3001,zt="",Gt="srgb",Ht="srgb-linear",Vt="display-p3",kt="display-p3-linear",Wt="linear",Xt="srgb",jt="rec709",qt="p3",Yt=7680,Zt=35044,Jt="300 es",Kt=1035,$t=2e3,Qt=2001;class te{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const ee=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let ne=1234567;const ie=Math.PI/180,re=180/Math.PI;function se(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(ee[255&t]+ee[t>>8&255]+ee[t>>16&255]+ee[t>>24&255]+"-"+ee[255&e]+ee[e>>8&255]+"-"+ee[e>>16&15|64]+ee[e>>24&255]+"-"+ee[63&n|128]+ee[n>>8&255]+"-"+ee[n>>16&255]+ee[n>>24&255]+ee[255&i]+ee[i>>8&255]+ee[i>>16&255]+ee[i>>24&255]).toLowerCase()}function ae(t,e,n){return Math.max(e,Math.min(n,t))}function oe(t,e){return(t%e+e)%e}function le(t,e,n){return(1-n)*t+n*e}function ce(t){return 0==(t&t-1)&&0!==t}function he(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function ue(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function de(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const pe={DEG2RAD:ie,RAD2DEG:re,generateUUID:se,clamp:ae,euclideanModulo:oe,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:le,damp:function(t,e,n,i){return le(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(oe(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatLine truncated