Compare commits

..

4 Commits

Author SHA1 Message Date
134e917cac Enhance responsive design: Adjust container positioning and border styles for smaller screens 2026-03-05 02:51:32 +11:00
b79cbf0545 Refactor code structure for improved readability and maintainability 2026-03-05 02:33:35 +11:00
7f5ae16dea Add build script for Cloudflare Pages deployment 2026-03-04 23:38:08 +11:00
4be791f90e Redesign: Replace site with interactive terminal interface
- New xterm.js-based terminal with retro CRT aesthetics
- Matrix chat integration with real-time messaging
- Bluesky social media integration
- Interactive commands (help, about, chat, bluesky, privacy, etc.)
- Responsive design with mobile support
- Privacy policy command
- Quick navigation links (Docs, GitHub, Bluesky)
- Preserved all image assets (blocks, items, particles, icons)
- Updated README and package.json with new features
2026-03-04 23:15:44 +11:00
33 changed files with 3385 additions and 6830 deletions

131
.gitignore vendored
View File

@@ -1,119 +1,34 @@
# Logs
logs
*.log
# Node modules
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# OS files
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# IDE
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Build outputs
dist/
build/
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
# Environment variables
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache files
.cache
.parcel-cache
# Logs
*.log
logs/
# Next.js build output
.next
out
# Nuxt.js build output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp files
.temp
# Docusaurus cache and generated files
.docusaurus
# SvelteKit build output
.svelte-kit
build
# Remix build output
.cache
build/
public/build/
# Vitest coverage directory
coverage/
# Temporary files
*.tmp
temp/

View File

@@ -1,3 +0,0 @@
{
"liveServer.settings.port": 5502
}

123
README.md
View File

@@ -1,5 +1,122 @@
# Litruv
# lit.ruv.wtf
Check out the live site: **[lit.ruv.wtf](https://lit.ruv.wtf)**
Interactive terminal interface for lit.ruv.wtf - A retro-styled command line experience with chat, documentation, and more.
Dev/Tech Artist at MatesMedia. Follow for coding, game development, and creative projects.
## Features
- **Classic CRT Aesthetics**: Green phosphor terminal with authentic CRT effects including scanlines and subtle flicker
- **Interactive Chat**: Matrix-powered chat integration with real-time messaging
- **Keyboard Navigation**: Full keyboard support with arrow keys, history navigation, and standard terminal shortcuts
- **Mouse Support**: Click to position cursor, scroll through history, and interact with links
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
- **Multiple Color Schemes**: Switch between green, amber, blue, and white phosphor themes
- **Command History**: Navigate through previous commands using up/down arrows
- **Bluesky Integration**: Fetch and display recent posts from Bluesky
- **Quick Links**: Easy access to documentation, GitHub, and social media
## Getting Started
### Quick Start
1. Open `index.html` directly in your browser, or
2. Serve with a local web server:
```bash
# Using Python 3
python3 -m http.server 8000
# Or using npm script
npm start
# Or using Node.js http-server
npx http-server -p 8000
```
3. Open `http://localhost:8000` in your browser
## Available Commands
- `help` - Display available commands
- `about` - Information about this terminal
- `clear` - Clear the terminal screen
- `echo [message]` - Echo back your message
- `date` - Display current date and time
- `whoami` - Display current user information
- `history` - Show command history
- `color [scheme]` - Change terminal color scheme (green, amber, blue, white)
- `banner` - Display welcome banner
- `bluesky [count]` - Fetch recent posts from Bluesky (default: 5)
- `chat` - Enter interactive chat (type /quit to exit)
- `github` - Visit GitHub repository
- `contact` - Display contact information
- `privacy` - Display privacy policy
## Chat Commands
While in chat mode (after running `chat`):
- `/help` - Show chat commands
- `/nick [name]` - Change your display name
- `/quit` or `/exit` - Exit chat mode
## Keyboard Shortcuts
- `↑/↓` - Navigate command history
- `←/→` - Move cursor within current line
- `Home` - Move cursor to start of line
- `End` - Move cursor to end of line
- `Ctrl+C` - Cancel current input
- `Ctrl+L` - Clear screen (keeping current input)
- `Enter` - Execute command
- `Backspace` - Delete character
## Mouse Support
- Click anywhere in the terminal to focus
- Scroll to view terminal history
- Click on links to open them
## Customization
### Colors
Change the color scheme by editing the theme in `terminal.js` or use the `color` command at runtime:
```
$ color amber
$ color blue
$ color white
$ color green
```
### Commands
Add new commands by extending the `commands` object in `terminal.js`:
```javascript
commands.mycommand = {
description: 'My custom command',
execute: (args) => {
return 'Output from my command';
}
};
```
## Technologies
- **xterm.js** - Terminal emulator for the web
- **xterm-addon-fit** - Responsive terminal sizing
- **xterm-addon-web-links** - Clickable URL support
- **Pure CSS** - Classic CRT effects and animations
## Browser Support
Works in all modern browsers:
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Opera 76+
## License
MIT

188
build.js
View File

@@ -1,176 +1,46 @@
const fs = require('fs').promises;
const path = require('path');
// Helper function to extract content between delimeters
function extractBlock(content, startDelimiter, endDelimiter) {
const startIndex = content.indexOf(startDelimiter);
if (startIndex === -1) return { blockContent: '', remainingContent: content };
const endIndex = content.indexOf(endDelimiter, startIndex + startDelimiter.length);
if (endIndex === -1) return { blockContent: '', remainingContent: content }; // Or throw error
const block = content.substring(startIndex + startDelimiter.length, endIndex).trim();
const remaining = content.substring(0, startIndex) + content.substring(endIndex + endDelimiter.length);
return { blockContent: block, remainingContent: remaining.trim() };
}
// Helper function to copy files recursively
// Copy files recursively from website to build
async function copyWebsiteFiles() {
const websiteDir = path.join(__dirname, 'website');
const buildDir = path.join(__dirname, 'build');
try {
const websiteExists = await fs.access(websiteDir).then(() => true).catch(() => false);
if (!websiteExists) {
console.log("No website directory found, skipping copy.");
return;
}
async function copyRecursive(src, dest) {
const stats = await fs.stat(src);
console.log('Building site from website/ to build/...');
async function copyRecursive(src, dest) {
const stats = await fs.stat(src);
if (stats.isDirectory()) {
await fs.mkdir(dest, { recursive: true });
const items = await fs.readdir(src);
if (stats.isDirectory()) {
await fs.mkdir(dest, { recursive: true });
const items = await fs.readdir(src);
for (const item of items) {
const srcPath = path.join(src, item);
const destPath = path.join(dest, item);
await copyRecursive(srcPath, destPath);
}
} else {
await fs.copyFile(src, dest);
for (const item of items) {
const srcPath = path.join(src, item);
const destPath = path.join(dest, item);
await copyRecursive(srcPath, destPath);
}
} else {
await fs.copyFile(src, dest);
}
}
try {
// Clean build directory
try {
await fs.rm(buildDir, { recursive: true, force: true });
} catch (err) {
// Directory might not exist, that's okay
}
// Copy website files
await copyRecursive(websiteDir, buildDir);
console.log("Website files copied successfully.");
console.log('✓ Build complete! Files copied to build/');
} catch (error) {
console.error('Error copying website files:', error);
throw error;
}
}
async function buildPage(pageName, outputName) {
const layoutPath = path.join(__dirname, 'templates', '_layout.html');
const commonHeaderPath = path.join(__dirname, 'templates', '_header.html');
const commonFooterPath = path.join(__dirname, 'templates', '_footer.html');
const contentPath = path.join(__dirname, 'src', `${pageName}.html`);
const outputDir = path.join(__dirname, 'build');
const outputPath = path.join(outputDir, `${outputName}.html`);
try {
// Ensure the build directory exists
await fs.mkdir(outputDir, { recursive: true });
let layoutContent = await fs.readFile(layoutPath, 'utf-8');
const srcFileContent = await fs.readFile(contentPath, 'utf-8');
// Extract metadata
const metadataRegex = /<!--\s*METADATA:([\s\S]*?)-->/;
const metadataMatch = srcFileContent.match(metadataRegex);
let metadata = {};
let mainPageContent = srcFileContent;
if (metadataMatch && metadataMatch[1]) {
try {
metadata = JSON.parse(metadataMatch[1].trim());
} catch (e) {
console.error(`Error parsing metadata JSON for ${pageName}.html:`, e);
}
mainPageContent = srcFileContent.replace(metadataRegex, '').trim();
}
// Defaults for metadata
const defaults = {
title: "Litruv",
metaDescription: "Litruv's personal website.",
csp: "script-src 'self' 'unsafe-inline';",
ogTitle: "Litruv",
ogDescription: "Litruv's personal website.",
ogUrl: "https://lit.ruv.wtf",
twitterTitle: "Litruv",
twitterDescription: "Litruv's personal website.",
twitterUrl: "https://lit.ruv.wtf",
pageSpecificStyles: "",
pageSpecificScriptsDefer: "",
pageSpecificScriptsBodyEnd: "",
useCommonHeader: true,
useCommonFooter: true
};
const finalMetadata = { ...defaults, ...metadata };
// Extract page-specific styles
const stylesExtraction = extractBlock(mainPageContent, '<!-- START_STYLES -->', '<!-- END_STYLES -->');
finalMetadata.pageSpecificStyles = stylesExtraction.blockContent;
mainPageContent = stylesExtraction.remainingContent;
// Extract page-specific defer scripts
const deferScriptsExtraction = extractBlock(mainPageContent, '<!-- START_SCRIPTS_DEFER -->', '<!-- END_SCRIPTS_DEFER -->');
finalMetadata.pageSpecificScriptsDefer = deferScriptsExtraction.blockContent;
mainPageContent = deferScriptsExtraction.remainingContent;
// Extract page-specific body-end scripts
const bodyEndScriptsExtraction = extractBlock(mainPageContent, '<!-- START_SCRIPTS_BODY_END -->', '<!-- END_SCRIPTS_BODY_END -->');
finalMetadata.pageSpecificScriptsBodyEnd = bodyEndScriptsExtraction.blockContent;
mainPageContent = bodyEndScriptsExtraction.remainingContent;
// Replace metadata placeholders in layout
layoutContent = layoutContent.replace(/<!-- ::PAGE_TITLE:: -->/g, finalMetadata.title)
.replace(/<!-- ::META_DESCRIPTION:: -->/g, finalMetadata.metaDescription)
.replace(/<!-- ::PAGE_CSP:: -->/g, finalMetadata.csp)
.replace(/<!-- ::OG_TITLE:: -->/g, finalMetadata.ogTitle)
.replace(/<!-- ::OG_DESCRIPTION:: -->/g, finalMetadata.ogDescription)
.replace(/<!-- ::OG_URL:: -->/g, finalMetadata.ogUrl)
.replace(/<!-- ::TWITTER_TITLE:: -->/g, finalMetadata.twitterTitle)
.replace(/<!-- ::TWITTER_DESCRIPTION:: -->/g, finalMetadata.twitterDescription)
.replace(/<!-- ::TWITTER_URL:: -->/g, finalMetadata.twitterUrl)
.replace(/<!-- ::PAGE_SPECIFIC_STYLES:: -->/g, finalMetadata.pageSpecificStyles)
.replace(/<!-- ::PAGE_SPECIFIC_SCRIPTS_DEFER:: -->/g, finalMetadata.pageSpecificScriptsDefer)
.replace(/<!-- ::PAGE_SPECIFIC_SCRIPTS_BODY_END:: -->/g, finalMetadata.pageSpecificScriptsBodyEnd);
// Handle common header and footer
const headerContentToInject = finalMetadata.useCommonHeader ? await fs.readFile(commonHeaderPath, 'utf-8') : '';
const footerContentToInject = finalMetadata.useCommonFooter ? await fs.readFile(commonFooterPath, 'utf-8') : '';
layoutContent = layoutContent.replace('<!-- ::HEADER:: -->', headerContentToInject);
layoutContent = layoutContent.replace('<!-- ::FOOTER:: -->', footerContentToInject);
layoutContent = layoutContent.replace('<!-- ::CONTENT:: -->', mainPageContent);
await fs.writeFile(outputPath, layoutContent, 'utf-8');
console.log(`Successfully built ${outputPath}`);
} catch (error) {
console.error(`Error building page ${pageName}:`, error);
throw error; // Re-throw to fail the build process if needed
}
}
async function main() {
try {
// Copy website files first
await copyWebsiteFiles();
const srcDir = path.join(__dirname, 'src');
const files = await fs.readdir(srcDir);
const htmlFiles = files.filter(file => file.endsWith('.html'));
if (htmlFiles.length === 0) {
console.log("No HTML files found in src directory to build.");
return;
}
for (const file of htmlFiles) {
const pageName = path.parse(file).name;
const outputName = pageName;
await buildPage(pageName, outputName);
}
console.log("All pages built successfully.");
} catch (error) {
console.error('Build process failed:', error);
console.error('✗ Build failed:', error);
process.exit(1);
}
}
main();
// Run the build
copyWebsiteFiles();

53
chromakey.js Normal file
View File

@@ -0,0 +1,53 @@
const fs = require('fs');
const path = require('path');
// Using Jimp for image processing
const { Jimp } = require('jimp');
async function chromakeyImage() {
try {
const inputPath = path.join(__dirname, 'website', 'screenborder.jpg');
const outputPath = path.join(__dirname, 'website', 'screenborder.png');
console.log('Loading image...');
const image = await Jimp.read(inputPath);
console.log('Removing green screen...');
// Scan through each pixel
image.scan(0, 0, image.bitmap.width, image.bitmap.height, function(x, y, idx) {
const red = this.bitmap.data[idx + 0];
const green = this.bitmap.data[idx + 1];
const blue = this.bitmap.data[idx + 2];
const alpha = this.bitmap.data[idx + 3];
// Very aggressive green detection
const isGreen = green > red * 1.05 && green > blue * 1.05 && green > 30;
// Catch any bright greens
const isBrightGreen = green > 120 && green > red && green > blue;
if (isGreen || isBrightGreen) {
// Make pixel transparent
this.bitmap.data[idx + 3] = 0;
} else if (green > red || green > blue) {
// Aggressively reduce alpha for any greenish tint
const greenness = Math.max(
(green - red) / 255,
(green - blue) / 255
);
this.bitmap.data[idx + 3] = Math.floor(alpha * (1 - greenness));
}
});
console.log('Saving PNG...');
await image.write(outputPath);
console.log('✓ Chromakey complete! Saved to website/screenborder.png');
} catch (error) {
console.error('✗ Chromakey failed:', error);
process.exit(1);
}
}
chromakeyImage();

46
create-bulge-map.js Normal file
View File

@@ -0,0 +1,46 @@
const { Jimp } = require('jimp');
const path = require('path');
async function createDisplacementMap() {
const size = 512;
const image = new Jimp({ width: size, height: size });
// Create a gentle CRT bulge displacement map
// Center appears to bulge outward (magnified)
// R channel = horizontal displacement
// G channel = vertical displacement
// 128 = no displacement, <128 = pull toward 0, >128 = push toward max
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// Normalize to -1 to 1 range (center is 0,0)
const nx = (x / (size - 1)) * 2 - 1;
const ny = (y / (size - 1)) * 2 - 1;
// Use smooth cubic falloff for gentle curve
// Pull edges slightly toward center (pincushion)
const distSq = nx * nx + ny * ny;
const strength = distSq * 0.15; // Gentle effect
// Direction away from center (positive = away from center)
const dx = nx * strength;
const dy = ny * strength;
// Convert to 0-255 range (128 = no displacement)
const r = Math.floor(128 + dx * 127);
const g = Math.floor(128 + dy * 127);
const idx = (y * size + x) * 4;
image.bitmap.data[idx + 0] = Math.max(0, Math.min(255, r));
image.bitmap.data[idx + 1] = Math.max(0, Math.min(255, g));
image.bitmap.data[idx + 2] = 128;
image.bitmap.data[idx + 3] = 255;
}
}
const outputPath = path.join(__dirname, 'website', 'bulge-map.png');
await image.write(outputPath);
console.log('✓ Displacement map created: website/bulge-map.png');
}
createDisplacementMap();

904
package-lock.json generated Normal file
View File

@@ -0,0 +1,904 @@
{
"name": "lit.ruv.wtf",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lit.ruv.wtf",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"jimp": "^1.6.0"
},
"devDependencies": {}
},
"node_modules/@jimp/core": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz",
"integrity": "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==",
"license": "MIT",
"dependencies": {
"@jimp/file-ops": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"await-to-js": "^3.0.0",
"exif-parser": "^0.1.12",
"file-type": "^16.0.0",
"mime": "3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/diff": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.0.tgz",
"integrity": "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw==",
"license": "MIT",
"dependencies": {
"@jimp/plugin-resize": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"pixelmatch": "^5.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/file-ops": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.0.tgz",
"integrity": "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-bmp": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.0.tgz",
"integrity": "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"bmp-ts": "^1.0.9"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-gif": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.0.tgz",
"integrity": "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/types": "1.6.0",
"gifwrap": "^0.10.1",
"omggif": "^1.0.10"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-jpeg": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.0.tgz",
"integrity": "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/types": "1.6.0",
"jpeg-js": "^0.4.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-png": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.0.tgz",
"integrity": "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/types": "1.6.0",
"pngjs": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/js-tiff": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.0.tgz",
"integrity": "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/types": "1.6.0",
"utif2": "^4.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blit": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.0.tgz",
"integrity": "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-blur": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz",
"integrity": "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/utils": "1.6.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-circle": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.0.tgz",
"integrity": "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-color": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz",
"integrity": "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"tinycolor2": "^1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-contain": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz",
"integrity": "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/plugin-blit": "1.6.0",
"@jimp/plugin-resize": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-cover": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz",
"integrity": "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/plugin-crop": "1.6.0",
"@jimp/plugin-resize": "1.6.0",
"@jimp/types": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-crop": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz",
"integrity": "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-displace": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz",
"integrity": "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-dither": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz",
"integrity": "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-fisheye": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.0.tgz",
"integrity": "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-flip": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz",
"integrity": "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-hash": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz",
"integrity": "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/js-bmp": "1.6.0",
"@jimp/js-jpeg": "1.6.0",
"@jimp/js-png": "1.6.0",
"@jimp/js-tiff": "1.6.0",
"@jimp/plugin-color": "1.6.0",
"@jimp/plugin-resize": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"any-base": "^1.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-mask": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.0.tgz",
"integrity": "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-print": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz",
"integrity": "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/js-jpeg": "1.6.0",
"@jimp/js-png": "1.6.0",
"@jimp/plugin-blit": "1.6.0",
"@jimp/types": "1.6.0",
"parse-bmfont-ascii": "^1.0.6",
"parse-bmfont-binary": "^1.0.6",
"parse-bmfont-xml": "^1.1.6",
"simple-xml-to-json": "^1.2.2",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-quantize": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz",
"integrity": "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-resize": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz",
"integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/types": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-rotate": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz",
"integrity": "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/plugin-crop": "1.6.0",
"@jimp/plugin-resize": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/plugin-threshold": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz",
"integrity": "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/plugin-color": "1.6.0",
"@jimp/plugin-hash": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0",
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/types": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz",
"integrity": "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==",
"license": "MIT",
"dependencies": {
"zod": "^3.23.8"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@jimp/utils": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz",
"integrity": "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==",
"license": "MIT",
"dependencies": {
"@jimp/types": "1.6.0",
"tinycolor2": "^1.6.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "16.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
"license": "MIT"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/any-base": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz",
"integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==",
"license": "MIT"
},
"node_modules/await-to-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz",
"integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bmp-ts": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz",
"integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==",
"license": "MIT"
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/exif-parser": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
},
"node_modules/file-type": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
"license": "MIT",
"dependencies": {
"readable-web-to-node-stream": "^3.0.0",
"strtok3": "^6.2.4",
"token-types": "^4.1.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/gifwrap": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz",
"integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==",
"license": "MIT",
"dependencies": {
"image-q": "^4.0.0",
"omggif": "^1.0.10"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/image-q": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz",
"integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==",
"license": "MIT",
"dependencies": {
"@types/node": "16.9.1"
}
},
"node_modules/jimp": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz",
"integrity": "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==",
"license": "MIT",
"dependencies": {
"@jimp/core": "1.6.0",
"@jimp/diff": "1.6.0",
"@jimp/js-bmp": "1.6.0",
"@jimp/js-gif": "1.6.0",
"@jimp/js-jpeg": "1.6.0",
"@jimp/js-png": "1.6.0",
"@jimp/js-tiff": "1.6.0",
"@jimp/plugin-blit": "1.6.0",
"@jimp/plugin-blur": "1.6.0",
"@jimp/plugin-circle": "1.6.0",
"@jimp/plugin-color": "1.6.0",
"@jimp/plugin-contain": "1.6.0",
"@jimp/plugin-cover": "1.6.0",
"@jimp/plugin-crop": "1.6.0",
"@jimp/plugin-displace": "1.6.0",
"@jimp/plugin-dither": "1.6.0",
"@jimp/plugin-fisheye": "1.6.0",
"@jimp/plugin-flip": "1.6.0",
"@jimp/plugin-hash": "1.6.0",
"@jimp/plugin-mask": "1.6.0",
"@jimp/plugin-print": "1.6.0",
"@jimp/plugin-quantize": "1.6.0",
"@jimp/plugin-resize": "1.6.0",
"@jimp/plugin-rotate": "1.6.0",
"@jimp/plugin-threshold": "1.6.0",
"@jimp/types": "1.6.0",
"@jimp/utils": "1.6.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/omggif": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
"license": "MIT"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-bmfont-ascii": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz",
"integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==",
"license": "MIT"
},
"node_modules/parse-bmfont-binary": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz",
"integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==",
"license": "MIT"
},
"node_modules/parse-bmfont-xml": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz",
"integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==",
"license": "MIT",
"dependencies": {
"xml-parse-from-string": "^1.0.0",
"xml2js": "^0.5.0"
}
},
"node_modules/peek-readable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/pixelmatch": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
"license": "ISC",
"dependencies": {
"pngjs": "^6.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pixelmatch/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readable-web-to-node-stream": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^4.7.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/sax": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/simple-xml-to-json": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz",
"integrity": "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==",
"license": "MIT",
"engines": {
"node": ">=20.12.2"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strtok3": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^4.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/token-types": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/utif2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
"integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.11"
}
},
"node_modules/xml-parse-from-string": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
"integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==",
"license": "MIT"
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "lit.ruv.wtf",
"version": "1.0.0",
"description": "Interactive terminal interface for lit.ruv.wtf - A retro-styled command line experience with chat, documentation, and more.",
"main": "index.html",
"scripts": {
"start": "python3 -m http.server 8000",
"start-alt": "python -m SimpleHTTPServer 8000"
},
"keywords": [
"terminal",
"xterm",
"retro",
"cli",
"web-terminal",
"matrix",
"chat",
"interactive"
],
"author": "Max Litruv Boonzaayer",
"license": "MIT",
"dependencies": {
"jimp": "^1.6.0"
},
"repository": {
"type": "git",
"url": "https://github.com/litruv/lit.ruv.wtf"
},
"homepage": "https://lit.ruv.wtf"
}

View File

@@ -1,16 +0,0 @@
<!--
METADATA:{
"title": "Litruv - Dev/Tech Artist",
"metaDescription": "Litruv - Dev/Tech Artist at MatesMedia. Follow for coding, game development, and creative projects.",
"csp": "script-src 'self' 'unsafe-inline'; connect-src https://public.api.bsky.app;",
"ogTitle": "Litruv - Dev/Tech Artist",
"ogDescription": "Dev/Tech Artist at MatesMedia. Follow for coding, game development, and creative projects.",
"ogUrl": "https://lit.ruv.wtf",
"twitterTitle": "Litruv - Dev/Tech Artist",
"twitterDescription": "Dev/Tech Artist at MatesMedia. Follow for coding, game development, and creative projects.",
"twitterUrl": "https://lit.ruv.wtf",
"useCommonHeader": true,
"useCommonFooter": true
}
-->
<div id="posts"></div>

View File

@@ -1,349 +0,0 @@
<!--
METADATA:{
"title": "Prison Mine - Litruv",
"metaDescription": "Infinite isometric prison mining game.",
"csp": "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net;",
"ogTitle": "Prison Mine - Litruv",
"ogDescription": "Dig deep in this infinite isometric mining game.",
"ogUrl": "https://lit.ruv.wtf/mining.html",
"twitterTitle": "Prison Mine - Litruv",
"twitterDescription": "Dig deep in this infinite isometric mining game.",
"twitterUrl": "https://lit.ruv.wtf/mining.html",
"useCommonHeader": true,
"useCommonFooter": true
}
-->
<!-- START_STYLES -->
<style>
.mining-container {
width: min(100%, 80vh);
aspect-ratio: 1;
margin: 0 auto;
position: relative;
overflow: hidden;
background: #000;
border: 2px solid var(--post-border);
box-shadow: 4px 6px 0 var(--shadow-light);
}
#game-canvas {
width: 100%;
height: 100%;
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.mining-ui {
position: absolute;
top: 10px;
left: 10px;
color: white;
font-family: 'Minecraft', monospace; /* Fallback */
pointer-events: none;
text-shadow: 2px 2px 0 #000;
z-index: 100;
}
.mining-stat {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.inventory-container {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 5px;
background: rgba(0, 0, 0, 0.5);
padding: 5px;
border-radius: 5px;
pointer-events: auto;
overflow-x: auto;
max-width: 95%;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
z-index: 100;
}
/* Scrollbar styling for inventory */
.inventory-container::-webkit-scrollbar {
height: 6px;
}
.inventory-container::-webkit-scrollbar-track {
background: rgba(0,0,0,0.3);
border-radius: 3px;
}
.inventory-container::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
border-radius: 3px;
}
.inventory-slot {
flex: 0 0 auto; /* Don't shrink */
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border: 2px solid #555;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.inventory-slot img {
width: 32px;
height: 32px;
image-rendering: pixelated;
}
.inventory-count {
position: absolute;
bottom: 0;
right: 2px;
font-size: 12px;
color: white;
text-shadow: 1px 1px 0 #000;
}
.crafting-container {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 5px;
background: rgba(0, 0, 0, 0.5);
padding: 5px;
border-radius: 5px;
pointer-events: auto; /* Allow clicking */
overflow-y: auto;
max-height: 60vh;
-webkit-overflow-scrolling: touch;
z-index: 100;
}
/* Scrollbar styling for crafting */
.crafting-container::-webkit-scrollbar {
width: 6px;
}
.crafting-container::-webkit-scrollbar-track {
background: rgba(0,0,0,0.3);
border-radius: 3px;
}
.crafting-container::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
border-radius: 3px;
}
.crafting-slot {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border: 2px solid #555;
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.crafting-slot:hover {
background: rgba(255, 255, 255, 0.2);
}
.crafting-slot.can-craft {
border-color: #79C05A;
background: rgba(121, 192, 90, 0.2);
}
.crafting-slot img {
width: 32px;
height: 32px;
image-rendering: pixelated;
}
.crafting-tooltip {
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
display: none;
margin-right: 10px;
pointer-events: none;
z-index: 20;
}
.crafting-slot:hover .crafting-tooltip {
display: block;
}
.shop-btn {
position: absolute;
top: 10px;
right: 10px;
background: #4CAF50;
color: white;
border: 2px solid #388E3C;
padding: 5px 10px;
font-family: 'Minecraft', monospace;
cursor: pointer;
z-index: 100;
}
.shop-container {
position: absolute;
top: 10%;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
border: 2px solid #555;
padding: 20px;
color: white;
font-family: 'Minecraft', monospace;
display: none;
z-index: 101;
min-width: 300px;
max-height: 80vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.shop-section {
margin-bottom: 20px;
}
.shop-section h3 {
border-bottom: 1px solid #555;
padding-bottom: 5px;
margin-top: 0;
}
.shop-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
padding: 5px;
background: rgba(255, 255, 255, 0.05);
}
.shop-item button {
background: #333;
color: white;
border: 1px solid #555;
padding: 2px 5px;
cursor: pointer;
font-family: inherit;
}
.shop-item button:hover {
background: #555;
}
.close-shop {
position: absolute;
top: 5px;
right: 5px;
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
}
/* Override for buttons inside shop */
.shop-container .shop-btn {
position: static;
display: block;
width: 100%;
margin-bottom: 5px;
background: #333;
text-align: left;
}
.shop-container .shop-btn:hover {
background: #555;
}
.shop-container .close-btn {
text-align: center;
background: #d32f2f;
border-color: #b71c1c;
}
.shop-container .close-btn:hover {
background: #f44336;
}
.fullscreen-btn {
position: absolute;
top: 10px;
right: 80px;
background: #2196F3;
color: white;
border: 2px solid #1976D2;
padding: 5px 10px;
font-family: 'Minecraft', monospace;
cursor: pointer;
z-index: 100;
}
.fullscreen-btn:hover {
background: #42A5F5;
}
.mining-container:fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
aspect-ratio: auto;
border: none;
background: #87CEEB; /* Match sky color */
}
.mining-container:fullscreen #game-canvas {
width: 100vw;
height: 100vh;
}
/* Fallback for iOS/Mobile */
.mining-container.pseudo-fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
max-width: none;
aspect-ratio: auto;
border: none;
background: #87CEEB;
z-index: 9999;
margin: 0;
}
.mining-container.pseudo-fullscreen #game-canvas {
width: 100vw;
height: 100vh;
}
</style>
<!-- END_STYLES -->
<!-- START_SCRIPTS_DEFER -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/SSAOShader.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/SSAOPass.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/GammaCorrectionShader.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/math/SimplexNoise.js" defer></script>
<script src="mining.js" defer></script>
<!-- END_SCRIPTS_DEFER -->
<div class="mining-container">
<div class="mining-ui">
<div class="mining-stat">Depth: <span id="depth-counter">0</span></div>
<div class="mining-stat">Score: <span id="score-counter">0</span></div>
</div>
<button id="fullscreen-btn" class="fullscreen-btn" onclick="toggleFullscreen()">[ ]</button>
<button id="shop-btn" class="shop-btn" onclick="toggleShop()">Shop</button>
<div id="inventory-display" class="inventory-container">
<!-- Slots will be added here dynamically -->
</div>
<div id="crafting-display" class="crafting-container">
<!-- Crafting slots will be added here dynamically -->
</div>
<div id="shop-modal" class="shop-container" style="display:none; flex-direction:column; max-height: 80vh; overflow-y: auto;">
<div id="shop-content"></div>
</div>
<canvas id="game-canvas"></canvas>
</div>

