Redesign: Replace site with interactive terminal interface

- New xterm.js-based terminal with retro CRT aesthetics
- Matrix chat integration with real-time messaging
- Bluesky social media integration
- Interactive commands (help, about, chat, bluesky, privacy, etc.)
- Responsive design with mobile support
- Privacy policy command
- Quick navigation links (Docs, GitHub, Bluesky)
- Preserved all image assets (blocks, items, particles, icons)
- Updated README and package.json with new features
This commit is contained in:
2026-03-04 23:15:44 +11:00
parent 361ea9ba90
commit 4be791f90e
26 changed files with 1607 additions and 6847 deletions

131
.gitignore vendored
View File

@@ -1,119 +1,34 @@
# Logs # Node modules
logs node_modules/
*.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # OS files
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json .DS_Store
Thumbs.db
*.swp
*.swo
*~
# Runtime data # IDE
pids .vscode/
*.pid .idea/
*.seed *.sublime-project
*.pid.lock *.sublime-workspace
# Directory for instrumented libs generated by jscoverage/JSCover # Build outputs
lib-cov dist/
build/
# Coverage directory used by tools like istanbul # Environment variables
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
.env .env
.env.development.local
.env.test.local
.env.production.local
.env.local .env.local
# parcel-bundler cache files # Logs
.cache *.log
.parcel-cache logs/
# Next.js build output # Temporary files
.next *.tmp
out temp/
# 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/

View File

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

123
README.md
View File

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

176
build.js
View File

@@ -1,176 +0,0 @@
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
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);
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);
}
}
await copyRecursive(websiteDir, buildDir);
console.log("Website files copied successfully.");
} 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);
process.exit(1);
}
}
main();

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"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": {},
"devDependencies": {},
"repository": {
"type": "git",
"url": "https://github.com/litruv/lit.ruv.wtf"
},
"homepage": "https://lit.ruv.wtf"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

59
website/index.html Normal file
View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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="container">
<div class="terminal-header">
<div class="header-left">
<span class="terminal-title">LIT.RUV.WTF TERMINAL</span>
<span class="header-links">
<a href="/docs/" target="_blank" class="header-link">Docs</a>
<a href="https://github.com/litruv" target="_blank" class="header-link">GitHub</a>
<a href="https://bsky.app/profile/lit.mates.dev" target="_blank" class="header-link">Bluesky</a>
</span>
</div>
<div class="header-right">
<span class="system-time" id="systemTime"></span>
</div>
</div>
<div id="terminal"></div>
<div class="status-bar">
<span class="status-left">Ready</span>
<span class="status-right">Type 'help' for available commands</span>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
<script src="terminal.js"></script>
</body>
</html>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -5,32 +5,12 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url> <url>
<loc>https://lit.ruv.wtf/</loc> <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> <priority>1.00</priority>
</url> </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> <url>
<loc>https://lit.ruv.wtf/docs/sitemap.xml</loc> <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> <priority>0.80</priority>
</url> </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> </urlset>

View File

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

222
website/styles.css Normal file
View File

@@ -0,0 +1,222 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', Courier, monospace;
background: #000;
color: #0f0;
overflow: hidden;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 95vw;
max-width: 1400px;
height: 90vh;
background: #001800;
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;
}
.terminal-header {
background: #002200;
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;
}
.status-bar {
background: #002200;
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 screen 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) {
.container {
width: 98vw;
height: 95vh;
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) {
.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;
}
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #001800;
}
::-webkit-scrollbar-thumb {
background: #0f0;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #0c0;
}

View File

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

1152
website/terminal.js Normal file

File diff suppressed because it is too large Load Diff

View File

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