master piece

This commit is contained in:
Marc Robin Richter
2026-03-16 11:41:31 +01:00
parent 08e6523bef
commit 508ba07848
23 changed files with 2024 additions and 466 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(pnpm add:*)",
"Bash(pnpm build:*)"
]
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>claude-test</title>
<title>503 - Stift15</title>
</head>
<body>
<div id="root"></div>
+7 -1
View File
@@ -10,14 +10,20 @@
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/vite": "^4.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.1",
"three": "^0.183.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^6.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
+765 -62
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
_____ _______ _____ ______ _______ __ _____
/ ____|__ __|_ _| ____|__ __/_ | ____|
| (___ | | | | | |__ | | | | |__
\___ \ | | | | | __| | | | |___ \
____) | | | _| |_| | | | | |___) |
|_____/ |_| |_____|_| |_| |_|____/
-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+39 -112
View File
@@ -1,120 +1,47 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
import { useState, useCallback } from 'react'
import { Terminal } from './components/Terminal'
import { MatrixRain } from './components/MatrixRain'
import { CommandPrompt } from './components/CommandPrompt'
import { BrickTransition } from './components/BrickTransition'
import { Bricked } from './components/Bricked'
import { CrtOverlay } from './components/CrtOverlay'
type AppPhase = 'boot' | 'matrix' | 'prompt' | 'brick-transition' | 'bricked';
function getInitialPhase(): AppPhase {
if (localStorage.getItem('stift15-bricked') === 'true') return 'bricked';
return 'boot';
}
function App() {
const [count, setCount] = useState(0)
const [phase, setPhase] = useState<AppPhase>(getInitialPhase);
const advancePhase = useCallback(() => {
setPhase((p) => {
if (p === 'boot') return 'matrix';
if (p === 'matrix') return 'prompt';
return p;
});
}, []);
const reboot = useCallback(() => {
setPhase('matrix');
}, []);
const brick = useCallback(() => {
localStorage.setItem('stift15-bricked', 'true');
setPhase('brick-transition');
}, []);
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
<div className="relative h-screen w-screen overflow-hidden bg-term-bg">
{phase === 'boot' && <Terminal onComplete={advancePhase} />}
{phase === 'matrix' && <MatrixRain onComplete={advancePhase} />}
{phase === 'prompt' && <CommandPrompt onReboot={reboot} onBrick={brick} />}
{phase === 'brick-transition' && <BrickTransition onComplete={() => setPhase('bricked')} />}
{phase === 'bricked' && <Bricked />}
<CrtOverlay />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
}
+222
View File
@@ -0,0 +1,222 @@
import type { LineColor } from '../components/BootSequence';
export interface OutputLine {
text: string;
color: LineColor;
}
type CommandHandler = (args: string[]) => OutputLine[];
function green(text: string): OutputLine {
return { text, color: 'green' };
}
function amber(text: string): OutputLine {
return { text, color: 'amber' };
}
function dim(text: string): OutputLine {
return { text, color: 'dim' };
}
function red(text: string): OutputLine {
return { text, color: 'red' };
}
const FS_LISTING = [
'drwxr-xr-x frank staff games/',
'drwxr-xr-x frank staff mods/',
'drwxr-xr-x frank staff frank_broke_this/',
'drwxr-xr-x frank staff backups_that_dont_work/',
'-rw-r--r-- root staff todo.txt',
'-rw-r--r-- root staff server_rules.txt',
'-rw-r--r-- frank staff definitely_not_a_virus.exe',
'-rwx------ root staff fix_franks_mess.sh',
'-rw-r--r-- frank staff iou_hosting_fees.txt',
'drwxrwxrwx frank staff node_modules/ (2.1 GB)',
];
const FILE_CONTENTS: Record<string, string[]> = {
'todo.txt': [
'1. Fix Frank\'s mess',
'2. Update the server (postponed since 2019)',
'3. Actually play games instead of fixing the server',
'4. Collect hosting fees from Frank',
'5. Give up',
],
'server_rules.txt': [
'STIFT15 SERVER RULES:',
'=====================',
'1. Do not let Frank push to main',
'2. Do NOT let Frank push to main',
'3. Seriously, Frank, stop pushing to main',
'4. No mining crypto on the game server',
'5. Frank, rule 4 applies to you specifically',
'6. Whoever keeps downloading texture packs: stop (Frank)',
'7. The server admin is always right',
'8. If the server is down, it\'s Frank\'s fault',
],
'iou_hosting_fees.txt': [
'Frank owes:',
' January .... $15.00',
' February ... $15.00',
' March ...... $15.00',
' April ...... $15.00 + $47.00 (emergency server recovery)',
' May ........ $15.00',
' Total ...... $122.00',
'',
'Status: "I\'ll get you next month" (since January)',
],
'definitely_not_a_virus.exe': [
'Nice try. This is a Linux server.',
],
};
const NEOFETCH = [
' _______ stift15@server',
' / _____| ---------------',
' | (___ OS: Linux 6.9.0-gamer-generic',
' \\___ \\ Host: Stift15 Gaming HQ',
' ____) | Kernel: 6.9.0-gamer',
' |_____/ Uptime: 4h 20m 69s',
' ____ _____ _____ Packages: 847 (node_modules)',
' |____|_____|_____| Shell: stift15-sh 1.0',
' CPU: AMD Ryzen 9 9950X @ 5.7GHz',
' S T I F T 1 5 GPU: NVIDIA RTX 5090 "Mortgage Ed."',
' Memory: 63420 MB / 65536 MB',
' Disk: 98% used (node_modules)',
' RGB: SYNCED (mood: aggressive)',
];
export const handlers: Record<string, CommandHandler> = {
help: () => [
amber('Available commands:'),
green(' help - Show this help message'),
green(' ls - List files'),
green(' cat <file> - Read a file'),
green(' cd <dir> - Change directory'),
green(' whoami - Who are you?'),
green(' nano - Text editor'),
green(' neofetch - System info'),
green(' clear - Clear terminal'),
green(' blame - Find who\'s responsible'),
green(' uptime - Server uptime'),
green(' ping <target> - Ping a target'),
green(' rm <file> - Remove a file'),
green(' sudo <cmd> - Run as root'),
green(' reboot - Reboot the server'),
],
ls: () => FS_LISTING.map((line) => dim(line)),
cat: (args) => {
const file = args[0];
if (!file) return [red('cat: missing file operand')];
const contents = FILE_CONTENTS[file];
if (!contents) return [red(`cat: ${file}: No such file or directory`)];
return contents.map((line) => green(line));
},
cd: (args) => {
const dir = args[0];
if (!dir) return [dim('~')];
if (dir === 'node_modules') return [red('cd: node_modules: event horizon detected, cannot enter')];
if (dir === 'frank_broke_this') return [red('cd: frank_broke_this: directory too corrupted to enter')];
if (dir === 'games') return [amber('cd: games: you should be fixing the server, not playing games')];
if (dir === 'backups_that_dont_work') return [red('cd: backups_that_dont_work: Permission denied (also they don\'t work)')];
return [red(`cd: ${dir}: No such file or directory`)];
},
whoami: () => [
green('guest'),
dim('(you thought you were root? lol)'),
],
nano: () => [
red('nano: command disabled.'),
dim('Frank deleted it to "save disk space."'),
dim('He then installed 47 texture packs.'),
],
neofetch: () => NEOFETCH.map((line) => green(line)),
blame: () => [
green('Analyzing git history...'),
green('Checking commit logs...'),
green('Cross-referencing with incident reports...'),
green(''),
amber('Result: It was Frank.'),
dim('(confidence: 99.7%)'),
dim('(remaining 0.3%: also Frank, using an alt account)'),
],
uptime: () => [
green(' 03:14:15 up 4:20, 69 users, load average: 4.04, 0.00, 0.00'),
dim('(new personal record!)'),
],
ping: (args) => {
const target = args[0];
if (!target) return [red('ping: missing target')];
const lower = target.toLowerCase();
if (lower === 'frank') {
return [
green(`PING frank (192.168.1.???) 56(84) bytes of data.`),
amber('Request timeout. Frank is AFK again.'),
amber('Request timeout. Frank is "getting food."'),
amber('Request timeout. Frank disconnected.'),
red('--- frank ping statistics ---'),
red('3 packets transmitted, 0 received, 100% packet loss'),
dim('(as usual)'),
];
}
if (lower === 'franks.mom' || lower === 'frank.mom' || lower === 'franksmom') {
return [
green(`PING franks.mom (192.168.1.1) 56(84) bytes of data.`),
dim('...'),
dim('...'),
dim('...'),
amber('Request timeout.'),
amber('Request timeout.'),
amber('Request timeout.'),
red('--- franks.mom ping statistics ---'),
red('3 packets transmitted, 0 received, 100% packet loss'),
dim(''),
dim('franks.mom is unreachable.'),
dim('She blocked this server after the 3 AM incident.'),
dim('Frank says "it wasn\'t that loud" but we have witnesses.'),
];
}
if (lower === 'google.com' || lower === 'google') {
return [
green(`PING google.com (142.250.80.46) 56(84) bytes of data.`),
green('64 bytes: icmp_seq=1 ttl=118 time=4.20ms'),
green('64 bytes: icmp_seq=2 ttl=118 time=6.90ms'),
dim('--- google.com ping statistics ---'),
dim('2 packets transmitted, 2 received, 0% packet loss'),
dim('(at least something works)'),
];
}
return [
green(`PING ${target} 56(84) bytes of data.`),
green(`64 bytes: icmp_seq=1 ttl=64 time=${(Math.random() * 100).toFixed(1)}ms`),
green(`64 bytes: icmp_seq=2 ttl=64 time=${(Math.random() * 100).toFixed(1)}ms`),
];
},
rm: (args) => {
const joined = args.join(' ');
if (joined.includes('-rf') && joined.includes('/')) {
// Special case handled in CommandPrompt
return [];
}
if (args.length === 0) return [red('rm: missing operand')];
return [red(`rm: cannot remove '${args[args.length - 1]}': Permission denied`)];
},
sudo: (args) => {
if (args.length === 0) return [red('sudo: missing command')];
return [
red('Nice try.'),
dim(`[sudo] incident reported to the server admin.`),
dim(`[sudo] also reported to Frank's mom.`),
];
},
};
+104
View File
@@ -0,0 +1,104 @@
export type LineColor = 'green' | 'amber' | 'red' | 'dim';
export interface BootLine {
text: string;
color: LineColor;
delay: number; // ms before this line appears after the previous
typewriter?: boolean;
}
export const BOOT_SEQUENCE: BootLine[] = [
// Phase 1 - BIOS POST
{ text: 'AWARD BIOS v6.9.420 (c) 2024 Stift15 Systems Ltd.', color: 'dim', delay: 300 },
{ text: 'Performing Power-On Self Test...', color: 'dim', delay: 200 },
{ text: '', color: 'dim', delay: 100 },
{ text: 'CPU: AMD Ryzen 9 9950X "GamerFuel Edition" @ 5.7 GHz .......... OK', color: 'green', delay: 150 },
{ text: 'RAM: 65536 MB DDR5-6000 ....................................... OK', color: 'green', delay: 120 },
{ text: 'GPU: NVIDIA RTX 5090 "Mortgage Edition" ........................ OK', color: 'green', delay: 120 },
{ text: 'RGB Subsystem .................................................. SYNCED (mood: aggressive)', color: 'green', delay: 100 },
{ text: 'Gamer Chair Lumbar Support ..................................... RECLINED', color: 'green', delay: 100 },
{ text: '', color: 'dim', delay: 80 },
{ text: 'Detecting boot devices...', color: 'dim', delay: 400 },
{ text: 'Boot device: /dev/sda1 (labeled "DO_NOT_DELETE_MOM")', color: 'dim', delay: 300 },
{ text: '', color: 'dim', delay: 200 },
// Phase 2 - Kernel Boot
{ text: 'Loading GRUB 2.12 ...', color: 'green', delay: 300 },
{ text: 'kernel: Linux 6.9.0-gamer-generic loading...', color: 'green', delay: 200 },
{ text: '[ 0.000000] Command line: root=/dev/sda1 ro quiet splash=off fps_unlock=true', color: 'dim', delay: 80 },
{ text: '[ 0.004200] Initializing gaming subsystems...', color: 'dim', delay: 80 },
{ text: '[ 0.013370] RGB controller mapped to /dev/rgb0', color: 'dim', delay: 60 },
{ text: '[ 0.024601] Registering thermal zone: "CPU Package" (target: yes)', color: 'dim', delay: 60 },
{ text: '[ 0.031337] Loading module: gamer_posture.ko', color: 'dim', delay: 60 },
{ text: '[ 0.042069] Mounting /dev/fridge (read-only, sadness mode)', color: 'dim', delay: 60 },
{ text: '', color: 'dim', delay: 200 },
// Phase 3 - Services Starting
{ text: '[ OK ] Started Gamer Posture Reminder Service', color: 'green', delay: 150 },
{ text: '[ OK ] Started Mountain Dew Level Monitor', color: 'green', delay: 120 },
{ text: '[ OK ] Started Discord Rich Presence Daemon', color: 'green', delay: 120 },
{ text: '[ OK ] Started Mechanical Keyboard Click Amplifier', color: 'green', delay: 100 },
{ text: '[ OK ] Started Rage Quit Prevention System (v0.1-beta)', color: 'green', delay: 100 },
{ text: '[WARN] Hot Pocket Proximity Sensor returned NaN', color: 'amber', delay: 300 },
{ text: '[ OK ] Started Frank\'s "Temporary" Minecraft Server (uptime: 847 days)', color: 'green', delay: 150 },
{ text: '[ OK ] Started nginx web server on port 80', color: 'green', delay: 100 },
{ text: '[ OK ] Started Node.js application server on port 3000', color: 'green', delay: 100 },
{ text: '[WARN] server.js has mass of 2.1 GB (node_modules singularity detected)', color: 'amber', delay: 400 },
{ text: '[WARN] Gravitational pull from node_modules is affecting nearby files', color: 'amber', delay: 200 },
{ text: '', color: 'dim', delay: 300 },
// Phase 4 - The Crash
{ text: '[FAIL] HTTP Health Check: GET /api/status returned... nothing', color: 'red', delay: 600 },
{ text: '[FAIL] The backend is not responding.', color: 'red', delay: 400 },
{ text: '[FAIL] The backend has never responded. To anything. Ever.', color: 'red', delay: 500 },
{ text: '', color: 'dim', delay: 200 },
{ text: '[WARN] Attempting emergency restart...', color: 'amber', delay: 800 },
{ text: '[ 4.040404] Process \'server.js\' invoked OOM killer', color: 'red', delay: 300 },
{ text: '[ 4.040405] OOM killer selected process \'stift15-game-server\' (adj 1000)', color: 'red', delay: 150 },
{ text: '[ 4.040406] Out of memory: Killed process 1337 (stift15-game-server)', color: 'red', delay: 150 },
{ text: '[ 4.040407] Reason: Frank downloaded the entire Steam Workshop into /tmp', color: 'red', delay: 300 },
{ text: '', color: 'dim', delay: 200 },
{ text: '[FAIL] Emergency restart failed. Exit code: 418 (I\'m a teapot)', color: 'red', delay: 600 },
{ text: '', color: 'dim', delay: 800 },
// Phase 5 - The 503 Error (typewriter lines)
{ text: '============================================================', color: 'red', delay: 400, typewriter: true },
{ text: ' ERROR 503 - SERVICE UNAVAILABLE', color: 'red', delay: 100, typewriter: true },
{ text: '============================================================', color: 'red', delay: 100, typewriter: true },
{ text: '', color: 'dim', delay: 200 },
{ text: ' The server hosting the Stift15 gaming group has encountered', color: 'green', delay: 60, typewriter: true },
{ text: ' a fatal error and is currently taking a rage-quit break.', color: 'green', delay: 60, typewriter: true },
{ text: '', color: 'dim', delay: 300 },
{ text: ' DIAGNOSTIC SUMMARY:', color: 'amber', delay: 200, typewriter: true },
{ text: ' - Last stable connection: "lol"', color: 'green', delay: 80, typewriter: true },
{ text: ' - Uptime before crash: 4h 20m 69s', color: 'green', delay: 80, typewriter: true },
{ text: ' - Packets lost: all of them', color: 'green', delay: 80, typewriter: true },
{ text: ' - Root cause: someone git pushed node_modules to prod', color: 'green', delay: 80, typewriter: true },
{ text: ' - Secondary cause: Frank', color: 'green', delay: 80, typewriter: true },
{ text: ' - Tertiary cause: also Frank', color: 'green', delay: 80, typewriter: true },
{ text: ' - Frank\'s response: "works on my machine"', color: 'green', delay: 80, typewriter: true },
{ text: '', color: 'dim', delay: 300 },
{ text: ' RECOMMENDED ACTIONS:', color: 'amber', delay: 200, typewriter: true },
{ text: ' 1. Touch grass (estimated ETA: never)', color: 'green', delay: 80, typewriter: true },
{ text: ' 2. Check if Frank pushed to main again', color: 'green', delay: 80, typewriter: true },
{ text: ' 3. Blame the lag', color: 'green', delay: 80, typewriter: true },
{ text: ' 4. Alt+F4 your expectations', color: 'green', delay: 80, typewriter: true },
{ text: ' 5. Have you tried turning Frank off and on again?', color: 'green', delay: 80, typewriter: true },
{ text: '', color: 'dim', delay: 300 },
{ text: ' If this error persists, please scream into the void or', color: 'green', delay: 60, typewriter: true },
{ text: ' message the server admin, who is also screaming into the void.', color: 'green', delay: 60, typewriter: true },
{ text: '', color: 'dim', delay: 400 },
{ text: '============================================================', color: 'red', delay: 100, typewriter: true },
{ text: ' SERVER ADMIN NOTE:', color: 'amber', delay: 200, typewriter: true },
{ text: ' No, I will not fix this at 3 AM. I have work tomorrow.', color: 'green', delay: 60, typewriter: true },
{ text: ' The server can stay dead. It builds character.', color: 'green', delay: 60, typewriter: true },
{ text: ' Also Frank still owes me for last month\'s hosting.', color: 'green', delay: 60, typewriter: true },
{ text: '============================================================', color: 'red', delay: 100, typewriter: true },
{ text: '', color: 'dim', delay: 1500 },
// Phase 6 - Reconnect
{ text: '> Reconnecting in 3 seconds...', color: 'dim', delay: 0 },
{ text: '> ...', color: 'dim', delay: 1000 },
{ text: '> ...', color: 'dim', delay: 1000 },
{ text: '> CONNECTION ESTABLISHED', color: 'green', delay: 1000 },
];
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react';
interface BrickTransitionProps {
onComplete: () => void;
}
const DELETE_PATHS = [
'/bin',
'/boot',
'/dev',
'/etc',
'/home',
'/lib',
'/media',
'/mnt',
'/opt',
'/proc',
'/root',
'/sbin',
'/srv',
'/sys',
'/tmp',
'/usr',
'/usr/lib',
'/usr/share',
'/usr/bin',
'/usr/local/games',
'/usr/local/node_modules (2.1 GB)',
'/var',
'/var/log',
'/var/frank_was_here',
];
type Phase = 'deleting' | 'kernel-panic' | 'glitch' | 'black';
export function BrickTransition({ onComplete }: BrickTransitionProps) {
const [phase, setPhase] = useState<Phase>('deleting');
const [deletedCount, setDeletedCount] = useState(0);
const [glitchOpacity, setGlitchOpacity] = useState(1);
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
// Phase 1: Rapid-fire file deletion
useEffect(() => {
if (phase !== 'deleting') return;
let i = 0;
intervalRef.current = setInterval(() => {
i++;
setDeletedCount(i);
if (i >= DELETE_PATHS.length) {
clearInterval(intervalRef.current);
setTimeout(() => setPhase('kernel-panic'), 400);
}
}, 80);
return () => clearInterval(intervalRef.current);
}, [phase]);
// Phase 2: Kernel panic text, then glitch
useEffect(() => {
if (phase !== 'kernel-panic') return;
const timer = setTimeout(() => setPhase('glitch'), 2000);
return () => clearTimeout(timer);
}, [phase]);
// Phase 3: Screen glitch — flicker and dissolve
useEffect(() => {
if (phase !== 'glitch') return;
let frame = 0;
const totalFrames = 30;
intervalRef.current = setInterval(() => {
frame++;
// Flicker between visible and dark, trending toward black
const progress = frame / totalFrames;
if (Math.random() < progress) {
setGlitchOpacity(0);
} else {
setGlitchOpacity(Math.random() * 0.5 + 0.3);
}
if (frame >= totalFrames) {
clearInterval(intervalRef.current);
setPhase('black');
}
}, 60);
return () => clearInterval(intervalRef.current);
}, [phase]);
// Phase 4: Black screen, then transition to bricked
useEffect(() => {
if (phase !== 'black') return;
const timer = setTimeout(onComplete, 1500);
return () => clearTimeout(timer);
}, [phase, onComplete]);
if (phase === 'black') {
return <div className="absolute inset-0 z-0 bg-term-bg" />;
}
return (
<div
ref={containerRef}
className="absolute inset-0 z-0 overflow-hidden p-4 font-mono text-sm bg-term-bg"
style={{ opacity: phase === 'glitch' ? glitchOpacity : 1 }}
>
{/* Deletion log */}
<div className="text-term-red leading-[1.4]">
rm: deleting entire filesystem...
</div>
{DELETE_PATHS.slice(0, deletedCount).map((path, i) => (
<div
key={i}
className="text-term-red whitespace-pre leading-[1.4]"
style={phase === 'glitch' ? {
transform: `translateX(${(Math.random() - 0.5) * 40}px)`,
opacity: Math.random() * 0.7 + 0.3,
} : undefined}
>
rm: {path} ... <span className="text-term-amber">deleted</span>
</div>
))}
{/* Kernel panic */}
{(phase === 'kernel-panic' || phase === 'glitch') && (
<div className="mt-4">
<div className="text-term-red leading-[1.4]">&nbsp;</div>
<div className="text-term-red leading-[1.4] font-bold">
Kernel panic - not syncing: Attempted to kill init!
</div>
<div className="text-term-red leading-[1.4]">
exitcode=0x00000009
</div>
<div className="text-term-dim leading-[1.4]">&nbsp;</div>
<div className="text-term-dim leading-[1.4]">
[ 9.999999] ---[ end Kernel panic - not syncing ]---
</div>
<div className="text-term-dim leading-[1.4]">&nbsp;</div>
<div className="text-term-amber leading-[1.4]">
You absolute legend. You actually did it.
</div>
</div>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from 'react';
const LINES = [
{ text: 'AWARD BIOS v6.9.420 (c) 2024 Stift15 Systems Ltd.', delay: 300 },
{ text: 'Performing Power-On Self Test...', delay: 200 },
{ text: '', delay: 100 },
{ text: 'CPU: AMD Ryzen 9 9950X "GamerFuel Edition" @ 5.7 GHz .......... OK', delay: 150 },
{ text: 'RAM: 65536 MB DDR5-6000 ....................................... OK', delay: 120 },
{ text: 'GPU: NVIDIA RTX 5090 "Mortgage Edition" ........................ OK', delay: 120 },
{ text: '', delay: 200 },
{ text: 'Detecting boot devices...', delay: 400 },
{ text: 'Boot device: /dev/sda1 (labeled "DO_NOT_DELETE_MOM")', delay: 300 },
{ text: '', delay: 200 },
{ text: 'Loading GRUB 2.12 ...', delay: 300 },
{ text: '', delay: 500 },
{ text: 'error: no such partition.', delay: 400 },
{ text: 'error: file \'/boot/vmlinuz-6.9.0-gamer-generic\' not found.', delay: 300 },
{ text: 'error: you need to load the kernel first.', delay: 300 },
{ text: '', delay: 600 },
{ text: 'GRUB rescue>', delay: 200 },
{ text: '', delay: 1000 },
{ text: '============================================================', delay: 400 },
{ text: ' FATAL: NO BOOTABLE FILESYSTEM FOUND', delay: 200 },
{ text: '============================================================', delay: 200 },
{ text: '', delay: 300 },
{ text: ' Someone ran rm -rf / on this server.', delay: 200 },
{ text: ' The entire filesystem has been deleted.', delay: 200 },
{ text: ' There is nothing left.', delay: 200 },
{ text: '', delay: 300 },
{ text: ' The server admin has been notified.', delay: 200 },
{ text: ' The server admin is crying.', delay: 300 },
{ text: '', delay: 500 },
{ text: ' This server is now a very expensive paperweight.', delay: 200 },
{ text: ' Good job.', delay: 300 },
{ text: '', delay: 800 },
{ text: ' Press F to pay respects.', delay: 0 },
];
export function Bricked() {
const [visibleCount, setVisibleCount] = useState(0);
const [unbricked, setUnbricked] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (visibleCount >= LINES.length) return;
timeoutRef.current = setTimeout(() => {
setVisibleCount((c) => c + 1);
}, LINES[visibleCount].delay);
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
}, [visibleCount]);
useEffect(() => {
containerRef.current?.focus();
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'f' || e.key === 'F') {
localStorage.removeItem('stift15-bricked');
setUnbricked(true);
}
};
if (unbricked) {
return (
<div className="absolute inset-0 z-0 flex items-center justify-center font-mono text-sm">
<div className="text-center">
<div className="text-term-green leading-[1.4]">Respects paid. Server restored.</div>
<div className="text-term-dim leading-[1.4] mt-2">Reload the page to reconnect.</div>
</div>
</div>
);
}
const visible = LINES.slice(0, visibleCount);
return (
<div
ref={containerRef}
className="absolute inset-0 z-0 overflow-y-auto p-4 font-mono text-sm outline-none"
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => containerRef.current?.focus()}
>
{visible.map((line, i) => (
<div
key={i}
className={`${i >= 12 && i <= 14 ? 'text-term-red' : i >= 18 ? 'text-term-red' : 'text-term-dim'} whitespace-pre leading-[1.4]`}
>
{line.text || '\u00A0'}
</div>
))}
{visibleCount >= LINES.length && (
<div className="text-term-dim whitespace-pre leading-[1.4] mt-2">
<span
className="inline-block w-[0.6em] h-[1em] bg-term-dim ml-0.5 align-middle"
style={{ animation: 'blink 1s step-end infinite' }}
/>
</div>
)}
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { handlers, type OutputLine } from '../commands/handlers';
const PROMPT = 'stift15@server:~$ ';
const WELCOME: OutputLine[] = [
{ text: '> Connection restored.', color: 'green' },
{ text: '> Welcome to Stift15 Terminal v1.0', color: 'green' },
{ text: '> Type \'help\' for available commands.', color: 'dim' },
{ text: '', color: 'dim' },
];
interface CommandPromptProps {
onReboot?: () => void;
onBrick?: () => void;
}
export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) {
const [outputLines, setOutputLines] = useState<OutputLine[]>(WELCOME);
const [input, setInput] = useState('');
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(scrollToBottom, [outputLines.length, scrollToBottom]);
// Focus container on mount
useEffect(() => {
containerRef.current?.focus();
}, []);
const executeCommand = useCallback(
(cmdLine: string) => {
const echoLine: OutputLine = { text: PROMPT + cmdLine, color: 'green' };
const trimmed = cmdLine.trim();
if (!trimmed) {
setOutputLines((prev) => [...prev, echoLine]);
return;
}
const parts = trimmed.split(/\s+/);
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
if (cmd === 'clear') {
setOutputLines([]);
return;
}
if (cmd === 'rm' && trimmed.includes('-rf') && trimmed.includes('/')) {
setOutputLines((prev) => [...prev, echoLine]);
setTimeout(() => onBrick?.(), 300);
return;
}
if (cmd === 'reboot') {
setOutputLines((prev) => [
...prev,
echoLine,
{ text: 'Rebooting server...', color: 'amber' },
]);
setTimeout(() => onReboot?.(), 800);
return;
}
const handler = handlers[cmd];
const result = handler
? handler(args)
: [{ text: `${cmd}: command not found. Type 'help' for available commands.`, color: 'red' as const }];
setOutputLines((prev) => [...prev, echoLine, ...result, { text: '', color: 'dim' }]);
setHistory((prev) => [...prev, trimmed]);
setHistoryIndex(-1);
},
[]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
executeCommand(input);
setInput('');
} else if (e.key === 'Backspace') {
setInput((prev) => prev.slice(0, -1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (history.length === 0) return;
const newIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1);
setHistoryIndex(newIndex);
setInput(history[newIndex]);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex === -1) return;
const newIndex = historyIndex + 1;
if (newIndex >= history.length) {
setHistoryIndex(-1);
setInput('');
} else {
setHistoryIndex(newIndex);
setInput(history[newIndex]);
}
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
setInput((prev) => prev + e.key);
}
},
[input, executeCommand, history, historyIndex]
);
const colorClass: Record<string, string> = {
green: 'text-term-green',
amber: 'text-term-amber',
red: 'text-term-red',
dim: 'text-term-dim',
};
return (
<div
ref={containerRef}
className="absolute inset-0 z-0 overflow-y-auto p-4 font-mono text-sm outline-none"
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => containerRef.current?.focus()}
>
{outputLines.map((line, i) => (
<div
key={i}
className={`${colorClass[line.color]} whitespace-pre leading-[1.4]`}
>
{line.text || '\u00A0'}
</div>
))}
{/* Input line */}
<div className="text-term-green whitespace-pre leading-[1.4]">
{PROMPT}
{input}
<span
className="inline-block w-[0.6em] h-[1em] bg-term-green ml-0.5 align-middle"
style={{ animation: 'blink 1s step-end infinite' }}
/>
</div>
<div ref={bottomRef} />
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useRef, useMemo } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { crtVertexShader } from '../shaders/crt.vert';
import { crtFragmentShader } from '../shaders/crt.frag';
function CrtMesh() {
const materialRef = useRef<THREE.ShaderMaterial>(null);
const uniforms = useMemo(
() => ({
uTime: { value: 0 },
}),
[]
);
useFrame(({ clock }) => {
if (materialRef.current) {
materialRef.current.uniforms.uTime.value = clock.getElapsedTime();
}
});
return (
<mesh>
<planeGeometry args={[2, 2]} />
<shaderMaterial
ref={materialRef}
vertexShader={crtVertexShader}
fragmentShader={crtFragmentShader}
uniforms={uniforms}
transparent
depthWrite={false}
/>
</mesh>
);
}
export function CrtOverlay() {
return (
<div className="absolute inset-0 z-10 pointer-events-none">
<Canvas
orthographic
camera={{ zoom: 1, position: [0, 0, 1] }}
gl={{ alpha: true }}
style={{ background: 'transparent' }}
>
<CrtMesh />
</Canvas>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { loadAsciiArt } from '../utils/loadAsciiArt';
interface MatrixRainProps {
onComplete?: () => void;
}
interface Column {
y: number;
speed: number;
chars: string[];
}
const FONT_SIZE = 14;
const CHAR_WIDTH = FONT_SIZE * 0.6;
const CHAR_HEIGHT = FONT_SIZE * 1.2;
const FADE_ALPHA = 0.05;
const RAIN_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789';
function randomChar(): string {
return RAIN_CHARS[Math.floor(Math.random() * RAIN_CHARS.length)];
}
function createColumn(totalRows: number): Column {
return {
y: Math.floor(Math.random() * -totalRows),
speed: 0.3 + Math.random() * 0.7,
chars: Array.from({ length: totalRows }, () => randomChar()),
};
}
export function MatrixRain({ onComplete }: MatrixRainProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [artLines, setArtLines] = useState<string[] | null>(null);
const completedRef = useRef(false);
useEffect(() => {
loadAsciiArt('/ascii/logo.txt').then(setArtLines);
}, []);
const startAnimation = useCallback(
(canvas: HTMLCanvasElement, art: string[]) => {
const ctx = canvas.getContext('2d')!;
let animId: number;
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(canvas.parentElement!);
const totalCols = Math.floor(canvas.width / CHAR_WIDTH);
const totalRows = Math.floor(canvas.height / CHAR_HEIGHT);
// Center the art on screen
const artWidth = Math.max(...art.map((l) => l.length));
const artHeight = art.length;
const artOffsetCol = Math.floor((totalCols - artWidth) / 2);
const artOffsetRow = Math.floor((totalRows - artHeight) / 2);
// Build art grid lookup: artGrid[row][col] = character or undefined
const artGrid: (string | undefined)[][] = [];
for (let r = 0; r < artHeight; r++) {
artGrid[r] = [];
for (let c = 0; c < artWidth; c++) {
const ch = art[r]?.[c];
if (ch && ch !== ' ') {
artGrid[r][c] = ch;
}
}
}
// Track which art cells have been revealed and when (for glow decay)
const revealed: boolean[][] = Array.from({ length: artHeight }, () =>
Array(artWidth).fill(false)
);
const revealTimeMap: number[][] = Array.from({ length: artHeight }, () =>
Array(artWidth).fill(0)
);
const GLOW_DURATION = 1200; // ms for glow to fade out
let totalArtChars = 0;
for (let r = 0; r < artHeight; r++) {
for (let c = 0; c < artWidth; c++) {
if (artGrid[r][c]) totalArtChars++;
}
}
let revealedCount = 0;
let revealCompleteTime = 0;
// Initialize columns
const columns: Column[] = Array.from({ length: totalCols }, () =>
createColumn(totalRows)
);
// Clear to black initially
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
function draw() {
// Fade existing content
ctx.fillStyle = `rgba(10, 10, 10, ${FADE_ALPHA})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = `${FONT_SIZE}px "IBM Plex Mono", "Fira Code", "Courier New", monospace`;
ctx.textBaseline = 'top';
for (let col = 0; col < totalCols; col++) {
const column = columns[col];
column.y += column.speed;
const headRow = Math.floor(column.y);
// Draw rain trail (a few characters behind the head)
const trailLen = 15;
for (let t = 0; t < trailLen; t++) {
const row = headRow - t;
if (row < 0 || row >= totalRows) continue;
const x = col * CHAR_WIDTH;
const y = row * CHAR_HEIGHT;
// Check if this position is an art cell
const artRow = row - artOffsetRow;
const artCol = col - artOffsetCol;
const isArtCell =
artRow >= 0 &&
artRow < artHeight &&
artCol >= 0 &&
artCol < artWidth &&
artGrid[artRow]?.[artCol];
if (isArtCell && t === 0) {
// Head just passed through — reveal this art character
if (!revealed[artRow][artCol]) {
revealed[artRow][artCol] = true;
revealTimeMap[artRow][artCol] = performance.now();
revealedCount++;
}
}
if (t === 0) {
// Head character — bright white-green
ctx.fillStyle = '#ccffcc';
} else {
// Trail fades from bright to dim
const brightness = Math.max(0, 1 - t / trailLen);
const g = Math.floor(100 + 155 * brightness);
ctx.fillStyle = `rgb(0, ${g}, 0)`;
}
// Randomly change trail characters
if (Math.random() < 0.05) {
column.chars[row % column.chars.length] = randomChar();
}
ctx.fillText(
column.chars[row % column.chars.length],
x,
y
);
}
// Reset column when it falls off screen
if (headRow - 15 > totalRows) {
columns[col] = createColumn(totalRows);
columns[col].y = Math.floor(Math.random() * -10);
}
}
// Draw revealed art characters on top with glow effect
const now = performance.now();
for (let r = 0; r < artHeight; r++) {
for (let c = 0; c < artWidth; c++) {
if (revealed[r][c] && artGrid[r][c]) {
const x = (artOffsetCol + c) * CHAR_WIDTH;
const y = (artOffsetRow + r) * CHAR_HEIGHT;
const elapsed = now - revealTimeMap[r][c];
const glowIntensity = Math.max(0, 1 - elapsed / GLOW_DURATION);
if (glowIntensity > 0) {
// Active glow: bright white core fading to green, with blur
ctx.shadowColor = `rgba(51, 255, 51, ${glowIntensity})`;
ctx.shadowBlur = 12 + glowIntensity * 16;
const white = Math.floor(glowIntensity * 200);
ctx.fillStyle = `rgb(${white + 51}, 255, ${white + 51})`;
} else {
// Settled: normal bright green, no shadow
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.fillStyle = '#33ff33';
}
ctx.fillText(artGrid[r][c]!, x, y);
}
}
}
// Reset shadow state for next frame's rain drawing
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
// Check completion
if (revealedCount >= totalArtChars && !completedRef.current) {
if (revealCompleteTime === 0) {
revealCompleteTime = performance.now();
} else if (performance.now() - revealCompleteTime > 2000) {
completedRef.current = true;
cancelAnimationFrame(animId);
onComplete?.();
return;
}
}
animId = requestAnimationFrame(draw);
}
animId = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(animId);
resizeObserver.disconnect();
};
},
[onComplete]
);
useEffect(() => {
if (!canvasRef.current || !artLines) return;
return startAnimation(canvasRef.current, artLines);
}, [artLines, startAnimation]);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 z-0"
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react';
import { BOOT_SEQUENCE } from './BootSequence';
import { TerminalLine } from './TerminalLine';
import { useTerminalSequence } from '../hooks/useTerminalSequence';
interface TerminalProps {
onComplete?: () => void;
}
export function Terminal({ onComplete }: TerminalProps) {
const { visibleLines } = useTerminalSequence(BOOT_SEQUENCE, onComplete);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [visibleLines.length]);
return (
<div className="absolute inset-0 z-0 overflow-y-auto p-4 font-mono text-sm">
{visibleLines.map((line, i) => (
<TerminalLine
key={i}
text={line.text}
color={line.color}
typewriter={line.typewriter}
isLast={i === visibleLines.length - 1}
/>
))}
<div ref={bottomRef} />
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useTypewriter } from '../hooks/useTypewriter';
import type { LineColor } from './BootSequence';
const colorClass: Record<LineColor, string> = {
green: 'text-term-green',
amber: 'text-term-amber',
red: 'text-term-red',
dim: 'text-term-dim',
};
interface TerminalLineProps {
text: string;
color: LineColor;
typewriter?: boolean;
isLast: boolean;
}
function TypewriterText({ text }: { text: string }) {
const revealed = useTypewriter(text, 2, 16);
return <>{revealed}</>;
}
export function TerminalLine({ text, color, typewriter, isLast }: TerminalLineProps) {
if (text === '') {
return <div className="h-[1.2em]" />;
}
return (
<div className={`${colorClass[color]} whitespace-pre leading-[1.4]`}>
{typewriter ? <TypewriterText text={text} /> : text}
{isLast && (
<span
className="inline-block w-[0.6em] h-[1em] bg-term-green ml-0.5 align-middle"
style={{ animation: 'blink 1s step-end infinite' }}
/>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useState, useEffect, useRef } from 'react';
import type { BootLine } from '../components/BootSequence';
export function useTerminalSequence(lines: BootLine[], onComplete?: () => void) {
const [visibleCount, setVisibleCount] = useState(0);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const completedRef = useRef(false);
useEffect(() => {
if (visibleCount >= lines.length) {
if (!completedRef.current && onComplete) {
completedRef.current = true;
timeoutRef.current = setTimeout(onComplete, 800);
}
return;
}
const nextLine = lines[visibleCount];
timeoutRef.current = setTimeout(() => {
setVisibleCount((c) => c + 1);
}, nextLine.delay);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [visibleCount, lines, onComplete]);
return {
visibleLines: lines.slice(0, visibleCount),
isComplete: visibleCount >= lines.length,
};
}
+26
View File
@@ -0,0 +1,26 @@
import { useState, useEffect, useRef } from 'react';
export function useTypewriter(text: string, charsPerTick = 2, tickMs = 16) {
const [charCount, setCharCount] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
useEffect(() => {
setCharCount(0);
intervalRef.current = setInterval(() => {
setCharCount((c) => {
const next = c + charsPerTick;
if (next >= text.length) {
clearInterval(intervalRef.current);
return text.length;
}
return next;
});
}, tickMs);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [text, charsPerTick, tickMs]);
return text.slice(0, charCount);
}
+16 -104
View File
@@ -1,111 +1,23 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
@import "tailwindcss";
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
@theme {
--color-term-green: #33ff33;
--color-term-amber: #ffbf00;
--color-term-red: #ff3333;
--color-term-dim: #338833;
--color-term-bg: #0a0a0a;
--font-mono: "IBM Plex Mono", "Fira Code", "Courier New", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
html, body, #root {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
padding: 0;
height: 100%;
background: var(--color-term-bg);
overflow: hidden;
}
+23
View File
@@ -0,0 +1,23 @@
export const crtFragmentShader = /* glsl */ `
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
// Scanlines - thin horizontal dark bars scrolling down
float scanline = sin(uv.y * 800.0 + uTime * 2.0) * 0.04;
// Vignette - darken edges to simulate CRT curvature
vec2 center = uv - 0.5;
float vignette = 1.0 - dot(center, center) * 1.5;
vignette = clamp(vignette, 0.0, 1.0);
// Flicker - subtle brightness oscillation
float flicker = sin(uTime * 8.0) * 0.008 + sin(uTime * 13.0) * 0.005;
// Combine: mostly transparent, with subtle darkening
float darkness = scanline + (1.0 - vignette) * 0.3 - flicker;
gl_FragColor = vec4(0.0, 0.0, 0.0, clamp(darkness, 0.0, 0.5));
}
`;
+7
View File
@@ -0,0 +1,7 @@
export const crtVertexShader = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
+5
View File
@@ -0,0 +1,5 @@
export async function loadAsciiArt(path: string): Promise<string[]> {
const res = await fetch(path);
const text = await res.text();
return text.split('\n');
}
+2 -1
View File
@@ -1,7 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
})