Compare commits

..
Author SHA1 Message Date
Claude a72be6317b Add README for the BeingOne project
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01829HyWt1ur24ovhzCVeBx4
2026-10-25 23:31:46 +00:00
Claude 8aac3f10e4 Redesign BeingOne front page to the cream, navy and terracotta reference
New custom look from the user's reference screenshot: DM Serif Display and
Figtree, split hero with the photo bleeding off the right edge, a floating
quick search bar (looking for, age, location) that drives the finder, a
mobile menu, and added motion (line-by-line headline, photo curtain, map pins
dropping in, count-up scores, hover lifts, progress on the checks).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01829HyWt1ur24ovhzCVeBx4
2026-10-25 23:29:40 +00:00
Claude f8c8cffb4d Add Hallmark salt file used for design theme picks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01829HyWt1ur24ovhzCVeBx4
2026-10-23 08:13:34 +00:00
Claude 2ff0e9202f Add BeingOne front page: new Squeeze-themed design with working demos
Phase 1 of the BeingOne plan: a front page with a tappable Australia map,
match finder with scores, four-check verification demo, photo privacy demo,
interest/accept/chat demo, family view, safety tools, and join/log in forms
that post nowhere yet. Includes the PRD.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01829HyWt1ur24ovhzCVeBx4
2026-10-23 08:13:06 +00:00
keithranaandClaude Sonnet 5.5 0b42fdeae1 Add remotion-video skill (#1)
* Add remotion-video skill

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

* Add Phase 2 templates to remotion-video skill

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

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

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

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

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

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

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

---------

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

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
---
name: remotion-video
description: Create videos from React code with Remotion. Use when the user asks to make, animate, preview, or render a video, motion graphic, intro, caption, or social clip using code.
---
# Remotion Video
Remotion makes videos out of React components. Each frame is a web page. Render them in order and you get an MP4.
## Core ideas
- **Composition**: one video. Has `width`, `height`, `fps`, `durationInFrames`.
- **Frame**: `useCurrentFrame()` returns the current frame number. All animation is a function of the frame.
- **`interpolate`**: maps a frame range to a value range (fade, slide).
- **`spring`**: natural bouncy motion.
- **`Sequence`**: shows children only during a frame range.
- **`AbsoluteFill`**: full-screen layer; stack them for backgrounds.
- **`Audio` / `OffthreadVideo` / `Img`**: media. Put files in `public/` and load with `staticFile("name.mp3")`.
## Workflow
1. **Plan first.** Ask or decide: length (seconds), size (1920x1080, or 1080x1920 for vertical), fps (30), and the scenes. Keep scenes to a short list.
2. **Set up** (skip if the project exists):
```
npx create-video@latest
cd <project> && npm install
```
3. **Write** compositions in `src/`, register them in `src/Root.tsx`.
4. **Preview**: `npm run dev` (opens Remotion Studio).
5. **Render**: `npx remotion render <CompositionId> out/video.mp4`
Still frame: `npx remotion still <CompositionId> out/frame.png --frame=30`
6. **Verify**: render one still first, check it looks right, then render the full video.
## Starter example
`src/Root.tsx`
```tsx
import { Composition } from "remotion";
import { Hello } from "./Hello";
export const RemotionRoot = () => (
<Composition
id="Hello"
component={Hello}
durationInFrames={150}
fps={30}
width={1920}
height={1080}
defaultProps={{ title: "Hello, world" }}
/>
);
```
`src/Hello.tsx`
```tsx
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
export const Hello = ({ title }: { title: string }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
const scale = spring({ frame, fps, config: { damping: 12 } });
return (
<AbsoluteFill style={{ backgroundColor: "#0b1020", justifyContent: "center", alignItems: "center" }}>
<h1 style={{ color: "white", fontSize: 120, opacity, transform: `scale(${scale})` }}>{title}</h1>
</AbsoluteFill>
);
};
```
## Rules of thumb
- Never animate with CSS transitions, `setTimeout`, or CSS animations. They don't render per frame. Drive everything from `useCurrentFrame()`.
- Always pass `extrapolateRight: "clamp"` to `interpolate` unless you want values to keep growing.
- Seconds to frames: `seconds * fps`.
- Use `<Sequence from={..} durationInFrames={..}>` to order scenes; `<Series>` to chain them.
- Load fonts with `@remotion/google-fonts` so they are ready before rendering.
- Keep props in `defaultProps` so the user can edit text in the Studio.
- Rendering needs Chromium and ffmpeg (Remotion installs/handles these); first render can be slow.
## Fonts
Google Fonts: `npm i @remotion/google-fonts`, then
```tsx
import { loadFont } from "@remotion/google-fonts/Inter";
const { fontFamily } = loadFont(); // use as style={{ fontFamily }}
```
It downloads the font while rendering, so the machine needs internet access to fonts.googleapis.com. If the render fails with "Failed to load font", the network is blocking it: use a local font file in `public/` with `@remotion/fonts` (`loadFont({ family, url: staticFile("x.woff2") })`) or a system font. (Not verified in the sandbox where this skill was tested, which had no access to Google.)
## Common problems
- **Can't download the browser (no internet / locked-down machine)**: point Remotion at an installed Chromium:
`npx remotion render <Id> out/video.mp4 --browser-executable=/path/to/chromium --chrome-mode=chrome-for-testing`
- **Blank/white frames**: an asset path is wrong; use `staticFile()`.
- **Flicker**: an image or font not loaded; use `<Img>` and `@remotion/google-fonts`.
- **Slow render**: lower resolution/fps while testing, or use `--concurrency`.
## Templates
Copy-paste starting points. Read the one that fits:
- `templates/captions.md` - words highlighted in time with speech
- `templates/charts.md` - animated bar chart
- `templates/intro-outro.md` - title card + chaining scenes with `Series`
- `templates/audio-voiceover.md` - background music, voiceover, auto video length
- `templates/vertical.md` - 1080x1920 clips for Reels/TikTok/Shorts
## Docs
https://www.remotion.dev/docs
@@ -0,0 +1,62 @@
# Template: Audio, music and voiceover
Put sound files in `public/` (mp3 or wav). Load them with `staticFile()`.
`src/Narrated.tsx`
```tsx
import { AbsoluteFill, Audio, Sequence, interpolate, staticFile, useVideoConfig } from "remotion";
import { getAudioDurationInSeconds } from "@remotion/media-utils";
import type { CalculateMetadataFunction } from "remotion";
type Props = { title: string; voice: string; music: string };
// Make the video exactly as long as the voiceover (+1 second of padding).
export const calcNarratedMetadata: CalculateMetadataFunction<Props> = async ({ props }) => {
const seconds = await getAudioDurationInSeconds(staticFile(props.voice));
return { durationInFrames: Math.ceil((seconds + 1) * 30) };
};
export const Narrated = ({ title, voice, music }: Props) => {
const { fps, durationInFrames } = useVideoConfig();
return (
<AbsoluteFill style={{ background: "#0b1020", justifyContent: "center", alignItems: "center" }}>
<h1 style={{ color: "white", fontSize: 110 }}>{title}</h1>
{/* Background music: quiet, fades out over the last second. */}
<Audio
src={staticFile(music)}
volume={(f) => interpolate(f, [durationInFrames - fps, durationInFrames], [0.15, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })}
/>
{/* Voiceover starts half a second in, full volume. */}
<Sequence from={Math.round(fps * 0.5)}>
<Audio src={staticFile(voice)} volume={1} />
</Sequence>
</AbsoluteFill>
);
};
```
Register it (note `calculateMetadata`):
```tsx
<Composition
id="Narrated"
component={Narrated}
calculateMetadata={calcNarratedMetadata}
durationInFrames={300} // placeholder, replaced by calculateMetadata
fps={30} width={1920} height={1080}
defaultProps={{ title: "My story", voice: "voice.mp3", music: "music.mp3" }}
/>
```
Install: `npm i @remotion/media-utils`
Getting a voiceover
- Record yourself, or generate speech with a text-to-speech service, and save as `public/voice.mp3`. Never commit API keys; keep them in environment variables.
- Pair with `templates/captions.md`: feed it word timings from a transcription of the same file.
Rules
- Music volume 0.1-0.2 so the voice stays clear.
- `<Sequence from={...}>` delays when a sound starts.
- Audio is only in the final render (mp4). Stills (`remotion still`) have no sound.
- Check the sound made it in: `ffprobe out/video.mp4` should list an `Audio:` stream.
@@ -0,0 +1,38 @@
# Template: Captions
Words appear in time with speech. Give each word a start time in seconds.
`src/Captions.tsx`
```tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
type Word = { text: string; start: number; end: number }; // seconds
export const Captions = ({ words, background = "#111" }: { words: Word[]; background?: string }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = frame / fps;
// Show a window of up to 5 words around the current one.
const current = words.findIndex((w) => t >= w.start && t < w.end);
const i = current === -1 ? 0 : current;
const visible = words.slice(Math.floor(i / 5) * 5, Math.floor(i / 5) * 5 + 5);
return (
<AbsoluteFill style={{ backgroundColor: background, justifyContent: "flex-end", alignItems: "center", paddingBottom: 200 }}>
<div style={{ fontSize: 72, fontWeight: 800, color: "white", textShadow: "0 4px 12px black", textAlign: "center", maxWidth: "80%" }}>
{visible.map((w, k) => (
<span key={k} style={{ color: t >= w.start && t < w.end ? "#ffd400" : "white", marginRight: 16 }}>
{w.text}
</span>
))}
</div>
</AbsoluteFill>
);
};
```
Tips
- Get word times from a transcription tool (e.g. `@remotion/captions` or Whisper) and save them as JSON in `public/`.
- `background` is the backdrop when there is no video. Pass `"transparent"` when layering over a video.
- Layer it over the video: `<AbsoluteFill><OffthreadVideo src={staticFile("clip.mp4")} /><Captions words={words} background="transparent" /></AbsoluteFill>`.
@@ -0,0 +1,37 @@
# Template: Animated bar chart
Bars grow one after another.
`src/BarChart.tsx`
```tsx
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
type Bar = { label: string; value: number };
export const BarChart = ({ data, color = "#4f8cff" }: { data: Bar[]; color?: string }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const max = Math.max(...data.map((d) => d.value));
return (
<AbsoluteFill style={{ backgroundColor: "#0b1020", padding: 120, justifyContent: "flex-end" }}>
<div style={{ display: "flex", alignItems: "flex-end", gap: 40, height: 700 }}>
{data.map((d, i) => {
const grow = spring({ frame: frame - i * 6, fps, config: { damping: 15 } });
return (
<div key={d.label} style={{ flex: 1, textAlign: "center", color: "white", fontSize: 36 }}>
<div style={{ height: (d.value / max) * 600 * grow, background: color, borderRadius: 8 }} />
<div style={{ marginTop: 16 }}>{d.label}</div>
</div>
);
})}
</div>
</AbsoluteFill>
);
};
```
Tips
- Delay each bar with `frame - i * 6` (6 frames apart).
- For lines, animate `strokeDashoffset` on an SVG path using `interpolate`.
- Keep numbers readable: show the value as text, rounded.
@@ -0,0 +1,34 @@
# Template: Title intro / outro card
A short card (2-3 seconds) with a title and subtitle. Use at the start or end.
`src/TitleCard.tsx`
```tsx
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
export const TitleCard = ({ title, subtitle }: { title: string; subtitle?: string }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const rise = spring({ frame, fps, config: { damping: 14 } });
const fadeOut = interpolate(frame, [durationInFrames - 15, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill style={{ background: "linear-gradient(135deg,#1b2a6b,#0b1020)", justifyContent: "center", alignItems: "center", opacity: fadeOut }}>
<h1 style={{ color: "white", fontSize: 130, margin: 0, transform: `translateY(${(1 - rise) * 60}px)`, opacity: rise }}>{title}</h1>
{subtitle && <p style={{ color: "#9fb3ff", fontSize: 48, opacity: rise }}>{subtitle}</p>}
</AbsoluteFill>
);
};
```
Chain with the main video using `<Series>`:
```tsx
<Series>
<Series.Sequence durationInFrames={90}><TitleCard title="My Video" /></Series.Sequence>
<Series.Sequence durationInFrames={600}><MainScene /></Series.Sequence>
<Series.Sequence durationInFrames={90}><TitleCard title="Thanks for watching" /></Series.Sequence>
</Series>
```
@@ -0,0 +1,23 @@
# Template: Vertical clips (Reels / TikTok / Shorts)
Same code, different size. Register a vertical composition:
```tsx
<Composition
id="Vertical"
component={MyScene}
width={1080}
height={1920}
fps={30}
durationInFrames={30 * 30} // 30 seconds
/>
```
Render: `npx remotion render Vertical out/vertical.mp4`
Rules
- Keep text inside the **safe zone**: about 150px from the top, 350px from the bottom (apps cover those parts with buttons and captions), and 60px from the sides.
- Use big text (60px+). Phone screens are small.
- Keep clips under 60 seconds (Shorts limit); 15-30 seconds does best.
- Use `useVideoConfig()` for width/height instead of hard-coding, so the same scene works wide and tall.
- Put captions in the lower-middle, above the bottom safe zone (see captions.md).
+1
View File
@@ -0,0 +1 @@
ff37d1cd
+1
View File
@@ -0,0 +1 @@
.hallmark/shots/
+5
View File
@@ -0,0 +1,5 @@
shots/
before/
before.json
before.txt
compare.html
+31
View File
@@ -0,0 +1,31 @@
[
{
"shape": "Marquee Hero",
"theme": "squeeze",
"axes": "mid/geometric-sans/warm",
"hero": "Banner",
"nav": "edge-aligned",
"footer": "letter close",
"imagery": "photographs, library 1, natural",
"photos": [
"worm-bay"
],
"entry": "BeingOne front page",
"sections": [
"Hero",
"Path",
"Finder",
"Checks",
"Privacy",
"Talk",
"Family+Safety",
"Questions+Join",
"Footer"
],
"signature": "match finder with a tappable map of Australia and a moving score",
"waived": [],
"prompt": "BeingOne marriage site for Australians",
"date": "2026-10-23",
"mode": "build"
}
]
+65
View File
@@ -0,0 +1,65 @@
# BeingOne: plan (PRD)
Tagline: Real people. Real profiles. Real marriage.
For: Australians, and the families of Australians, who want to find a life partner. English only. Australia only.
Written for: Keith. Plain words on purpose.
## The big idea
BeingOne is a marriage website where every profile is checked to be a real person. People find matches, talk safely, and families can be part of it.
## The 13 core features (from Keith's plan)
1. Sign up and log in with email.
2. Make a profile: photos, age, city, job, things you love, a short "about me".
3. Check the profile is real: phone check, photo check, ID check, and a quick look by a person on the team.
4. Search by Australian city, age and shared interests.
5. Match score: a number showing how well two people fit.
6. Photo privacy: you decide who sees your photos.
7. Send an interest. The other person accepts or says no thanks.
8. Private chat that opens only when both say yes.
9. Family view: a parent or sibling helps look at profiles.
10. Safety tools: block, report, hide your profile.
11. Free plan and paid plan (payments).
12. Admin area for the BeingOne team: review profiles, handle reports, see numbers.
13. Emails and phone alerts: new matches, interests, messages.
## Phase 1 (today): the new look, with working demos
What you get: one front page in a new design. Everything on it is a demo that runs in your browser, with made-up sample people.
| Feature | Where it shows up on the page |
| --- | --- |
| 1 Sign up, log in | The join card at the bottom (Join and Log in tabs). It does not save anything yet. |
| 2 Profile | Shown through the sample people and the checks demo. The real profile editor is Phase 3. |
| 3 Checks | "Every profile is checked": run the four checks on a sample profile. |
| 4 Search | "Find your match across Australia": map of Australia, city, ages, interests. |
| 5 Match score | The score and bar on every sample person. |
| 6 Photo privacy | "You choose who sees your photos": pick who can see, pick who is looking. |
| 7 Interest, accept | "Chat opens when both say yes": accept or say no thanks. |
| 8 Private chat | Same section: the chat unlocks after Accept. |
| 9 Family view | "Family view" tile: pick who helps you look. |
| 10 Safety tools | "Safety tools" tile: block, report, hide. |
| 11 Plans | The "What does it cost?" answer. The paid price is left blank until you give it. |
| 12 Admin | Mentioned in "Who runs the site?". The real admin area is Phase 7 and is a separate private page. |
| 13 Alerts | The "Will I get alerts?" answer. Real alerts are Phase 6. |
Tests, in this order:
1. The page loads with no errors.
2. Every button, chip, slider, tab and form reacts.
3. Real connections: none yet, so nothing to test.
4. Security: no passwords, no keys, no calls to other servers for data. The forms only show a message on screen.
## Phases 2 to 8 (one at a time, each must work before the next)
2. Real sign-up and login (needs a host, a domain, a privacy policy and a consent step).
3. Real profiles and photos, with private photo storage.
4. Real checks (phone code, ID check through a trusted provider, team review screen).
5. Real search and matching on real profiles.
6. Private chat, block and report, email and phone alerts.
7. Payments (test mode first) and the admin area.
8. Launch checks: speed, phones, privacy rules, backups, a trial with friends and family.
## Important
Marriage sites hold very private details. In Australia some of them are "sensitive information" under the Privacy Act 1988, so people must clearly agree to how they are used. Plan the privacy policy and consent step in Phase 2. Please check with a lawyer before launch. This is not legal advice.
+42
View File
@@ -0,0 +1,42 @@
# BeingOne
Real people. Real profiles. Real marriage.
BeingOne is a marriage website for Australians and their families. Every profile is checked to be a real person. People find matches, talk safely, and families can be part of it.
This is **Phase 1**: the front page, in its new design, with working demos that run in the browser. The people on it are made up. Nothing is saved and nothing is sent anywhere yet.
## See it
There is no build step. Open `index.html` in a browser.
## What is in this folder
| File | What it is |
| --- | --- |
| `index.html` | The page |
| `style.css` | How it looks (colours, fonts, spacing, motion) |
| `tokens.css`, `base.css` | The colour and font settings, and the basic rules |
| `app.js` | What the buttons, map, search, checks, chat and forms do |
| `images/hero.jpg` | The photo at the top of the page (swap in the original) |
| `PRD.md` | The plan: 13 features and the 8 phases |
## What the demos do
- **Search bar and map of Australia:** pick a city, ages and interests. Made-up people appear with a match score.
- **Four checks:** run the phone, photo, ID and team checks on a sample profile.
- **Photo privacy:** choose who can see a photo, and who is looking.
- **Interest and chat:** accept an interest and a private chat opens.
- **Family view and safety tools:** pick who helps you look; block, report and hide.
- **Join and log in:** forms that do not send anything yet.
## Before it goes live
- The fonts load from Google Fonts. The beach photo loads from Wikimedia Commons (CC BY-SA 4.0, credited in the footer). Download both into this folder if you want the page to make no outside calls.
- Replace `images/hero.jpg` with the original photo file.
- The paid-plan price is a blank box on the page until you decide it.
- Marriage sites hold very private details. In Australia some of them count as "sensitive information" under the Privacy Act 1988, so people must clearly agree to how it is used. Plan the privacy policy and consent step in Phase 2, and check with a lawyer before launch. This is not legal advice.
## Next: Phase 2
Real sign-up and login. This needs a hosting place (for example Vercel and Supabase) and a domain name.
+313
View File
@@ -0,0 +1,313 @@
(() => {
'use strict';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* made-up sample people; nothing here is a real person (g: w = woman, m = man) */
const PEOPLE = [
{ n: 'Mia', g: 'w', a: 28, c: 'Sydney', l: ['beach', 'coffee', 'travel'], t: 'a' },
{ n: 'Arjun', g: 'm', a: 31, c: 'Melbourne', l: ['footy', 'cooking', 'music'], t: 'b' },
{ n: 'Hannah', g: 'w', a: 27, c: 'Brisbane', l: ['beach', 'hiking', 'pets'], t: 'c' },
{ n: 'Wei', g: 'm', a: 30, c: 'Perth', l: ['coffee', 'travel', 'hiking'], t: 'd' },
{ n: 'Sana', g: 'w', a: 29, c: 'Adelaide', l: ['cooking', 'music', 'pets'], t: 'a' },
{ n: 'Tom', g: 'm', a: 33, c: 'Hobart', l: ['hiking', 'footy', 'coffee'], t: 'b' },
{ n: 'Priya', g: 'w', a: 26, c: 'Sydney', l: ['music', 'cooking', 'beach'], t: 'c' },
{ n: 'Jack', g: 'm', a: 35, c: 'Melbourne', l: ['footy', 'beach', 'pets'], t: 'd' },
{ n: 'Elena', g: 'w', a: 32, c: 'Brisbane', l: ['travel', 'coffee', 'music'], t: 'a' },
{ n: 'Dev', g: 'm', a: 34, c: 'Perth', l: ['footy', 'cooking', 'hiking'], t: 'b' },
{ n: 'Grace', g: 'w', a: 30, c: 'Canberra', l: ['hiking', 'coffee', 'pets'], t: 'c' },
{ n: 'Sam', g: 'm', a: 28, c: 'Darwin', l: ['beach', 'footy', 'travel'], t: 'd' },
{ n: 'Lena', g: 'w', a: 38, c: 'Sydney', l: ['travel', 'music', 'pets'], t: 'a' },
{ n: 'Omar', g: 'm', a: 29, c: 'Melbourne', l: ['coffee', 'cooking', 'hiking'], t: 'c' }
];
/* ---------- scroll reveal, once ---------- */
const revealEls = $$('[data-reveal]');
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver((es) => es.forEach((e) => {
if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
}), { rootMargin: '0px 0px -10% 0px' });
revealEls.forEach((el) => io.observe(el));
} else {
revealEls.forEach((el) => el.classList.add('is-in'));
}
/* ---------- mobile menu ---------- */
const menu = $('.nav__menu'), links = $('#nav-links');
menu.addEventListener('click', () => {
const open = links.classList.toggle('is-open');
menu.setAttribute('aria-expanded', String(open));
menu.textContent = open ? 'Close' : 'Menu';
});
links.addEventListener('click', (e) => {
if (e.target.closest('a')) { links.classList.remove('is-open'); menu.setAttribute('aria-expanded', 'false'); menu.textContent = 'Menu'; }
});
/* ---------- match finder (the quick search bar drives it too) ---------- */
const st = { city: 'Anywhere', gender: 'any', from: 25, to: 40, likes: new Set(), sent: new Set() };
const from = $('#f-from'), to = $('#f-to'), oFrom = $('#o-from'), oTo = $('#o-to');
const results = $('#results'), status = $('#finder-status'), mapCity = $('#map-city');
const qFor = $('#q-for'), qAge = $('#q-age'), qCity = $('#q-city');
const pins = $$('.pin');
function scoreOf(p) {
const mid = (st.from + st.to) / 2, half = Math.max((st.to - st.from) / 2, 1);
const ageFit = Math.max(0, 1 - Math.abs(p.a - mid) / (half + 4));
const shared = p.l.filter((x) => st.likes.has(x)).length;
const likeFit = st.likes.size ? Math.min(1, shared / Math.min(st.likes.size, 3)) : 0.5;
const cityFit = st.city === 'Anywhere' ? 0.7 : 1;
return Math.round(55 + 45 * (0.5 * likeFit + 0.3 * ageFit + 0.2 * cityFit));
}
function tile(r, i) {
const p = r.p, sent = st.sent.has(p.n);
const tags = p.l.map((x) => '<li' + (st.likes.has(x) ? ' class="is-shared"' : '') + '>' + cap(x) + '</li>').join('');
return '<article class="person" style="--i:' + i + '">' +
'<div class="person__who"><span class="avatar" style="--tone:var(--tone-' + p.t + ')" aria-hidden="true">' + p.n.charAt(0) + '</span>' +
'<div><h3>' + p.n + ', ' + p.a + '</h3><p class="label">' + p.c + '</p></div></div>' +
'<div><div class="score"><b data-to="' + r.score + '">' + r.score + '</b><span class="label" title="Counts shared interests, city and age">match</span></div>' +
'<div class="bar" role="img" aria-label="Match score ' + r.score + ' out of 100"><i style="--s:' + r.score + '"></i></div></div>' +
'<ul class="tags">' + tags + '</ul>' +
'<button class="btn btn--sm" type="button" data-interest="' + p.n + '" aria-pressed="' + sent + '">' + (sent ? 'Interest sent' : 'Send interest') + '</button>' +
'</article>';
}
/* scores count up from 55 to their value, once per render */
function countUp(el) {
const to = +el.dataset.to;
if (reduced) { el.textContent = to; return; }
const t0 = performance.now(), dur = 900, start = 55;
el.textContent = start;
(function step(now) {
const k = Math.min(1, (now - t0) / dur), e = 1 - Math.pow(1 - k, 3);
el.textContent = Math.round(start + (to - start) * e);
if (k < 1) requestAnimationFrame(step);
})(t0);
}
function renderFinder() {
const pool = PEOPLE
.filter((p) => (st.city === 'Anywhere' || p.c === st.city) && (st.gender === 'any' || p.g === st.gender) && p.a >= st.from && p.a <= st.to)
.map((p) => ({ p, score: scoreOf(p) }))
.sort((x, y) => y.score - x.score || x.p.n.localeCompare(y.p.n));
const top = pool.slice(0, 4);
results.innerHTML = top.length
? top.map(tile).join('')
: '<p class="results__empty">No one fits yet. Widen the ages or try another city.</p>';
$$('.score b', results).forEach(countUp);
status.textContent = top.length ? top.length + ' of ' + pool.length + ' shown.' : '';
mapCity.textContent = st.city === 'Anywhere' ? 'All of Australia' : st.city;
qCity.value = st.city;
pins.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.city === st.city)));
}
function setAges(a, b) {
st.from = a; st.to = b; from.value = a; to.value = b;
oFrom.textContent = a; oTo.textContent = b;
}
pins.forEach((b) => b.addEventListener('click', () => {
st.city = st.city === b.dataset.city ? 'Anywhere' : b.dataset.city;
if (st.city === b.dataset.city && !reduced) {
const ping = document.createElement('span');
ping.className = 'ping';
ping.addEventListener('animationend', () => ping.remove(), { once: true });
b.appendChild(ping);
}
renderFinder();
}));
from.addEventListener('input', () => {
st.from = +from.value;
if (st.from > st.to) { st.to = st.from; to.value = st.to; }
oFrom.textContent = st.from; oTo.textContent = st.to; renderFinder();
});
to.addEventListener('input', () => {
st.to = +to.value;
if (st.to < st.from) { st.from = st.to; from.value = st.from; }
oFrom.textContent = st.from; oTo.textContent = st.to; renderFinder();
});
$$('#f-likes input').forEach((c) => c.addEventListener('change', () => {
c.checked ? st.likes.add(c.value) : st.likes.delete(c.value);
renderFinder();
}));
results.addEventListener('click', (e) => {
const b = e.target.closest('[data-interest]');
if (!b) return;
const name = b.dataset.interest;
st.sent.has(name) ? st.sent.delete(name) : st.sent.add(name);
const on = st.sent.has(name);
b.setAttribute('aria-pressed', String(on));
b.textContent = on ? 'Interest sent' : 'Send interest';
});
/* quick search bar: each choice updates the finder, the button also scrolls to the people */
function applyQuick() {
st.gender = qFor.value;
st.city = qCity.value;
const [a, b] = qAge.value.split('-').map(Number);
setAges(a, b);
renderFinder();
}
[qFor, qAge, qCity].forEach((el) => el.addEventListener('change', applyQuick));
$('#quick').addEventListener('submit', (e) => {
e.preventDefault();
applyQuick();
results.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'center' });
});
renderFinder();
/* ---------- the four checks ---------- */
const rows = $$('[data-check]'), vStatus = $('#v-status'), vText = $('#v-status-text'), vBar = $('#v-progress');
const timers = new Map();
function updateChecks() {
const n = rows.filter((r) => r.classList.contains('is-done')).length;
vStatus.dataset.state = n === rows.length ? 'done' : 'idle';
vText.textContent = n === rows.length ? 'Verified' : n ? n + ' of 4 passed' : 'Not verified yet';
vBar.style.setProperty('--p', n / rows.length);
}
function runCheck(row) {
if (row.classList.contains('is-done') || timers.has(row)) return;
const btn = $('[data-run]', row);
btn.setAttribute('aria-busy', 'true'); btn.disabled = true; btn.textContent = 'Checking…';
timers.set(row, setTimeout(() => {
timers.delete(row);
row.classList.add('is-done');
btn.hidden = true; btn.removeAttribute('aria-busy');
$('.passed', row).hidden = false;
updateChecks();
}, 800));
}
rows.forEach((r) => $('[data-run]', r).addEventListener('click', () => runCheck(r)));
$('#v-all').addEventListener('click', () => rows.forEach((r, i) => setTimeout(() => runCheck(r), i * 250)));
$('#v-reset').addEventListener('click', () => {
timers.forEach((t) => clearTimeout(t)); timers.clear();
rows.forEach((r) => {
r.classList.remove('is-done');
const btn = $('[data-run]', r);
btn.hidden = false; btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = 'Run';
$('.passed', r).hidden = true;
});
updateChecks();
});
/* ---------- photo privacy ---------- */
const photo = $('#photo'), photoState = $('#photo-state');
const rank = { stranger: 0, verified: 1, accepted: 2 };
function renderPhoto() {
const setting = $('input[name="setting"]:checked').value;
const viewer = $('input[name="viewer"]:checked').value;
const can = setting === 'everyone' ||
(setting === 'verified' && rank[viewer] >= 1) ||
(setting === 'accepted' && rank[viewer] >= 2);
photo.classList.toggle('is-hidden', !can);
photoState.textContent = can ? 'Visible to this visitor' : 'Hidden from this visitor';
}
$$('input[name="setting"], input[name="viewer"]').forEach((r) => r.addEventListener('change', renderPhoto));
renderPhoto();
/* ---------- interest, accept, chat ---------- */
const chat = $('#chat'), log = $('#chat-log'), tResult = $('#t-result');
const accept = $('#t-accept'), decline = $('#t-decline'), tReset = $('#t-reset');
const chatForm = $('#chat-form'), chatInput = $('#chat-input');
function addLine(text, cls) {
const li = document.createElement('li');
if (cls) li.className = cls;
li.textContent = text;
log.appendChild(li);
log.scrollTop = log.scrollHeight;
}
function decide(yes) {
accept.hidden = true; decline.hidden = true; tReset.hidden = false;
tResult.textContent = yes ? 'You both said yes. Say hello.' : 'You said no thanks. Nothing opens.';
if (yes) {
chat.classList.remove('is-locked');
addLine('You and Priya both said yes.', 'note');
chatInput.focus();
}
}
accept.addEventListener('click', () => decide(true));
decline.addEventListener('click', () => decide(false));
tReset.addEventListener('click', () => {
accept.hidden = false; decline.hidden = false; tReset.hidden = true;
tResult.textContent = ''; log.textContent = '';
chat.classList.add('is-locked'); chatInput.value = '';
});
chatForm.addEventListener('submit', (e) => {
e.preventDefault();
const v = chatInput.value.trim();
if (!v) { chatInput.focus(); return; }
addLine(v, 'me'); chatInput.value = ''; chatInput.focus();
});
/* ---------- family view and safety tools ---------- */
const famStatus = $('#fam-status');
function renderFam() {
const on = $$('#fam input:checked').map((c) => c.value);
famStatus.textContent = !on.length ? 'Just you for now.'
: (on.length === 1 ? on[0] : on.slice(0, -1).join(', ') + ' and ' + on[on.length - 1]) +
(on.length === 1 ? ' is' : ' are') + ' looking with you.';
}
$$('#fam input').forEach((c) => c.addEventListener('change', renderFam));
const safe = $('#safe-status'), sBlock = $('#s-block'), sHide = $('#s-hide'), sReport = $('#s-report');
let blocked = false;
sBlock.addEventListener('click', () => {
blocked = !blocked;
$('span', sBlock).textContent = blocked ? 'Unblock' : 'Block';
sBlock.setAttribute('aria-pressed', String(blocked));
safe.textContent = blocked ? 'Jack is blocked. He cannot message you.' : 'Jack is unblocked.';
});
sReport.addEventListener('click', () => { safe.textContent = 'Demo only: nothing was sent to our team.'; });
sHide.addEventListener('click', () => {
const on = sHide.getAttribute('aria-pressed') !== 'true';
sHide.setAttribute('aria-pressed', String(on));
$('span', sHide).textContent = on ? 'Show my profile' : 'Hide my profile';
safe.textContent = on ? 'Your profile is hidden from search.' : 'Your profile shows in search.';
});
/* ---------- join card: tabs and forms that post nowhere yet ---------- */
const tabs = $$('[role="tab"]', $('#join-card'));
const panels = { join: $('#panel-join'), login: $('#panel-login') };
function openTab(name, focus) {
tabs.forEach((t) => {
const on = t.id === 'tab-' + name;
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
if (on && focus) t.focus();
});
Object.keys(panels).forEach((k) => { panels[k].hidden = k !== name; });
}
tabs.forEach((t, i) => {
t.addEventListener('click', () => openTab(t.id.replace('tab-', '')));
t.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault();
const next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
openTab(next.id.replace('tab-', ''), true);
});
});
$$('[data-open-tab]').forEach((a) => a.addEventListener('click', () => openTab(a.dataset.openTab)));
openTab('join');
$$('form[data-endpoint]').forEach((form) => {
const help = $('.form-help', form);
form.addEventListener('input', (e) => { e.target.removeAttribute && e.target.removeAttribute('aria-invalid'); });
form.addEventListener('submit', (e) => {
e.preventDefault();
help.classList.remove('is-error');
const bad = $$('input', form).filter((i) => !i.checkValidity());
$$('input', form).forEach((i) => i.toggleAttribute('aria-invalid', bad.includes(i)));
if (bad.length) {
help.classList.add('is-error');
help.textContent = bad[0].type === 'email'
? 'That email looks incomplete. Write it as name@example.com.'
: 'Please fill in the highlighted field.';
bad[0].focus();
return;
}
if (!form.dataset.endpoint) { help.textContent = 'Nothing was sent: this form is not connected yet.'; return; }
});
});
})();
+23
View File
@@ -0,0 +1,23 @@
*, *::before, *::after { box-sizing: border-box; }
html, body { overflow-x: clip; }
html { scrollbar-color: var(--color-muted) var(--color-paper); scroll-padding-top: var(--space-3xl); }
body { margin: 0; background: var(--color-paper); color: var(--color-ink); font-family: var(--font-body); font-size: var(--text-md); line-height: 1.55; -webkit-font-smoothing: antialiased; caret-color: var(--color-accent); }
::selection { background: color-mix(in oklab, var(--color-accent) 22%, transparent); color: var(--color-ink); }
:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 0.16em; }
h1, h2, h3 { margin: 0; font-family: var(--font-display); font-synthesis: none; overflow-wrap: break-word; min-width: 0; text-wrap: balance; }
p { margin: 0; text-wrap: pretty; }
p:not([class]) { max-width: 65ch; }
img, video { max-width: 100%; height: auto; display: block; }
svg { display: block; }
[hidden] { display: none !important; }
button, input, select, textarea { font: inherit; }
.container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); box-sizing: border-box; }
.num, .figure, td.num { font-variant-numeric: tabular-nums; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
[data-slot] { position: relative; overflow: hidden; background: linear-gradient(160deg, var(--color-paper-2, var(--color-paper)), var(--color-paper-3, var(--color-paper))); }
[data-slot] > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1; }
[data-slot]:has(> img) > :not(img):not([data-keep]) { display: none !important; }
@media (max-width: 60rem) { :root { --gutter: 24px; --section-gap-s: 80px; --section-gap: 96px; --section-gap-l: 120px; } }
@media (max-width: 40rem) { :root { --gutter: 16px; --section-gap-s: 56px; --section-gap: 72px; --section-gap-l: 96px; --gap-columns: 16px; } }
@media (prefers-reduced-motion: reduce) { *:not(.keep-motion), *:not(.keep-motion)::before, *:not(.keep-motion)::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } }
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

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

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

@@ -1,30 +0,0 @@
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>
);
}
@@ -1,21 +0,0 @@
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>
);
}
@@ -1,101 +0,0 @@
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
@@ -1,42 +0,0 @@
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
@@ -1,58 +0,0 @@
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>
);
}
@@ -1,149 +0,0 @@
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
@@ -1,140 +0,0 @@
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
@@ -1,25 +0,0 @@
@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
@@ -1,10 +0,0 @@
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>
);
@@ -1,46 +0,0 @@
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>
);
}
@@ -1,47 +0,0 @@
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>
);
}
@@ -1,31 +0,0 @@
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>
);
}
@@ -1,55 +0,0 @@
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>
);
}
@@ -1,66 +0,0 @@
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>
);
}
@@ -1,75 +0,0 @@
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>
);
}
@@ -1,87 +0,0 @@
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>
);
}
@@ -1,32 +0,0 @@
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
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-6
View File
@@ -1,6 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { extend: { fontFamily: { sans: ['Kanit', 'sans-serif'] } } },
plugins: [],
};
-19
View File
@@ -1,19 +0,0 @@
{
"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
@@ -1,9 +0,0 @@
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 },
});