View File

@@ -1,498 +0,0 @@
<!--
METADATA:{
"title": "Number Match - Litruv",
"metaDescription": "Play Number Match right on Litruv's site. Pair numbers that match or add to ten and clear the board.",
"csp": "script-src 'self' 'unsafe-inline';",
"ogTitle": "Number Match - Litruv",
"ogDescription": "Clear the board by pairing numbers that match or add to ten.",
"ogUrl": "https://lit.ruv.wtf/numbermatch.html",
"twitterTitle": "Number Match - Litruv",
"twitterDescription": "Clear the board by pairing numbers that match or add to ten.",
"twitterUrl": "https://lit.ruv.wtf/numbermatch.html",
"useCommonHeader": true,
"useCommonFooter": true
}
-->
<!-- START_STYLES -->
<style>
.numbermatch-page {
margin-top: 2.5rem;
padding: 0 1rem 3.5rem;
color: #1f2933;
}
.numbermatch-page .sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.numbermatch-page .app {
display: grid;
gap: 2.5rem;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
max-width: 1100px;
margin: 0 auto;
}
.numbermatch-page .app__game,
.numbermatch-page .app__info {
background: var(--post-bg);
border: 2px solid var(--post-border);
box-shadow: 4px 6px 0 var(--shadow-light);
padding: 2rem 2.25rem;
position: relative;
transform-origin: center;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.numbermatch-page .app__game {
transform: none;
}
.numbermatch-page .app__info {
transform: rotate(0.55deg);
}
.numbermatch-page .app__game:hover {
transform: scale(1.01);
box-shadow: 6px 9px 0 var(--shadow-light);
}
.numbermatch-page .app__info:hover {
transform: rotate(0deg) scale(1.01);
box-shadow: 6px 9px 0 var(--shadow-light);
}
.numbermatch-page .app__title {
margin: 0;
font-size: 2rem;
text-shadow: 1px 1px 0 #fff;
color: #111827;
}
.numbermatch-page .app__subtitle {
margin: 0.5rem 0 0;
color: #364152;
font-size: 1rem;
}
.numbermatch-page .controls {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
margin: 2rem 0 1.5rem;
}
.numbermatch-page .control-button {
border: 2px solid var(--post-border);
background: var(--accent);
color: #fff;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
padding: 0.65rem 1.75rem;
box-shadow: 3px 4px 0 var(--shadow-light);
transition: transform 0.18s ease, box-shadow 0.18s ease;
font-family: inherit;
}
.numbermatch-page .control-button:hover,
.numbermatch-page .control-button:focus {
transform: translateY(-2px);
box-shadow: 5px 6px 0 var(--shadow-light);
}
.numbermatch-page .control-button:active {
transform: translateY(0);
box-shadow: 2px 2px 0 var(--shadow-light);
}
.numbermatch-page .control-button--secondary {
background: var(--main-bg);
color: var(--main-fg);
}
.numbermatch-page .status {
display: flex;
flex-direction: column;
gap: 0.85rem;
background: rgba(36, 36, 36, 0.04);
border: 1px dashed rgba(0, 0, 0, 0.12);
padding: 1rem 1.25rem;
border-radius: 12px;
}
.numbermatch-page .status__message {
min-height: 1.5rem;
margin: 0;
font-weight: 600;
color: #1f2933;
}
.numbermatch-page .status__message.status__message--error {
color: #be123c;
}
.numbermatch-page .status__message.status__message--success {
color: #15803d;
}
.numbermatch-page .status__metrics {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.95rem;
color: #364152;
}
.numbermatch-page .status__metric strong {
color: #111827;
}
.numbermatch-page .board {
margin-top: 1.75rem;
display: grid;
gap: 0.6rem;
width: 100%;
}
.numbermatch-page .board-actions {
margin-top: 1.75rem;
display: flex;
justify-content: center;
align-items: center;
gap: 0.85rem;
}
.numbermatch-page .board-action {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
border: 2px solid var(--post-border);
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 2rem;
font-weight: 700;
cursor: pointer;
color: var(--main-bg);
background: var(--accent);
box-shadow: 3px 4px 0 var(--shadow-light);
transition: transform 0.18s ease, box-shadow 0.18s ease;
position: relative;
}
.numbermatch-page .board-action:hover,
.numbermatch-page .board-action:focus {
transform: translateY(-2px);
box-shadow: 5px 6px 0 var(--shadow-light);
}
.numbermatch-page .board-action:active {
transform: translateY(0);
box-shadow: 2px 2px 0 var(--shadow-light);
}
.numbermatch-page .board-action--secondary {
background: var(--post-bg);
color: #111827;
}
.numbermatch-page .board-action__badge {
position: absolute;
top: -0.45rem;
right: -0.45rem;
min-width: 1.5rem;
padding: 0.2rem 0.45rem;
border-radius: 999px;
background: var(--like);
color: #fff;
font-size: 0.85rem;
font-weight: 700;
box-shadow: 0 6px 12px rgba(219, 39, 119, 0.35);
}
.numbermatch-page .board-action__badge.board-action__badge--empty {
background: rgba(0, 0, 0, 0.12);
color: #111827;
box-shadow: none;
}
.numbermatch-page .board-action--attention {
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18), 0 12px 24px rgba(37, 99, 235, 0.3);
animation: numbermatch-attention 0.95s ease-in-out infinite alternate;
}
@keyframes numbermatch-attention {
from {
transform: translateY(-2px) scale(1);
}
to {
transform: translateY(-3px) scale(1.05);
}
}
.numbermatch-page .tile {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 14px;
border: 1.5px solid var(--post-border);
font-size: clamp(1.1rem, 4vw, 1.55rem);
font-weight: 700;
color: #111827;
background: linear-gradient(165deg, #fffef4 0%, #f7f0b7 100%);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 2px 3px 0 rgba(0, 0, 0, 0.15);
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
position: relative;
}
.numbermatch-page .tile:hover,
.numbermatch-page .tile:focus {
transform: translateY(-2px);
box-shadow: 4px 6px 0 rgba(0, 0, 0, 0.18);
}
.numbermatch-page .tile--selected {
background: linear-gradient(160deg, #fde68a 0%, #fb923c 100%);
box-shadow: 4px 6px 0 rgba(251, 191, 36, 0.4);
}
.numbermatch-page .tile--hinted:not(.tile--selected) {
background: linear-gradient(160deg, #bfdbfe 0%, #93c5fd 100%);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.35), 4px 6px 0 rgba(37, 99, 235, 0.25);
}
.numbermatch-page .tile--empty {
background: rgba(0, 0, 0, 0.05);
border: 1.5px dashed rgba(0, 0, 0, 0.25);
cursor: default;
box-shadow: none;
}
.numbermatch-page .tile--empty:hover,
.numbermatch-page .tile--empty:focus {
transform: none;
}
.numbermatch-page .app__info h2,
.numbermatch-page .app__info h3 {
margin-top: 0;
color: #111827;
text-shadow: 1px 1px 0 #fff;
}
.numbermatch-page .app__info p,
.numbermatch-page .app__info li {
color: #364152;
line-height: 1.6;
}
.numbermatch-page .app__info ul {
padding-left: 1.25rem;
margin: 0.75rem 0 1.5rem;
}
.numbermatch-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.numbermatch-dialog-backdrop.is-visible {
opacity: 1;
pointer-events: all;
}
.numbermatch-dialog {
background: var(--post-bg);
border: 2px solid var(--post-border);
box-shadow: 6px 8px 0 var(--shadow-light);
padding: 1.75rem 2rem;
max-width: 420px;
width: calc(100% - 2rem);
transform: rotate(-1deg);
position: relative;
}
.numbermatch-dialog h2 {
margin-top: 0;
margin-bottom: 0.75rem;
font-size: 1.35rem;
color: #111827;
}
.numbermatch-dialog p {
margin: 0 0 1.5rem;
color: #374151;
line-height: 1.5;
}
.numbermatch-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.numbermatch-dialog-button {
border: 2px solid var(--post-border);
padding: 0.55rem 1.35rem;
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
box-shadow: 3px 4px 0 var(--shadow-light);
transition: transform 0.18s ease, box-shadow 0.18s ease;
background: var(--post-bg);
color: #111827;
}
.numbermatch-dialog-button:hover,
.numbermatch-dialog-button:focus-visible {
transform: translateY(-2px);
box-shadow: 5px 6px 0 var(--shadow-light);
}
.numbermatch-dialog-button--confirm {
background: var(--accent);
color: #fff;
}
.numbermatch-dialog-button--danger {
background: var(--like);
color: #fff;
}
@media (max-width: 1023px) {
.numbermatch-page .app {
grid-template-columns: minmax(0, 1fr);
}
.numbermatch-page .app__game,
.numbermatch-page .app__info {
transform: rotate(0deg);
}
}
@media (max-width: 640px) {
.numbermatch-page {
padding: 0 0 3rem;
}
.numbermatch-page .app__game,
.numbermatch-page .app__info {
padding: 1.5rem;
}
.numbermatch-page .board {
gap: 0.4rem;
}
.numbermatch-page .board-action {
width: 3.1rem;
height: 3.1rem;
font-size: 1.8rem;
}
.numbermatch-page .board-action__badge {
top: -0.25rem;
right: -0.25rem;
min-width: 1.35rem;
font-size: 0.75rem;
}
}
</style>
<!-- END_STYLES -->
<!-- START_SCRIPTS_DEFER -->
<script src="numbermatch.js" defer></script>
<!-- END_SCRIPTS_DEFER -->
<div class="numbermatch-page">
<div class="app">
<section class="app__game" aria-label="Number Match board and controls">
<header class="app__header">
<h1 class="app__title">Number Match</h1>
<p class="app__subtitle">Clear the board by pairing numbers that match or add to ten.</p>
</header>
<div class="controls" role="group" aria-label="Game controls">
<button id="newGameButton" type="button" class="control-button control-button--secondary">New Game</button>
</div>
<section class="status" aria-live="polite">
<p id="message" class="status__message" role="status"></p>
<div class="status__metrics">
<span class="status__metric">
Matches cleared: <strong id="matchesCount">0</strong>
</span>
<span class="status__metric">
Tiles remaining: <strong id="tilesRemaining">0</strong>
</span>
<span class="status__metric">
Add Numbers used: <strong id="addNumbersUsed">0/3</strong>
</span>
<span class="status__metric">
Hints used: <strong id="hintsUsed">0</strong>
</span>
</div>
</section>
<section class="board" id="board" aria-label="Number grid"></section>
<div class="board-actions">
<button id="addNumbersButton" type="button" class="board-action board-action--primary" aria-label="Add numbers">
<span aria-hidden="true" class="board-action__icon">+</span>
<span id="addNumbersCounter" class="board-action__badge" aria-hidden="true">3</span>
<span class="sr-only">Add numbers</span>
</button>
<button id="hintButton" type="button" class="board-action board-action--secondary" aria-label="Hint">
<span aria-hidden="true">?</span>
<span class="sr-only">Hint</span>
</button>
</div>
</section>
<aside class="app__info" aria-labelledby="info-heading">
<h2 id="info-heading">How To Play</h2>
<p>Select two tiles that are either identical (like 8 and 8) or sum to ten (for example, 3 and 7). Clear every number to win.</p>
<h3>Match Rules</h3>
<ul>
<li>Paths can run horizontally, vertically, diagonally, or wrap in reading order.</li>
<li>Every cell between selections must be empty along the chosen path.</li>
<li>Wrap-around allows the end of a row to connect to the start of the next when the path is clear.</li>
</ul>
<h3>Strategy &amp; Tips</h3>
<ul>
<li>Scan rows and columns for classic pairs like 9-1, 8-2, 7-3, 6-4, and 5-5.</li>
<li>Use diagonal and wrap-around matches to reach blocked numbers.</li>
<li><em>Add Numbers</em> unlocks fresh tiles in three batches—save it for when the board runs dry.</li>
</ul>
<h3>Frequently Asked Questions</h3>
<p><strong>Which numbers appear?</strong> Numbers 1-9 are distributed to encourage plenty of identical and ten-sum pairs.</p>
<p><strong>What counts as wrap-around?</strong> Reading order connections across a row break, provided every intervening cell is empty.</p>
</aside>
</div>
</div>
<div class="numbermatch-dialog-backdrop" id="newGameDialogBackdrop" hidden>
<div class="numbermatch-dialog" id="newGameDialog" role="dialog" aria-modal="true" aria-labelledby="newGameDialogTitle" aria-describedby="newGameDialogDescription" tabindex="-1">
<h2 id="newGameDialogTitle">Start a fresh puzzle?</h2>
<p id="newGameDialogDescription">Your current board and progress will be replaced with a new layout. You can always reload the page to resume later.</p>
<div class="numbermatch-dialog-actions">
<button type="button" class="numbermatch-dialog-button" id="cancelNewGameButton">Keep playing</button>
<button type="button" class="numbermatch-dialog-button numbermatch-dialog-button--danger" id="confirmNewGameButton">New game</button>
</div>
</div>
</div>

View File

@@ -1,97 +0,0 @@
<!--
METADATA:{
"title": "Privacy Policy - Litruv",
"metaDescription": "Privacy Policy for Litruv's website and applications.",
"csp": "script-src 'self' 'unsafe-inline';",
"ogTitle": "Privacy Policy - Litruv",
"ogDescription": "Privacy Policy for Litruv's website and applications.",
"ogUrl": "https://lit.ruv.wtf/privacy.html",
"twitterTitle": "Privacy Policy - Litruv",
"twitterDescription": "Privacy Policy for Litruv's website and applications.",
"twitterUrl": "https://lit.ruv.wtf/privacy.html",
"useCommonHeader": false,
"useCommonFooter": true
}
-->
<!-- START_STYLES -->
<style>
.privacy-content {
background: white;
padding: 1.5rem 2rem;
margin-top: 2rem;
border: 2px solid #000;
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.1), 6px 6px 0 rgba(0, 0, 0, 0.05);
color: #333;
text-align: left;
}
.privacy-content h1 {
text-align: center;
font-size: 1.8rem;
color: #242424;
text-shadow: 1px 1px 0 #ffffff52, 2px 2px 0 #00000080;
margin-bottom: 1.5rem;
}
.privacy-content h2 {
font-size: 1.3rem;
color: #242424;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.privacy-content p {
line-height: 1.6;
margin-bottom: 1rem;
}
.privacy-content a {
color: #2563eb;
text-decoration: underline;
}
.privacy-content a:hover {
color: #1d4ed8;
}
</style>
<!-- END_STYLES -->
<!-- START_SCRIPTS_BODY_END -->
<script>
function getRandomRotation() {
return (Math.random() * 3) - 1.5;
}
document.addEventListener('DOMContentLoaded', function() {
const privacyBox = document.querySelector('.privacy-content');
if (privacyBox) {
privacyBox.style.transform = `rotate(${getRandomRotation()}deg)`;
}
// Ensure avatar also gets random rotation if desired for this page
const avatarImg = document.querySelector('.avatar-img');
if (avatarImg) {
// You might want a different rotation range or logic here
// For consistency with how posts might be styled if they were on this page:
// avatarImg.style.transform = `rotate(${(Math.random() * 6) - 3}deg)`;
}
});
</script>
<!-- END_SCRIPTS_BODY_END -->
<!-- Main page content for privacy.html -->
<img src="avatar.webp" alt="Avatar" class="avatar-img" width="250" height="250">
<header>
<h1><a href="/">Litruv</a></h1>
<div class="subtext">Privacy Policy</div>
</header>
<div class="privacy-content">
<h1>Privacy Policy</h1>
<p>I respect your privacy.</p>
<h2>Apps</h2>
<p>My apps do not collect, store, or share any personal information. No analytics, no tracking, no funny business.</p>
<h2>Website</h2>
<p>This site may collect anonymous usage data (such as page views or general browser information) to help improve functionality and performance. This data is non-personal and cannot be used to identify you. I do not sell or share this data with third parties.</p>
<p>Additionally, this site uses Cloudflare for performance and security. Cloudflare may collect its own analytics and statistical data, subject to their <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer">privacy policy</a>.</p>
<p>By using this site, you agree to this policy.</p>
</div>

View File

@@ -1,7 +0,0 @@
<footer>
<a href="privacy.html">Privacy Policy</a>
<p>&copy; 2013-<span id="current-year"></span> Max Litruv Boonzaayer</p>
<script>
document.getElementById('current-year').textContent = new Date().getFullYear();
</script>
</footer>

View File

@@ -1,32 +0,0 @@
<img src="avatar.webp" alt="Avatar" class="avatar-img" width="250" height="250">
<header>
<h1>Litruv</h1>
<div class="subtext">Dev/Tech Artist @<a href="https://mates.dev/">MatesMedia</a>
</div>
<div class="social-links">
<a href="https://github.com/Litruv" target="_blank" title="GitHub - Litruv">
<i class="fab fa-github"></i>
</a>
<a href="https://www.youtube.com/c/Litruv" target="_blank" title="YouTube - Litruv">
<i class="fab fa-youtube"></i>
</a>
<a href="https://steamcommunity.com/id/Litruv" target="_blank" title="Steam - Litruv">
<i class="fab fa-steam"></i>
</a>
<a href="https://discordapp.com/users/220772082055774210" target="_blank" title="Discord - @Litruv">
<i class="fab fa-discord"></i>
</a>
<a href="https://bsky.app/profile/lit.mates.dev" target="_blank" title="Bluesky - lit.mates.dev">
<i class="fa-brands fa-bluesky"></i>
</a>
</div>
<div class="project-links">
<a href="https://mates.dev" target="_blank">MatesMedia</a>
<a href="https://lit.ruv.wtf/materials/" target="_blank">UE Material Snippets</a>
<a href="https://lit.ruv.wtf/docs/" target="_blank">Documentation</a>
<a href="https://lit.ruv.wtf/numbermatch/">Number Match</a>
<a href="https://lit.ruv.wtf/mining/">Mining Game</a>
</div>
</header>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="description" content="<!-- ::META_DESCRIPTION:: -->">
<meta name="author" content="Litruv">
<meta http-equiv="Content-Security-Policy" content="<!-- ::PAGE_CSP:: -->">
<meta property="og:title" content="<!-- ::OG_TITLE:: -->">
<meta property="og:description" content="<!-- ::OG_DESCRIPTION:: -->">
<meta property="og:image" content="https://lit.ruv.wtf/banner.webp"> <!-- Common image -->
<meta property="og:url" content="<!-- ::OG_URL:: -->">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Lit.ruv.wtf" /> <!-- Common site name -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<!-- ::TWITTER_TITLE:: -->">
<meta name="twitter:description" content="<!-- ::TWITTER_DESCRIPTION:: -->">
<meta name="twitter:image" content="https://lit.ruv.wtf/banner.webp"> <!-- Common image -->
<meta name="twitter:url" content="<!-- ::TWITTER_URL:: -->">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/icons/48.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/180.png">
<link rel="stylesheet" href="style.css" crossorigin="">
<!-- ::PAGE_SPECIFIC_STYLES:: -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.7/hls.min.js" defer></script>
<script src="main.js" defer></script> <!-- Common main script -->
<script src="dynamic-rotation.js" defer></script> <!-- Common main script -->
<script src="tape.js" defer></script>
<!-- ::PAGE_SPECIFIC_SCRIPTS_DEFER:: -->
<title><!-- ::PAGE_TITLE:: --></title>
</head>
<body>
<div class="bg-pattern"></div>
<div class="container">
<!-- ::HEADER:: -->
<!-- ::CONTENT:: -->
<!-- ::FOOTER:: -->
</div>
<!-- ::PAGE_SPECIFIC_SCRIPTS_BODY_END:: -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/js/all.min.js" defer=""></script>
</body>
</html>

BIN
website/bulge-map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

238
website/crt-bulge.js Normal file
View File

@@ -0,0 +1,238 @@
/**
* CRT Bulge Coordinate Transformer
* Intercepts mouse events and applies inverse distortion to match the visual bulge effect
*/
class CRTBulgeTransformer {
constructor(containerSelector, strength = 0.15) {
this.container = document.querySelector(containerSelector);
this.strength = strength;
this.setupEventListeners();
}
/**
* Apply inverse barrel distortion to transform visual coords to DOM coords
* @param {number} x - Visual X coordinate relative to container
* @param {number} y - Visual Y coordinate relative to container
* @param {number} width - Container width
* @param {number} height - Container height
* @returns {{x: number, y: number}} - Transformed coordinates
*/
inverseDistort(x, y, width, height) {
// Normalize to -1 to 1 (center is 0,0)
const nx = (x / width) * 2 - 1;
const ny = (y / height) * 2 - 1;
// The visual filter pushes pixels outward based on distance squared
// To invert: we need to find where this pixel came FROM
// Using Newton-Raphson iteration to solve the inverse
let ux = nx;
let uy = ny;
// Iterate to find the original position
for (let i = 0; i < 5; i++) {
const distSq = ux * ux + uy * uy;
const factor = 1 + this.strength * distSq;
// Forward distortion: visual = original * factor
// Inverse: original = visual / factor (approximately)
ux = nx / factor;
uy = ny / factor;
}
// Convert back to pixel coordinates
const newX = ((ux + 1) / 2) * width;
const newY = ((uy + 1) / 2) * height;
return { x: newX, y: newY };
}
/**
* Transform mouse event coordinates
* @param {MouseEvent} e - Original mouse event
* @returns {{x: number, y: number}} - Transformed page coordinates
*/
transformEvent(e) {
const rect = this.container.getBoundingClientRect();
// Get position relative to container
const relX = e.clientX - rect.left;
const relY = e.clientY - rect.top;
// Apply inverse distortion
const transformed = this.inverseDistort(relX, relY, rect.width, rect.height);
// Convert back to page coordinates
return {
x: rect.left + transformed.x,
y: rect.top + transformed.y
};
}
/**
* Set up event listeners for mouse interaction
*/
setupEventListeners() {
// Create invisible overlay to capture events
const overlay = document.createElement('div');
overlay.id = 'crt-event-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
cursor: inherit;
`;
document.body.appendChild(overlay);
// Track currently hovered element for hover states
let lastHoveredElement = null;
// Handle mouse movement
overlay.addEventListener('mousemove', (e) => {
const transformed = this.transformEvent(e);
// Temporarily hide overlay to find element underneath
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
// Update cursor based on element below
if (elementBelow) {
const computedStyle = window.getComputedStyle(elementBelow);
overlay.style.cursor = computedStyle.cursor;
// Handle hover state changes
if (elementBelow !== lastHoveredElement) {
if (lastHoveredElement) {
lastHoveredElement.dispatchEvent(new MouseEvent('mouseleave', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
}));
}
elementBelow.dispatchEvent(new MouseEvent('mouseenter', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
}));
lastHoveredElement = elementBelow;
}
// Dispatch mousemove to element
elementBelow.dispatchEvent(new MouseEvent('mousemove', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
}));
}
});
// Handle clicks
overlay.addEventListener('click', (e) => {
e.preventDefault();
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
elementBelow.dispatchEvent(new MouseEvent('click', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
}));
// Focus if it's an interactive element
if (elementBelow.tagName === 'INPUT' || elementBelow.tagName === 'TEXTAREA' || elementBelow.tabIndex >= 0) {
elementBelow.focus();
}
}
});
// Handle mousedown
overlay.addEventListener('mousedown', (e) => {
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
elementBelow.dispatchEvent(new MouseEvent('mousedown', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y,
button: e.button
}));
}
});
// Handle mouseup
overlay.addEventListener('mouseup', (e) => {
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
elementBelow.dispatchEvent(new MouseEvent('mouseup', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y,
button: e.button
}));
}
});
// Handle wheel/scroll
overlay.addEventListener('wheel', (e) => {
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
elementBelow.dispatchEvent(new WheelEvent('wheel', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y,
deltaX: e.deltaX,
deltaY: e.deltaY,
deltaMode: e.deltaMode
}));
}
}, { passive: true });
// Handle context menu
overlay.addEventListener('contextmenu', (e) => {
const transformed = this.transformEvent(e);
overlay.style.pointerEvents = 'none';
const elementBelow = document.elementFromPoint(transformed.x, transformed.y);
overlay.style.pointerEvents = 'auto';
if (elementBelow) {
const event = new MouseEvent('contextmenu', {
bubbles: true,
clientX: transformed.x,
clientY: transformed.y
});
if (!elementBelow.dispatchEvent(event)) {
e.preventDefault();
}
}
});
}
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Match the strength value from create-bulge-map.js
new CRTBulgeTransformer('.container', 0.15);
});

459
website/crt-canvas.js Normal file
View File

@@ -0,0 +1,459 @@
/**
* CRT Canvas Renderer
* Renders content to a canvas with WebGL barrel distortion shader
* Handles input coordinate transformation automatically
*/
class CRTCanvasRenderer {
/**
* @param {HTMLElement} sourceElement - Element to render (will be hidden)
* @param {Object} options - Configuration options
*/
constructor(sourceElement, options = {}) {
this.source = sourceElement;
this.options = {
bulgeStrength: options.bulgeStrength ?? 0.08,
scanlineIntensity: options.scanlineIntensity ?? 0.1,
vignetteStrength: options.vignetteStrength ?? 0.2,
fps: options.fps ?? 60,
...options
};
this.canvas = null;
this.gl = null;
this.program = null;
this.animationId = null;
this.init();
}
/**
* Initialize the canvas and WebGL context
*/
init() {
// Create canvas
this.canvas = document.createElement('canvas');
this.canvas.id = 'crt-canvas';
this.canvas.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
`;
// Insert canvas before source
this.source.parentNode.insertBefore(this.canvas, this.source);
// Hide source but keep it functional
this.source.style.position = 'absolute';
this.source.style.opacity = '0';
this.source.style.pointerEvents = 'none';
// Get WebGL context
this.gl = this.canvas.getContext('webgl', {
alpha: true,
antialias: false,
preserveDrawingBuffer: true
});
if (!this.gl) {
console.error('WebGL not supported, falling back to source element');
this.source.style.opacity = '1';
this.source.style.pointerEvents = 'auto';
this.canvas.remove();
return;
}
this.setupShaders();
this.setupGeometry();
this.setupTexture();
this.setupEventListeners();
this.resize();
window.addEventListener('resize', () => this.resize());
this.startRenderLoop();
}
/**
* Create and compile WebGL shaders
*/
setupShaders() {
const gl = this.gl;
// Vertex shader
const vertexSource = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}
`;
// Fragment shader with barrel distortion
const fragmentSource = `
precision mediump float;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform float u_bulgeStrength;
uniform float u_scanlineIntensity;
uniform float u_vignetteStrength;
uniform float u_time;
varying vec2 v_texCoord;
vec2 barrelDistort(vec2 uv) {
// Center UV around origin
vec2 centered = uv * 2.0 - 1.0;
// Calculate distance from center
float distSq = dot(centered, centered);
// Apply barrel distortion
float distortion = 1.0 + u_bulgeStrength * distSq;
centered *= distortion;
// Convert back to 0-1 range
return centered * 0.5 + 0.5;
}
void main() {
// Apply barrel distortion
vec2 distortedUV = barrelDistort(v_texCoord);
// Check if UV is out of bounds
if (distortedUV.x < 0.0 || distortedUV.x > 1.0 ||
distortedUV.y < 0.0 || distortedUV.y > 1.0) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// Sample texture
vec4 color = texture2D(u_texture, distortedUV);
// Scanlines
float scanline = sin(distortedUV.y * u_resolution.y * 1.5) * 0.5 + 0.5;
color.rgb *= 1.0 - (u_scanlineIntensity * (1.0 - scanline));
// Subtle RGB shift for chromatic aberration
float shift = u_bulgeStrength * 0.002;
float r = texture2D(u_texture, distortedUV + vec2(shift, 0.0)).r;
float b = texture2D(u_texture, distortedUV - vec2(shift, 0.0)).b;
color.r = mix(color.r, r, 0.5);
color.b = mix(color.b, b, 0.5);
// Vignette
vec2 vignetteUV = v_texCoord * 2.0 - 1.0;
float vignette = 1.0 - dot(vignetteUV, vignetteUV) * u_vignetteStrength;
color.rgb *= vignette;
gl_FragColor = color;
}
`;
// Compile shaders
const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource);
const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource);
// Create program
this.program = gl.createProgram();
gl.attachShader(this.program, vertexShader);
gl.attachShader(this.program, fragmentShader);
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
console.error('Shader program failed to link:', gl.getProgramInfoLog(this.program));
}
gl.useProgram(this.program);
// Get uniform locations
this.uniforms = {
texture: gl.getUniformLocation(this.program, 'u_texture'),
resolution: gl.getUniformLocation(this.program, 'u_resolution'),
bulgeStrength: gl.getUniformLocation(this.program, 'u_bulgeStrength'),
scanlineIntensity: gl.getUniformLocation(this.program, 'u_scanlineIntensity'),
vignetteStrength: gl.getUniformLocation(this.program, 'u_vignetteStrength'),
time: gl.getUniformLocation(this.program, 'u_time')
};
}
/**
* Compile a shader
*/
compileShader(type, source) {
const gl = this.gl;
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
/**
* Set up geometry (full-screen quad)
*/
setupGeometry() {
const gl = this.gl;
// Position attribute
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1
]), gl.STATIC_DRAW);
const positionLoc = gl.getAttribLocation(this.program, 'a_position');
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
// Texture coordinate attribute
const texCoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
0, 1, 1, 1, 0, 0,
0, 0, 1, 1, 1, 0
]), gl.STATIC_DRAW);
const texCoordLoc = gl.getAttribLocation(this.program, 'a_texCoord');
gl.enableVertexAttribArray(texCoordLoc);
gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 0, 0);
}
/**
* Set up texture for rendering source element
*/
setupTexture() {
const gl = this.gl;
this.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texture);
// Set texture parameters
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
}
/**
* Handle canvas resize
*/
resize() {
const rect = this.source.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.canvas.style.width = rect.width + 'px';
this.canvas.style.height = rect.height + 'px';
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
}
/**
* Apply inverse barrel distortion to get source coordinates from canvas coordinates
*/
inverseDistort(canvasX, canvasY) {
const rect = this.canvas.getBoundingClientRect();
// Normalize to 0-1
let u = canvasX / rect.width;
let v = canvasY / rect.height;
// Center around 0
let x = u * 2 - 1;
let y = v * 2 - 1;
// Iteratively solve inverse (Newton-Raphson)
for (let i = 0; i < 10; i++) {
const distSq = x * x + y * y;
const distortion = 1 + this.options.bulgeStrength * distSq;
// The forward transform is: distorted = original * distortion
// So inverse is approximately: original = distorted / distortion
x = (u * 2 - 1) / distortion;
y = (v * 2 - 1) / distortion;
}
// Convert back to pixel coordinates
const sourceX = ((x + 1) / 2) * rect.width;
const sourceY = ((y + 1) / 2) * rect.height;
return { x: sourceX, y: sourceY };
}
/**
* Set up event listeners for mouse/touch input
*/
setupEventListeners() {
const forwardEvent = (e, type) => {
const rect = this.canvas.getBoundingClientRect();
const canvasX = e.clientX - rect.left;
const canvasY = e.clientY - rect.top;
const sourceCoords = this.inverseDistort(canvasX, canvasY);
const sourceX = rect.left + sourceCoords.x;
const sourceY = rect.top + sourceCoords.y;
// Find element at transformed position in source
this.source.style.pointerEvents = 'auto';
this.source.style.opacity = '0.001'; // Nearly invisible but still there
const element = document.elementFromPoint(sourceX, sourceY);
this.source.style.pointerEvents = 'none';
this.source.style.opacity = '0';
if (element && this.source.contains(element)) {
const newEvent = new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: sourceX,
clientY: sourceY,
button: e.button,
buttons: e.buttons
});
element.dispatchEvent(newEvent);
// Update cursor
this.canvas.style.cursor = window.getComputedStyle(element).cursor;
}
};
// Mouse events
this.canvas.addEventListener('click', (e) => forwardEvent(e, 'click'));
this.canvas.addEventListener('mousedown', (e) => forwardEvent(e, 'mousedown'));
this.canvas.addEventListener('mouseup', (e) => forwardEvent(e, 'mouseup'));
this.canvas.addEventListener('mousemove', (e) => forwardEvent(e, 'mousemove'));
this.canvas.addEventListener('dblclick', (e) => forwardEvent(e, 'dblclick'));
this.canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
forwardEvent(e, 'contextmenu');
});
// Wheel events
this.canvas.addEventListener('wheel', (e) => {
const rect = this.canvas.getBoundingClientRect();
const canvasX = e.clientX - rect.left;
const canvasY = e.clientY - rect.top;
const sourceCoords = this.inverseDistort(canvasX, canvasY);
const sourceX = rect.left + sourceCoords.x;
const sourceY = rect.top + sourceCoords.y;
this.source.style.pointerEvents = 'auto';
const element = document.elementFromPoint(sourceX, sourceY);
this.source.style.pointerEvents = 'none';
if (element && this.source.contains(element)) {
element.dispatchEvent(new WheelEvent('wheel', {
bubbles: true,
clientX: sourceX,
clientY: sourceY,
deltaX: e.deltaX,
deltaY: e.deltaY,
deltaMode: e.deltaMode
}));
}
}, { passive: true });
// Keyboard events should go to focused elements naturally
// Focus management
this.canvas.addEventListener('mousedown', () => {
// Focus the terminal when clicking canvas
const terminal = this.source.querySelector('.xterm-helper-textarea');
if (terminal) {
terminal.focus();
}
});
}
/**
* Capture source element to texture using html2canvas
*/
async captureSource() {
const gl = this.gl;
// Use html2canvas to render the source element
try {
const canvas = await html2canvas(this.source, {
backgroundColor: null,
scale: window.devicePixelRatio || 1,
logging: false,
useCORS: true
});
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
} catch (err) {
console.error('Failed to capture source:', err);
}
}
/**
* Render frame
*/
render(time) {
const gl = this.gl;
gl.clear(gl.COLOR_BUFFER_BIT);
// Set uniforms
gl.uniform1i(this.uniforms.texture, 0);
gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height);
gl.uniform1f(this.uniforms.bulgeStrength, this.options.bulgeStrength);
gl.uniform1f(this.uniforms.scanlineIntensity, this.options.scanlineIntensity);
gl.uniform1f(this.uniforms.vignetteStrength, this.options.vignetteStrength);
gl.uniform1f(this.uniforms.time, time * 0.001);
// Draw
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
/**
* Start the render loop
*/
startRenderLoop() {
const frameInterval = 1000 / this.options.fps;
let lastCapture = 0;
const loop = async (time) => {
// Capture at reduced rate to save performance
if (time - lastCapture > frameInterval) {
await this.captureSource();
lastCapture = time;
}
this.render(time);
this.animationId = requestAnimationFrame(loop);
};
this.animationId = requestAnimationFrame(loop);
}
/**
* Stop rendering
*/
destroy() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
this.canvas.remove();
this.source.style.opacity = '1';
this.source.style.pointerEvents = 'auto';
}
}
// Export for use
window.CRTCanvasRenderer = CRTCanvasRenderer;

View File

@@ -1,55 +0,0 @@
(function() {
function updatePostRotations() {
const posts = document.querySelectorAll('.post');
posts.forEach(post => {
const height = post.offsetHeight;
// Calculate rotation: 3deg for 200px posts, 0.5deg for 500px+ posts
const minRotation = 0.5;
const maxRotation = 2.5;
const minHeight = 200;
const maxHeight = 600;
// Linear interpolation between min and max rotation based on height
let rotation = maxRotation - ((height - minHeight) / (maxHeight - minHeight)) * (maxRotation - minRotation);
// Clamp between min and max
rotation = Math.max(minRotation, Math.min(maxRotation, rotation));
// Apply alternating direction
const isOdd = Array.from(posts).indexOf(post) % 2 === 0;
const finalRotation = isOdd ? -rotation : rotation;
// Set CSS custom property
post.style.setProperty('--dynamic-rotation', `${finalRotation}deg`);
post.style.transform = `rotate(var(--dynamic-rotation, ${finalRotation}deg))`;
});
}
// Update rotations when DOM is ready
function init() {
// Wait for images to load to get accurate heights
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(updatePostRotations, 100);
});
} else {
setTimeout(updatePostRotations, 100);
}
// Update after window load for images
window.addEventListener('load', () => {
setTimeout(updatePostRotations, 200);
});
// Update on resize with debouncing
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updatePostRotations, 300);
});
}
init();
})();

60
website/index.html Normal file
View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Lit.ruv.wtf Terminal</title>
<meta name="description" content="Interactive terminal interface for Lit.ruv.wtf - A retro-styled command line experience with chat, documentation, and more.">
<!-- Favicons -->
<link rel="icon" type="image/png" sizes="16x16" href="/icons/16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/icons/48.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/180.png">
<!-- Open Graph / Social Media -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://lit.ruv.wtf/">
<meta property="og:title" content="Lit.ruv.wtf Terminal">
<meta property="og:description" content="Interactive terminal interface with chat, docs, and retro vibes">
<meta property="og:image" content="https://lit.ruv.wtf/banner.webp">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://lit.ruv.wtf/">
<meta name="twitter:title" content="Lit.ruv.wtf Terminal">
<meta name="twitter:description" content="Interactive terminal interface with chat, docs, and retro vibes">
<meta name="twitter:image" content="https://lit.ruv.wtf/banner.webp">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" />
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="crt-border-overlay"></div>
<div class="container">
<div class="terminal-header">
<div class="header-left">
<span class="terminal-title">LIT.RUV.WTF TERMINAL</span>
<span class="header-links">
<a href="/docs/" target="_blank" class="header-link">Docs</a>
<a href="https://github.com/litruv" target="_blank" class="header-link">GitHub</a>
<a href="https://bsky.app/profile/lit.mates.dev" target="_blank" class="header-link">Bluesky</a>
</span>
</div>
<div class="header-right">
<span class="system-time" id="systemTime"></span>
</div>
</div>
<div id="terminal"></div>
<div class="status-bar">
<span class="status-left">Ready</span>
<span class="status-right">Type 'help' for available commands</span>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
<script src="terminal.js"></script>
</body>
</html>

View File

@@ -1,674 +0,0 @@
function formatDate(t) {
return new Date(t).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
});
}
function getRandomRotation() {
return 3 * Math.random() - 1.5;
}
const postItColors = [
'#E6F3FF', // faded blue
'#FFF9C4', // yellow
'#E8F5E8', // green
'#FFE4B5', // orange
'#FFE1E6' // pink
];
function getRandomPostItColor() {
return postItColors[Math.floor(Math.random() * postItColors.length)];
}
function getPostItColorByDate(dateString) {
let hash = 0;
for (let i = 0; i < dateString.length; i++) {
hash = ((hash << 5) - hash) + dateString.charCodeAt(i);
hash = hash & hash;
}
const colorIndex = Math.abs(hash) % postItColors.length;
return postItColors[colorIndex];
}
let lastMediaVolume = 1;
// Define attachHoverListeners function
function attachHoverListeners(postElement) {
const baseRotation = postElement.style.getPropertyValue('--post-rotation');
postElement.addEventListener('mouseenter', function () {
postElement.style.zIndex = 100;
postElement.style.transition = "transform 0.22s cubic-bezier(.5,1.5,.5,1), box-shadow 0.22s cubic-bezier(.5,1.5,.5,1)";
postElement.style.transform = `rotate(${getRandomRotation()}deg) scale(1.1)`;
});
postElement.addEventListener('mouseleave', function () {
postElement.style.transition = "transform 0.18s cubic-bezier(.5,0,.5,1), box-shadow 0.18s cubic-bezier(.5,0,.5,1)";
postElement.style.transform = `rotate(${baseRotation || '0deg'}) scale(1)`;
postElement.style.zIndex = 1;
});
}
function createPost(t) {
const post = document.createElement("div");
post.className = "post";
post.style.transform = `rotate(${getRandomRotation()}deg)`;
post.style.backgroundColor = getPostItColorByDate(t.createdAt);
const link = document.createElement("a");
link.href = t.url;
link.target = "_blank";
link.style.textDecoration = "none";
let embedHtml = "";
if (t.embed && t.embed.length > 0) {
embedHtml = t.embed.map((embed, idx) => {
if (embed.type === "image") {
return `
<a href="${embed.url}" target="_blank">
<img class="image-placeholder" src="${embed.url}" alt="${embed.alt || "Image"}" data-fullres="${embed.fullres || embed.url}" />
</a>
`;
} else if (embed.type === "video") {
const isM3u8 = embed.playlist && embed.playlist.endsWith('.m3u8');
return `
<div class="video-embed-container">
<video
class="video-embed"
${isM3u8 ? `data-hls-src="${embed.playlist}"` : `src="${embed.playlist}"`}
poster="${embed.thumbnail || ""}"
controls
preload="metadata"
tabindex="0"
>
Sorry, your browser doesn't support embedded videos.
</video>
</div>
`;
}
return "";
}).join("");
}
function linkifyText(text, highlightColor) {
text = text.replace(/(^|[\s.,;:!?])#([a-zA-Z0-9_]{2,50})\b/g, (m, pre, tag) =>
`${pre}<span class="hashtag" style="--hashtag-bg:${highlightColor};">#${tag}</span>`
);
text = text.replace(/(^|[\s.,;:!?])@([a-zA-Z0-9_.-]+\.[a-zA-Z0-9_.-]+)/g, (m, pre, handle) =>
`${pre}<span class="mention"><b>@${handle}</b></span>`
);
return text;
}
function getHighlighterColor(cardColor) {
if (cardColor === '#E6F3FF') return '#ffb7ce';
if (cardColor === '#FFF9C4') return '#b7e0fd';
if (cardColor === '#E8F5E8') return '#fff89a';
if (cardColor === '#FFE4B5') return '#b6fcb6';
if (cardColor === '#FFE1E6') return '#ffd59e';
return '#fff89a';
}
const cardColor = getPostItColorByDate(t.createdAt);
const highlightColor = getHighlighterColor(cardColor);
const textHtml = linkifyText(t.text, highlightColor).replace(/\n/g, "<br>");
post.innerHTML = `
<div class="post-header">
<div class="avatar">
<img src="${t.avatar}" alt="${t.author}" />
</div>
<div>
<div class="post-author">${t.author} (@${t.handle})</div>
<div class="post-date">
${formatDate(t.createdAt)}
</div>
</div>
</div>
<div class="post-content">${textHtml}</div>
${embedHtml}
<div class="post-actions">
<button class="action-btn like">♥ ${t.likes}</button>
<button class="action-btn repost">⟲ ${t.reposts}</button>
<button class="action-btn comment">↳ ${t.comments}</button>
</div>
`;
setTimeout(() => {
const imgs = post.querySelectorAll('img');
imgs.forEach(img => {
img.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
createShadowbox(post, img);
});
});
post.querySelectorAll('.video-embed-container').forEach(container => {
const video = container.querySelector('video');
if (!video) return;
video.volume = lastMediaVolume;
video.addEventListener('mousedown', function (e) {
e.stopPropagation();
});
video.addEventListener('volumechange', function () {
if (!isNaN(video.volume)) {
lastMediaVolume = video.volume;
document.querySelectorAll('video.video-embed').forEach(v => {
if (v !== video && Math.abs(v.volume - lastMediaVolume) > 0.01) {
v.volume = lastMediaVolume;
}
});
}
});
video.addEventListener('play', function () {
document.querySelectorAll('video.video-embed').forEach(v => {
if (v !== video && !v.paused) {
v.pause();
}
});
});
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting && !video.paused) {
video.pause();
}
});
}, {
threshold: 0.25
});
observer.observe(video);
});
const hlsVideos = post.querySelectorAll('video[data-hls-src]');
if (hlsVideos.length > 0 && window.Hls) {
hlsVideos.forEach(video => {
const src = video.getAttribute('data-hls-src');
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src;
} else if (window.Hls.isSupported()) {
const hls = new window.Hls();
hls.loadSource(src);
hls.attachMedia(video);
} else {
video.outerHTML = '<div style="color:red;padding:1em;text-align:center;">Video format not supported</div>';
}
});
}
}, 0);
link.href = t.url;
link.appendChild(post);
return link;
}
function createShadowbox(postEl, imgEl) {
const existing = document.getElementById('shadowbox-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'shadowbox-modal';
modal.className = 'shadowbox-modal';
const postClone = postEl.cloneNode(true);
postClone.className += ' shadowbox-post';
postClone.style.background = postEl.style.backgroundColor || '#fff';
const tapeElements = postClone.querySelectorAll('.tape');
tapeElements.forEach(tape => tape.remove());
const imgs = postClone.querySelectorAll('img');
const originalImgs = postEl.querySelectorAll('img');
let focusedImg = null;
let clickedImageIndex = -1;
originalImgs.forEach((originalImg, idx) => {
if (originalImg === imgEl) {
clickedImageIndex = idx;
}
});
imgs.forEach((cloneImg, idx) => {
if (cloneImg.classList.contains('image-placeholder')) {
const fullSizeUrl = getFullSizeImageUrl(cloneImg.src);
cloneImg.src = fullSizeUrl;
cloneImg.className += ' shadowbox-image';
}
if (idx === clickedImageIndex && cloneImg.classList.contains('image-placeholder')) {
focusedImg = cloneImg;
}
});
const closeBtn = document.createElement('button');
closeBtn.textContent = '×';
closeBtn.className = 'shadowbox-close';
closeBtn.addEventListener('click', e => {
e.stopPropagation();
closeShadowbox();
});
postClone.appendChild(closeBtn);
postClone.addEventListener('click', e => e.stopPropagation());
modal.appendChild(postClone);
modal.addEventListener('click', closeShadowbox);
document.body.appendChild(modal);
setTimeout(() => {
modal.classList.add('visible');
postClone.classList.add('visible');
}, 10);
setTimeout(() => {
if (focusedImg) {
focusedImg.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 400);
function closeShadowbox() {
modal.classList.remove('visible');
postClone.classList.remove('visible');
setTimeout(() => {
modal.remove();
}, 350);
}
function escListener(e) {
if (e.key === 'Escape') {
closeShadowbox();
window.removeEventListener('keydown', escListener);
}
}
window.addEventListener('keydown', escListener);
let lastFocusedIdx = Array.from(imgs).findIndex(cloneImg => cloneImg === focusedImg);
let snapTimeout = null;
let isInitialScroll = true;
setTimeout(() => {
isInitialScroll = false;
}, 800);
postClone.addEventListener('scroll', () => {
if (isInitialScroll) return;
let modalRect = postClone.getBoundingClientRect();
let modalCenter = modalRect.top + modalRect.height / 2;
let candidates = [];
imgs.forEach((img, idx) => {
const rect = img.getBoundingClientRect();
const center = rect.top + rect.height / 2;
const dist = Math.abs(center - modalCenter);
candidates.push({ idx, dist, center, rect });
});
candidates.sort((a, b) => a.dist - b.dist);
let best = candidates[0];
if (
best.idx !== lastFocusedIdx &&
best.dist < best.rect.height * 0.75
) {
lastFocusedIdx = best.idx;
if (snapTimeout) clearTimeout(snapTimeout);
snapTimeout = setTimeout(() => {
imgs[lastFocusedIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 60);
}
});
}
function getFullSizeImageUrl(thumbUrl) {
if (!thumbUrl) return thumbUrl;
return thumbUrl.replace(/@jpeg$/, '').replace(/\?.*$/, '');
}
function filterOriginalPosts(feed) {
return feed.filter(item => {
const isRepost = item?.reason?.$type === "app.bsky.feed.defs#reasonRepost";
const isReply = item?.post?.record?.reply;
return !isRepost && !isReply;
});
}
let postElementsCache = null;
let layoutColumns = null;
let postQueue = [];
let isProcessingQueue = false;
let currentNumColumns = 0; // Variable to store the current number of columns
function createColumns(num) {
const postsContainer = document.getElementById("posts");
if (!postsContainer) return [];
postsContainer.innerHTML = "";
const columns = [];
for (let i = 0; i < num; i++) {
const col = document.createElement("div");
col.className = "masonry-col";
postsContainer.appendChild(col);
columns.push(col);
}
return columns;
}
function getNumColumns() {
if (window.innerWidth >= 1200) return 3;
if (window.innerWidth >= 800) return 2;
return 1;
}
function getShortestColumn(columns) {
let minHeight = Infinity;
let shortestColumns = [];
// Find all columns with the minimum height
for (const col of columns) {
const height = col.offsetHeight;
if (height < minHeight) {
minHeight = height;
shortestColumns = [col];
} else if (height === minHeight) {
shortestColumns.push(col);
}
}
// If multiple columns have the same height, prefer the middle one
if (shortestColumns.length > 1) {
const middleIndex = Math.floor(columns.length / 2);
const middleColumn = columns[middleIndex];
if (shortestColumns.includes(middleColumn)) {
return middleColumn;
}
// If middle column isn't shortest, return the one closest to middle
let closestToMiddle = shortestColumns[0];
let minDistance = Math.abs(columns.indexOf(closestToMiddle) - middleIndex);
for (const col of shortestColumns) {
const distance = Math.abs(columns.indexOf(col) - middleIndex);
if (distance < minDistance) {
minDistance = distance;
closestToMiddle = col;
}
}
return closestToMiddle;
}
return shortestColumns[0];
}
function addPostToLayout(postEl, zIndex) {
if (!layoutColumns) return;
const col = getShortestColumn(layoutColumns);
const postDiv = postEl.firstElementChild;
const currentTransform = postDiv.style.transform;
const rotationMatch = currentTransform.match(/rotate\(([-\d.]+deg)\)/);
const rotation = rotationMatch ? rotationMatch[1] : '0deg';
postDiv.style.setProperty('--post-rotation', rotation);
postDiv.style.zIndex = zIndex;
postDiv.classList.add('loading');
col.appendChild(postEl);
setTimeout(() => {
postDiv.classList.remove('loading');
attachHoverListeners(postDiv);
}, 600);
setTimeout(() => {
const imgs = postEl.querySelectorAll('img.image-placeholder');
if (imgs.length > 0 && window.addTapeToPost) {
window.addTapeToPost(postEl);
}
}, 50);
}
async function processPostQueue() {
if (isProcessingQueue || postQueue.length === 0) return;
isProcessingQueue = true;
while (postQueue.length > 0) {
const { postEl, zIndex } = postQueue.shift();
// Create promises for this post's media
const promises = [];
// Preload images
const imgs = postEl.querySelectorAll("img");
if (imgs.length) {
promises.push(...Array.from(imgs).map(img => {
if (img.complete) return Promise.resolve();
return new Promise(res => {
img.onload = img.onerror = res;
});
}));
}
// Preload video metadata
const videos = postEl.querySelectorAll("video");
if (videos.length) {
promises.push(...Array.from(videos).map(video => {
if (video.readyState >= 1) return Promise.resolve();
return new Promise(res => {
video.onloadedmetadata = video.onerror = res;
});
}));
}
// Wait for this post's media to load before adding to layout
if (promises.length > 0) {
await Promise.all(promises);
}
// Add post to layout
addPostToLayout(postEl, zIndex);
// Small delay between posts for visual effect
await new Promise(resolve => setTimeout(resolve, 100));
}
isProcessingQueue = false;
// Replace loading indicator with "view more" button when all posts are processed
hideLoadingIndicator();
showViewMoreButton();
}
function layoutPosts(postElements) {
if (document.fullscreenElement && document.fullscreenElement.tagName === "VIDEO") {
return;
}
const numCols = getNumColumns();
const columns = createColumns(numCols);
postElements.forEach((postEl, index) => {
const col = getShortestColumn(columns);
postEl.firstElementChild.style.zIndex = postElements.length - index;
col.appendChild(postEl);
});
setTimeout(() => {
if (window.addTapeToImages) {
window.addTapeToImages();
} else {
console.error('[Main] addTapeToImages function not available');
}
}, 200);
}
function showLoadingIndicator() {
const postsContainer = document.getElementById("posts");
if (!postsContainer) return;
const loadingDiv = document.createElement("div");
loadingDiv.id = "loading-posts";
loadingDiv.className = "loading-posts";
loadingDiv.innerHTML = `
<div class="loading-posts-content">
<div class="loading-spinner"></div>
Loading posts...
</div>
`;
postsContainer.appendChild(loadingDiv);
}
function hideLoadingIndicator() {
const loadingDiv = document.getElementById("loading-posts");
if (loadingDiv) {
loadingDiv.remove();
}
}
function showViewMoreButton() {
const postsContainer = document.getElementById("posts");
// Check if button already exists
if (document.getElementById("view-more-button")) {
return;
}
const viewMoreDiv = document.createElement("a");
viewMoreDiv.id = "view-more-button";
viewMoreDiv.href = "https://bsky.app/profile/lit.mates.dev";
viewMoreDiv.target = "_blank";
viewMoreDiv.className = "view-more-posts";
viewMoreDiv.innerHTML = `
<div class="view-more-posts-content">
<strong>View more on Bluesky →</strong><br>
<small>Follow @lit.mates.dev for more posts</small>
</div>
`;
// Insert after the posts container but before footer
postsContainer.insertAdjacentElement('afterend', viewMoreDiv);
}
async function fetchPosts() {
// Show loading indicator
showLoadingIndicator();
try {
const res = await fetch(
"https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=lit.mates.dev&limit=20&filter=posts_no_replies"
);
if (!res.ok) throw new Error("Failed to fetch posts");
const data = await res.json();
const posts = filterOriginalPosts(data.feed).map(item => {
let embeds = [];
const embedObj = item.post.embed;
if (embedObj) {
if (embedObj.$type === "app.bsky.embed.images#view" && Array.isArray(embedObj.images)) {
embeds = embedObj.images.map(img => ({
type: "image",
url: img.thumb,
alt: img.alt || "",
fullres: img.fullsize || img.full || img.thumb
}));
} else if (embedObj.$type === "app.bsky.embed.video#view") {
embeds = [{
type: "video",
playlist: embedObj.playlist,
thumbnail: embedObj.thumbnail,
alt: embedObj.alt || "",
aspectRatio: embedObj.aspectRatio || null
}];
}
}
return {
author: item.post.author.displayName,
handle: item.post.author.handle,
avatar: item.post.author.avatar,
createdAt: item.post.record.createdAt,
text: item.post.record.text,
likes: item.post.likeCount || 0,
reposts: item.post.repostCount || 0,
comments: item.post.replyCount || 0,
url: `https://bsky.app/profile/${item.post.author.handle}/post/${item.post.uri.split("/").pop()}`,
embed: embeds
};
});
// Initialize layout columns
const numCols = getNumColumns();
layoutColumns = createColumns(numCols);
currentNumColumns = numCols; // Initialize currentNumColumns
// Create all post elements
postElementsCache = posts.map(post => createPost(post));
// Add posts to queue in order
postQueue = postElementsCache.map((postEl, index) => ({
postEl,
zIndex: postElementsCache.length - index
}));
// Start processing the queue
processPostQueue();
} catch (err) {
console.error("Error fetching posts:", err);
// Hide loading indicator on error
hideLoadingIndicator();
// Show error message
const postsContainer = document.getElementById("posts");
if (postsContainer) {
const errorDiv = document.createElement("div");
errorDiv.className = "loading-posts";
errorDiv.innerHTML = `
<div class="loading-posts-content" style="color: #dc2626;">
⚠ Failed to load posts. Please refresh the page.
</div>
`;
postsContainer.appendChild(errorDiv);
}
}
}
window.addEventListener("resize", () => {
if (document.fullscreenElement && document.fullscreenElement.tagName === "VIDEO") {
return;
}
const newNumCols = getNumColumns();
if (postElementsCache && layoutColumns && newNumCols !== currentNumColumns) {
currentNumColumns = newNumCols; // Update the current number of columns
// Re-layout all existing posts
layoutColumns = createColumns(newNumCols);
postElementsCache.forEach((postEl, index) => {
if (postEl.parentNode) { // Only re-layout posts that are already added
const zIndex = postElementsCache.length - index;
const col = getShortestColumn(layoutColumns);
const postDiv = postEl.firstElementChild;
postDiv.style.zIndex = zIndex;
col.appendChild(postEl);
}
});
// Re-add tape after layout changes using the full refresh
setTimeout(() => {
if (window.refreshTape) {
window.refreshTape();
}
}, 100);
}
});
if (document.getElementById("posts")) {
fetchPosts();
}

View File

@@ -1,58 +0,0 @@
(function() {
let currentLayout = null;
let posts = [];
let columns = [];
let originalPositions = new Map();
function initMasonry() {
const postsContainer = document.getElementById('posts');
if (!postsContainer) return;
posts = Array.from(postsContainer.querySelectorAll('.post'));
columns = Array.from(postsContainer.querySelectorAll('.masonry-col'));
if (posts.length === 0 || columns.length === 0) return;
// Assign z-index based on post order (newer posts first)
posts.forEach((post, index) => {
post.style.zIndex = 100 - index;
});
// Store original positions of posts
posts.forEach((post, index) => {
const rect = post.getBoundingClientRect();
originalPositions.set(post, {
originalParent: post.parentNode,
originalIndex: index,
originalRect: rect
});
});
checkLayout();
}
function checkLayout() {
const isMobile = window.innerWidth <= 799;
const newLayout = isMobile ? 'mobile' : 'desktop';
if (newLayout !== currentLayout) {
currentLayout = newLayout;
// Don't redistribute - just let CSS media queries handle it
// The layout change is handled purely by CSS
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMasonry);
} else {
initMasonry();
}
// Listen for resize events but don't do anything that affects layout
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(checkLayout, 150);
});
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Number Match</title>
<link rel="stylesheet" href="numbermatchstyle.css">
</head>
<body>
<main class="app">
<section class="app__game" aria-label="Number Match board and controls">
<header class="app__header">
<h1 class="app__title">Number Match</h1>
<p class="app__subtitle">Clear the board by pairing numbers that match or add to ten.</p>
</header>
<div class="controls" role="group" aria-label="Game controls">
<button id="newGameButton" type="button" class="control-button control-button--secondary">New Game</button>
</div>
<section class="status" aria-live="polite">
<p id="message" class="status__message" role="status"></p>
<div class="status__metrics">
<span class="status__metric">
Matches cleared: <strong id="matchesCount">0</strong>
</span>
<span class="status__metric">
Tiles remaining: <strong id="tilesRemaining">0</strong>
</span>
<span class="status__metric">
Add Numbers used: <strong id="addNumbersUsed">0/3</strong>
</span>
<span class="status__metric">
Hints used: <strong id="hintsUsed">0</strong>
</span>
</div>
</section>
<section class="board" id="board" aria-label="Number grid"></section>
<div class="board-actions">
<button id="addNumbersButton" type="button" class="board-action board-action--primary" aria-label="Add numbers">
<span aria-hidden="true" class="board-action__icon">+</span>
<span id="addNumbersCounter" class="board-action__badge" aria-hidden="true">3</span>
<span class="sr-only">Add numbers</span>
</button>
<button id="hintButton" type="button" class="board-action board-action--secondary" aria-label="Hint">
<span aria-hidden="true">?</span>
<span class="sr-only">Hint</span>
</button>
</div>
</section>
<aside class="app__info" aria-labelledby="info-heading">
<h2 id="info-heading">How To Play</h2>
<p>Select two tiles that are either identical (for example, 8 and 8) or sum to ten (like 3 and 7). Clear every number to win.</p>
<h3>Match Rules</h3>
<ul>
<li>Paths can run horizontally, vertically, diagonally, or wrap in reading order.</li>
<li>Every cell between selections must be empty along the chosen path.</li>
<li>Wrap-around allows the end of a row to connect to the start of the next when the path is clear.</li>
</ul>
<h3>Strategy &amp; Tips</h3>
<ul>
<li>Scan rows and columns first for obvious pairs like 9-1, 8-2, 7-3, 6-4, and 5-5.</li>
<li>Use diagonal and wrap-around matches to reach blocked numbers.</li>
<li><em>Add Numbers</em> unlocks fresh tiles in up to three batches—save it for when the board runs dry.</li>
</ul>
<h3>Frequently Asked Questions</h3>
<p><strong>Which numbers appear?</strong> Numbers 1-9 are distributed to encourage plenty of identical and ten-sum pairs.</p>
<p><strong>What counts as wrap-around?</strong> Reading order connections across a row break, provided every intervening cell is empty.</p>
</aside>
</main>
<script src="numbermatch.js" defer></script>
</body>
</html>

View File

@@ -1,958 +0,0 @@
/**
* Shuffle an array in place using the Fisher-Yates algorithm.
* @template T
* @param {T[]} array Array to shuffle.
* @returns {T[]} The shuffled array.
*/
function shuffle(array) {
for (let i = array.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
const temp = array[i];
array[i] = array[j];
array[j] = temp;
}
return array;
}
/**
* Core game state manager for Number Match logic.
*/
class NumberMatchGame {
/**
* @param {{ width?: number, rows?: number }} [options] Configuration object.
*/
constructor(options = {}) {
const { width = 9, rows = 6 } = options;
this.width = width;
this.initialRows = rows;
const initialState = NumberMatchGame.generateInitialState(width, rows);
this.tiles = initialState.boardTiles;
this.reserveTiles = initialState.reserveTiles;
}
/**
* Regenerate the board with a fresh puzzle.
* @returns {void}
*/
reset() {
const initialState = NumberMatchGame.generateInitialState(this.width, this.initialRows);
this.tiles = initialState.boardTiles;
this.reserveTiles = initialState.reserveTiles;
}
/**
* Get a shallow copy of the current tiles.
* @returns {(number|null)[]} Current tile values.
*/
getTiles() {
return [...this.tiles];
}
/**
* Count the remaining non-null tiles.
* @returns {number} Number of tiles left on the board.
*/
getRemainingCount() {
const boardCount = this.tiles.filter((tile) => tile !== null).length;
return boardCount;
}
/**
* Determine whether the board has been cleared.
* @returns {boolean} True when all tiles have been removed.
*/
isComplete() {
return this.tiles.every((tile) => tile === null);
}
/**
* Attempt to match and remove two indices.
* @param {number} firstIndex Index of the first tile.
* @param {number} secondIndex Index of the second tile.
* @returns {boolean} True if the match succeeded.
*/
selectPair(firstIndex, secondIndex) {
if (!this.canPair(firstIndex, secondIndex)) {
return false;
}
this.tiles[firstIndex] = null;
this.tiles[secondIndex] = null;
this.compactEmptyRows();
return true;
}
/**
* Check whether the two indices can form a valid pair.
* @param {number} firstIndex Index of the first tile.
* @param {number} secondIndex Index of the second tile.
* @returns {boolean} True if the tiles satisfy match rules and path constraints.
*/
canPair(firstIndex, secondIndex) {
if (firstIndex === secondIndex) {
return false;
}
if (!NumberMatchGame.isValidIndex(firstIndex, this.tiles) || !NumberMatchGame.isValidIndex(secondIndex, this.tiles)) {
return false;
}
const valueA = this.tiles[firstIndex];
const valueB = this.tiles[secondIndex];
if (valueA === null || valueB === null) {
return false;
}
if (!(valueA === valueB || valueA + valueB === 10)) {
return false;
}
return this.hasClearPath(firstIndex, secondIndex);
}
/**
* Append remaining numbers to the end of the grid, respecting Add Numbers behavior.
* @returns {boolean} True when new numbers were appended.
*/
addNumbers(usesRemaining = 1) {
this.compactEmptyRows();
return this.appendRemainingTiles();
}
/**
* Collect active tiles in reading order.
* @returns {number[]} Non-null tile values.
*/
getActiveTiles() {
return this.tiles.filter((tile) => tile !== null);
}
/**
* Append remaining tiles in reading order after exhausting reserve uses.
* @returns {boolean} True when tiles were appended.
*/
appendRemainingTiles() {
const remaining = this.getActiveTiles();
if (remaining.length === 0) {
return false;
}
this.trimTrailingEmptySlots();
this.tiles.push(...remaining);
return true;
}
/**
* Remove trailing null placeholders from the board.
* @returns {void}
*/
trimTrailingEmptySlots() {
while (this.tiles.length > 0 && this.tiles[this.tiles.length - 1] === null) {
this.tiles.pop();
}
}
/**
* Verify that a straight path between two indices is unobstructed.
* @param {number} firstIndex One tile index.
* @param {number} secondIndex The other tile index.
* @returns {boolean} True if any allowed path between the indices is clear.
*/
hasClearPath(firstIndex, secondIndex) {
const start = Math.min(firstIndex, secondIndex);
const end = Math.max(firstIndex, secondIndex);
const rowStart = Math.floor(start / this.width);
const rowEnd = Math.floor(end / this.width);
const colStart = start % this.width;
const colEnd = end % this.width;
if (rowStart === rowEnd && this.isSegmentClear(start, end, 1)) {
return true;
}
const diff = end - start;
if (colStart === colEnd && diff % this.width === 0 && this.isSegmentClear(start, end, this.width)) {
return true;
}
const rowDelta = rowEnd - rowStart;
const colDelta = colEnd - colStart;
if (rowDelta === colDelta && rowDelta > 0) {
if (colStart + rowDelta < this.width && this.isSegmentClear(start, end, this.width + 1)) {
return true;
}
}
if (rowDelta === -colDelta && rowDelta > 0) {
if (colStart - rowDelta >= 0 && this.isSegmentClear(start, end, this.width - 1)) {
return true;
}
}
return this.isSegmentClear(start, end, 1);
}
/**
* Remove any fully empty rows from the board to keep the grid compact.
* @returns {void}
*/
compactEmptyRows() {
let index = 0;
while (index < this.tiles.length) {
const remaining = this.tiles.length - index;
const span = Math.min(this.width, remaining);
const slice = this.tiles.slice(index, index + span);
const isEmptyRow = slice.every((value) => value === null);
if (isEmptyRow) {
this.tiles.splice(index, span);
continue;
}
index += this.width;
}
}
/**
* Verify that the indices along a stepped path are empty.
* @param {number} start Starting index (inclusive).
* @param {number} end Ending index (inclusive).
* @param {number} step Increment applied each iteration.
* @returns {boolean} True if no non-null tiles exist between start and end.
*/
isSegmentClear(start, end, step) {
for (let current = start + step; current < end; current += step) {
if (this.tiles[current] !== null) {
return false;
}
}
return true;
}
/**
* Determine whether the provided index is valid for the current tile array.
* @param {number} index Index to evaluate.
* @param {(number|null)[]} tiles Tile array.
* @returns {boolean} True when the index is within bounds.
*/
static isValidIndex(index, tiles) {
return index >= 0 && index < tiles.length;
}
/**
* Create a randomized board configuration split between visible tiles and a reserve pile.
* @param {number} width Number of columns.
* @param {number} rows Number of initial rows.
* @returns {{ boardTiles: number[], reserveTiles: number[] }} Generated tile sets.
*/
static generateInitialState(width, rows) {
const boardCapacity = width * rows;
const maxBoardMatches = Math.min(6, Math.floor(boardCapacity / 2));
const forcedPairs = NumberMatchGame.getForcedPairs(maxBoardMatches);
const forcedAssignments = new Map();
const horizontalStarts = [];
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let colIndex = 0; colIndex < width - 1; colIndex += 1) {
horizontalStarts.push(rowIndex * width + colIndex);
}
}
const shuffledStarts = shuffle(horizontalStarts);
const reserved = new Set();
let startCursor = 0;
forcedPairs.forEach((pair) => {
let startIndex = null;
while (startCursor < shuffledStarts.length) {
const candidate = shuffledStarts[startCursor];
startCursor += 1;
if (reserved.has(candidate) || reserved.has(candidate + 1)) {
continue;
}
startIndex = candidate;
break;
}
if (startIndex === null) {
return;
}
forcedAssignments.set(startIndex, pair[0]);
forcedAssignments.set(startIndex + 1, pair[1]);
reserved.add(startIndex);
reserved.add(startIndex + 1);
});
const boardTiles = new Array(boardCapacity).fill(null);
for (let index = 0; index < boardCapacity; index += 1) {
const forcedValue = forcedAssignments.get(index);
if (typeof forcedValue === "number") {
boardTiles[index] = forcedValue;
continue;
}
const rawNextForced = forcedAssignments.get(index + 1);
const nextForcedValue = typeof rawNextForced === "number" ? rawNextForced : null;
boardTiles[index] = NumberMatchGame.pickSafeFillerValue(boardTiles, width, index, nextForcedValue);
}
const reserveTiles = NumberMatchGame.buildReserveTiles(boardTiles, boardCapacity);
return {
boardTiles,
reserveTiles
};
}
/**
* Produce a random pair of values that form a legal match.
* @returns {[number, number]} Two values that either match or sum to ten.
*/
static generatePair() {
const complementPairs = [
[1, 9],
[2, 8],
[3, 7],
[4, 6],
[5, 5]
];
const identicalValues = [1, 2, 3, 4, 5, 6, 7, 8, 9];
if (Math.random() < 0.5) {
const value = identicalValues[Math.floor(Math.random() * identicalValues.length)];
return [value, value];
}
const pair = complementPairs[Math.floor(Math.random() * complementPairs.length)];
return [...pair];
}
/**
* Create the predetermined matches that appear on the initial board.
* @param {number} target Total matches to prepare.
* @returns {Array<[number, number]>} Ordered list of forced pairs.
*/
static getForcedPairs(target) {
const essentialPairs = [[5, 5]];
const complementPool = shuffle([
[1, 9],
[2, 8],
[3, 7],
[4, 6],
[9, 1],
[8, 2],
[7, 3],
[6, 4]
]);
const pairs = [];
for (let i = 0; i < essentialPairs.length && pairs.length < target; i += 1) {
pairs.push(essentialPairs[i]);
}
let complementIndex = 0;
while (pairs.length < target && complementIndex < complementPool.length) {
pairs.push(complementPool[complementIndex]);
complementIndex += 1;
}
while (pairs.length < target) {
pairs.push(NumberMatchGame.generatePair());
}
return shuffle(pairs);
}
/**
* Pick a filler value that will not immediately create an accessible match.
* @param {Array<number|null>} tiles Current partially filled board.
* @param {number} width Board width.
* @param {number} index Index to populate.
* @param {number|null} nextForcedValue Upcoming forced value, if one exists.
* @returns {number} Safe filler digit.
*/
static pickSafeFillerValue(tiles, width, index, nextForcedValue) {
const attempt = (respectNext) => {
const forbidden = NumberMatchGame.collectForbiddenValues(tiles, width, index, respectNext ? nextForcedValue : null);
const candidates = [];
for (let value = 1; value <= 9; value += 1) {
if (!forbidden.has(value)) {
candidates.push(value);
}
}
return candidates;
};
let candidates = attempt(true);
if (candidates.length === 0) {
candidates = attempt(false);
}
if (candidates.length === 0) {
return Math.floor(Math.random() * 9) + 1;
}
return candidates[Math.floor(Math.random() * candidates.length)];
}
/**
* Collect forbidden values based on already placed neighbors and optional future constraints.
* @param {Array<number|null>} tiles Current board state.
* @param {number} width Board width.
* @param {number} index Target index.
* @param {number|null} nextForcedValue Forced value to appear next in sequence, if any.
* @returns {Set<number>} Values that should not be used at the index.
*/
static collectForbiddenValues(tiles, width, index, nextForcedValue) {
const forbidden = new Set();
const register = NumberMatchGame.registerForbiddenValue;
if (index > 0) {
register(forbidden, tiles[index - 1]);
}
const row = Math.floor(index / width);
const col = index % width;
if (row > 0) {
register(forbidden, tiles[index - width]);
if (col > 0) {
register(forbidden, tiles[index - width - 1]);
}
if (col < width - 1) {
register(forbidden, tiles[index - width + 1]);
}
}
if (typeof nextForcedValue === "number") {
register(forbidden, nextForcedValue);
}
return forbidden;
}
/**
* Register a value and its complement as forbidden for immediate placement.
* @param {Set<number>} forbidden Collection of disallowed values.
* @param {number|null|undefined} value Value to mark as forbidden.
* @returns {void}
*/
static registerForbiddenValue(forbidden, value) {
if (typeof value !== "number") {
return;
}
forbidden.add(value);
const complement = NumberMatchGame.getComplement(value);
forbidden.add(complement);
}
/**
* Compute the complement digit that forms a sum-to-ten relationship.
* @param {number} value Value between 1 and 9.
* @returns {number} Complement digit.
*/
static getComplement(value) {
if (value === 5) {
return 5;
}
const complement = 10 - value;
return Math.min(9, Math.max(1, complement));
}
/**
* Build the reserve pile so that complements exist for non-matching board values.
* @param {Array<number|null>} boardTiles Generated board.
* @param {number} boardCapacity Total board slots.
* @returns {number[]} Reserve tiles array.
*/
static buildReserveTiles(boardTiles, boardCapacity) {
const reserve = [];
boardTiles.forEach((value) => {
if (typeof value !== "number") {
return;
}
reserve.push(NumberMatchGame.getComplement(value));
});
while (reserve.length < Math.ceil(boardCapacity * 1.1)) {
const [first, second] = NumberMatchGame.generatePair();
reserve.push(first, second);
}
return shuffle(reserve);
}
}
/**
* UI controller responsible for rendering and interactions.
*/
class NumberMatchUI {
/**
* @param {NumberMatchGame} game Game state instance.
*/
constructor(game) {
this.game = game;
this.boardElement = /** @type {HTMLElement} */ (document.getElementById("board"));
this.messageElement = /** @type {HTMLElement} */ (document.getElementById("message"));
this.matchesElement = /** @type {HTMLElement} */ (document.getElementById("matchesCount"));
this.tilesRemainingElement = /** @type {HTMLElement} */ (document.getElementById("tilesRemaining"));
this.addNumbersElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersUsed"));
this.addNumbersBadgeElement = /** @type {HTMLElement} */ (document.getElementById("addNumbersCounter"));
this.hintsElement = /** @type {HTMLElement} */ (document.getElementById("hintsUsed"));
this.addNumbersButton = /** @type {HTMLButtonElement} */ (document.getElementById("addNumbersButton"));
this.hintButton = /** @type {HTMLButtonElement} */ (document.getElementById("hintButton"));
this.newGameButton = /** @type {HTMLButtonElement} */ (document.getElementById("newGameButton"));
this.newGameDialogBackdrop = /** @type {HTMLElement | null} */ (document.getElementById("newGameDialogBackdrop"));
this.newGameDialog = /** @type {HTMLElement | null} */ (document.getElementById("newGameDialog"));
this.newGameConfirmButton = /** @type {HTMLButtonElement | null} */ (document.getElementById("confirmNewGameButton"));
this.newGameCancelButton = /** @type {HTMLButtonElement | null} */ (document.getElementById("cancelNewGameButton"));
this.previouslyFocusedElement = null;
this.selectedIndices = [];
this.matchesCleared = 0;
this.addNumbersUsed = 0;
this.hintsUsed = 0;
this.highlightedIndices = [];
}
/**
* Initialize event listeners and render the starting board.
* @returns {void}
*/
init() {
this.bindControlEvents();
const restored = this.restoreState();
this.renderBoard();
this.updateMetrics();
this.hintButton.disabled = this.game.isComplete();
if (restored) {
this.showMessage("Welcome back! Your last board was restored.", "success");
} else {
this.showMessage("Select two tiles that match or sum to ten.");
}
this.persistState();
}
/**
* Attach event listeners to control buttons.
* @returns {void}
*/
bindControlEvents() {
this.addNumbersButton.addEventListener("click", () => {
const remainingUses = NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed;
const appended = this.game.addNumbers(remainingUses);
if (appended) {
if (remainingUses > 0) {
this.addNumbersUsed += 1;
}
this.showMessage("Numbers duplicated to the end of the grid.", "success");
this.selectedIndices = [];
this.clearHighlights();
this.renderBoard();
this.updateMetrics();
this.persistState();
} else {
this.showMessage("No numbers remain to duplicate.", "error");
this.updateAddNumbersPrompt();
}
});
this.hintButton.addEventListener("click", () => {
const hint = this.findHintPair();
if (!hint) {
this.showMessage("No matches available. Try adding numbers.", "error");
this.updateAddNumbersPrompt();
return;
}
this.selectedIndices = [];
this.highlightedIndices = hint;
this.hintsUsed += 1;
this.renderBoard();
this.updateMetrics();
this.showMessage("Try matching the highlighted tiles.");
this.persistState();
});
this.newGameButton.addEventListener("click", () => {
this.openNewGameDialog();
});
if (this.newGameDialogBackdrop && this.newGameConfirmButton && this.newGameCancelButton) {
this.newGameConfirmButton.addEventListener("click", () => {
this.closeNewGameDialog();
this.startNewGame();
});
this.newGameCancelButton.addEventListener("click", () => {
this.closeNewGameDialog();
});
this.newGameDialogBackdrop.addEventListener("mousedown", (event) => {
if (event.target === this.newGameDialogBackdrop) {
this.closeNewGameDialog();
}
});
this.newGameDialogBackdrop.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
this.closeNewGameDialog();
}
});
}
}
/**
* Handle tile clicks and manage selection/matching lifecycle.
* @param {number} index Tile index that was clicked.
* @returns {void}
*/
handleTileClick(index) {
const tiles = this.game.getTiles();
this.clearHighlights();
if (tiles[index] === null) {
return;
}
if (this.selectedIndices.includes(index)) {
this.selectedIndices = this.selectedIndices.filter((item) => item !== index);
this.renderBoard();
return;
}
this.selectedIndices.push(index);
if (this.selectedIndices.length < 2) {
this.renderBoard();
return;
}
this.renderBoard();
this.clearHighlights();
const [first, second] = this.selectedIndices;
const didMatch = this.game.selectPair(first, second);
if (didMatch) {
this.matchesCleared += 1;
this.selectedIndices = [];
this.renderBoard();
this.updateMetrics();
this.showMessage("Great match!", "success");
if (this.game.isComplete()) {
this.showMessage("Board cleared! Start a new game when you are ready.", "success");
this.addNumbersButton.disabled = true;
this.hintButton.disabled = true;
this.clearHighlights();
}
this.persistState();
return;
}
this.selectedIndices = [second];
this.renderBoard();
}
/**
* Render the board based on the current game state.
* @returns {void}
*/
renderBoard() {
const tiles = this.game.getTiles();
this.boardElement.innerHTML = "";
this.boardElement.style.gridTemplateColumns = `repeat(${this.game.width}, minmax(0, 1fr))`;
tiles.forEach((value, index) => {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.index = index.toString();
if (value === null) {
tile.classList.add("tile--empty");
tile.disabled = true;
tile.setAttribute("aria-hidden", "true");
} else {
tile.textContent = String(value);
tile.addEventListener("click", () => this.handleTileClick(index));
}
if (this.selectedIndices.includes(index)) {
tile.classList.add("tile--selected");
}
if (this.highlightedIndices.includes(index)) {
tile.classList.add("tile--hinted");
}
this.boardElement.appendChild(tile);
});
this.updateAddNumbersPrompt();
}
/**
* Update score-related UI metrics.
* @returns {void}
*/
updateMetrics() {
this.matchesElement.textContent = String(this.matchesCleared);
this.tilesRemainingElement.textContent = String(this.game.getRemainingCount());
this.addNumbersElement.textContent = `${this.addNumbersUsed}/${NumberMatchUI.ADD_NUMBERS_LIMIT}`;
this.hintsElement.textContent = String(this.hintsUsed);
this.updateAddNumbersBadge();
}
/**
* Reflect remaining Add Numbers uses in the badge.
* @returns {void}
*/
updateAddNumbersBadge() {
const remaining = Math.max(0, NumberMatchUI.ADD_NUMBERS_LIMIT - this.addNumbersUsed);
if (this.addNumbersBadgeElement) {
this.addNumbersBadgeElement.textContent = String(remaining);
this.addNumbersBadgeElement.classList.toggle("board-action__badge--empty", remaining === 0);
}
}
/**
* Clear any highlighted tile indices.
* @returns {void}
*/
clearHighlights() {
this.highlightedIndices = [];
}
/**
* Display a status message with optional styling.
* @param {string} message Message to display.
* @param {"success"|"error"|"info"} [type="info"] Message type for styling.
* @returns {void}
*/
showMessage(message, type = "info") {
this.messageElement.textContent = message;
this.messageElement.classList.remove("status__message--error", "status__message--success");
if (type === "success") {
this.messageElement.classList.add("status__message--success");
} else if (type === "error") {
this.messageElement.classList.add("status__message--error");
}
}
/**
* Reset state and begin a new puzzle.
* @returns {void}
*/
startNewGame() {
this.game.reset();
this.matchesCleared = 0;
this.addNumbersUsed = 0;
this.hintsUsed = 0;
this.selectedIndices = [];
this.addNumbersButton.disabled = false;
this.hintButton.disabled = false;
this.clearHighlights();
this.renderBoard();
this.updateMetrics();
this.showMessage("New puzzle loaded. Good luck!", "success");
this.hintButton.disabled = false;
this.persistState();
}
/**
* Display the custom new game confirmation dialog.
* @returns {void}
*/
openNewGameDialog() {
if (!this.newGameDialogBackdrop || !this.newGameDialog || !this.newGameConfirmButton) {
this.startNewGame();
return;
}
this.previouslyFocusedElement = document.activeElement;
this.newGameDialogBackdrop.hidden = false;
requestAnimationFrame(() => {
this.newGameDialogBackdrop.classList.add("is-visible");
this.newGameDialogBackdrop.setAttribute("aria-hidden", "false");
this.newGameDialog.focus({ preventScroll: true });
this.newGameConfirmButton.focus({ preventScroll: true });
});
}
/**
* Hide the custom new game dialog and restore focus.
* @returns {void}
*/
closeNewGameDialog() {
if (!this.newGameDialogBackdrop) {
return;
}
this.newGameDialogBackdrop.classList.remove("is-visible");
this.newGameDialogBackdrop.setAttribute("aria-hidden", "true");
setTimeout(() => {
if (this.newGameDialogBackdrop) {
this.newGameDialogBackdrop.hidden = true;
}
}, 180);
if (this.previouslyFocusedElement instanceof HTMLElement) {
this.previouslyFocusedElement.focus({ preventScroll: true });
}
}
/**
* Determine whether any valid matches remain on the board.
* @returns {boolean} True if at least one valid pair exists.
*/
hasAvailableMoves() {
return NumberMatchUI.findFirstValidPair(this.game) !== null;
}
/**
* Update Add Numbers control state, including glow cues and disabling logic.
* @returns {void}
*/
updateAddNumbersPrompt() {
const hasNumbersRemaining = this.game.getRemainingCount() > 0;
const activeAction = hasNumbersRemaining && !this.game.isComplete();
this.addNumbersButton.disabled = !activeAction;
const noMoves = !this.hasAvailableMoves();
const shouldGlow = noMoves && hasNumbersRemaining && activeAction;
this.addNumbersButton.classList.toggle("board-action--attention", shouldGlow);
this.updateAddNumbersBadge();
}
/**
* Persist the current board and score metrics to localStorage.
* @returns {void}
*/
persistState() {
try {
const storage = window.localStorage;
if (!storage) {
return;
}
const state = {
version: NumberMatchUI.STORAGE_VERSION,
width: this.game.width,
tiles: this.game.getTiles(),
reserveTiles: Array.isArray(this.game.reserveTiles) ? [...this.game.reserveTiles] : undefined,
matchesCleared: this.matchesCleared,
addNumbersUsed: this.addNumbersUsed,
hintsUsed: this.hintsUsed,
timestamp: Date.now()
};
storage.setItem(NumberMatchUI.STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.error("[NumberMatch] Failed to persist state", error);
}
}
/**
* Attempt to restore board and metrics from localStorage.
* @returns {boolean} True when a valid state was restored.
*/
restoreState() {
try {
const storage = window.localStorage;
if (!storage) {
return false;
}
const raw = storage.getItem(NumberMatchUI.STORAGE_KEY);
if (!raw) {
return false;
}
const parsed = JSON.parse(raw);
if (!parsed || parsed.version !== NumberMatchUI.STORAGE_VERSION) {
return false;
}
if (parsed.width !== this.game.width || !Array.isArray(parsed.tiles)) {
return false;
}
const tiles = parsed.tiles.map((value) => {
if (value === null) {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
});
if (tiles.length === 0 || tiles.every((value) => value === undefined)) {
return false;
}
this.game.tiles = tiles;
if (Array.isArray(parsed.reserveTiles)) {
this.game.reserveTiles = parsed.reserveTiles
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
}
const numericOrDefault = (value, fallback, clampMin = null, clampMax = null) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
let result = numeric;
if (clampMin !== null) {
result = Math.max(clampMin, result);
}
if (clampMax !== null) {
result = Math.min(clampMax, result);
}
return result;
};
this.matchesCleared = numericOrDefault(parsed.matchesCleared, 0, 0, Number.MAX_SAFE_INTEGER);
this.addNumbersUsed = numericOrDefault(parsed.addNumbersUsed, 0, 0, NumberMatchUI.ADD_NUMBERS_LIMIT);
this.hintsUsed = numericOrDefault(parsed.hintsUsed, 0, 0, Number.MAX_SAFE_INTEGER);
this.selectedIndices = [];
this.highlightedIndices = [];
return true;
} catch (error) {
console.error("[NumberMatch] Failed to restore state", error);
return false;
}
}
/**
* Find a valid pair of tiles for hint functionality.
NumberMatchUI.STORAGE_KEY = "numbermatch-state-v1";
NumberMatchUI.STORAGE_VERSION = 1;
* @returns {[number, number] | null} Tuple of indices or null when no match exists.
*/
findHintPair() {
return NumberMatchUI.findFirstValidPair(this.game);
}
/**
* Compute the first valid pair of tiles available.
* @param {NumberMatchGame} game Game state instance to inspect.
* @returns {[number, number] | null} Indices representing a valid pair or null.
*/
static findFirstValidPair(game) {
const tiles = game.getTiles();
for (let i = 0; i < tiles.length; i += 1) {
if (tiles[i] === null) {
continue;
}
for (let j = i + 1; j < tiles.length; j += 1) {
if (tiles[j] === null) {
continue;
}
if (game.canPair(i, j)) {
return [i, j];
}
}
}
return null;
}
}
NumberMatchUI.ADD_NUMBERS_LIMIT = 3;
window.addEventListener("DOMContentLoaded", () => {
const game = new NumberMatchGame({ width: 9, rows: 6 });
const ui = new NumberMatchUI(game);
ui.init();
});

View File

@@ -1,313 +0,0 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: stretch;
padding: 1.5rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.app {
display: grid;
gap: 2rem;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
width: min(1100px, 100%);
}
.app__game,
.app__info {
background-color: #1e293b;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
}
.app__title {
margin: 0;
font-size: 2rem;
font-weight: 700;
}
.app__subtitle {
margin: 0.25rem 0 0;
color: #94a3b8;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1.5rem 0;
}
.control-button {
background: linear-gradient(135deg, #22d3ee, #2563eb);
border: none;
border-radius: 999px;
color: #0f172a;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
padding: 0.75rem 1.5rem;
transition: transform 120ms ease, box-shadow 120ms ease;
}
.control-button:hover,
.control-button:focus {
transform: translateY(-1px);
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.45);
}
.control-button:active {
transform: translateY(0);
box-shadow: none;
}
.control-button--secondary {
background: #334155;
color: #e2e8f0;
}
.status {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.status__message {
min-height: 1.5rem;
margin: 0;
color: #f8fafc;
font-weight: 600;
}
.status__message.status__message--error {
color: #f87171;
}
.status__message.status__message--success {
color: #4ade80;
}
.status__metrics {
display: flex;
flex-wrap: wrap;
gap: 1rem;
color: #cbd5f5;
}
.status__metric {
font-size: 0.95rem;
}
.board {
margin-top: 1.5rem;
display: grid;
gap: 0.5rem;
width: 100%;
}
.board-actions {
margin-top: 1.25rem;
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
}
.board-action {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
border: none;
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 2rem;
font-weight: 700;
cursor: pointer;
color: #0f172a;
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
position: relative;
}
.board-action:hover,
.board-action:focus {
transform: translateY(-2px);
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.35);
}
.board-action:active {
transform: translateY(0);
box-shadow: none;
}
.board-action--primary {
background: linear-gradient(135deg, #22d3ee, #2563eb);
}
.board-action--secondary {
background: #e2e8f0;
color: #0f172a;
}
.board-action__icon {
line-height: 1;
}
.board-action__badge {
position: absolute;
top: -0.35rem;
right: -0.35rem;
min-width: 1.5rem;
padding: 0.15rem 0.4rem;
border-radius: 999px;
background: #0ea5e9;
color: #0f172a;
font-size: 0.85rem;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 14px rgba(14, 165, 233, 0.45);
}
.board-action__badge.board-action__badge--empty {
background: #cbd5f5;
color: #475569;
box-shadow: none;
}
.board-action--attention {
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.4), 0 18px 36px rgba(8, 145, 178, 0.45);
animation: board-action-pulse 0.9s ease-in-out infinite alternate;
}
@keyframes board-action-pulse {
from {
transform: translateY(-2px) scale(1);
}
to {
transform: translateY(-3px) scale(1.05);
}
}
.tile {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 0.75rem;
border: none;
font-size: clamp(1.125rem, 4vw, 1.5rem);
font-weight: 700;
color: #0f172a;
background: linear-gradient(145deg, #f1f5f9, #cbd5f5);
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
display: flex;
justify-content: center;
align-items: center;
}
.tile:hover,
.tile:focus {
transform: translateY(-2px);
box-shadow: 0 14px 25px rgba(15, 23, 42, 0.35);
}
.tile--selected {
background: linear-gradient(145deg, #fde68a, #f97316);
box-shadow: 0 16px 35px rgba(251, 191, 36, 0.4);
}
.tile--hinted:not(.tile--selected) {
background: linear-gradient(145deg, #bfdbfe, #93c5fd);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5), 0 12px 24px rgba(37, 99, 235, 0.35);
}
.tile--empty {
background: rgba(148, 163, 184, 0.25);
border: 1px dashed rgba(148, 163, 184, 0.45);
cursor: default;
box-shadow: none;
}
.tile--empty:hover,
.tile--empty:focus {
transform: none;
}
.app__info h2,
.app__info h3 {
margin-top: 0;
color: #f8fafc;
}
.app__info ul {
padding-left: 1.25rem;
margin: 0.75rem 0 1rem;
}
.app__info li {
margin-bottom: 0.5rem;
}
.app__info p {
color: #cbd5f5;
line-height: 1.5;
}
@media (max-width: 960px) {
body {
padding: 0;
}
.app {
grid-template-columns: minmax(0, 1fr);
gap: 1.25rem;
}
.app__info {
order: 1;
margin-top: 0;
}
.board {
gap: 0.35rem;
}
.board-actions {
margin-top: 1rem;
gap: 0.5rem;
}
.board-action {
width: 3.1rem;
height: 3.1rem;
font-size: 1.75rem;
}
.board-action__badge {
top: -0.25rem;
right: -0.25rem;
min-width: 1.35rem;
font-size: 0.75rem;
}
}

BIN
website/screenborder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

View File

@@ -5,32 +5,12 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://lit.ruv.wtf/</loc>
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
<lastmod>2026-03-04T00:00:00+00:00</lastmod>
<priority>1.00</priority>
</url>
<url>
<loc>https://lit.ruv.wtf/materials/</loc>
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://lit.ruv.wtf/numbermatch/</loc>
<lastmod>2025-11-19T00:00:00+00:00</lastmod>
<priority>0.70</priority>
</url>
<url>
<loc>https://lit.ruv.wtf/docs/sitemap.xml</loc>
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
<lastmod>2026-03-04T00:00:00+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://lit.ruv.wtf/privacy.html</loc>
<lastmod>2025-02-09T22:00:52+00:00</lastmod>
<priority>0.50</priority>
</url>
<url>
<loc>https://lit.ruv.wtf/mining/</loc>
<lastmod>2025-11-24T00:00:00+00:00</lastmod>
<priority>0.70</priority>
</url>
</urlset>

View File

@@ -1,730 +0,0 @@
:root {
--main-bg: #242424;
--main-fg: #f0f0f0;
--accent: #2563eb;
--like: #db2777;
--comment: #059669;
--github: #181717;
--youtube: #FF0000;
--steam: #1b2838;
--discord: #7289da;
--bluesky: #3E5BFF;
--post-bg: #fff;
--post-border: #000;
--avatar-bg: #f0f0f0;
--avatar-border: #000;
--shadow-light: rgba(0, 0, 0, 0.3);
--shadow-heavy: rgba(0, 0, 0, 1);
--shadow-mid: rgba(0, 0, 0, 0.05);
--header-shadow1: #ffffff52;
--header-shadow2: #000;
}
html {
box-sizing: border-box;
scroll-behavior: smooth;
}
*, *::before, *::after {
box-sizing: inherit;
}
body {
margin: 0;
min-height: 100vh;
background: var(--main-bg);
font-family: monospace;
background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0h20v20H0V0zm2 2v16h16V2H2z' fill='%23000000' fill-opacity='0.05'/%3E%3C/svg%3E");
overflow-x: hidden;
}
a {
text-decoration: none;
color: inherit;
}
.bg-pattern {
position: fixed;
inset: 0;
opacity: 0.05;
z-index: 0;
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%239C92AC' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
.container {
max-width: 1560px; /* increased by 30% from 1200px */
width: 100%;
margin: 0 auto;
padding: 2rem 0 2rem 0;
position: relative;
z-index: 1;
padding-top: 160px; /* increased from 100px */
box-sizing: border-box;
overflow: visible; /* ensure expanded content is visible */
}
header {
text-align: center;
margin-bottom: 80px;
color: var(--main-fg);
font-size: 20px;
padding-top: 2rem; /* add extra space above header */
overflow: visible;
}
h1 {
transition: all 0.3s ease;
text-shadow: 2px 2px 0 var(--header-shadow1), 3px 3px 0 var(--header-shadow2);
margin-bottom: 0.5rem;
transform-origin: center;
cursor: pointer;
position: relative;
display: inline-block;
}
.social-links {
margin-top: 1rem;
font-size: 1rem;
}
.social-links a {
margin: 0 10px;
color: var(--main-fg);
text-decoration: none;
font-weight: bold;
font-size: 2rem;
display: inline-block;
}
.social-links a:hover {
transform: scale(1.1);
transition: transform 0.3s ease;
text-shadow: 0 0 3px white;
}
.social-links a:hover.fa-github,
.social-links a:hover .fab.fa-github {
color: var(--github) !important;
}
.social-links a:hover.fa-youtube,
.social-links a:hover .fab.fa-youtube {
color: var(--youtube) !important;
}
.social-links a:hover.fa-steam,
.social-links a:hover .fab.fa-steam {
color: var(--steam) !important;
}
.social-links a:hover.fa-discord,
.social-links a:hover .fab.fa-discord {
color: var(--discord) !important;
}
.social-links a:hover.fa-bluesky,
.social-links a:hover .fa-brands.fa-bluesky {
color: var(--bluesky) !important;
}
#posts {
max-width: 1560px; /* increased by 30% from 1200px */
width: 100%;
margin-left: auto;
margin-right: auto;
margin-bottom: 2rem;
display: flex;
justify-content: center;
align-items: flex-start;
padding-left: 80px; /* Increase left padding to balance the negative margin */
padding-right: 40px;
box-sizing: border-box;
text-align: left;
min-height: 0; /* Prevent flex container from causing jumps */
/* Add stable height to prevent jumps */
contain: layout style;
}
.masonry-col {
flex: 1 1 0;
min-width: 320px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 0; /* Remove gap so negative margins overlap */
transition: none; /* Prevent transitions during layout changes */
will-change: auto; /* Prevent unnecessary layer promotion */
/* Prevent reflow during resize */
}
@media (max-width: 799px) {
#posts {
flex-direction: column;
gap: 0;
transition: none; /* Prevent transitions during layout changes */
/* Remove the min-height that was causing issues */
min-height: auto;
margin-top: 2rem; /* Add top margin to push posts below header */
margin-right: 0px;
margin-left:0px;
padding-right: 0; /* Remove right padding on mobile */
}
.masonry-col {
min-width: 0;
width: 100%;
/* Hide overflow columns on mobile */
}
.masonry-col:not(:first-child) {
display: none;
}
/* Ensure posts don't have excessive negative margins on mobile */
.post {
margin-top: 1rem;
margin-bottom: 1rem;
}
}
.post {
background: var(--post-bg);
/* Add post-it note lines: slightly darker than background, full width, spaced ~32px apart */
background-image:
repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 27px,
rgba(0,0,0,0.04) 27px,
rgba(0,0,0,0.04) 29px,
transparent 29px,
transparent 32px
);
background-size: 100% 32px;
background-repeat: repeat;
padding: 2rem 1.5rem;
margin-bottom: -0.2rem;
margin-top: -0.2rem;
margin-left: -40px;
margin-right: -40px;
border: 1.8px solid var(--post-border);
position: relative; /* Add back for z-index to work */
box-shadow: 3px 7px 4px var(--shadow-light);
transition: transform 0.35s cubic-bezier(0.68, -0.55, 0.27, 1.55),
box-shadow 0.5s cubic-bezier(0.68, -0.55, 0.27, 1.55),
opacity 0.2s ease;
display: inline-block;
width: 100%;
min-width: 320px;
break-inside: avoid;
z-index: 1;
/* Dynamic rotation based on height */
--post-height: 300px; /* Default fallback */
--min-rotation: 0.5deg; /* Minimum rotation for tall posts */
--max-rotation: 3deg; /* Maximum rotation for short posts */
--rotation-factor: clamp(var(--min-rotation), calc(var(--max-rotation) - (var(--post-height) - 200px) * 0.005), var(--max-rotation));
/* Apply rotation with random direction */
transform: rotate(var(--rotation-factor));
}
.post::before {
content: "";
position: absolute;
top: 5px;
left: 0;
width: 100%;
height: 55px; /* Keep existing height */
background-color: rgba(0, 0, 0, 0.05); /* Keep existing background color */
z-index: 0; /* Ensure it's behind the post content but above the post background lines */
border-radius: 25%; /* Add radius to top-left corner */
filter: blur(5px); /* Add a slight blur to soften the edges */
}
/* Remove these alternate rotation rules, JS now handles rotation and color */
/*
.post:nth-child(odd) {
transform: rotate(calc(-1 * var(--rotation-factor)));
}
.post:nth-child(even) {
transform: rotate(var(--rotation-factor));
}
*/
.post:hover {
/* Remove transform here, JS will handle */
box-shadow: 6px 6px 30px var(--shadow-heavy);
z-index: 100;
}
.post-header {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
margin: -2rem -1.5rem 1.25rem -1.5rem; /* stretch to post edge, extra space below */
padding: 0.75rem 1.5rem 0.75rem 1.5rem;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.avatar {
width: 48px;
height: 48px;
background: var(--avatar-bg);
border: 2px solid var(--avatar-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
transform: rotate(-2deg);
box-shadow: 2px 2px 0 var(--avatar-border);
}
.post-content {
text-shadow: 1px 1px 0 #fff;
}
/* Ensure image containers can hold positioned tape */
.image-placeholder,
.image-placeholder img {
position: relative;
}
/* Force relative positioning on image links (containers) */
a:has(.image-placeholder) {
position: relative !important;
display: block !important;
}
/* JavaScript-generated tape styling */
.tape {
position: absolute;
background: rgba(255, 248, 220, 0.9);
border: 0.5px solid rgba(0, 0, 0, 0.18);
z-index: 100;
pointer-events: none;
box-shadow: 1px 1px 3px rgba(0,0,0,0.3);
background-image: linear-gradient(45deg,
rgba(255, 255, 255, 0.3) 25%,
transparent 25%,
transparent 75%,
rgba(255, 255, 255, 0.3) 75%);
background-size: 3px 3px;
/* Zig-zag mask on top/bottom (long) sides, subtle and thin */
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'><polygon points='0,0 100,0 100,20 0,20' fill='white'/><polyline points='0,0 5,8 10,0 15,8 20,0 25,8 30,0 35,8 40,0 45,8 50,0 55,8 60,0 65,8 70,0 75,8 80,0 85,8 90,0 95,8 100,0' fill='none' stroke='white' stroke-width='1'/><polyline points='0,20 5,12 10,20 15,12 20,20 25,12 30,20 35,12 40,20 45,12 50,20 55,12 60,20 65,12 70,20 75,12 80,20 85,12 90,20 95,12 100,20' fill='none' stroke='white' stroke-width='1'/></svg>");
mask-image: url("data:image/svg+xml;utf8,<svg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'><polygon points='0,0 100,0 100,20 0,20' fill='white'/><polyline points='0,0 5,8 10,0 15,8 20,0 25,8 30,0 35,8 40,0 45,8 50,0 55,8 60,0 65,8 70,0 75,8 80,0 85,8 90,0 95,8 100,0' fill='none' stroke='white' stroke-width='1'/><polyline points='0,20 5,12 10,20 15,12 20,20 25,12 30,20 35,12 40,20 45,12 50,20 55,12 60,20 65,12 70,20 75,12 80,20 85,12 90,20 95,12 100,20' fill='none' stroke='white' stroke-width='1'/></svg>");
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
.post-actions {
margin-top: 1rem;
display: flex;
gap: 1rem;
}
.action-btn {
border: none;
background: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-family: inherit;
}
.action-btn.like {
color: var(--like);
transform: rotate(-1deg);
}
.action-btn.repost {
color: var(--accent);
transform: rotate(1deg);
}
.action-btn.comment {
color: var(--comment);
transform: rotate(-0.5deg);
}
.avatar-img {
width: 250px;
margin: 20px auto;
display: block;
}
.post:hover {
/* Remove transform here, JS will handle */
box-shadow: 6px 6px 30px var(--shadow-heavy);
z-index: 100;
}
.subtext {
margin-bottom: 30px;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px) translateY(5px);
}
50% {
transform: translateX(5px) translateY(-5px);
}
75% {
transform: translateX(-5px) translateY(-5px);
}
}
h1:hover {
animation:
wildSpin 3s ease-in-out,
rainbow 3s linear,
shake 0.5s ease-in-out infinite;
}
social-links a {
position: relative;
}
social-links a::after {
content: attr(title);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.3rem;
border-radius: 0.3rem;
font-size: 0.875rem;
opacity: 0;
transition: opacity 0.2s;
white-space: nowrap;
}
social-links a:hover::after {
opacity: 1;
}
.project-links {
margin-top: 2rem;
font-size: 1rem;
}
.project-links a {
display: inline-block;
margin: 0.5rem 0;
padding: 0.5rem 1rem;
background: var(--post-bg);
color: var(--main-bg);
text-decoration: none;
font-weight: bold;
border-radius: 20px;
transition: background-color 0.3s ease, color 0.3s ease;
}
.project-links a:hover {
background: var(--accent);
color: var(--post-bg);
}
footer {
text-align: center;
margin-top: 3rem;
margin-bottom: 2rem;
font-size: 0.9rem;
color: var(--main-fg);
}
footer a {
color: var(--main-fg);
text-decoration: underline;
}
footer a:hover {
color: var(--accent);
}
.hashtag {
color: #222;
/* Remove text rotation/skew, only background is "sloppy" */
background: none;
border-radius: 6px 12px 8px 10px / 10px 8px 12px 6px;
padding: 0 6px 2px 6px;
font-weight: 500;
cursor: pointer;
box-shadow:
0 2px 8px 0 rgba(255, 248, 154, 0.18),
0 1px 0 0 rgba(255, 248, 154, 0.25);
display: inline-block;
margin: 0 2px;
position: relative;
z-index: 1;
}
.hashtag::before {
content: "";
position: absolute;
z-index: -1;
left: -3px;
right: -3px;
top: 2px;
bottom: 2px;
/* Default fallback color, will be overridden inline by JS */
background: linear-gradient(
4deg,
var(--hashtag-bg, #fff89a) 0%,
var(--hashtag-bg, #fff89a) 100%
);
opacity: 0.7;
border-radius: 8px 14px 10px 12px / 12px 10px 14px 8px;
transform: rotate(-2deg) skewX(-2deg);
pointer-events: none;
}
.mention {
color: #388e3c;
background: #e8f5e9;
border-radius: 4px;
color: #388e3c;
background: #e8f5e9;
border-radius: 4px;
padding: 0 4px;
font-weight: 500; cursor: pointer;
}
/* Video center play button styling */
.video-center-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80px;
height: 80px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
pointer-events: auto;
}
.video-center-play::before {
content: "▶";
color: white;
font-size: 24px;
margin-left: 4px; /* Slight offset to center the triangle visually */
}
.video-center-play:hover {
background: rgba(0, 0, 0, 0.9);
transform: translate(-50%, -50%) scale(1.1);
}
.video-center-play:focus {
outline: 2px solid white;
outline-offset: 2px;
}
/* Video embed container and video styling */
.video-embed-container {
position: relative;
}
.video-embed {
width: 100%;
max-height: 60vh;
margin: 1.5rem 0 12px 0;
background: #000;
border-radius: 8px;
}
/* Shadowbox modal styling */
.shadowbox-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.0);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 9999;
cursor: zoom-out;
transition: background 0.35s cubic-bezier(.5,1.5,.5,1);
}
.shadowbox-modal.visible {
background: rgba(0,0,0,0.7);
}
.shadowbox-post {
transform: translateY(100vh) scale(0.98);
max-width: 75vw;
max-height: 90vh;
overflow: auto;
position: relative;
z-index: 10001;
cursor: default;
opacity: 0;
transition: opacity 0.25s cubic-bezier(.5,1.5,.5,1), transform 0.5s cubic-bezier(.5,1.5,.5,1);
}
.shadowbox-post.visible {
transform: translateY(0) scale(1) rotate(0deg);
opacity: 1;
}
.shadowbox-image {
max-width: 90vw !important;
max-height: 80vh !important;
width: auto !important;
object-fit: contain !important;
display: block !important;
margin: 24px auto !important;
opacity: 1 !important;
}
.shadowbox-close {
position: absolute;
top: 10px;
right: 16px;
font-size: 2rem;
background: rgba(0,0,0,0.2);
color: #fff;
border: none;
width: 40px;
height: 40px;
cursor: pointer;
z-index: 10002;
transition: background 0.2s;
}
.shadowbox-close:hover {
background: rgba(0,0,0,0.5);
}
/* Post header avatar image styling */
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Post metadata styling */
.post-author {
font-weight: bold;
}
.post-date {
font-size: 0.875rem;
color: #666;
}
/* Image placeholder styling */
.image-placeholder {
width: 100%;
margin-top: 1.5rem;
border: 4px solid var(--post-border);
background: var(--avatar-bg);
text-align: center;
transform: rotate(0deg);
box-shadow: 4px 4px 0 var(--post-border);
display: block;
}
/* Post animation for loading */
@keyframes slideUpFadeIn {
from {
opacity: 0;
transform: translateY(50px) rotate(var(--post-rotation, 0deg));
}
to {
opacity: 1;
transform: translateY(0) rotate(var(--post-rotation, 0deg));
}
}
.post.loading {
opacity: 0;
transform: translateY(50px) rotate(var(--post-rotation, 0deg));
animation: slideUpFadeIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
/* Loading indicator and view more button styles */
.loading-posts, .view-more-posts {
text-align: center;
margin: 2rem auto;
padding: 2rem;
background: var(--post-bg);
border: 2px solid var(--post-border);
box-shadow: 3px 3px 0 var(--shadow-light);
max-width: 400px;
transform: rotate(-1deg);
color: #666;
font-family: inherit;
position: relative;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.view-more-posts {
cursor: pointer;
text-decoration: none;
display: block;
clear: both;
margin-top: 3rem;
}
.view-more-posts:hover {
transform: rotate(-1deg) scale(1.05);
box-shadow: 6px 6px 0 var(--shadow-light);
color: var(--bluesky);
}
.loading-posts::before, .view-more-posts::before {
content: "";
position: absolute;
top: 5px;
left: 0;
width: 100%;
height: 40px;
background-color: rgba(0, 0, 0, 0.05);
z-index: 0;
border-radius: 25%;
filter: blur(3px);
}
.loading-posts-content, .view-more-posts-content {
position: relative;
z-index: 1;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
vertical-align: middle;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

283
website/styles.css Normal file
View File

@@ -0,0 +1,283 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', Courier, monospace;
background: #000;
color: #0f0;
overflow: hidden;
height: 100vh;
position: relative;
}
.crt-border-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 80px solid transparent;
border-image-source: url('screenborder.png');
border-image-slice: 200 95 fill;
border-image-width: 80px;
border-image-repeat: stretch;
pointer-events: none;
z-index: 9999;
}
.container {
position: absolute;
top: 80px;
left: 80px;
right: 80px;
bottom:100px;
background: transparent;
border: 3px solid #0f0;
border-radius: 8px;
box-shadow:
0 0 20px rgba(0, 255, 0, 0.3),
inset 0 0 50px rgba(0, 255, 0, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 1;
}
.terminal-header {
background: transparent;
border-bottom: 2px solid #0f0;
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-weight: bold;
text-shadow: 0 0 5px #0f0;
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.header-links {
display: flex;
gap: 12px;
font-size: 12px;
font-weight: normal;
}
.header-link {
color: #0f0;
text-decoration: none;
padding: 4px 8px;
border: 1px solid #0f0;
border-radius: 3px;
transition: all 0.2s;
text-shadow: 0 0 5px #0f0;
}
.header-link:hover {
background: #0f0;
color: #001800;
box-shadow: 0 0 10px #0f0;
}
.terminal-title {
letter-spacing: 2px;
}
.system-time {
font-size: 12px;
opacity: 0.8;
}
#terminal {
flex: 1;
padding: 5px 10px;
overflow: hidden;
}
.xterm {
height: 100%;
padding: 0;
}
.xterm-viewport {
background: transparent !important;
}
.xterm-screen {
cursor: text;
}
/* Inline terminal input */
.terminal-inline-input {
position: absolute;
background: transparent;
border: none;
outline: none;
color: #0f0;
font-family: 'Courier New', Courier, monospace;
font-size: inherit;
padding: 0;
margin: 0;
caret-color: #0f0;
text-shadow: 0 0 3px #0f0;
z-index: 10;
min-width: 50%;
max-width: 80%;
}
.terminal-inline-input::placeholder {
color: rgba(0, 255, 0, 0.3);
}
.status-bar {
background: transparent;
border-top: 2px solid #0f0;
padding: 6px 16px;
display: flex;
justify-content: space-between;
font-size: 12px;
text-shadow: 0 0 3px #0f0;
}
.status-left {
font-weight: bold;
}
.status-right {
opacity: 0.8;
}
/* CRT scanlines effect */
.container::before {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
z-index: 2;
background-size: 100% 2px, 3px 100%;
pointer-events: none;
}
/* Subtle screen flicker */
@keyframes flicker {
0% { opacity: 0.98; }
50% { opacity: 1; }
100% { opacity: 0.98; }
}
.container {
animation: flicker 1s infinite;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.crt-border-overlay {
border-width: 40px;
border-image-width: 40px;
}
.container {
top: 40px;
left: 40px;
right: 40px;
bottom: 50px;
border-radius: 4px;
}
.terminal-header {
padding: 6px 12px;
font-size: 12px;
}
.header-links {
gap: 8px;
font-size: 10px;
}
.header-link {
padding: 3px 6px;
}
.system-time {
font-size: 10px;
}
.status-bar {
padding: 4px 12px;
font-size: 10px;
}
}
@media (max-width: 480px) {
.crt-border-overlay {
border-width: 25px;
border-image-width: 25px;
}
.container {
top: 25px;
left: 25px;
right: 25px;
bottom: 35px;
}
.terminal-title {
letter-spacing: 1px;
font-size: 10px;
}
.header-left {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-links {
gap: 6px;
font-size: 9px;
}
.header-link {
padding: 2px 4px;
}
.status-right {
display: none;
}
/* Prevent iOS auto-zoom on input focus */
.terminal-inline-input {
font-size: 16px;
}
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #001800;
}
::-webkit-scrollbar-thumb {
background: #0f0;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #0c0;
}

View File

@@ -1,173 +0,0 @@
function addTapeToImages() {
const images = document.querySelectorAll('.image-placeholder');
if (images.length === 0) {
return;
}
images.forEach((image, index) => {
const existingTape = image.querySelectorAll('.tape');
if (existingTape.length > 0) {
return;
}
const imageParent = image.parentElement;
if (imageParent && window.getComputedStyle(imageParent).position === 'static') {
imageParent.style.position = 'relative';
}
const tape1 = createTapeElement(index, 1);
const tape2 = createTapeElement(index, 2);
const usePattern1 = index % 2 === 0;
if (usePattern1) {
positionTape(tape1, 'top-left');
positionTape(tape2, 'bottom-right');
} else {
positionTape(tape1, 'top-right');
positionTape(tape2, 'bottom-left');
}
imageParent.appendChild(tape1);
imageParent.appendChild(tape2);
});
}
function createTapeElement(imageIndex, tapeNumber) {
const tape = document.createElement('div');
tape.className = 'tape';
const width = 60 + Math.random() * 20;
const height = 40 + Math.random() * 16;
tape.style.cssText = `
position: absolute;
width: ${width}px;
height: ${height}px;
background: rgba(255, 248, 220, 0.93);
z-index: 100;
pointer-events: none;
box-shadow: 2px 2px 6px rgba(0,0,0,0.18);
background-image: linear-gradient(45deg,
rgba(255, 255, 255, 0.25) 25%,
transparent 25%,
transparent 75%,
rgba(255, 255, 255, 0.25) 75%);
background-size: 4px 4px;
`;
return tape;
}
function positionTape(tape, corner) {
const randomOffset = () => Math.random() * 8 - 4;
const randomRotation = (Math.random() * 20 - 10);
const OUT_X = 25;
const OUT_Y = 18;
switch (corner) {
case 'top-left':
tape.style.top = `${-OUT_Y + randomOffset()}px`;
tape.style.left = `${-OUT_X + randomOffset()}px`;
tape.style.transform = `rotate(${-45 + randomRotation}deg)`;
break;
case 'top-right':
tape.style.top = `${-OUT_Y + randomOffset()}px`;
tape.style.right = `${-OUT_X + randomOffset()}px`;
tape.style.transform = `rotate(${45 + randomRotation}deg)`;
break;
case 'bottom-left':
tape.style.bottom = `${-OUT_Y + randomOffset()}px`;
tape.style.left = `${-OUT_X + randomOffset()}px`;
tape.style.transform = `rotate(${45 + randomRotation}deg)`;
break;
case 'bottom-right':
tape.style.bottom = `${-OUT_Y + randomOffset()}px`;
tape.style.right = `${-OUT_X + randomOffset()}px`;
tape.style.transform = `rotate(${-45 + randomRotation}deg)`;
break;
}
}
function refreshTape() {
const existingTape = document.querySelectorAll('.tape');
existingTape.forEach(tape => tape.remove());
addTapeToImages();
}
function addTapeToPost(postElement) {
const images = postElement.querySelectorAll('.image-placeholder');
if (images.length === 0) {
return;
}
images.forEach((image, index) => {
const existingTape = image.parentElement.querySelectorAll('.tape');
if (existingTape.length > 0) {
return;
}
const imageParent = image.parentElement;
if (imageParent && window.getComputedStyle(imageParent).position === 'static') {
imageParent.style.position = 'relative';
}
const tape1 = createTapeElement(index, 1);
const tape2 = createTapeElement(index, 2);
const usePattern1 = index % 2 === 0;
if (usePattern1) {
positionTape(tape1, 'top-left');
positionTape(tape2, 'bottom-right');
} else {
positionTape(tape1, 'top-right');
positionTape(tape2, 'bottom-left');
}
imageParent.appendChild(tape1);
imageParent.appendChild(tape2);
});
}
function testSimpleTape() {
const firstImage = document.querySelector('.image-placeholder');
if (!firstImage) {
console.error('[Tape Test] No images found');
return;
}
const parent = firstImage.parentElement;
parent.style.position = 'relative';
const testTape = document.createElement('div');
testTape.className = 'tape';
testTape.style.cssText = `
position: absolute;
top: 10px;
left: 10px;
width: 60px;
height: 30px;
background: red;
border: 2px solid black;
z-index: 999;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
`;
testTape.textContent = 'TEST';
parent.appendChild(testTape);
setTimeout(() => {
testTape.remove();
}, 3000);
}
// Export functions
window.addTapeToImages = addTapeToImages;
window.addTapeToPost = addTapeToPost;
window.refreshTape = refreshTape;
window.testSimpleTape = testSimpleTape;

1138
website/terminal.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +0,0 @@
(function() {
let scrollPosition = 0;
let isResizing = false;
let resizeTimeout;
function preserveScroll() {
if (!isResizing) {
scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
isResizing = true;
// Temporarily disable smooth scrolling during resize
document.documentElement.style.scrollBehavior = 'auto';
}
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
// Force restore scroll position multiple times to combat layout changes
const restoreScroll = () => {
window.scrollTo(0, scrollPosition);
};
restoreScroll();
requestAnimationFrame(restoreScroll);
setTimeout(restoreScroll, 10);
setTimeout(restoreScroll, 50);
setTimeout(() => {
// Re-enable smooth scrolling
document.documentElement.style.scrollBehavior = 'smooth';
isResizing = false;
}, 100);
}, 100);
}
// Listen for resize events
window.addEventListener('resize', preserveScroll);
// Listen for orientation changes
window.addEventListener('orientationchange', () => {
preserveScroll();
// Additional delay for orientation changes
setTimeout(() => {
window.scrollTo(0, scrollPosition);
}, 500);
});
// Also intercept media query changes that affect layout
const mediaQuery = window.matchMedia('(max-width: 799px)');
mediaQuery.addListener(preserveScroll);
})();