mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-26 03:36:03 +10:00
Compare commits
4 Commits
361ea9ba90
...
134e917cac
| Author | SHA1 | Date | |
|---|---|---|---|
| 134e917cac | |||
| b79cbf0545 | |||
| 7f5ae16dea | |||
| 4be791f90e |
131
.gitignore
vendored
131
.gitignore
vendored
@@ -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/
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"liveServer.settings.port": 5502
|
||||
}
|
||||
123
README.md
123
README.md
@@ -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
188
build.js
@@ -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
53
chromakey.js
Normal 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
46
create-bulge-map.js
Normal 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
904
package-lock.json
generated
Normal 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
30
package.json
Normal 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"
|
||||
}
|
||||
@@ -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>
|
||||
349
src/mining.html
349
src/mining.html
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<footer>
|
||||
<a href="privacy.html">Privacy Policy</a>
|
||||
<p>© 2013-<span id="current-year"></span> Max Litruv Boonzaayer</p>
|
||||
<script>
|
||||
document.getElementById('current-year').textContent = new Date().getFullYear();
|
||||
</script>
|
||||
</footer>
|
||||
@@ -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>
|
||||
@@ -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
BIN
website/bulge-map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
238
website/crt-bulge.js
Normal file
238
website/crt-bulge.js
Normal 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
459
website/crt-canvas.js
Normal 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;
|
||||
@@ -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
60
website/index.html
Normal 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>
|
||||
674
website/main.js
674
website/main.js
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
2402
website/mining.js
2402
website/mining.js
File diff suppressed because it is too large
Load Diff
@@ -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 & 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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
BIN
website/screenborder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
@@ -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>
|
||||
|
||||
@@ -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
283
website/styles.css
Normal 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;
|
||||
}
|
||||
173
website/tape.js
173
website/tape.js
@@ -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
1138
website/terminal.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user