Turn template into Keith Rana's portfolio with real content, photos and bundled font

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
This commit is contained in:
Claude committed 2026-10-02 03:05:18 +00:00
1 parent 1500ba1054
commit 55f7434bf2
38 files changed
+491 -271

No files matched your search

-16
View File
@@ -1,16 +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="Jack — a 3D creator crafting striking and unforgettable projects." />
<title>Jack -- 3D Creator</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-68
View File
@@ -1,68 +0,0 @@
// Builds ONE self-contained HTML file for the Claude cloud preview.
// Claude's cloud pages block outside images, so every image is downloaded
// here and baked into the page as a data: URI.
// 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] ?? "jack-preview.html";
import { buildSync } from 'esbuild';
import { rmSync, writeFileSync as write } from 'node:fs';
// 1. Read the real list of image URLs from src/data/content.ts
buildSync({ entryPoints: ['src/data/content.ts'], bundle: true, format: 'esm', outfile: 'dist-tmp/content.mjs', logLevel: 'error' });
const content = await import(new URL('../dist-tmp/content.mjs', import.meta.url).href);
rmSync('dist-tmp', { recursive: true, force: true });
const urls = new Set();
const walk = (v) => {
if (typeof v === 'string' && v.startsWith('https://')) urls.add(v);
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
};
walk(content);
// 2. Download each one and turn it into a data: URI
const mime = { png: 'image/png', gif: 'image/gif', webp: 'image/webp', jpg: 'image/jpeg' };
const map = {};
let total = 0;
for (const url of urls) {
try {
const buf = execFileSync('curl', ['-sSfL', '-m', '60', url], { maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
const head = buf.subarray(0, 12).toString('latin1');
const type = head.startsWith('GIF') ? 'gif' : head.includes('WEBP') ? 'webp' : head.startsWith('\x89PNG') ? 'png' : 'jpg';
map[url] = `data:${mime[type]};base64,${buf.toString('base64')}`;
total += buf.length;
} catch {
console.warn('could not download:', url.slice(0, 90));
}
}
console.log(`images embedded: ${Object.keys(map).length}/${urls.size} (${(total / 1048576).toFixed(1)} MB)`);
// 3. Build with the pictures baked in, then put the empty lookup file back
const stub = 'src/data/embedded.ts';
const original = readFileSync(stub, 'utf8');
write(stub, `export const embedded: Record<string, string> = ${JSON.stringify(map)};\n`);
try {
execFileSync('npm', ['run', 'build'], { stdio: 'inherit' });
} finally {
write(stub, original);
}
const dir = 'dist/assets/';
const files = readdirSync(dir);
let js = readFileSync(dir + files.find((f) => f.endsWith('.js')), 'utf8');
const css = readFileSync(dir + files.find((f) => f.endsWith('.css')), 'utf8');
js = js.replace(/<\/script/g, '<\\/script');
const html = `<title>Jack 3D Creator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>${css}
html,body{background:#0C0C0C}
img{background:linear-gradient(135deg,#17171c,#24242c)}</style>
<div id="root"></div>
<script>document.addEventListener("error",function(e){var t=e.target;if(t&&t.tagName==="IMG"){t.removeAttribute("src");t.alt="";}},true);</script>
<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' : ''}`);
@@ -1,10 +0,0 @@
export default function LiveProjectButton() {
return (
<button
type="button"
className="whitespace-nowrap rounded-full border-2 border-[#D7E2EA] px-8 py-3 text-sm font-medium uppercase tracking-widest text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10 sm:px-10 sm:py-3.5 sm:text-base"
>
Live Project
</button>
);
}
-76
View File
@@ -1,76 +0,0 @@
import { embedded } from './embedded';
const A = (url: string) => embedded[url] ?? url;
const FIGMA = 'https://shrug-person-78902957.figma.site/_components/v2';
const ABOUT = `${FIGMA}/ebb2b8f25d8e24d5f0a5ca8af4c950de81aa2fd7`;
export const portrait = A(`${FIGMA}/d24c01ad3a56fc65e942a1f501eb73db42d7cf9a/Rectangle_40443.81459862.png`);
export const aboutIcons = {
moon: A(`${ABOUT}/moon_icon.11395d36.png`),
object: A(`${ABOUT}/p59_1.4659672e.png`),
lego: A(`${ABOUT}/lego_icon-1.703bb594.png`),
group: A(`${ABOUT}/Group_134-1.2e04f3ce.png`),
};
export const aboutText =
"With more than five years of experience in design, i focus on branding, web design, and user experience, i truly enjoy working with businesses that aim to stand out and present their best image. Let's build something incredible together!";
export const marqueeImages = [
'hero-space-voyage-preview-eECLH3Yc',
'hero-codenest-preview-Cgppc2qV',
'hero-vex-ventures-preview-BczMFIiw',
'hero-stellar-ai-v2-preview-DjvxjG3C',
'hero-asme-preview-B_nGDnTP',
'hero-transform-data-preview-Cx5OU29N',
'hero-vitara-preview-Cjz2QYyU',
'hero-terra-preview-BFjrCr7T',
'hero-skyelite-preview-DHaZIgUv',
'hero-aethera-preview-DknSlcTa',
'hero-designpro-preview-D8c5_een',
'hero-stellar-ai-preview-D3HL6bw1',
'hero-xportfolio-preview-D4A8maiC',
'hero-orbit-web3-preview-BXt4OttD',
'hero-nexora-preview-cx5HmUgo',
'hero-evr-ventures-preview-DZxeVFEX',
'hero-planet-orbit-preview-DWAP8Z1P',
'hero-new-era-preview-CocuDUm9',
'hero-wealth-preview-B70idl_u',
'hero-luminex-preview-CxOP7ce6',
'hero-celestia-preview-0yO3jXO8',
].map((n) => A(`https://motionsites.ai/assets/${n}.gif`));
export const services = [
{ name: '3D Modeling', description: 'Creation of detailed objects, characters, or environments tailored to specific client needs, ideal for games, products, and visualizations.' },
{ name: 'Rendering', description: 'High-quality, photorealistic renders that showcase designs with custom lighting, textures, and materials to bring concepts to life.' },
{ name: 'Motion Design', description: 'Dynamic animations and motion graphics that add energy and storytelling to brands, products, and digital experiences.' },
{ name: 'Branding', description: 'Crafting cohesive visual identities -- from logos to full brand systems -- that communicate a clear and memorable presence.' },
{ name: 'Web Design', description: 'Designing clean, modern, and conversion-focused websites with attention to layout, typography, and user experience.' },
];
const cf = (file: string) =>
A(`https://images.higgs.ai/?default=1&output=webp&url=${encodeURIComponent(
`https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/${file}`
)}&w=1280&q=85`);
export const projects = [
{
category: 'Client',
name: 'Nextlevel Studio',
col1: [cf('hf_20260412_055344_5eff02e0-87a5-41ce-b64f-eb08da8f33db.png'), cf('hf_20260412_055431_11d841fd-8b41-46a5-82e4-b04f2407a7d8.png')],
col2: cf('hf_20260412_055451_e317bf2d-28d4-48cc-86b0-6f72f25b6327.png'),
},
{
category: 'Personal',
name: 'Aura Brand Identity',
col1: [cf('hf_20260412_055654_911201c5-36d9-4bc6-bac7-331adfce159f.png'), cf('hf_20260412_055723_5ceda0b8-d9c2-4665-b2e3-83ba19ba76d1.png')],
col2: cf('hf_20260412_055753_adc5dcbd-a8e6-49c0-b43a-9b030d835cea.png'),
},
{
category: 'Client',
name: 'Solaris Digital',
col1: [cf('hf_20260412_055759_963cfb0b-4bd1-4b0f-9d0a-09bd6cf95b2f.png'), cf('hf_20260412_060108_438f781a-9846-4dcc-89ab-c4e6cb830f5b.png')],
col2: cf('hf_20260412_055818_9d062121-ad7e-46b9-999a-1a6a692ef1ee.png'),
},
];
-3
View File
@@ -1,3 +0,0 @@
// Filled in automatically by scripts/build-preview.mjs (image URL -> data: URI).
// Empty in normal use, so the site loads images from their original URLs.
export const embedded: Record<string, string> = {};
@@ -1,46 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { marqueeImages } from '../data/content';
const row1 = marqueeImages.slice(0, 11);
const row2 = marqueeImages.slice(11);
function Row({ images, transform }: { images: string[]; transform: string }) {
const tripled = [...images, ...images, ...images];
return (
<div className="flex gap-3" style={{ transform, willChange: 'transform' }}>
{tripled.map((src, i) => (
<img
key={i}
src={src}
alt=""
loading="lazy"
className="rounded-2xl object-cover"
style={{ width: 420, height: 270, flexShrink: 0 }}
/>
))}
</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 });
return () => window.removeEventListener('scroll', onScroll);
}, []);
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 images={row1} transform={`translateX(${offset - 200}px)`} />
<Row images={row2} transform={`translateX(${-(offset - 200)}px)`} />
</section>
);
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: './',
});
File renamed without changes.
@@ -1,9 +1,9 @@
# PRD: "Jack — 3D Creator" Portfolio Template
# PRD: Keith Rana Portfolio (built from the "Jack 3D Creator" template)
## The restaurant analogy
| Stage | Restaurant | This project |
|---|---|---|
| Phase 1 (today) | Food stand: one great dish, open today | The full single-page site, looks right, animations work |
| 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 |
@@ -33,3 +33,13 @@ Done when: builds with no errors; page loads with no console errors; every butto
## 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`).
- Sections: Hero (portrait), scrolling photo/skills rows, About, Capability, Experience, Projects (stacking cards), Credentials, Contact.
- Photos and the Kanit font ship inside the site (no outside image hosts needed).
- Tested at 360, 390, 768, 1024, 1440 and 1920 px wide: no sideways scroll, no console errors, all images load.
- Cloud preview: `node scripts/build-preview.mjs out.html` builds one self-contained page.
## Not used yet
- `it_helpdesk.db` (87 IT help-desk FAQs) was supplied but is not portfolio content. Candidate for a Phase 2 FAQ section if wanted.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Keith Rana, IT Service Delivery Manager in Melbourne, Australia." />
<title>Keith Rana -- IT Service Delivery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -1,13 +1,14 @@
{
"name": "jack-portfolio",
"name": "keith-portfolio",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jack-portfolio",
"name": "keith-portfolio",
"version": "1.0.0",
"dependencies": {
"@fontsource/kanit": "^5.3.0",
"framer-motion": "^12.38.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
@@ -710,6 +711,15 @@
"node": ">=12"
}
},
"node_modules/@fontsource/kanit": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/kanit/-/kanit-5.3.0.tgz",
"integrity": "sha512-XrVxf5NWb8QLAEbQS2GYir3AY0WbOLr5Q80fWurnoFWXyc7CWvioGNPC2dxRGZKocMUXbwGhuK+sJqWkRLAWNA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1,5 +1,5 @@
{
"name": "jack-portfolio",
"name": "keith-portfolio",
"private": true,
"version": "1.0.0",
"type": "module",
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/kanit": "^5.3.0",
"framer-motion": "^12.38.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
File renamed without changes.
+24
View File
@@ -0,0 +1,24 @@
// Builds ONE self-contained HTML page for the Claude cloud preview.
// Claude's cloud pages block outside images and files, so photos and fonts are baked in.
// Usage: node scripts/build-preview.mjs <output.html>
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
const out = process.argv[2] ?? 'preview.html';
execFileSync('npm', ['run', 'build'], { stdio: 'inherit', env: { ...process.env, INLINE: '1' } });
const dir = 'dist/assets/';
const files = readdirSync(dir);
const js = readFileSync(dir + files.find((f) => f.endsWith('.js')), 'utf8').replace(/<\/script/g, '<\\/script');
const css = readFileSync(dir + files.find((f) => f.endsWith('.css')), 'utf8');
const html = `<meta charset="utf-8">
<title>Keith Rana Portfolio</title>
<style>${css}
html,body{background:#0C0C0C}</style>
<div id="root"></div>
<script type="module">${js}</script>
`;
writeFileSync(out, html);
const mb = Buffer.byteLength(html) / 1048576;
console.log(`wrote ${out} (${mb.toFixed(1)} MB)${mb > 16 ? ' WARNING: over the 16 MB page limit' : ''}`);
@@ -2,7 +2,10 @@ 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';
export default function App() {
return (
@@ -11,7 +14,10 @@ export default function App() {
<MarqueeSection />
<AboutSection />
<ServicesSection />
<ExperienceSection />
<ProjectsSection />
<CredentialsSection />
<ContactSection />
</main>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

@@ -1,7 +1,9 @@
import { profile } from "../data/content";
export default function ContactButton() {
return (
<a
href="mailto:hello@example.com"
href={`mailto:${profile.email}`}
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%)',
+130
View File
@@ -0,0 +1,130 @@
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';
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'],
images: [systems, leadership] as [string, string],
position: ['center', 'center'] as [string, string],
},
{
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'],
images: [leadership, systems] as [string, string],
position: ['70% 40%', '20% 60%'] as [string, string],
},
{
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'],
images: [systems, leadership] as [string, string],
position: ['80% 20%', '30% 70%'] as [string, string],
},
{
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'],
images: [leadership, systems] as [string, string],
position: ['50% 80%', '60% 30%'] as [string, string],
},
];
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' };
@@ -1,3 +1,8 @@
@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;
@@ -5,6 +10,7 @@
* { 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 {
@@ -13,3 +19,7 @@ body { font-family: 'Kanit', sans-serif; }
background-clip: text;
-webkit-text-fill-color: transparent;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
File renamed without changes.
@@ -1,22 +1,29 @@
import FadeIn from '../components/FadeIn';
import AnimatedText from '../components/AnimatedText';
import ContactButton from '../components/ContactButton';
import { aboutIcons, aboutText } from '../data/content';
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-[4%] w-[120px] sm:left-[2%] sm:w-[160px] md:left-[4%] md:w-[210px]">
<img src={aboutIcons.moon} alt="" className="w-full" />
<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-[8%] left-[3%] w-[100px] sm:left-[6%] sm:w-[140px] md:left-[10%] md:w-[180px]">
<img src={aboutIcons.object} alt="" className="w-full" />
<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-[4%] w-[120px] sm:right-[2%] sm:w-[160px] md:right-[4%] md:w-[210px]">
<img src={aboutIcons.lego} alt="" className="w-full" />
<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-[8%] right-[3%] w-[130px] sm:right-[6%] sm:w-[170px] md:right-[10%] md:w-[220px]">
<img src={aboutIcons.group} alt="" className="w-full" />
<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">
@@ -31,7 +38,7 @@ export default function AboutSection() {
style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}
/>
</div>
<div className="relative z-10">
<div className="relative z-10 mb-24 sm:mb-28 md:mb-0">
<ContactButton />
</div>
</section>
@@ -0,0 +1,47 @@
import { ArrowUpRight, Phone } from 'lucide-react';
import FadeIn from '../components/FadeIn';
import ContactButton from '../components/ContactButton';
import { profile } from '../data/content';
export default function ContactSection() {
return (
<section id="contact" className="flex flex-col items-center gap-10 px-5 pb-10 pt-20 sm:gap-14 sm:px-8 md:px-10 md:pt-28">
<FadeIn>
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(2.6rem, 10vw, 140px)' }}>
Let’s talk
</h2>
</FadeIn>
<FadeIn>
<p className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]" style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}>
Based in {profile.location}. Available for the right conversation.
</p>
</FadeIn>
<FadeIn>
<ContactButton />
</FadeIn>
<FadeIn className="w-full max-w-3xl">
<div className="grid gap-3 sm:grid-cols-2">
<a href={`mailto:${profile.email}`} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
<span className="min-w-0">
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Email</span>
<strong className="block break-words font-medium">{profile.email}</strong>
</span>
<ArrowUpRight size={20} className="shrink-0" />
</a>
<a href={profile.phoneHref} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
<span className="min-w-0">
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Phone</span>
<strong className="block font-medium">{profile.phone}</strong>
</span>
<Phone size={18} className="shrink-0" />
</a>
</div>
</FadeIn>
<footer className="mt-10 flex w-full flex-wrap justify-between gap-2 border-t border-[#D7E2EA]/20 pt-6 text-xs font-light uppercase tracking-wider text-[#D7E2EA]/70">
<span>{profile.name}</span>
<span>{profile.role}</span>
<span>{profile.location}</span>
</footer>
</section>
);
}
@@ -0,0 +1,31 @@
import FadeIn from '../components/FadeIn';
import { certifications, education } from '../data/content';
export default function CredentialsSection() {
return (
<section id="credentials" className="px-5 py-20 sm:px-8 sm:py-24 md:px-10 md:py-32">
<FadeIn>
<h2 className="hero-heading mb-10 text-center font-black uppercase leading-none tracking-tight sm:mb-14" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
Credentials
</h2>
</FadeIn>
<FadeIn>
<div className="mx-auto mb-12 flex max-w-5xl flex-col items-center gap-1 text-center text-[#D7E2EA] sm:mb-16">
<span className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">Education</span>
<strong className="font-medium uppercase" style={{ fontSize: 'clamp(1.1rem, 2.2vw, 2.1rem)' }}>{education.degree}</strong>
<span className="font-light opacity-70">{education.school}, {education.year}</span>
</div>
</FadeIn>
<div className="mx-auto grid max-w-5xl gap-3 sm:grid-cols-2">
{certifications.map((c, i) => (
<FadeIn key={c} delay={(i % 2) * 0.08}>
<div className="flex h-full items-center gap-4 rounded-3xl border border-[#D7E2EA]/25 p-5 sm:p-6">
<span className="hero-heading text-3xl font-black leading-none sm:text-4xl">{String(i + 1).padStart(2, '0')}</span>
<p className="min-w-0 font-medium uppercase leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.1rem)' }}>{c}</p>
</div>
</FadeIn>
))}
</div>
</section>
);
}
@@ -0,0 +1,55 @@
import { Check } from 'lucide-react';
import FadeIn from '../components/FadeIn';
import { foundations, roles } from '../data/content';
export default function ExperienceSection() {
return (
<section id="experience" className="bg-white px-5 pb-28 pt-20 sm:px-8 sm:pb-32 sm:pt-24 md:px-10 md:pb-40 md:pt-32">
<FadeIn>
<h2 className="mb-16 text-center font-black uppercase leading-none text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
Experience
</h2>
</FadeIn>
<div className="mx-auto max-w-5xl">
{roles.map((r, i) => (
<FadeIn key={r.role + r.organisation}>
<article className="grid gap-4 py-8 sm:py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
<div className="flex flex-col gap-1 text-[#0C0C0C]">
<span className="text-sm font-medium uppercase tracking-wider opacity-60">{r.dates}</span>
<span className="font-black uppercase leading-tight" style={{ fontSize: 'clamp(1.1rem, 1.8vw, 1.5rem)' }}>{r.organisation}</span>
<span className="text-sm font-light opacity-60">{String(i + 1).padStart(2, '0')}</span>
</div>
<div className="flex min-w-0 flex-col gap-4 text-[#0C0C0C]">
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{r.role}</h3>
<p className="max-w-2xl font-light leading-relaxed opacity-70" style={{ fontSize: 'clamp(0.9rem, 1.6vw, 1.2rem)' }}>{r.summary}</p>
<ul className="flex max-w-2xl flex-col gap-3">
{r.points.map((p) => (
<li key={p} className="flex gap-3 font-light leading-relaxed" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.05rem)' }}>
<Check size={18} className="mt-1 shrink-0" />
<span>{p}</span>
</li>
))}
</ul>
</div>
</article>
</FadeIn>
))}
<FadeIn>
<div className="grid gap-6 py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
<h3 className="text-sm font-medium uppercase tracking-wider text-[#0C0C0C] opacity-60">Earlier foundations</h3>
<div className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
{foundations.map((f) => (
<div key={f.role + f.organisation} className="flex min-w-0 flex-col text-[#0C0C0C]">
<span className="text-xs font-medium uppercase tracking-wider opacity-60">{f.dates}</span>
<strong className="font-medium uppercase">{f.role}</strong>
<span className="font-light opacity-70">{f.organisation}</span>
</div>
))}
</div>
</div>
</FadeIn>
</div>
</section>
);
}
@@ -1,20 +1,20 @@
import FadeIn from '../components/FadeIn';
import Magnet from '../components/Magnet';
import ContactButton from '../components/ContactButton';
import { portrait } from '../data/content';
import { images, profile } from '../data/content';
const links = [
{ label: 'About', href: '#about' },
{ label: 'Price', href: '#services' },
{ label: 'Skills', href: '#skills' },
{ label: 'Projects', href: '#projects' },
{ label: 'Contact', href: 'mailto:hello@example.com' },
{ label: 'Contact', href: '#contact' },
];
export default function HeroSection() {
return (
<section className="relative flex h-screen flex-col" style={{ overflowX: 'clip' }}>
<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">
<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}
@@ -29,27 +29,32 @@ export default function HeroSection() {
<div className="overflow-hidden">
<FadeIn delay={0.15} y={40}>
<h1 className="hero-heading mt-6 w-full whitespace-nowrap text-center text-[14vw] font-black uppercase leading-none tracking-tight sm:mt-4 sm:text-[15vw] md:-mt-5 md:text-[16vw] lg:text-[17.5vw]">
Hi, i’m jack
<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-[280px] -translate-x-1/2 -translate-y-1/2 sm:bottom-0 sm:top-auto sm:w-[360px] sm:translate-y-0 md:w-[440px] lg:w-[520px]">
<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={portrait} alt="Portrait of Jack" className="w-full" />
<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 px-6 pb-7 sm:pb-8 md:px-10 md:pb-10">
<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.75rem, 1.4vw, 1.5rem)' }}
style={{ fontSize: 'clamp(0.7rem, 1.4vw, 1.5rem)' }}
>
a 3d creator driven by crafting striking and unforgettable projects
{profile.tagline}
</p>
</FadeIn>
<FadeIn delay={0.5} y={20} className="relative z-20">
@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { expertise, images } from '../data/content';
const TILE_W = 420;
const GAP = 12;
const photoTiles: { src: string; alt: string; position: string }[] = [
{ src: images.portrait, alt: 'Keith Rana at his work desk', position: 'center 20%' },
{ src: images.leadership, alt: 'Hands organising a service-delivery briefing', position: 'center' },
{ src: images.systems, alt: 'Abstract composition of connected service pathways', position: 'center' },
];
const photos = photoTiles.map((t) => (
<img
key={t.alt}
src={t.src}
alt={t.alt}
loading="lazy"
className="h-[270px] w-[420px] shrink-0 rounded-2xl object-cover"
style={{ objectPosition: t.position }}
/>
));
const skills = expertise.map((e, i) => (
<div
key={e.name}
className="flex h-[270px] w-[420px] shrink-0 flex-col justify-between rounded-2xl border border-[#D7E2EA]/25 p-6"
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
>
<span className="hero-heading text-5xl font-black leading-none">{String(i + 1).padStart(2, '0')}</span>
<span className="text-2xl font-medium uppercase leading-tight text-[#D7E2EA]">{e.name}</span>
</div>
));
function Row({ tiles, setWidth, transform }: { tiles: ReactNode[]; setWidth: number; transform: string }) {
return (
<div className="flex" style={{ gap: GAP, marginLeft: -setWidth, transform, willChange: 'transform' }} aria-hidden={tiles === skills}>
{[0, 1, 2].map((copy) => (
<div key={copy} className="flex" style={{ gap: GAP }}>
{tiles}
</div>
))}
</div>
);
}
export default function MarqueeSection() {
const ref = useRef<HTMLElement>(null);
const [offset, setOffset] = useState(0);
useEffect(() => {
const onScroll = () => {
if (!ref.current) return;
const top = ref.current.getBoundingClientRect().top + window.scrollY;
setOffset((window.scrollY - top + window.innerHeight) * 0.3);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
}, []);
const width = (n: number) => n * (TILE_W + GAP) + GAP;
return (
<section ref={ref} className="flex flex-col gap-3 bg-[#0C0C0C] pb-10 pt-24 sm:pt-32 md:pt-40" style={{ overflow: 'clip' }}>
<Row tiles={photos} setWidth={width(photos.length)} transform={`translateX(${offset - 200}px)`} />
<Row tiles={skills} setWidth={width(skills.length)} transform={`translateX(${-(offset - 200)}px)`} />
</section>
);
}
@@ -1,7 +1,7 @@
import { useRef } from 'react';
import { motion, useScroll, useTransform } from 'framer-motion';
import { Check } from 'lucide-react';
import FadeIn from '../components/FadeIn';
import LiveProjectButton from '../components/LiveProjectButton';
import { projects } from '../data/content';
type Project = (typeof projects)[number];
@@ -15,30 +15,40 @@ function ProjectCard({ project, index, total }: { project: Project; index: numbe
const scale = useTransform(scrollYProgress, [0, 1], [1, targetScale]);
return (
<div ref={ref} className="sticky top-24 h-[85vh] md:top-32">
<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-wrap items-center justify-between gap-4 sm:mb-6">
<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="text-[#D7E2EA]">
<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>
<LiveProjectButton />
<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 gap-3 sm:gap-4">
<div className="flex w-[40%] flex-col gap-3 sm:gap-4">
<img src={project.col1[0]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(130px, 16vw, 230px)' }} />
<img src={project.col1[1]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(160px, 22vw, 340px)' }} />
<img src={project.images[0]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(130px, 16vw, 230px)', objectPosition: project.position[0] }} />
<img src={project.images[1]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(160px, 22vw, 340px)', objectPosition: project.position[1] }} />
</div>
<div className="w-[60%]">
<img src={project.col2} alt="" loading="lazy" className={`h-full w-full object-cover ${radius}`} />
<div className={`flex w-[60%] flex-col justify-center gap-3 border border-[#D7E2EA]/25 p-4 sm:gap-5 sm:p-8 md:p-12 ${radius}`} style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}>
<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-4">
{project.delivered.map((d) => (
<li key={d} className="flex gap-2 font-light leading-snug text-[#D7E2EA] sm:gap-3" style={{ fontSize: 'clamp(0.8rem, 1.6vw, 1.35rem)' }}>
<Check className="mt-0.5 h-4 w-4 shrink-0 sm:mt-1 sm:h-5 sm:w-5" />
<span>{d}</span>
</li>
))}
</ul>
</div>
</div>
</motion.div>
@@ -51,7 +61,7 @@ export default function ProjectsSection() {
<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)' }}>
Project
Projects
</h2>
</FadeIn>
{projects.map((p, i) => (
@@ -1,23 +1,23 @@
import FadeIn from '../components/FadeIn';
import { services } from '../data/content';
import { expertise } from '../data/content';
export default function ServicesSection() {
return (
<section id="services" className="rounded-t-[40px] bg-white px-5 py-20 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:rounded-t-[60px] md:px-10 md:py-32">
<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)' }}>
Services
Capability
</h2>
<div className="mx-auto max-w-5xl">
{services.map((s, i) => (
<FadeIn key={s.name} delay={i * 0.1}>
{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="font-black leading-none text-[#0C0C0C]" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
<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 flex-col gap-3 text-[#0C0C0C]">
<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}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
File renamed without changes.
File renamed without changes.
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// INLINE=1 bakes every image and font into the JS/CSS (used for the one-file cloud preview).
export default defineConfig({
plugins: [react()],
base: './',
build: { assetsInlineLimit: process.env.INLINE ? 100_000_000 : 4096 },
});