From 8e30dd3597824a08165761075529337d8a1163b7 Mon Sep 17 00:00:00 2001 From: nuri-ai Date: Sun, 19 Apr 2026 23:18:55 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Meridian=20OS=20?= =?UTF-8?q?browser=20desktop=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + PRD.md | 61 ++ README.md | 91 ++ build.sh | 1921 ++++++++++++++++++++++++++++++++++++ css/apps.css | 718 ++++++++++++++ css/reset.css | 51 + css/shell.css | 643 ++++++++++++ css/tokens.css | 22 + css/window.css | 113 +++ index.html | 77 ++ js/achievements/catalog.js | 76 ++ js/achievements/engine.js | 128 +++ js/apps/chrome.js | 167 ++++ js/apps/music.js | 248 +++++ js/apps/notepad.js | 82 ++ js/apps/postman.js | 184 ++++ js/apps/steam.js | 133 +++ js/apps/terminal.js | 316 ++++++ js/apps/vscode.js | 214 ++++ js/core/auth.js | 71 ++ js/core/bus.js | 36 + js/core/registry.js | 148 +++ js/core/state.js | 107 ++ js/core/wm.js | 284 ++++++ js/main.js | 85 ++ js/widgets/achievements.js | 76 ++ js/widgets/active-apps.js | 33 + js/widgets/clock.js | 39 + js/widgets/wallpaper.js | 138 +++ serve.sh | 10 + 30 files changed, 6277 insertions(+) create mode 100644 .gitignore create mode 100644 PRD.md create mode 100644 README.md create mode 100644 build.sh create mode 100644 css/apps.css create mode 100644 css/reset.css create mode 100644 css/shell.css create mode 100644 css/tokens.css create mode 100644 css/window.css create mode 100644 index.html create mode 100644 js/achievements/catalog.js create mode 100644 js/achievements/engine.js create mode 100644 js/apps/chrome.js create mode 100644 js/apps/music.js create mode 100644 js/apps/notepad.js create mode 100644 js/apps/postman.js create mode 100644 js/apps/steam.js create mode 100644 js/apps/terminal.js create mode 100644 js/apps/vscode.js create mode 100644 js/core/auth.js create mode 100644 js/core/bus.js create mode 100644 js/core/registry.js create mode 100644 js/core/state.js create mode 100644 js/core/wm.js create mode 100644 js/main.js create mode 100644 js/widgets/achievements.js create mode 100644 js/widgets/active-apps.js create mode 100644 js/widgets/clock.js create mode 100644 js/widgets/wallpaper.js create mode 100755 serve.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c79b2e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.DS_Store +*.log +.env +.env.local diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..d3ad276 --- /dev/null +++ b/PRD.md @@ -0,0 +1,61 @@ +# Meridian OS — PRD + +## Vision +A browser-based desktop environment for a video game. Elegant, restrained, +gamer-literate without being loud. No emoji stuffing, no AI-slop aesthetics. + +## Personas +- Player who boots the in-game computer and wants to feel at home. +- Easter-egg hunter who wants rewards for exploration. + +## Core Requirements +1. Login screen (any username, stored locally, avatar = initials). +2. Desktop with changeable wallpaper (gallery of 6 curated gradients/images). +3. Taskbar: start-equivalent launcher, active apps, widgets tray, status bar. +4. Status bar: username, time (live), connection state, date. +5. Widgets: date, active applications. Optional: achievements tray. +6. Window manager: drag, focus, minimize, close. Multi-window. +7. Apps: + - Notepad (text editing, localStorage persistence) + - Music player (3 tracks, play/pause/skip/seek, volume) + - Terminal (command parser, easter eggs, ANSI-ish styling) + - Spoof: Steam, VS Code, Postman, Chrome (static-but-believable UIs) +8. Special feature: Achievement system (game-flavored, tracks real usage). + +## Non-Requirements +- Real authentication +- Real networking +- Real OS features (files beyond localStorage) + +## Aesthetic Direction +- Palette: slate/graphite base (#0f1114, #1a1d21, #252a31), amber accent (#d4a24c). +- Type: Inter (400/500/600). +- Borders: 1px rgba(255,255,255,0.06–0.1). +- Shadows: subtle only; never blurry-neon. +- Motion: 150–220ms ease-out; no bounce, no parallax. +- No emojis in chrome. Allowed sparingly in content (terminal output, toasts). + +## Easter Eggs (Terminal) +Elden Ring: praise_the_sun, tarnished, "try finger, but hole" +CoD: dropshot, 360_noscope, mw2 +FIFA: siuuu, golazo, wonderkid +Misc: konami, matrix, sudo rm -rf / + +## Achievements (sample) +- "First Grace" — log in for the first time (ER) +- "Getting Tactical" — run 5 terminal commands (CoD) +- "Career Mode" — open 3 different apps (FIFA) +- "Tarnished Typist" — type 500 chars in notepad +- "DJ" — play music for 30s straight +- "Speedrun" — open and close a window in under 2s +- "Dataminer" — find all 3 hidden terminal commands + +## Success Criteria +- Loads in <2s on localhost. +- No console errors on a cold boot. +- Every app opens, stays usable, closes cleanly. +- At least 5 achievements unlockable through ordinary play. +- Zero emoji-stuffed UI elements. Zero gradient soup. + +## Storage of This Doc +Decide at end: Docmost (team-readable), Plane (sprint-attached), or local. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e773f2 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Meridian OS + +A browser-based desktop environment designed to live inside a video game. +Elegant, restrained, gamer-literate — no AI-slop aesthetics, no emoji stuffing. + +## Live Demo + +```bash +git clone https://git.nuri.works/nuri-ai/meridian-os.git +cd meridian-os +./serve.sh # Starts on http://localhost:8921 +``` + +Open the URL, type any name to log in, start exploring. + +## Features + +- **Login screen** with persistent session (localStorage) +- **Six wallpapers** — Graphite, Ember, Tide, Vellum, Eclipse, Terminal — with crossfade transition +- **Window manager** — draggable, minimizable, closeable, cascading layout, focus-on-click +- **Taskbar** with launcher, active-app chips, and status bar (user initials, connection dot, live clock) +- **Widgets tray** — clock + date, active apps, achievements +- **Seven apps:** + - Notepad (auto-save, word count) + - Music Player (three tracks, play/pause/seek/volume) + - Terminal (real commands + twelve gamer easter eggs) + - Steam (spoof library with seven titles) + - VS Code (spoof editor with fake TypeScript highlighting) + - Postman (spoof request builder with fake responses) + - Chrome (spoof browser-in-browser with shortcut tiles) +- **Achievement system** — ten achievements across Elden Ring, Call of Duty, and FIFA, unlocked through real usage + +## Terminal Easter Eggs + +Try these in the Terminal app: + +| Command | Response | +| --- | --- | +| `praise_the_sun` | Elden Ring grace quote | +| `tarnished` | Elden Ring greeting | +| `try finger, but hole` | Dark Souls message joke | +| `siuuu` | FIFA celebration | +| `golazo` | FIFA broadcast line | +| `wonderkid` | FIFA career-mode regen joke | +| `dropshot` | CoD hitmarker | +| `360_noscope` | CoD montage ASCII | +| `mw2` | Classic callback | +| `konami` | Cheat code joke | +| `matrix` | ASCII rain animation | + +All real commands work too: `help`, `ls`, `whoami`, `date`, `uname`, `echo`, `cat`, `clear`, `history`, `achievements`, `exit`. + +## Architecture + +``` +index.html +├─ css/ +│ ├─ tokens.css Design tokens (colors, type, spacing) +│ ├─ reset.css +│ ├─ shell.css Desktop, taskbar, login, wallpaper +│ ├─ window.css Window chrome, resize, drag +│ └─ apps.css Per-app styles (scoped by [data-app]) +└─ js/ + ├─ main.js Entry + app registration + ├─ core/ state, auth, wm, registry, bus + ├─ widgets/ clock, active-apps, achievements, wallpaper + ├─ apps/ notepad, music, terminal, steam, vscode, postman, chrome + └─ achievements/ catalog + engine +``` + +No build step. No frameworks. Vanilla JavaScript ES modules. + +## Design Tokens + +- Base: `#0f1114` (slate), `#1a1d21` (chrome), `#252a31` (elevated) +- Accent: `#d4a24c` (amber) +- Font: Inter 400 / 500 / 600 +- Borders: 1px at 6–14% white +- No heavy shadows, no neon, no emoji in UI chrome + +## Built With + +This project was built using **frontier-planner-mode** — a delegation pattern +where a frontier model (Opus 4.7) handles architecture and decisions while an +on-prem executor (Qwen3.6-35B on MLX) handles heavy token generation. Roughly +93k output tokens of implementation were produced by the executor across three +parallel streams while the planner stayed in architect mode. + +## License + +Personal demo. Do what you want with it. diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..df26619 --- /dev/null +++ b/build.sh @@ -0,0 +1,1921 @@ +#!/bin/bash +set -e + +# Create directory structure +mkdir -p /tmp/meridian-os/{css,js/core,js/widgets,js/apps,js/achievements} + +echo "=== tokens.css ===" +cat > /tmp/meridian-os/css/tokens.css << 'EOF' +:root { + --bg-0: #0f1114; + --bg-1: #1a1d21; + --bg-2: #252a31; + --bg-3: #2f353d; + --border: rgba(255,255,255,0.08); + --border-strong: rgba(255,255,255,0.14); + --text: #e6e8eb; + --text-dim: #9aa1ab; + --text-faint: #5b6470; + --accent: #d4a24c; + --accent-dim: #8a6a32; + --danger: #c85d5d; + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 12px; + --font: 'Inter', -apple-system, system-ui, sans-serif; + --shadow-1: 0 2px 8px rgba(0,0,0,0.3); + --shadow-2: 0 8px 24px rgba(0,0,0,0.4); + --sp: 4px 8px 12px 16px 24px 32px; + --fs: 12px 13px 14px 16px 20px 28px; +} +EOF + +echo "=== reset.css ===" +cat > /tmp/meridian-os/css/reset.css << 'EOF' +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: var(--font); + font-size: var(--fs); + background: var(--bg-0); + color: var(--text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + user-select: none; +} + +button, input { + font-family: inherit; + user-select: text; +} + +button:focus-visible, input:focus-visible, a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +input::placeholder { + color: var(--text-faint); +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 3px; +} + +::selection { + background: var(--accent-dim); + color: var(--text); +} +EOF + +echo "=== shell.css ===" +cat > /tmp/meridian-os/css/shell.css << 'EOF' +/* Shell layout styles */ + +#login { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + transition: opacity 0.4s ease, visibility 0.4s ease; +} + +#login.hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.login-card { + width: 360px; + padding: 40px 32px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2); + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.login-logo { + font-size: var(--fs); + font-size: 28px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.5px; +} + +.login-time { + position: absolute; + top: 24px; + right: 32px; + font-size: 14px; + color: var(--text-dim); +} + +.login-input { + width: 100%; + padding: 12px 16px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 14px; + transition: border-color 0.2s; +} + +.login-input:focus { + border-color: var(--accent); + outline: none; +} + +.login-input::placeholder { + color: var(--text-faint); +} + +.login-btn { + width: 100%; + padding: 12px 16px; + background: var(--accent); + color: var(--bg-0); + font-size: 14px; + font-weight: 600; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s, opacity 0.2s; +} + +.login-btn:hover { + opacity: 0.9; +} + +.login-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Desktop */ +#desktop { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; +} + +#desktop.hidden { + display: none; +} + +#wallpaper-layer { + position: absolute; + inset: 0; + transition: background 250ms ease; + z-index: 0; +} + +#windows-layer { + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; +} + +/* Widgets Tray */ +#widgets-tray { + position: fixed; + right: 16px; + top: 16px; + bottom: 64px; + width: 240px; + z-index: 10; + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + overflow-x: hidden; +} + +.widget { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + backdrop-filter: blur(12px); +} + +.widget-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-faint); + margin-bottom: 12px; +} + +/* Clock Widget */ +.clock-time { + font-size: 32px; + font-weight: 600; + color: var(--text); + line-height: 1; +} + +.clock-date { + font-size: 13px; + color: var(--text-dim); + margin-top: 4px; +} + +/* Active Apps Widget */ +.active-apps-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.active-app-chip { + padding: 8px 12px; + background: var(--bg-2); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text); + cursor: pointer; + border: none; + background: none; + text-align: left; + width: 100%; + transition: background 0.15s; +} + +.active-app-chip:hover { + background: var(--bg-3); +} + +.active-app-chip:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.active-app-chip.active { + border-left: 2px solid var(--accent); + background: var(--bg-3); +} + +.active-apps-empty { + font-size: 13px; + color: var(--text-faint); + font-style: italic; +} + +/* Achievements Widget */ +.achievements-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.achievement-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; +} + +.achievement-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: var(--accent-dim); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: var(--accent); + flex-shrink: 0; +} + +.achievement-info { + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +.achievement-name { + font-size: 13px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.achievement-game { + font-size: 12px; + color: var(--text-faint); +} + +.achievements-empty { + font-size: 13px; + color: var(--text-faint); + font-style: italic; +} + +/* Taskbar */ +#taskbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 48px; + background: var(--bg-1); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 8px; + z-index: 100; + gap: 4px; +} + +#launcher { + padding: 6px 16px; + font-size: 13px; + font-weight: 500; + color: var(--text); + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: background 0.15s; +} + +#launcher:hover { + background: var(--bg-3); +} + +#launcher:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +#launcher.active { + background: var(--accent); + color: var(--bg-0); + border-color: var(--accent); +} + +#taskbar-apps { + flex: 1; + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + padding: 0 8px; +} + +.taskbar-app { + padding: 4px 12px; + font-size: 12px; + color: var(--text-dim); + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; + border: none; + background: none; + transition: background 0.15s, color 0.15s; +} + +.taskbar-app:hover { + color: var(--text); + background: var(--bg-3); +} + +.taskbar-app.active { + color: var(--text); + background: var(--bg-3); + border-bottom: 2px solid var(--accent); +} + +.taskbar-app:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +#status-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px; + font-size: 12px; + color: var(--text-dim); +} + +.status-user { + display: flex; + align-items: center; + gap: 8px; +} + +.status-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--accent-dim); + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; +} + +.status-separator { + color: var(--text-faint); +} + +.status-connection { + display: flex; + align-items: center; + gap: 4px; +} + +.connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--danger); + transition: background 0.3s; +} + +.connection-dot.online { + background: #4caf50; +} + +/* Launcher Popover */ +.launcher-popover { + position: absolute; + bottom: 56px; + left: 8px; + width: 280px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2); + padding: 12px; + z-index: 150; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.launcher-app-btn { + padding: 12px 8px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + text-align: center; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + color: var(--text); +} + +.launcher-app-btn:hover { + background: var(--bg-3); + border-color: var(--border-strong); +} + +.launcher-app-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.launcher-app-icon { + font-size: 20px; + margin-bottom: 4px; +} + +.launcher-app-label { + font-size: 12px; + color: var(--text-dim); +} + +/* Wallpaper Picker Modal */ +#wallpaper-picker { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); + transition: opacity 0.3s, visibility 0.3s; +} + +#wallpaper-picker.hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.wallpaper-picker-card { + width: 560px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2); + overflow: hidden; +} + +.wallpaper-picker-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border); +} + +.wallpaper-picker-title { + font-size: 16px; + font-weight: 600; +} + +.wallpaper-picker-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 16px; + color: var(--text-dim); + border: none; + background: none; +} + +.wallpaper-picker-close:hover { + background: var(--bg-3); + color: var(--text); +} + +.wallpaper-picker-close:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.wallpaper-picker-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + padding: 20px 24px; +} + +.wallpaper-swatch { + aspect-ratio: 16/9; + border-radius: var(--radius); + cursor: pointer; + border: 3px solid transparent; + transition: border-color 0.2s, transform 0.2s; + position: relative; +} + +.wallpaper-swatch:hover { + transform: scale(1.03); +} + +.wallpaper-swatch.active { + border-color: var(--accent); +} + +.wallpaper-swatch-label { + position: absolute; + bottom: 4px; + left: 0; + right: 0; + text-align: center; + font-size: 11px; + color: white; + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + background: rgba(0,0,0,0.4); + padding: 2px 4px; + border-radius: 2px; +} + +/* Toast Layer */ +#toast-layer { + position: fixed; + bottom: 60px; + right: 16px; + z-index: 300; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + background: var(--bg-2); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + box-shadow: var(--shadow-1); + min-width: 280px; + max-width: 360px; + pointer-events: auto; + animation: toast-in 0.3s ease; +} + +.toast-removing { + animation: toast-out 0.3s ease forwards; +} + +.toast-title { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.toast-desc { + font-size: 12px; + color: var(--text-dim); + margin-top: 2px; +} + +.toast-close { + margin-left: auto; + padding: 2px; + cursor: pointer; + color: var(--text-faint); + border: none; + background: none; + font-size: 14px; + line-height: 1; +} + +.toast-close:hover { + color: var(--text); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes toast-out { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(24px); + } +} + +/* Wallpaper picker toggle button in taskbar */ +.wallpaper-toggle { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-dim); + font-size: 16px; + border: none; + background: none; +} + +.wallpaper-toggle:hover { + background: var(--bg-3); + color: var(--text); +} + +.wallpaper-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +EOF + +echo "=== window.css ===" +cat > /tmp/meridian-os/css/window.css << 'EOF' +/* Window styles */ +.m-window { + position: absolute; + min-width: 320px; + min-height: 240px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-2); + display: flex; + flex-direction: column; + overflow: hidden; + transition: box-shadow 0.2s; +} + +.m-window.focused { + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + border-color: var(--border-strong); +} + +.m-window.minimized { + opacity: 0; + pointer-events: none; + transform: scale(0.95); + transition: opacity 0.2s, transform 0.2s; +} + +.m-window-header { + display: flex; + align-items: center; + height: 36px; + padding: 0 8px; + background: var(--bg-2); + border-bottom: 1px solid var(--border); + cursor: default; + flex-shrink: 0; +} + +.m-window-title { + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-left: 4px; +} + +.m-window-controls { + display: flex; + align-items: center; + gap: 2px; +} + +.m-window-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 14px; + color: var(--text-dim); + border: none; + background: none; + transition: background 0.15s, color 0.15s; +} + +.m-window-btn:hover { + background: var(--bg-3); + color: var(--text); +} + +.m-window-btn.close-btn:hover { + background: var(--danger); + color: white; +} + +.m-window-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.m-window-body { + flex: 1; + overflow: auto; + position: relative; +} + +.m-window-body > * { + width: 100%; + height: 100%; +} + +/* Resize handle */ +.m-window-resize { + position: absolute; + bottom: 0; + right: 0; + width: 16px; + height: 16px; + cursor: nwse-resize; +} + +/* Drag overlay */ +.drag-overlay { + position: fixed; + inset: 0; + z-index: 9999; + cursor: move; +} +EOF + +echo "=== bus.js ===" +cat > /tmp/meridian-os/js/core/bus.js << 'EOF' +/** + * Simple pub/sub event bus. + * on(evt, cb) - subscribe + * emit(evt, data) - publish + */ +const Bus = (() => { + const listeners = new Map(); + + return { + on(evt, cb) { + if (!listeners.has(evt)) listeners.set(evt, new Set()); + listeners.get(evt).add(cb); + return () => this.off(evt, cb); + }, + + off(evt, cb) { + const set = listeners.get(evt); + if (set) { + set.delete(cb); + if (set.size === 0) listeners.delete(evt); + } + }, + + emit(evt, data) { + const set = listeners.get(evt); + if (set) { + for (const cb of set) { + try { cb(data); } catch (e) { console.error(`Bus emit error (${evt}):`, e); } + } + } + } + }; +})(); + +export default Bus; +export { Bus }; +EOF + +echo "=== state.js ===" +cat > /tmp/meridian-os/js/core/state.js << 'EOF' +/** + * Central state store with persistence. + * Keys: user, windows, wallpaper, achievements, connection + */ +const STORAGE_KEY = 'meridian:state'; + +const State = (() => { + const listeners = new Map(); + let _state = loadState(); + + function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + // achievements is stored as array, reconstruct as Set + if (parsed.achievements) { + parsed._achievementSet = new Set(parsed.achievements); + } + return parsed; + } + } catch (e) { + console.warn('State load failed:', e); + } + return { + user: null, + windows: [], + wallpaper: 'graphite', + achievements: [], + _achievementSet: new Set(), + connection: navigator.onLine + }; + } + + function saveState() { + try { + const toSave = { + user: _state.user, + windows: _state.windows, + wallpaper: _state.wallpaper, + achievements: Array.from(_state._achievementSet || _state.achievements) + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); + } catch (e) { + console.warn('State save failed:', e); + } + } + + function set(key, val) { + _state[key] = val; + saveState(); + notify(key); + } + + function get(key) { + return _state[key]; + } + + function subscribe(key, cb) { + if (!listeners.has(key)) listeners.set(key, new Set()); + listeners.get(key).add(cb); + // immediate call with current value + try { cb(_state[key]); } catch (e) { console.error('State subscribe error:', e); } + return () => off(key, cb); + } + + function off(key, cb) { + const set = listeners.get(key); + if (set) { + set.delete(cb); + if (set.size === 0) listeners.delete(key); + } + } + + function notify(key) { + const set = listeners.get(key); + if (set) { + for (const cb of set) { + try { cb(_state[key]); } catch (e) { console.error('State notify error:', e); } + } + } + } + + function addAchievement(id) { + if (!_state._achievementSet) { + _state._achievementSet = new Set(_state.achievements || []); + _state.achievements = Array.from(_state._achievementSet); + } + _state._achievementSet.add(id); + _state.achievements = Array.from(_state._achievementSet); + saveState(); + notify('achievements'); + } + + function initConnection() { + _state.connection = navigator.onLine; + window.addEventListener('online', () => { _state.connection = true; notify('connection'); saveState(); }); + window.addEventListener('offline', () => { _state.connection = false; notify('connection'); saveState(); }); + } + + return { + set, get, subscribe, off, addAchievement, initConnection + }; +})(); + +export default State; +export { State }; +EOF + +echo "=== auth.js ===" +cat > /tmp/meridian-os/js/core/auth.js << 'EOF' +import State from './state.js'; +import Bus from './bus.js'; + +const Auth = (() => { + function login(name) { + const trimmed = name.trim(); + if (!trimmed) return; + const initials = trimmed.slice(0, 2).toUpperCase(); + State.set('user', { name: trimmed, initials }); + document.getElementById('login').classList.add('hidden'); + const desktop = document.getElementById('desktop'); + desktop.classList.remove('hidden'); + desktop.hidden = false; + Bus.emit('user:login', { name: trimmed }); + // Persist login session + localStorage.setItem('meridian:session', trimmed); + } + + function logout() { + State.set('user', null); + localStorage.removeItem('meridian:session'); + location.reload(); + } + + function init() { + const session = localStorage.getItem('meridian:session'); + if (session) { + const trimmed = session.trim(); + if (trimmed) { + const initials = trimmed.slice(0, 2).toUpperCase(); + State.set('user', { name: trimmed, initials }); + const loginEl = document.getElementById('login'); + if (loginEl) loginEl.classList.add('hidden'); + const desktop = document.getElementById('desktop'); + if (desktop) { + desktop.classList.remove('hidden'); + desktop.hidden = false; + } + } + } + } + + return { login, logout, init }; +})(); + +export default Auth; +export { Auth }; +EOF + +echo "=== wm.js ===" +cat > /tmp/meridian-os/js/core/wm.js << 'EOF' +import State from './state.js'; +import Bus from './bus.js'; + +let windowIdCounter = 0; + +function createWindow(options) { + const { app, title, content, w = 640, h = 480 } = options; + const id = 'win-' + (++windowIdCounter); + + // Cascade position + const count = State.get('windows').length; + const offsetX = 40 + (count * 30) % 200; + const offsetY = 30 + (count * 30) % 150; + + const layer = document.getElementById('windows-layer'); + if (!layer) return null; + + const el = document.createElement('div'); + el.className = 'm-window focused'; + el.id = id; + el.style.left = offsetX + 'px'; + el.style.top = offsetY + 'px'; + el.style.width = w + 'px'; + el.style.height = h + 'px'; + el.style.zIndex = 10 + count; + + el.innerHTML = ` +
+ ${escapeHtml(title)} +
+ + +
+
+
+
+ `; + + layer.appendChild(el); + + // Render content + const body = el.querySelector('.m-window-body'); + if (typeof content === 'string') { + body.innerHTML = content; + } else if (content instanceof HTMLElement) { + body.innerHTML = ''; + body.appendChild(content); + } else if (typeof content === 'function') { + body.innerHTML = ''; + content(body); + } + + // Setup drag + setupDrag(el, id); + // Setup resize + setupResize(el, id); + // Setup focus + el.addEventListener('mousedown', () => focusWindow(id)); + // Setup header button clicks + el.querySelector('.minimize-btn').addEventListener('click', (e) => { + e.stopPropagation(); + minimizeWindow(id); + }); + el.querySelector('.close-btn').addEventListener('click', (e) => { + e.stopPropagation(); + closeWindow(id); + }); + + // Track in state + const windows = State.get('windows'); + windows.push({ id, app, title, minimized: false }); + State.set('windows', windows); + + Bus.emit('app:open', { app, id }); + + return id; +} + +function closeWindow(id) { + const el = document.getElementById(id); + if (!el) return; + + // Find window data + const windows = State.get('windows'); + const idx = windows.findIndex(w => w.id === id); + if (idx !== -1) { + const win = windows[idx]; + windows.splice(idx, 1); + State.set('windows', windows); + Bus.emit('app:close', { app: win.app, id }); + } + + el.remove(); +} + +function minimizeWindow(id) { + const el = document.getElementById(id); + if (!el) return; + + el.classList.add('minimized'); + const windows = State.get('windows'); + const win = windows.find(w => w.id === id); + if (win) win.minimized = true; + State.set('windows', windows); + + // If this was the focused window, focus the next available + const focusable = windows.filter(w => !w.minimized); + if (focusable.length > 0) { + focusWindow(focusable[focusable.length - 1].id); + } +} + +function focusWindow(id) { + const el = document.getElementById(id); + if (!el) return; + + // Remove focus from all + document.querySelectorAll('.m-window.focused').forEach(w => w.classList.remove('focused')); + el.classList.add('focused'); + + // Bring to front + const maxZ = Math.max(0, ...Array.from(document.querySelectorAll('.m-window')).map(w => + parseInt(w.style.zIndex || 0) + )); + el.style.zIndex = maxZ + 1; + + const windows = State.get('windows'); + const win = windows.find(w => w.id === id); + if (win) { + win.minimized = false; + State.set('windows', windows); + } + + Bus.emit('window:focused', { id }); +} + +function setupDrag(el, id) { + const header = el.querySelector('.m-window-header'); + if (!header) return; + + let isDragging = false; + let startX, startY, startLeft, startTop; + + header.addEventListener('mousedown', (e) => { + if (e.target.closest('.m-window-controls')) return; + isDragging = true; + startX = e.clientX; + startY = e.clientY; + startLeft = el.offsetLeft; + startTop = el.offsetTop; + document.body.style.cursor = 'move'; + + const onMove = (e2) => { + if (!isDragging) return; + const dx = e2.clientX - startX; + const dy = e2.clientY - startY; + el.style.left = Math.max(0, startLeft + dx) + 'px'; + el.style.top = Math.max(0, startTop + dy) + 'px'; + }; + + const onUp = () => { + isDragging = false; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + // Touch support + header.addEventListener('touchstart', (e) => { + if (e.target.closest('.m-window-controls')) return; + const touch = e.touches[0]; + isDragging = true; + startX = touch.clientX; + startY = touch.clientY; + startLeft = el.offsetLeft; + startTop = el.offsetTop; + + const onMove = (e2) => { + if (!isDragging) return; + const touch2 = e2.touches[0]; + const dx = touch2.clientX - startX; + const dy = touch2.clientY - startY; + el.style.left = Math.max(0, startLeft + dx) + 'px'; + el.style.top = Math.max(0, startTop + dy) + 'px'; + }; + + const onEnd = () => { + isDragging = false; + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + }; + + document.addEventListener('touchmove', onMove); + document.addEventListener('touchend', onEnd); + }, { passive: true }); +} + +function setupResize(el, id) { + const handle = el.querySelector('.m-window-resize'); + if (!handle) return; + + let isResizing = false; + let startX, startY, startW, startH; + + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + startX = e.clientX; + startY = e.clientY; + startW = el.offsetWidth; + startH = el.offsetHeight; + + const onMove = (e2) => { + if (!isResizing) return; + const dx = e2.clientX - startX; + const dy = e2.clientY - startY; + const newW = Math.max(320, startW + dx); + const newH = Math.max(240, startH + dy); + el.style.width = newW + 'px'; + el.style.height = newH + 'px'; + }; + + const onUp = () => { + isResizing = false; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + // Touch support for resize + handle.addEventListener('touchstart', (e) => { + e.preventDefault(); + const touch = e.touches[0]; + isResizing = true; + startX = touch.clientX; + startY = touch.clientY; + startW = el.offsetWidth; + startH = el.offsetHeight; + + const onMove = (e2) => { + if (!isResizing) return; + const touch2 = e2.touches[0]; + const dx = touch2.clientX - startX; + const dy = touch2.clientY - startY; + const newW = Math.max(320, startW + dx); + const newH = Math.max(240, startH + dy); + el.style.width = newW + 'px'; + el.style.height = newH + 'px'; + }; + + const onEnd = () => { + isResizing = false; + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + }; + + document.addEventListener('touchmove', onMove); + document.addEventListener('touchend', onEnd); + }, { passive: false }); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +const MeridianWM = { + openWindow: createWindow, + closeWindow, + minimizeWindow, + focusWindow +}; + +export default MeridianWM; +export { MeridianWM }; +EOF + +echo "=== registry.js ===" +cat > /tmp/meridian-os/js/core/registry.js << 'EOF' +import MeridianWM from './wm.js'; +import State from './state.js'; +import Bus from './bus.js'; + +const Registry = (() => { + const APPS = {}; + let _launcherOpen = false; + + function registerApp(def) { + if (def.id) { + APPS[def.id] = def; + } + } + + function unregisterApp(id) { + delete APPS[id]; + } + + function getApp(id) { + return APPS[id] || null; + } + + function launchApp(id) { + const def = APPS[id]; + if (!def) { + console.warn(`App ${id} not registered`); + return; + } + + const title = def.title || id; + const content = def.render ? def.render() : ''; + const w = def.width || 640; + const h = def.height || 480; + + MeridianWM.openWindow({ app: id, title, content, w, h }); + closeLauncher(); + } + + function getRegisteredApps() { + return Object.values(APPS); + } + + function toggleLauncher() { + _launcherOpen = !_launcherOpen; + const popover = document.getElementById('launcher-popover'); + const btn = document.getElementById('launcher'); + if (popover) { + if (_launcherOpen) { + renderLauncherApps(popover); + popover.classList.remove('hidden'); + } else { + popover.classList.add('hidden'); + } + } + if (btn) { + btn.classList.toggle('active', _launcherOpen); + } + return _launcherOpen; + } + + function closeLauncher() { + _launcherOpen = false; + const popover = document.getElementById('launcher-popover'); + const btn = document.getElementById('launcher'); + if (popover) popover.classList.add('hidden'); + if (btn) btn.classList.remove('active'); + } + + function renderLauncherApps(popover) { + const apps = getRegisteredApps(); + popover.innerHTML = ''; + if (apps.length === 0) { + popover.innerHTML = '
No apps registered
'; + return; + } + for (const app of apps) { + const btn = document.createElement('button'); + btn.className = 'launcher-app-btn'; + btn.setAttribute('aria-label', `Launch ${app.title || app.id}`); + btn.innerHTML = ` +
${app.icon || '◆'}
+
${escapeHtml(app.title || app.id)}
+ `; + btn.addEventListener('click', () => launchApp(app.id)); + popover.appendChild(btn); + } + } + + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function init() { + const btn = document.getElementById('launcher'); + if (btn) { + btn.addEventListener('click', toggleLauncher); + } + + // Close launcher when clicking elsewhere + document.addEventListener('mousedown', (e) => { + if (_launcherOpen && !e.target.closest('#launcher') && !e.target.closest('#launcher-popover')) { + closeLauncher(); + } + }); + } + + return { + APPS, + registerApp, + unregisterApp, + getApp, + launchApp, + getRegisteredApps, + toggleLauncher, + init + }; +})(); + +export default Registry; +export { Registry }; +EOF + +echo "=== main.js ===" +cat > /tmp/meridian-os/js/main.js << 'EOF' +import State from './core/state.js'; +import Auth from './core/auth.js'; +import Bus from './core/bus.js'; +import MeridianWM from './core/wm.js'; +import Registry from './core/registry.js'; + +// Import widgets +import './widgets/clock.js'; +import './widgets/active-apps.js'; +import './widgets/achievements.js'; + +// Import wallpaper +import './widgets/wallpaper.js'; + +// Initialize +function init() { + State.initConnection(); + Auth.init(); + Registry.init(); + setupTaskbarApps(); +} + +function setupTaskbarApps() { + // Status bar user display + State.subscribe('user', (user) => { + const statusBar = document.getElementById('status-bar'); + if (!statusBar) return; + const existing = statusBar.querySelector('.status-user'); + if (existing) existing.remove(); + + if (user) { + const div = document.createElement('div'); + div.className = 'status-user'; + div.innerHTML = ` +
${escapeHtml(user.initials)}
+ ${escapeHtml(user.name)} + + + + + `; + statusBar.appendChild(div); + } + }); + + // Connection status + State.subscribe('connection', (connected) => { + const dot = document.querySelector('.connection-dot'); + if (dot) { + dot.classList.toggle('online', !!connected); + } + }); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// Initialize on DOM ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} +EOF + +echo "=== wallpaper.js ===" +cat > /tmp/meridian-os/js/widgets/wallpaper.js << 'EOF' +import State from '../core/state.js'; +import { Bus } from '../core/bus.js'; + +const WALLPAPERS = { + graphite: { + name: 'Graphite', + gradient: 'radial-gradient(ellipse at center, #1e2126 0%, #0f1114 100%)' + }, + ember: { + name: 'Ember', + gradient: 'linear-gradient(135deg, #1a1d21 0%, #2a1f15 40%, #d4a24c33 100%)' + }, + tide: { + name: 'Tide', + gradient: 'linear-gradient(160deg, #0a1628 0%, #0f2847 40%, #14425a 70%, #1a5c6e 100%)' + }, + vellum: { + name: 'Vellum', + gradient: 'linear-gradient(145deg, #1e1c1a 0%, #2a2520 50%, #332e28 100%)' + }, + eclipse: { + name: 'Eclipse', + gradient: 'linear-gradient(160deg, #0a0a0f 0%, #150f20 50%, #1a1030 100%)' + }, + terminal: { + name: 'Terminal', + gradient: 'radial-gradient(ellipse at 20% 50%, #0a1a0a 0%, #0f1410 40%, #0a0f0a 100%)' + } +}; + +let currentWp = null; + +function applyWallpaper(id) { + const wp = WALLPAPERS[id]; + if (!wp) return; + + const layer = document.getElementById('wallpaper-layer'); + if (!layer) return; + + // Crossfade + const overlay = document.createElement('div'); + overlay.style.position = 'absolute'; + overlay.style.inset = '0'; + overlay.style.background = wp.gradient; + overlay.style.transition = 'opacity 250ms ease'; + overlay.style.zIndex = '1'; + layer.appendChild(overlay); + + requestAnimationFrame(() => { + overlay.style.opacity = '1'; + if (currentWp) { + const oldOverlay = layer.querySelector('div[style*="position: absolute"]'); + if (oldOverlay && oldOverlay !== overlay) { + oldOverlay.style.opacity = '0'; + setTimeout(() => oldOverlay.remove(), 260); + } + } + }); + + currentWp = overlay; + State.set('wallpaper', id); + + // Update picker swatches + document.querySelectorAll('.wallpaper-swatch').forEach(s => { + s.classList.toggle('active', s.dataset.id === id); + }); +} + +function initPicker() { + const picker = document.getElementById('wallpaper-picker'); + const grid = document.getElementById('wallpaper-picker-grid'); + if (!picker || !grid) return; + + // Populate swatches + for (const [id, wp] of Object.entries(WALLPAPERS)) { + const swatch = document.createElement('div'); + swatch.className = 'wallpaper-swatch'; + swatch.dataset.id = id; + swatch.setAttribute('role', 'button'); + swatch.setAttribute('aria-label', `Select ${wp.name} wallpaper`); + swatch.style.background = wp.gradient; + swatch.innerHTML = `${wp.name}`; + swatch.addEventListener('click', () => applyWallpaper(id)); + grid.appendChild(swatch); + } + + // Apply saved wallpaper + const saved = State.get('wallpaper'); + if (saved && WALLPAPERS[saved]) { + applyWallpaper(saved); + } + + // Toggle button in status bar + const toggleBtn = document.getElementById('wallpaper-toggle'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + picker.classList.toggle('hidden'); + }); + } + + // Close picker on backdrop click + picker.addEventListener('click', (e) => { + if (e.target === picker) { + picker.classList.add('hidden'); + } + }); + + // Close on Escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !picker.classList.contains('hidden')) { + picker.classList.add('hidden'); + } + }); +} + +function init() { + // Listen for state changes to wallpaper + State.subscribe('wallpaper', (id) => { + if (id && WALLPAPERS[id] && id !== (currentWp ? null : State.get('wallpaper'))) { + applyWallpaper(id); + } + }); + + // Also apply on load if state says so + const saved = State.get('wallpaper'); + if (saved && WALLPAPERS[saved]) { + applyWallpaper(saved); + } + + initPicker(); +} + +init(); +EOF + +echo "=== clock.js ===" +cat > /tmp/meridian-os/js/widgets/clock.js << 'EOF' +import State from '../core/state.js'; + +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function formatTime(date) { + let h = date.getHours(); + const m = date.getMinutes().toString().padStart(2, '0'); + const ampm = h >= 12 ? 'PM' : 'AM'; + h = h % 12 || 12; + return `${h}:${m} ${ampm}`; +} + +function formatDate(date) { + return `${DAYS[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()}`; +} + +function updateClock() { + const timeEl = document.getElementById('clock-time'); + const dateEl = document.getElementById('clock-date'); + if (timeEl && dateEl) { + const now = new Date(); + timeEl.textContent = formatTime(now); + dateEl.textContent = formatDate(now); + } + + // Update login screen time + const loginTimeEl = document.getElementById('login-time'); + if (loginTimeEl) { + loginTimeEl.textContent = formatTime(now); + } + + // Update taskbar time + const statusTimeEl = document.getElementById('status-time'); + if (statusTimeEl) { + statusTimeEl.textContent = formatTime(now); + } +} + +function init() { + updateClock(); + setInterval(updateClock, 1000); +} + +init(); +EOF + +echo "=== active-apps.js ===" +cat > /tmp/meridian-os/js/widgets/active-apps.js << 'EOF' +import State from '../core/state.js'; +import MeridianWM from '../core/wm.js'; +import Registry from '../core/registry.js'; + +function renderApps(apps) { + const container = document.getElementById('active-apps-list'); + if (!container) return; + + if (!apps || apps.length === 0) { + container.innerHTML = '
No apps open
'; + return; + } + + container.innerHTML = ''; + for (const win of apps) { + const btn = document.createElement('button'); + btn.className = 'active-app-chip'; + btn.setAttribute('aria-label', `Focus ${win.title}`); + btn.textContent = win.title; + + btn.addEventListener('click', () => { + MeridianWM.focusWindow(win.id); + }); + + container.appendChild(btn); + } +} + +function init() { + State.subscribe('windows', renderApps); +} + +init(); +EOF + +echo "=== achievements.js ===" +cat > /tmp/meridian-os/js/widgets/achievements.js << 'EOF' +import State from '../core/state.js'; +import { Bus } from '../core/bus.js'; + +function renderAchievements(achievements) { + const container = document.getElementById('achievements-list'); + if (!container) return; + + if (!achievements || achievements.length === 0) { + container.innerHTML = '
No achievements yet
'; + return; + } + + // Show last 3 + const last3 = achievements.slice(-3).reverse(); + container.innerHTML = ''; + for (const achId of last3) { + const item = document.createElement('div'); + item.className = 'achievement-item'; + item.innerHTML = ` +
${getInitial(achId)}
+
+ ${escapeHtml(achId)} + Unlocked +
+ `; + container.appendChild(item); + } +} + +function getInitial(id) { + return id.charAt(0).toUpperCase(); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function showToast({ id, title, desc }) { + const layer = document.getElementById('toast-layer'); + if (!layer) return; + + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.innerHTML = ` +
+
Achievement Unlocked
+
${escapeHtml(title || desc || id)}
+
+ + `; + + layer.appendChild(toast); + + // Close button + toast.querySelector('.toast-close').addEventListener('click', () => removeToast(toast)); + + // Auto dismiss + setTimeout(() => removeToast(toast), 4000); +} + +function removeToast(toast) { + if (!toast.parentNode) return; + toast.classList.add('toast-removing'); + setTimeout(() => toast.remove(), 300); +} + +function init() { + State.subscribe('achievements', renderAchievements); + + // Listen for achievement unlocks + Bus.on('achievement:unlocked', showToast); +} + +init(); +EOF + +echo "=== index.html ===" +cat > /tmp/meridian-os/index.html << 'EOF' + + + + + + Meridian OS + + + + + + + + + + + +
+ + +
+ + + + + +
+ + + + + + + +EOF + +echo "=== DONE ===" +find /tmp/meridian-os -type f | sort \ No newline at end of file diff --git a/css/apps.css b/css/apps.css new file mode 100644 index 0000000..b3948c7 --- /dev/null +++ b/css/apps.css @@ -0,0 +1,718 @@ +/* apps.css — styles for all Meridian OS apps */ + +/* ===== Shared app shell ===== */ +[data-app='notepad'] .app-toolbar, +[data-app='music'] .app-toolbar, +[data-app='steam'] .app-toolbar, +[data-app='vscode'] .app-toolbar, +[data-app='postman'] .app-toolbar, +[data-app='chrome'] .app-toolbar, +[data-app='notepad'] .app-status, +[data-app='steam'] .app-status, +[data-app='vscode'] .app-status, +[data-app='postman'] .app-status { + height: 40px; + background: var(--bg-2); + border-bottom: 1px solid var(--border); + padding: 0 12px; + display: flex; + align-items: center; + justify-content: space-between; +} + +[data-app='notepad'] .app-toolbar .doc-title { + font-size: 13px; + font-weight: 500; + color: var(--text); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-app='notepad'] .app-toolbar .btn-amber { + padding: 5px 14px; + background: var(--accent); + color: #0f1114; + border: none; + border-radius: 3px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: opacity 150ms; +} + +[data-app='notepad'] .app-toolbar .btn-amber:hover { + opacity: 0.85; +} + +[data-app='notepad'] .app-status { + height: 24px; + font-size: 11px; + color: var(--text-dim); + border-top: 1px solid var(--border); + padding: 0 12px; + align-items: baseline; +} + +/* ===== Notepad ===== */ +[data-app='notepad'] textarea { + flex: 1; + background: var(--bg-1); + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 14px; + padding: 16px; + border: none; + resize: none; + outline: none; + line-height: 1.6; +} + +[data-app='notepad'] textarea::placeholder { + color: var(--text-dim); +} + +/* ===== Music ===== */ +[data-app='music'] .track-row { + padding: 10px 14px; + cursor: pointer; + display: flex; + flex-direction: column; + border-left: 2px solid transparent; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + transition: all 150ms ease-out; +} + +[data-app='music'] .track-row:hover { + background: var(--bg-3); +} + +[data-app='music'] .track-row.active { + border-left-color: var(--accent); + background: var(--bg-3); +} + +[data-app='music'] .track-title { + font-size: 13px; + font-weight: 500; + color: var(--text); +} + +[data-app='music'] .track-artist { + font-size: 11px; + color: var(--text-dim); + margin-top: 2px; +} + +[data-app='music'] .cover-art { + width: 120px; + height: 120px; + background: rgba(212, 162, 76, 0.15); + border: 1px solid rgba(212, 162, 76, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-size: 48px; + font-weight: 600; + color: var(--accent); + margin-bottom: 24px; +} + +[data-app='music'] .now-title { + font-size: 28px; + font-weight: 600; + color: var(--text); + margin-bottom: 6px; +} + +[data-app='music'] .now-artist { + font-size: 14px; + color: var(--text-dim); + margin-bottom: 32px; +} + +[data-app='music'] .play-btn { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--accent); + border: none; + color: #0f1114; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + font-weight: 700; + transition: opacity 150ms; +} + +[data-app='music'] .play-btn:hover { + opacity: 0.85; +} + +[data-app='music'] .play-btn:disabled { + opacity: 0.3; + cursor: default; +} + +[data-app='music'] .nav-btn { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: background 150ms; +} + +[data-app='music'] .nav-btn:hover { + background: var(--border); +} + +[data-app='music'] input[type='range'] { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--bg-3); + border-radius: 2px; + outline: none; + cursor: pointer; +} + +[data-app='music'] input[type='range']::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; +} + +[data-app='music'] .seek-label { + font-size: 11px; + color: var(--text-dim); + margin-bottom: 4px; + display: flex; + justify-content: space-between; +} + +/* ===== Steam ===== */ +[data-app='steam'] .lib-item { + padding: 8px 14px; + cursor: pointer; + font-size: 13px; + color: var(--text); + transition: background 150ms ease-out; + border-left: 2px solid transparent; +} + +[data-app='steam'] .lib-item:hover { + background: var(--bg-3); +} + +[data-app='steam'] .lib-item.selected { + border-left-color: var(--accent); + background: var(--bg-3); +} + +[data-app='steam'] .lib-label { + padding: 12px 14px 6px; + font-size: 11px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +[data-app='steam'] .game-hero { + width: 100%; + height: 200px; + background: linear-gradient(135deg, rgba(37, 42, 49, 0.9), rgba(212, 162, 76, 0.2)); + border: 1px solid var(--border); + display: flex; + align-items: flex-end; + padding: 20px; + margin-bottom: 20px; + position: relative; + overflow: hidden; + border-radius: 4px; +} + +[data-app='steam'] .game-hero-title { + font-size: 32px; + font-weight: 700; + color: var(--text); + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.6); +} + +[data-app='steam'] .game-desc { + font-size: 13px; + color: var(--text); + line-height: 1.7; + margin-bottom: 20px; + max-width: 600px; +} + +[data-app='steam'] .install-btn { + padding: 10px 28px; + background: var(--accent); + color: #0f1114; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 150ms; + width: fit-content; +} + +[data-app='steam'] .install-btn:hover { + opacity: 0.85; +} + +[data-app='steam'] .install-btn.installed { + background: var(--bg-3); + color: var(--text-dim); + border: 1px solid var(--border); +} + +[data-app='steam'] .install-btn:disabled { + opacity: 0.5; + cursor: wait; +} + +[data-app='steam'] .progress-wrap { + width: 280px; + height: 6px; + background: var(--bg-3); + border-radius: 3px; + margin-top: 12px; + overflow: hidden; +} + +[data-app='steam'] .progress-fill { + width: 0%; + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 2s linear; +} + +[data-app='steam'] .steam-status { + background: var(--bg-2); + border-top: 1px solid var(--border); + padding: 6px 14px; + font-size: 11px; + color: var(--text-dim); + flex-shrink: 0; +} + +[data-app='steam'] .placeholder-msg { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + font-size: 14px; +} + +/* ===== VS Code ===== */ +[data-app='vscode'] .activity-bar { + width: 40px; + background: #333; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 8px; + border-right: 1px solid #444; + flex-shrink: 0; +} + +[data-app='vscode'] .activity-icon { + width: 24px; + height: 24px; + margin-bottom: 16px; + cursor: pointer; + opacity: 0.5; + transition: opacity 150ms; +} + +[data-app='vscode'] .activity-icon.active { + opacity: 1; +} + +[data-app='vscode'] .explorer { + width: 200px; + background: #252526; + border-right: 1px solid #444; + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +[data-app='vscode'] .explorer-label { + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: #bbbbbb; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +[data-app='vscode'] .folder-label { + padding: 4px 12px; + font-size: 12px; + color: #9cdcfe; + font-weight: 600; + cursor: pointer; +} + +[data-app='vscode'] .file-item { + padding: 4px 12px 4px 24px; + font-size: 13px; + color: #cccccc; + cursor: pointer; + display: flex; + align-items: center; + transition: background 100ms; +} + +[data-app='vscode'] .file-item:hover { + background: rgba(255, 255, 255, 0.04); +} + +[data-app='vscode'] .file-item.active { + background: rgba(255, 255, 255, 0.08); +} + +[data-app='vscode'] .editor { + flex: 1; + overflow: auto; + padding: 16px 20px; + background: #1e1e1e; +} + +[data-app='vscode'] .editor-content { + margin: 0; + font-family: Consolas, Menlo, Monaco, monospace; + font-size: 13px; + line-height: 1.6; + white-space: pre; + tab-size: 2; +} + +[data-app='vscode'] .line-numbers { + color: #858585; +} + +[data-app='vscode'] .editor-keyword { + color: #c678dd; +} + +[data-app='vscode'] .editor-string { + color: #ce9178; +} + +[data-app='vscode'] .editor-comment { + color: #6a9955; +} + +[data-app='vscode'] .editor-number { + color: #b5cea8; +} + +[data-app='vscode'] .editor-func { + color: #61afef; +} + +[data-app='vscode'] .editor-type { + color: #e5c07b; +} + +[data-app='vscode'] .status-bar { + height: 22px; + background: #007acc; + display: flex; + align-items: center; + padding: 0 12px; + font-size: 12px; + color: #ffffff; + flex-shrink: 0; + gap: 16px; +} + +/* ===== Postman ===== */ +[data-app='postman'] .req-bar { + display: flex; + align-items: center; + gap: 0; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg-2); + flex-shrink: 0; +} + +[data-app='postman'] .method-select { + padding: 6px 10px; + background: var(--bg-3); + border: 1px solid var(--border); + border-right: none; + border-radius: 4px 0 0 4px; + color: var(--accent); + font-weight: 600; + font-size: 13px; + outline: none; + cursor: pointer; + min-width: 70px; +} + +[data-app='postman'] .url-input { + flex: 1; + padding: 6px 12px; + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--text); + font-size: 13px; + font-family: monospace; + outline: none; +} + +[data-app='postman'] .send-btn { + padding: 6px 20px; + background: var(--accent); + color: #0f1114; + border: none; + border-radius: 0 4px 4px 0; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity 150ms; +} + +[data-app='postman'] .send-btn:hover { + opacity: 0.85; +} + +[data-app='postman'] .send-btn:disabled { + opacity: 0.5; + cursor: wait; +} + +[data-app='postman'] .tab-bar { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + background: var(--bg-2); + flex-shrink: 0; +} + +[data-app='postman'] .tab-btn { + padding: 8px 16px; + background: transparent; + border: none; + border-right: 1px solid var(--border); + color: var(--text-dim); + font-size: 12px; + cursor: pointer; + transition: color 150ms; +} + +[data-app='postman'] .tab-btn.active { + color: var(--text); +} + +[data-app='postman'] .tab-btn:hover { + background: var(--bg-3); +} + +[data-app='postman'] .tab-area { + flex: 1; + overflow-y: auto; + background: var(--bg-2); +} + +[data-app='postman'] .resp-label { + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--border); + background: var(--bg-2); + flex-shrink: 0; +} + +[data-app='postman'] .resp-area { + flex: 1; + overflow: auto; + padding: 12px 16px; + margin: 0; + font-size: 12px; + line-height: 1.6; + color: var(--text); + font-family: monospace; + background: var(--bg-1); +} + +[data-app='postman'] .status-line { + padding: 4px 12px; + font-size: 11px; + color: var(--text-dim); + border-top: 1px solid var(--border); + background: var(--bg-2); + flex-shrink: 0; +} + +/* ===== Chrome ===== */ +[data-app='chrome'] .tab-strip { + display: flex; + background: var(--bg-2); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + overflow-x: auto; +} + +[data-app='chrome'] .tab-btn { + padding: 8px 16px; + font-size: 12px; + border: none; + background: transparent; + color: var(--text-dim); + border-right: 1px solid var(--border); + cursor: pointer; + transition: color 150ms; + max-width: 180px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-app='chrome'] .tab-btn.active { + color: var(--text); +} + +[data-app='chrome'] .new-tab-btn { + padding: 8px 12px; + font-size: 16px; + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; + flex-shrink: 0; +} + +[data-app='chrome'] .addr-bar { + display: flex; + align-items: center; + padding: 6px 12px; + gap: 8px; + background: var(--bg-2); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +[data-app='chrome'] .url-bar-input { + flex: 1; + padding: 6px 12px; + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 20px; + color: var(--text); + font-size: 13px; + outline: none; +} + +[data-app='chrome'] .url-bar-input::placeholder { + color: var(--text-dim); +} + +[data-app='chrome'] .reload-btn { + padding: 4px 8px; + background: transparent; + border: none; + color: var(--text-dim); + cursor: pointer; + font-size: 14px; +} + +[data-app='chrome'] .page-body { + flex: 1; + overflow-y: auto; + padding: 40px 20px; + background: var(--bg-1); +} + +[data-app='chrome'] .shortcut-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + max-width: 600px; + margin: 0 auto; +} + +[data-app='chrome'] .shortcut-tile { + padding: 16px 12px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 8px; + text-align: center; + cursor: pointer; + transition: background 150ms; +} + +[data-app='chrome'] .shortcut-tile:hover { + background: var(--bg-3); +} + +[data-app='chrome'] .shortcut-icon { + font-size: 18px; + font-weight: 600; + color: var(--accent); + margin-bottom: 4px; +} + +[data-app='chrome'] .shortcut-label { + font-size: 11px; + color: var(--text-dim); +} + +[data-app='chrome'] .page-title { + font-size: 22px; + font-weight: 500; + color: var(--text); + margin-bottom: 28px; + text-align: center; +} + +[data-app='chrome'] .page-heading { + font-size: 22px; + font-weight: 500; + color: var(--text); + margin-bottom: 12px; +} + +[data-app='chrome'] .page-url { + font-size: 12px; + color: var(--text-dim); + margin-bottom: 24px; + font-family: monospace; + padding: 8px 12px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 4px; +} + +[data-app='chrome'] .page-text { + font-size: 14px; + color: var(--text-dim); + line-height: 1.7; +} diff --git a/css/reset.css b/css/reset.css new file mode 100644 index 0000000..ff44955 --- /dev/null +++ b/css/reset.css @@ -0,0 +1,51 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: var(--font); + font-size: var(--fs); + background: var(--bg-0); + color: var(--text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + user-select: none; +} + +button, input { + font-family: inherit; + user-select: text; +} + +button:focus-visible, input:focus-visible, a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +input::placeholder { + color: var(--text-faint); +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 3px; +} + +::selection { + background: var(--accent-dim); + color: var(--text); +} diff --git a/css/shell.css b/css/shell.css new file mode 100644 index 0000000..2f4a357 --- /dev/null +++ b/css/shell.css @@ -0,0 +1,643 @@ +/* Shell layout styles */ + +#login { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + transition: opacity 0.4s ease, visibility 0.4s ease; +} + +#login.hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.login-card { + width: 360px; + padding: 40px 32px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2); + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.login-logo { + font-size: var(--fs); + font-size: 28px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.5px; +} + +.login-time { + position: absolute; + top: 24px; + right: 32px; + font-size: 14px; + color: var(--text-dim); +} + +.login-input { + width: 100%; + padding: 12px 16px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 14px; + transition: border-color 0.2s; +} + +.login-input:focus { + border-color: var(--accent); + outline: none; +} + +.login-input::placeholder { + color: var(--text-faint); +} + +.login-btn { + width: 100%; + padding: 12px 16px; + background: var(--accent); + color: var(--bg-0); + font-size: 14px; + font-weight: 600; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s, opacity 0.2s; +} + +.login-btn:hover { + opacity: 0.9; +} + +.login-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Desktop */ +#desktop { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; +} + +#desktop.hidden { + display: none; +} + +#wallpaper-layer { + position: absolute; + inset: 0; + transition: background 250ms ease; + z-index: 0; +} + +#windows-layer { + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; +} + +/* Widgets Tray */ +#widgets-tray { + position: fixed; + right: 16px; + top: 16px; + bottom: 64px; + width: 240px; + z-index: 10; + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + overflow-x: hidden; +} + +.widget { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + backdrop-filter: blur(12px); +} + +.widget-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-faint); + margin-bottom: 12px; +} + +/* Clock Widget */ +.clock-time { + font-size: 32px; + font-weight: 600; + color: var(--text); + line-height: 1; +} + +.clock-date { + font-size: 13px; + color: var(--text-dim); + margin-top: 4px; +} + +/* Active Apps Widget */ +.active-apps-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.active-app-chip { + padding: 8px 12px; + background: var(--bg-2); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text); + cursor: pointer; + border: none; + background: none; + text-align: left; + width: 100%; + transition: background 0.15s; +} + +.active-app-chip:hover { + background: var(--bg-3); +} + +.active-app-chip:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.active-app-chip.active { + border-left: 2px solid var(--accent); + background: var(--bg-3); +} + +.active-apps-empty { + font-size: 13px; + color: var(--text-faint); + font-style: italic; +} + +/* Achievements Widget */ +.achievements-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.achievement-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; +} + +.achievement-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: var(--accent-dim); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: var(--accent); + flex-shrink: 0; +} + +.achievement-info { + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +.achievement-name { + font-size: 13px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.achievement-game { + font-size: 12px; + color: var(--text-faint); +} + +.achievements-empty { + font-size: 13px; + color: var(--text-faint); + font-style: italic; +} + +/* Taskbar */ +#taskbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 48px; + background: var(--bg-1); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 8px; + z-index: 100; + gap: 4px; +} + +#launcher { + padding: 6px 16px; + font-size: 13px; + font-weight: 500; + color: var(--text); + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: background 0.15s; +} + +#launcher:hover { + background: var(--bg-3); +} + +#launcher:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +#launcher.active { + background: var(--accent); + color: var(--bg-0); + border-color: var(--accent); +} + +#taskbar-apps { + flex: 1; + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + padding: 0 8px; +} + +.taskbar-app { + padding: 4px 12px; + font-size: 12px; + color: var(--text-dim); + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; + border: none; + background: none; + transition: background 0.15s, color 0.15s; +} + +.taskbar-app:hover { + color: var(--text); + background: var(--bg-3); +} + +.taskbar-app.active { + color: var(--text); + background: var(--bg-3); + border-bottom: 2px solid var(--accent); +} + +.taskbar-app:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +#status-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px; + font-size: 12px; + color: var(--text-dim); +} + +.status-user { + display: flex; + align-items: center; + gap: 8px; +} + +.status-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--accent-dim); + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; +} + +.status-separator { + color: var(--text-faint); +} + +.status-connection { + display: flex; + align-items: center; + gap: 4px; +} + +.connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--danger); + transition: background 0.3s; +} + +.connection-dot.online { + background: #4caf50; +} + +/* Launcher Popover */ +.launcher-popover { + position: absolute; + bottom: 56px; + left: 8px; + width: 280px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2); + padding: 12px; + z-index: 150; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.launcher-app-btn { + padding: 12px 8px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + text-align: center; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + color: var(--text); +} + +.launcher-app-btn:hover { + background: var(--bg-3); + border-color: var(--border-strong); +} + +.launcher-app-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.launcher-app-icon { + font-size: 20px; + margin-bottom: 4px; +} + +.launcher-app-label { + font-size: 12px; + color: var(--text-dim); +} + +/* Wallpaper Picker Modal */ +#wallpaper-picker { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); + transition: opacity 0.3s, visibility 0.3s; +} + +#wallpaper-picker.hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.wallpaper-picker-card { + width: 560px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2); + overflow: hidden; +} + +.wallpaper-picker-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border); +} + +.wallpaper-picker-title { + font-size: 16px; + font-weight: 600; +} + +.wallpaper-picker-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 16px; + color: var(--text-dim); + border: none; + background: none; +} + +.wallpaper-picker-close:hover { + background: var(--bg-3); + color: var(--text); +} + +.wallpaper-picker-close:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.wallpaper-picker-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + padding: 20px 24px; +} + +.wallpaper-swatch { + aspect-ratio: 16/9; + border-radius: var(--radius); + cursor: pointer; + border: 3px solid transparent; + transition: border-color 0.2s, transform 0.2s; + position: relative; +} + +.wallpaper-swatch:hover { + transform: scale(1.03); +} + +.wallpaper-swatch.active { + border-color: var(--accent); +} + +.wallpaper-swatch-label { + position: absolute; + bottom: 4px; + left: 0; + right: 0; + text-align: center; + font-size: 11px; + color: white; + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + background: rgba(0,0,0,0.4); + padding: 2px 4px; + border-radius: 2px; +} + +/* Toast Layer */ +#toast-layer { + position: fixed; + bottom: 60px; + right: 16px; + z-index: 300; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + background: var(--bg-2); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + box-shadow: var(--shadow-1); + min-width: 280px; + max-width: 360px; + pointer-events: auto; + animation: toast-in 0.3s ease; +} + +.toast-removing { + animation: toast-out 0.3s ease forwards; +} + +.toast-title { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.toast-desc { + font-size: 12px; + color: var(--text-dim); + margin-top: 2px; +} + +.toast-close { + margin-left: auto; + padding: 2px; + cursor: pointer; + color: var(--text-faint); + border: none; + background: none; + font-size: 14px; + line-height: 1; +} + +.toast-close:hover { + color: var(--text); +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes toast-out { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(24px); + } +} + +/* Wallpaper picker toggle button in taskbar */ +.wallpaper-toggle { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-dim); + font-size: 16px; + border: none; + background: none; +} + +.wallpaper-toggle:hover { + background: var(--bg-3); + color: var(--text); +} + +.wallpaper-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} diff --git a/css/tokens.css b/css/tokens.css new file mode 100644 index 0000000..77d027b --- /dev/null +++ b/css/tokens.css @@ -0,0 +1,22 @@ +:root { + --bg-0: #0f1114; + --bg-1: #1a1d21; + --bg-2: #252a31; + --bg-3: #2f353d; + --border: rgba(255,255,255,0.08); + --border-strong: rgba(255,255,255,0.14); + --text: #e6e8eb; + --text-dim: #9aa1ab; + --text-faint: #5b6470; + --accent: #d4a24c; + --accent-dim: #8a6a32; + --danger: #c85d5d; + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 12px; + --font: 'Inter', -apple-system, system-ui, sans-serif; + --shadow-1: 0 2px 8px rgba(0,0,0,0.3); + --shadow-2: 0 8px 24px rgba(0,0,0,0.4); + --sp: 4px 8px 12px 16px 24px 32px; + --fs: 12px 13px 14px 16px 20px 28px; +} diff --git a/css/window.css b/css/window.css new file mode 100644 index 0000000..b406c7c --- /dev/null +++ b/css/window.css @@ -0,0 +1,113 @@ +/* Window styles */ +.m-window { + position: absolute; + min-width: 320px; + min-height: 240px; + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-2); + display: flex; + flex-direction: column; + overflow: hidden; + transition: box-shadow 0.2s; +} + +.m-window.focused { + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + border-color: var(--border-strong); +} + +.m-window.minimized { + opacity: 0; + pointer-events: none; + transform: scale(0.95); + transition: opacity 0.2s, transform 0.2s; +} + +.m-window-header { + display: flex; + align-items: center; + height: 36px; + padding: 0 8px; + background: var(--bg-2); + border-bottom: 1px solid var(--border); + cursor: default; + flex-shrink: 0; +} + +.m-window-title { + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-left: 4px; +} + +.m-window-controls { + display: flex; + align-items: center; + gap: 2px; +} + +.m-window-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 14px; + color: var(--text-dim); + border: none; + background: none; + transition: background 0.15s, color 0.15s; +} + +.m-window-btn:hover { + background: var(--bg-3); + color: var(--text); +} + +.m-window-btn.close-btn:hover { + background: var(--danger); + color: white; +} + +.m-window-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.m-window-body { + flex: 1; + overflow: auto; + position: relative; +} + +.m-window-body > * { + width: 100%; + height: 100%; +} + +/* Resize handle */ +.m-window-resize { + position: absolute; + bottom: 0; + right: 0; + width: 16px; + height: 16px; + cursor: nwse-resize; +} + +/* Drag overlay */ +.drag-overlay { + position: fixed; + inset: 0; + z-index: 9999; + cursor: move; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..a939721 --- /dev/null +++ b/index.html @@ -0,0 +1,77 @@ + + + + + + Meridian OS + + + + + + + + + + + + +
+ + +
+ + + + + +
+ + + + + + + diff --git a/js/achievements/catalog.js b/js/achievements/catalog.js new file mode 100644 index 0000000..efa1713 --- /dev/null +++ b/js/achievements/catalog.js @@ -0,0 +1,76 @@ +// Achievement definitions for Meridian OS + +const catalog = [ + { + id: 'first_grace', + title: 'First Grace', + desc: 'Welcome to the Lands Between.', + game: 'ER', + trigger: { event: 'user:login' } + }, + { + id: 'getting_tactical', + title: 'Getting Tactical', + desc: 'Run 5 terminal commands.', + game: 'CoD', + trigger: { event: 'term:cmd', count: 5 } + }, + { + id: 'career_mode', + title: 'Career Mode', + desc: 'Open 3 different apps.', + game: 'FIFA', + trigger: { event: 'app:open', uniqueField: 'app', count: 3 } + }, + { + id: 'tarnished_typist', + title: 'Tarnished Typist', + desc: 'Type 500 characters in Notepad.', + game: 'ER', + trigger: { event: 'text:typed', accumulate: 'chars', threshold: 500 } + }, + { + id: 'dj', + title: 'DJ', + desc: 'Play music for 30 seconds.', + game: 'CoD', + trigger: { event: 'music:played', accumulate: 'seconds', threshold: 30 } + }, + { + id: 'speedrun', + title: 'Speedrun', + desc: 'Open and close a window in under 2 seconds.', + game: 'FIFA', + trigger: { custom: 'speedrun' } + }, + { + id: 'dataminer', + title: 'Dataminer', + desc: 'Discover 3 hidden terminal commands.', + game: 'ER', + trigger: { event: 'easter:found', count: 3, unique: true } + }, + { + id: 'grace_bestowed', + title: 'Touched by Grace', + desc: 'Praise the Sun.', + game: 'ER', + trigger: { event: 'easter:found', id: 'er_grace' } + }, + { + id: 'siuuu_master', + title: 'SIUUU Master', + desc: 'Celebrate in the terminal.', + game: 'FIFA', + trigger: { event: 'easter:found', id: 'er_siuuu' } + }, + { + id: 'cheater', + title: 'Cheat Code Activated', + desc: 'Enter the Konami code.', + game: 'CoD', + trigger: { event: 'easter:found', id: 'er_konami' } + } +]; + +export default catalog; diff --git a/js/achievements/engine.js b/js/achievements/engine.js new file mode 100644 index 0000000..d4b9eaa --- /dev/null +++ b/js/achievements/engine.js @@ -0,0 +1,128 @@ +// Achievements engine — subscribes to bus events, unlocks achievements. +// On unlock: pushes id into State, emits 'achievement:unlocked' for the widget. + +import Bus from '../core/bus.js'; +import State from '../core/state.js'; +import catalog from './catalog.js'; + +// In-memory counters per achievement id +const counters = {}; +// For uniqueField tracking: Set of seen values per achievement id +const uniqueSets = {}; +// For speedrun: map of windowId -> open timestamp +const openTimestamps = {}; + +let initialized = false; + +function hasUnlocked(id) { + const unlocked = State.get('achievements'); + return unlocked && unlocked.includes(id); +} + +function unlock(ach) { + if (hasUnlocked(ach.id)) return; + State.addAchievement(ach.id); + Bus.emit('achievement:unlocked', { + id: ach.id, + title: ach.title, + desc: ach.desc, + game: ach.game + }); +} + +function handleEvent(ach, data) { + const trigger = ach.trigger; + + // Event+id specific trigger (easter egg matches) + if (trigger.id) { + if (data && data.id === trigger.id) { + unlock(ach); + } + return; + } + + // Simple event trigger (no count, no accumulate) — unlock on first fire + if (!trigger.count && !trigger.accumulate && !trigger.uniqueField) { + unlock(ach); + return; + } + + // Count-based trigger + if (trigger.count) { + counters[ach.id] = (counters[ach.id] || 0) + 1; + if (counters[ach.id] >= trigger.count) { + unlock(ach); + } + return; + } + + // UniqueField trigger + if (trigger.uniqueField) { + if (!uniqueSets[ach.id]) { + uniqueSets[ach.id] = new Set(); + } + const val = data && data[trigger.uniqueField]; + if (val) { + uniqueSets[ach.id].add(val); + if (uniqueSets[ach.id].size >= trigger.count) { + unlock(ach); + } + } + return; + } + + // Accumulate trigger + if (trigger.accumulate) { + const key = trigger.accumulate; + const value = data && data[key]; + if (typeof value === 'number') { + counters[ach.id] = (counters[ach.id] || 0) + value; + if (counters[ach.id] >= trigger.threshold) { + unlock(ach); + } + } + return; + } +} + +function init() { + if (initialized) return; + initialized = true; + + // Pre-initialize counters for all achievements + catalog.forEach(ach => { + counters[ach.id] = 0; + uniqueSets[ach.id] = ach.trigger.uniqueField ? new Set() : undefined; + }); + + // Subscribe to each achievement's trigger event + catalog.forEach(ach => { + const trigger = ach.trigger; + if (trigger.custom === 'speedrun') { + // Speedrun: listen to app:open and app:close on wm.js + Bus.on('app:open', (data) => { + if (data && data.id) { + openTimestamps[data.id] = Date.now(); + } + }); + Bus.on('app:close', (data) => { + if (data && data.id && openTimestamps[data.id]) { + const delta = Date.now() - openTimestamps[data.id]; + if (delta < 2000) { + unlock(ach); + } + delete openTimestamps[data.id]; + } + }); + return; + } + + if (trigger.event) { + Bus.on(trigger.event, (data) => { + handleEvent(ach, data); + }); + } + }); +} + +export default { init }; diff --git a/js/apps/chrome.js b/js/apps/chrome.js new file mode 100644 index 0000000..1d66379 --- /dev/null +++ b/js/apps/chrome.js @@ -0,0 +1,167 @@ +// Chrome spoof — static-but-believable browser UI. + +const TABS = [ + { title: 'New Tab', active: true }, + { title: 'Meridian Docs', active: false }, + { title: 'Search', active: false }, +]; + +const SHORTCUTS = [ + { label: 'GitHub', url: 'https://github.com' }, + { label: 'Gmail', url: 'https://mail.google.com' }, + { label: 'YouTube', url: 'https://youtube.com' }, + { label: 'Maps', url: 'https://maps.google.com' }, + { label: 'Drive', url: 'https://drive.google.com' }, + { label: 'Calendar', url: 'https://calendar.google.com' }, + { label: 'Docs', url: 'https://docs.google.com' }, + { label: 'Translate', url: 'https://translate.google.com' }, +]; + +export default { + id: 'chrome', + title: 'Chrome', + render(container, api) { + let activeTab = 0; + let navUrl = ''; + + const root = document.createElement('div'); + root.style.cssText = 'height:100%;display:flex;flex-direction:column;background:var(--bg-1);'; + + // Tab strip + const tabStrip = document.createElement('div'); + tabStrip.style.cssText = 'display:flex;background:var(--bg-2);border-bottom:1px solid var(--border);flex-shrink:0;overflow-x:auto;'; + + // New tab button + const newTabBtn = document.createElement('button'); + newTabBtn.style.cssText = 'padding:8px 12px;font-size:16px;border:none;background:transparent;color:var(--text-dim);cursor:pointer;flex-shrink:0;'; + newTabBtn.textContent = '+'; + newTabBtn.addEventListener('click', () => { + const newIdx = TABS.length; + TABS.push({ title: 'New Tab', active: true }); + TABS.forEach((t) => { t.active = false; }); + TABS[newIdx].active = true; + activeTab = newIdx; + navUrl = ''; + updateTabBar(); + updateBody(); + }); + + function updateTabBar() { + tabStrip.innerHTML = ''; + TABS.forEach((tab, i) => { + const btn = document.createElement('button'); + btn.style.cssText = 'padding:8px 16px;font-size:12px;border:none;background:transparent;color:' + (tab.active ? 'var(--text)' : 'var(--text-dim)') + ';border-right:1px solid var(--border);cursor:pointer;transition:color 150ms;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;'; + btn.textContent = tab.title; + btn.addEventListener('click', () => { + activeTab = i; + navUrl = SHORTCUTS[i] ? SHORTCUTS[i].url : ''; + updateTabBar(); + updateBody(); + }); + tabStrip.appendChild(btn); + }); + tabStrip.appendChild(newTabBtn); + } + + updateTabBar(); + root.appendChild(tabStrip); + + // Address bar + const addrBar = document.createElement('div'); + addrBar.style.cssText = 'display:flex;align-items:center;padding:6px 12px;gap:8px;background:var(--bg-2);border-bottom:1px solid var(--border);flex-shrink:0;'; + + const urlInput = document.createElement('input'); + urlInput.type = 'text'; + urlInput.value = navUrl || (SHORTCUTS[activeTab] ? SHORTCUTS[activeTab].url : ''); + urlInput.style.cssText = 'flex:1;padding:6px 12px;background:var(--bg-3);border:1px solid var(--border);border-radius:20px;color:var(--text);font-size:13px;outline:none;'; + urlInput.placeholder = 'Search or enter web address'; + urlInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const val = urlInput.value.trim(); + if (val) { + navUrl = val; + updateBody(); + } + } + }); + + const reloadBtn = document.createElement('button'); + reloadBtn.style.cssText = 'padding:4px 8px;background:transparent;border:none;color:var(--text-dim);cursor:pointer;font-size:14px;'; + reloadBtn.textContent = '\u{21BB}'; + reloadBtn.addEventListener('click', () => { updateBody(); }); + + addrBar.appendChild(urlInput); + addrBar.appendChild(reloadBtn); + root.appendChild(addrBar); + + // Body + const body = document.createElement('div'); + body.style.cssText = 'flex:1;overflow-y:auto;padding:40px 20px;background:var(--bg-1);'; + + function updateBody() { + body.innerHTML = ''; + urlInput.value = navUrl || (TABS[activeTab].title === 'New Tab' ? '' : TABS[activeTab].title.toLowerCase().replace(/\s+/g, '-') + '.meridian.dev'); + + if (activeTab === 0 && !navUrl) { + // New tab page + const titleEl = document.createElement('h2'); + titleEl.style.cssText = 'font-size:22px;font-weight:500;color:var(--text);margin-bottom:28px;text-align:center;'; + titleEl.textContent = 'Meridian Browser'; + body.appendChild(titleEl); + + const grid = document.createElement('div'); + grid.style.cssText = 'display:grid;grid-template-columns:repeat(4,1fr);gap:12px;max-width:600px;margin:0 auto;'; + + SHORTCUTS.forEach((sc) => { + const tile = document.createElement('div'); + tile.style.cssText = 'padding:16px 12px;background:var(--bg-2);border:1px solid var(--border);border-radius:8px;text-align:center;cursor:pointer;transition:background 150ms;'; + tile.addEventListener('mouseenter', () => { tile.style.background = 'var(--bg-3)'; }); + tile.addEventListener('mouseleave', () => { tile.style.background = 'var(--bg-2)'; }); + tile.addEventListener('click', () => { + navUrl = sc.url; + TABS[0].title = sc.label; + updateTabBar(); + updateBody(); + }); + const tileIcon = document.createElement('div'); + tileIcon.style.cssText = 'font-size:18px;font-weight:600;color:var(--accent);margin-bottom:4px;'; + tileIcon.textContent = sc.label.charAt(0); + const tileLabel = document.createElement('div'); + tileLabel.style.cssText = 'font-size:11px;color:var(--text-dim);'; + tileLabel.textContent = sc.label; + tile.appendChild(tileIcon); + tile.appendChild(tileLabel); + grid.appendChild(tile); + }); + + body.appendChild(grid); + } else { + // Simulated page + const page = document.createElement('div'); + page.style.cssText = 'max-width:600px;margin:0 auto;'; + + const heading = document.createElement('h2'); + heading.style.cssText = 'font-size:22px;font-weight:500;color:var(--text);margin-bottom:12px;'; + heading.textContent = 'Simulated page for ' + (navUrl || TABS[activeTab].title); + page.appendChild(heading); + + const urlDisplay = document.createElement('div'); + urlDisplay.style.cssText = 'font-size:12px;color:var(--text-dim);margin-bottom:24px;font-family:monospace;padding:8px 12px;background:var(--bg-2);border:1px solid var(--border);border-radius:4px;'; + urlDisplay.textContent = navUrl || TABS[activeTab].title; + page.appendChild(urlDisplay); + + const para = document.createElement('p'); + para.style.cssText = 'font-size:14px;color:var(--text-dim);line-height:1.7;'; + const displayUrl = navUrl || TABS[activeTab].title; + para.textContent = 'This is a simulated page for ' + displayUrl + '. Meridian Browser does not make actual network requests — it is a spoof. Nothing to see here unless you count the aesthetic choices.'; + page.appendChild(para); + + body.appendChild(page); + } + } + + root.appendChild(body); + container.appendChild(root); + updateBody(); + }, +}; diff --git a/js/apps/music.js b/js/apps/music.js new file mode 100644 index 0000000..2aea42d --- /dev/null +++ b/js/apps/music.js @@ -0,0 +1,248 @@ +// Music player — three tracks, play/pause/skip/seek/volume. + +const TRACKS = [ + { title: 'Grace Ascent', artist: 'Meridian OST', src: 'https://cdn.pixabay.com/audio/2022/10/18/audio_31c1679d6c.mp3' }, + { title: 'Siege Breaker', artist: 'Meridian OST', src: 'https://cdn.pixabay.com/audio/2022/11/17/audio_febc508520.mp3' }, + { title: 'Final Whistle', artist: 'Meridian OST', src: 'https://cdn.pixabay.com/audio/2023/06/13/audio_2c8e3b31b0.mp3' }, +]; + +export default { + id: 'music', + title: 'Music', + render(container, api) { + const audio = new Audio(); + let currentTrack = 0; + let playedSeconds = 0; + let playSecondsTimer = null; + + const root = document.createElement('div'); + root.style.cssText = 'height:100%;display:flex;overflow:hidden;'; + + // Left panel: track list + const listPanel = document.createElement('div'); + listPanel.style.cssText = 'width:220px;background:var(--bg-2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto;flex-shrink:0;'; + + const trackList = TRACKS.map((track, i) => { + const row = document.createElement('div'); + row.style.cssText = 'padding:10px 14px;cursor:pointer;display:flex;flex-direction:column;border-left:2px solid transparent;border-bottom:1px solid rgba(255,255,255,0.04);transition:all 150ms ease-out;'; + const titleSpan = document.createElement('div'); + titleSpan.style.cssText = 'font-size:13px;font-weight:500;color:var(--text);'; + titleSpan.textContent = track.title; + const artistSpan = document.createElement('div'); + artistSpan.style.cssText = 'font-size:11px;color:var(--text-dim);margin-top:2px;'; + artistSpan.textContent = track.artist; + row.appendChild(titleSpan); + row.appendChild(artistSpan); + row.addEventListener('click', () => loadTrack(i)); + return { row, titleSpan, artistSpan }; + }); + + TRACKS.forEach((_, i) => listPanel.appendChild(trackList[i].row)); + + // Right panel: now playing + const nowPanel = document.createElement('div'); + nowPanel.style.cssText = 'flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 24px;background:var(--bg-1);overflow-y:auto;'; + + const cover = document.createElement('div'); + cover.style.cssText = 'width:120px;height:120px;background:rgba(212,162,76,0.15);border:1px solid rgba(212,162,76,0.2);display:flex;align-items:center;justify-content:center;font-size:48px;font-weight:600;color:var(--accent);margin-bottom:24px;'; + + const nowTitle = document.createElement('div'); + nowTitle.style.cssText = 'font-size:28px;font-weight:600;color:var(--text);margin-bottom:6px;text-align:center;'; + + const nowArtist = document.createElement('div'); + nowArtist.style.cssText = 'font-size:14px;color:var(--text-dim);margin-bottom:32px;text-align:center;'; + + // Controls row + const controls = document.createElement('div'); + controls.style.cssText = 'display:flex;align-items:center;gap:20px;margin-bottom:28px;'; + + const btnStyle = 'width:40px;height:40px;border-radius:50%;background:var(--bg-3);border:1px solid var(--border);color:var(--text);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:background 150ms ease-out;'; + const prevBtn = document.createElement('button'); + prevBtn.style.cssText = btnStyle; + prevBtn.textContent = '<<'; + prevBtn.addEventListener('click', () => { currentTrack = (currentTrack - 1 + TRACKS.length) % TRACKS.length; loadTrack(currentTrack); }); + + const playBtn = document.createElement('button'); + playBtn.style.cssText = 'width:48px;height:48px;border-radius:50%;background:var(--accent);border:none;color:#0f1114;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;transition:opacity 150ms ease-out;'; + playBtn.textContent = '>'; + + const nextBtn = document.createElement('button'); + nextBtn.style.cssText = btnStyle; + nextBtn.textContent = '>>'; + nextBtn.addEventListener('click', () => { currentTrack = (currentTrack + 1) % TRACKS.length; loadTrack(currentTrack); }); + + controls.appendChild(prevBtn); + controls.appendChild(playBtn); + controls.appendChild(nextBtn); + + // Seek bar + const seekContainer = document.createElement('div'); + seekContainer.style.cssText = 'width:100%;max-width:400px;margin-bottom:16px;'; + const seekLabel = document.createElement('div'); + seekLabel.style.cssText = 'font-size:11px;color:var(--text-dim);margin-bottom:4px;display:flex;justify-content:space-between;'; + const seekLabelLeft = document.createElement('span'); + seekLabelLeft.textContent = '0:00'; + const seekLabelRight = document.createElement('span'); + seekLabelRight.textContent = '0:00'; + seekLabel.appendChild(seekLabelLeft); + seekLabel.appendChild(seekLabelRight); + + const seekInput = document.createElement('input'); + seekInput.type = 'range'; + seekInput.min = '0'; + seekInput.max = '100'; + seekInput.value = '0'; + seekInput.style.cssText = 'width:100%;height:4px;-webkit-appearance:none;appearance:none;background:var(--bg-3);border-radius:2px;outline:none;cursor:pointer;'; + seekInput.addEventListener('input', () => { + if (audio.duration) { + audio.currentTime = (seekInput.value / 100) * audio.duration; + } + }); + seekContainer.appendChild(seekLabel); + seekContainer.appendChild(seekInput); + + // Volume slider + const volContainer = document.createElement('div'); + volContainer.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:12px;'; + const volLabel = document.createElement('span'); + volLabel.style.cssText = 'font-size:12px;color:var(--text-dim);'; + volLabel.textContent = '\u{25A0}\u{25A0}'; + const volInput = document.createElement('input'); + volInput.type = 'range'; + volInput.min = '0'; + volInput.max = '100'; + volInput.value = '70'; + volInput.style.cssText = 'width:100px;height:4px;-webkit-appearance:none;appearance:none;background:var(--bg-3);border-radius:2px;outline:none;cursor:pointer;'; + volInput.addEventListener('input', () => { + audio.volume = volInput.value / 100; + }); + volContainer.appendChild(volLabel); + volContainer.appendChild(volInput); + + nowPanel.appendChild(cover); + nowPanel.appendChild(nowTitle); + nowPanel.appendChild(nowArtist); + nowPanel.appendChild(controls); + nowPanel.appendChild(seekContainer); + nowPanel.appendChild(volContainer); + + root.appendChild(listPanel); + root.appendChild(nowPanel); + container.appendChild(root); + + function formatTime(s) { + if (!isFinite(s)) return '0:00'; + return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0'); + } + + function updateUI() { + const track = TRACKS[currentTrack]; + const urlOk = urlAvailable(track.src); + cover.textContent = track.title.charAt(0); + nowTitle.textContent = track.title; + nowArtist.textContent = track.artist; + playBtn.textContent = audio.paused ? '>' : '||'; + + // Highlight selected track + TRACKS.forEach((_, i) => { + const row = trackList[i].row; + if (i === currentTrack) { + row.style.borderLeftColor = 'var(--accent)'; + row.style.background = 'var(--bg-3)'; + } else { + row.style.borderLeftColor = 'transparent'; + row.style.background = 'transparent'; + } + }); + + seekLabelLeft.textContent = formatTime(audio.currentTime); + seekLabelRight.textContent = formatTime(audio.duration); + seekInput.value = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0; + } + + function loadTrack(index) { + stopPlaySecondsTimer(); + currentTrack = index; + const track = TRACKS[index]; + + if (!urlAvailable(track.src)) { + nowTitle.textContent = track.title; + nowArtist.textContent = 'Track unavailable (offline)'; + cover.textContent = track.title.charAt(0); + cover.style.background = 'rgba(150,150,150,0.15)'; + playBtn.textContent = '>'; + playBtn.disabled = true; + seekInput.value = 0; + seekLabelLeft.textContent = '0:00'; + seekLabelRight.textContent = '0:00'; + return; + } + + cover.style.background = 'rgba(212,162,76,0.15)'; + playBtn.disabled = false; + audio.src = track.src; + audio.volume = volInput.value / 100; + audio.load(); + updateUI(); + } + + playBtn.addEventListener('click', () => { + if (playBtn.disabled) return; + if (!audio.src) return; + if (audio.paused) { + audio.play().then(() => { + api.bus.emit('music:play'); + updateUI(); + }).catch(() => { + updateUI(); + }); + startPlaySecondsTimer(); + } else { + audio.pause(); + updateUI(); + stopPlaySecondsTimer(); + } + }); + + audio.addEventListener('timeupdate', updateUI); + audio.addEventListener('ended', () => { + currentTrack = (currentTrack + 1) % TRACKS.length; + loadTrack(currentTrack); + audio.play().then(() => { + updateUI(); + startPlaySecondsTimer(); + }).catch(() => { updateUI(); }); + }); + audio.addEventListener('error', () => { + nowArtist.textContent = 'Track unavailable (offline)'; + playBtn.disabled = true; + updateUI(); + stopPlaySecondsTimer(); + }); + + updateUI(); + loadTrack(0); + audio.pause(); + + function urlAvailable(url) { + try { + const u = new URL(url); + return true; + } catch { + return false; + } + } + + function startPlaySecondsTimer() { + stopPlaySecondsTimer(); + playSecondsTimer = setInterval(() => { + playedSeconds++; + api.bus.emit('music:played', { seconds: playedSeconds }); + }, 5000); + } + + function stopPlaySecondsTimer() { + clearInterval(playSecondsTimer); + } + }, +}; diff --git a/js/apps/notepad.js b/js/apps/notepad.js new file mode 100644 index 0000000..21e2763 --- /dev/null +++ b/js/apps/notepad.js @@ -0,0 +1,82 @@ +// Notepad app — simple text editor with localStorage persistence. + +const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7); + +export default { + id: 'notepad', + title: 'Notepad', + render(container, api) { + const docId = uid(); + const storageKey = `meridian:notepad:${docId}`; + let renamed = false; + let saveTimer = null; + + // Load saved content + const saved = localStorage.getItem(storageKey); + let docTitle = 'Untitled'; + let content = saved !== null ? saved : ''; + + // Build UI + const toolbar = document.createElement('div'); + toolbar.className = 'app-toolbar'; + + const titleEl = document.createElement('span'); + titleEl.className = 'doc-title'; + titleEl.textContent = docTitle; + titleEl.title = 'Click to rename'; + titleEl.style.cursor = 'pointer'; + titleEl.addEventListener('click', () => { + const newName = prompt('Rename document:', titleEl.textContent); + if (newName !== null && newName.trim()) { + docTitle = newName.trim(); + titleEl.textContent = docTitle; + renamed = true; + api.setTitle(docTitle); + } + }); + + const saveBtn = document.createElement('button'); + saveBtn.className = 'btn-amber'; + saveBtn.textContent = 'Save'; + saveBtn.addEventListener('click', () => { + saveContent(); + saveBtn.textContent = 'Saved!'; + setTimeout(() => { saveBtn.textContent = 'Save'; }, 1200); + }); + + toolbar.appendChild(titleEl); + toolbar.appendChild(saveBtn); + + const textarea = document.createElement('textarea'); + textarea.value = content; + + const statusbar = document.createElement('div'); + statusbar.className = 'app-status'; + + function updateCounts() { + const text = textarea.value; + const chars = text.length; + const words = text.trim() === '' ? 0 : text.trim().split(/\s+/).length; + statusbar.textContent = `${words} word${words !== 1 ? 's' : ''} · ${chars} character${chars !== 1 ? 's' : ''}`; + } + + textarea.addEventListener('input', () => { + updateCounts(); + api.bus.emit('text:typed', { chars: textarea.value.length }); + // Auto-save after 1s of inactivity + clearTimeout(saveTimer); + saveTimer = setTimeout(saveContent, 1000); + }); + + function saveContent() { + localStorage.setItem(storageKey, textarea.value); + } + + container.style.cssText = 'height:100%;display:flex;flex-direction:column;'; + container.appendChild(toolbar); + container.appendChild(textarea); + container.appendChild(statusbar); + + updateCounts(); + }, +}; diff --git a/js/apps/postman.js b/js/apps/postman.js new file mode 100644 index 0000000..2e592a6 --- /dev/null +++ b/js/apps/postman.js @@ -0,0 +1,184 @@ +// Postman spoof — static-but-believable HTTP client UI. + +const RESPONSES = { + GET: `{ + "id": 1, + "name": "Alice Freeman", + "email": "alice@meridian.dev", + "role": "developer", + "active": true +}`, + POST: `{ + "id": 42, + "name": "New Resource", + "created": "2025-03-14T10:30:00Z", + "status": "created", + "message": "Resource created successfully" +}`, + PUT: `{ + "id": 42, + "name": "Updated Resource", + "updated": "2025-03-14T10:32:00Z", + "status": "updated", + "message": "Resource updated successfully" +}`, + DELETE: `{ + "id": 42, + "status": "deleted", + "message": "Resource deleted successfully" +}`, +}; + +const METHODS = ['GET', 'POST', 'PUT', 'DELETE']; + +export default { + id: 'postman', + title: 'Postman', + render(container, api) { + let method = 'GET'; + let sending = false; + + const root = document.createElement('div'); + root.style.cssText = 'height:100%;display:flex;flex-direction:column;background:var(--bg-1);'; + + // Request bar: method select + URL input + Send button + const reqBar = document.createElement('div'); + reqBar.style.cssText = 'display:flex;align-items:center;gap:0;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg-2);flex-shrink:0;'; + + const methodSelect = document.createElement('select'); + methodSelect.style.cssText = 'padding:6px 10px;background:var(--bg-3);border:1px solid var(--border);border-right:none;border-radius:4px 0 0 4px;color:var(--accent);font-weight:600;font-size:13px;outline:none;cursor:pointer;min-width:70px;'; + METHODS.forEach((m) => { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = m; + if (m === method) opt.selected = true; + methodSelect.appendChild(opt); + }); + methodSelect.addEventListener('change', () => { method = methodSelect.value; }); + + const urlInput = document.createElement('input'); + urlInput.type = 'text'; + urlInput.value = 'https://api.meridian.dev/v1/users'; + urlInput.style.cssText = 'flex:1;padding:6px 12px;background:var(--bg-3);border:1px solid var(--border);color:var(--text);font-size:13px;font-family:monospace;outline:none;'; + urlInput.placeholder = 'Enter request URL'; + + const sendBtn = document.createElement('button'); + sendBtn.style.cssText = 'padding:6px 20px;background:var(--accent);color:#0f1114;border:none;border-radius:0 4px 4px 0;font-size:13px;font-weight:600;cursor:pointer;'; + sendBtn.textContent = 'Send'; + + sendBtn.addEventListener('click', () => { + if (sending) return; + sending = true; + sendBtn.textContent = 'Sending...'; + sendBtn.disabled = true; + + setTimeout(() => { + sendBtn.textContent = 'Send'; + sendBtn.disabled = false; + sending = false; + showResponse(); + }, 200); + }); + + reqBar.appendChild(methodSelect); + reqBar.appendChild(urlInput); + reqBar.appendChild(sendBtn); + root.appendChild(reqBar); + + // Tabs + const tabNames = ['Params', 'Headers', 'Body']; + let activeTab = 'Params'; + const tabBtns = []; + + const tabBar = document.createElement('div'); + tabBar.style.cssText = 'display:flex;gap:0;border-bottom:1px solid var(--border);background:var(--bg-2);flex-shrink:0;'; + + tabNames.forEach((tab) => { + const btn = document.createElement('button'); + const isActive = activeTab === tab; + btn.style.cssText = 'padding:8px 16px;background:transparent;border:none;border-right:1px solid var(--border);color:' + (isActive ? 'var(--text)' : 'var(--text-dim)') + ';font-size:12px;cursor:pointer;transition:color 150ms;'; + btn.textContent = tab; + btn.addEventListener('click', () => { + activeTab = tab; + tabArea.innerHTML = contentPanels[tab]; + tabBtns.forEach((b) => { b.style.color = 'var(--text-dim)'; }); + btn.style.color = 'var(--text)'; + }); + tabBtns.push(btn); + tabBar.appendChild(btn); + }); + + root.appendChild(tabBar); + + // Tab content panels + const contentPanels = { + Params: '
' + + '
' + + 'Authorization' + + '' + + '
' + + '
' + + 'Content-Type' + + '' + + '
' + + '
' + + 'X-Request-ID' + + '' + + '
' + + '
', + Headers: '
' + + '
' + + 'User-Agent' + + 'MeridianClient/1.0' + + '
' + + '
' + + 'Accept' + + 'application/json' + + '
' + + '
' + + 'Cache-Control' + + 'no-cache' + + '
' + + '
', + Body: '
' + + '' + + '
', + }; + + // Build body content separately since it has real JSON + const postmanBody = JSON.stringify({ name: 'Alice Freeman', email: 'alice@meridian.dev', role: 'developer' }, null, 2); + + const tabArea = document.createElement('div'); + tabArea.style.cssText = 'flex:1;overflow-y:auto;background:var(--bg-2);'; + tabArea.innerHTML = contentPanels[activeTab]; + root.appendChild(tabArea); + + // Response label + const respLabel = document.createElement('div'); + respLabel.style.cssText = 'padding:8px 12px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid var(--border);background:var(--bg-2);flex-shrink:0;'; + respLabel.textContent = 'Response'; + + // Response pre + const respArea = document.createElement('pre'); + respArea.style.cssText = 'flex:1;overflow:auto;padding:12px 16px;margin:0;font-size:12px;line-height:1.6;color:var(--text);font-family:monospace;background:var(--bg-1);'; + respArea.textContent = 'Click Send to execute the request'; + + // Status line + const statusLine = document.createElement('div'); + statusLine.style.cssText = 'padding:4px 12px;font-size:11px;color:var(--text-dim);border-top:1px solid var(--border);background:var(--bg-2);flex-shrink:0;'; + statusLine.textContent = 'Ready'; + + root.appendChild(respLabel); + root.appendChild(respArea); + root.appendChild(statusLine); + container.appendChild(root); + + function showResponse() { + const resp = RESPONSES[method] || RESPONSES.GET; + const time = Math.floor(Math.random() * 60) + 40; + const size = resp.length + 2; + respArea.textContent = resp; + statusLine.textContent = 'Status: 200 OK \u{B7} Time: ' + time + 'ms \u{B7} Size: ' + size + ' B'; + } + }, +}; diff --git a/js/apps/steam.js b/js/apps/steam.js new file mode 100644 index 0000000..e516842 --- /dev/null +++ b/js/apps/steam.js @@ -0,0 +1,133 @@ +// Steam spoof — static-but-believable game library UI. + +const GAMES = [ + { id: 'elden', title: 'Elden Ring', desc: 'Explore the Lands Between in this open-world action RPG from FromSoftware. Face legendary bosses, forge your build, and uncover the mystery of the shattered Elden Ring. Every step is a descent into the unknown.' }, + { id: 'cod', title: 'Call of Duty: MW2', desc: 'Relive the global conflict in this first-person shooter campaign. From Operation Deadbolt to the final push, the stakes have never been higher. Multiplayer is included, if you can find it in the files.' }, + { id: 'fifa', title: 'FIFA 24', desc: 'The world\'s game, rendered in full hyper-realistic detail. Play Career Mode, Ultimate Team, or just stare at the menu screen for three hours. Your choice, really.' }, + { id: 'hades', title: 'Hades II', desc: 'Return to the Underworld as Melinoch, sister of Zagreus. Wield witchcraft, summon gods, and break out of the cycle of death and rebirth. Again. This time with more style.' }, + { id: 'factorio', title: 'Factorio', desc: 'Build factories, then build bigger factories, then automate your entire existence into a sprawling industrial complex that will consume all your spare time. There is no escape.' }, + { id: 'stardew', title: 'Stardew Valley', desc: 'Leave the city. Take over your grandfather\'s old farm plot. Grow crops, raise animals, befriend the locals. It\'s a quiet life, and you\'ll never leave.' }, + { id: 'bg3', title: "Baldur's Gate 3", desc: 'Gather your party. Roll the dice. Make the worst possible choice. Larian Studios has crafted a deep RPG experience with consequences that actually matter. Mostly.' }, +]; + +export default { + id: 'steam', + title: 'Steam', + render(container, api) { + let selectedGame = null; + let installing = null; + + const root = document.createElement('div'); + root.style.cssText = 'height:100%;display:flex;flex-direction:column;background:var(--bg-1);'; + + // Top bar + const topBar = document.createElement('div'); + topBar.style.cssText = 'background:var(--bg-2);padding:8px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;'; + const topTitle = document.createElement('span'); + topTitle.style.cssText = 'font-size:14px;font-weight:600;color:var(--text);'; + topTitle.textContent = 'Steam'; + topBar.appendChild(topTitle); + root.appendChild(topBar); + + // Body: sidebar + main + const body = document.createElement('div'); + body.style.cssText = 'flex:1;display:flex;overflow:hidden;'; + + // Sidebar + const sidebar = document.createElement('div'); + sidebar.style.cssText = 'width:220px;background:var(--bg-2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto;flex-shrink:0;'; + const sidebarLabel = document.createElement('div'); + sidebarLabel.style.cssText = 'padding:12px 14px 6px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;'; + sidebarLabel.textContent = 'Library'; + sidebar.appendChild(sidebarLabel); + + GAMES.forEach((game) => { + const item = document.createElement('div'); + item.style.cssText = 'padding:8px 14px;cursor:pointer;font-size:13px;color:var(--text);transition:background 150ms ease-out;border-left:2px solid transparent;'; + item.textContent = game.title; + item.addEventListener('click', () => { selectGame(game); }); + item.dataset.gameId = game.id; + sidebar.appendChild(item); + }); + + // Main area + const mainArea = document.createElement('div'); + mainArea.style.cssText = 'flex:1;display:flex;flex-direction:column;overflow-y:auto;padding:24px;'; + + // Initial state: "Select a game" + const placeholder = document.createElement('div'); + placeholder.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:14px;'; + placeholder.textContent = 'Select a game from your library'; + mainArea.appendChild(placeholder); + + body.appendChild(sidebar); + body.appendChild(mainArea); + root.appendChild(body); + + // Status bar + const status = document.createElement('div'); + status.style.cssText = 'background:var(--bg-2);border-top:1px solid var(--border);padding:6px 14px;font-size:11px;color:var(--text-dim);flex-shrink:0;'; + status.textContent = 'Online \u{B7} 142 friends online \u{B7} 4.7 TB free'; + root.appendChild(status); + + container.appendChild(root); + + function selectGame(game) { + selectedGame = game; + installing = null; + + // Clear main area and add game details + while (mainArea.firstChild) mainArea.removeChild(mainArea.firstChild); + + // Hero image (gradient block with title overlay) + const hero = document.createElement('div'); + hero.style.cssText = 'width:100%;height:200px;background:linear-gradient(135deg, rgba(37,42,49,0.9), rgba(212,162,76,0.2));border:1px solid var(--border);display:flex;align-items:flex-end;padding:20px;margin-bottom:20px;position:relative;overflow:hidden;border-radius:4px;'; + const heroOverlay = document.createElement('div'); + heroOverlay.style.cssText = 'font-size:32px;font-weight:700;color:var(--text);text-shadow:0 2px 8px rgba(0,0,0,0.6);'; + heroOverlay.textContent = game.title; + hero.appendChild(heroOverlay); + mainArea.appendChild(hero); + + // Description + const desc = document.createElement('p'); + desc.style.cssText = 'font-size:13px;color:var(--text);line-height:1.7;margin-bottom:20px;max-width:600px;'; + desc.textContent = game.desc; + mainArea.appendChild(desc); + + // Install button + const installBtn = document.createElement('button'); + installBtn.style.cssText = 'padding:10px 28px;background:var(--accent);color:#0f1114;border:none;border-radius:4px;font-size:14px;font-weight:600;cursor:pointer;transition:opacity 150ms ease-out;width:fit-content;'; + installBtn.textContent = 'Install'; + installBtn.addEventListener('click', () => startInstall(game, installBtn)); + mainArea.appendChild(installBtn); + } + + function startInstall(game, btn) { + installing = game.id; + btn.textContent = 'Downloading...'; + btn.disabled = true; + + const progressWrap = document.createElement('div'); + progressWrap.style.cssText = 'width:280px;height:6px;background:var(--bg-3);border-radius:3px;margin-top:12px;overflow:hidden;'; + const progressFill = document.createElement('div'); + progressFill.style.cssText = 'width:0%;height:100%;background:var(--accent);border-radius:3px;transition:width 2s linear;'; + progressWrap.appendChild(progressFill); + mainArea.appendChild(progressWrap); + + // Animate progress over 2s + requestAnimationFrame(() => { + progressFill.style.width = '100%'; + }); + + setTimeout(() => { + btn.textContent = 'Installed (in your imagination)'; + btn.style.background = 'var(--bg-3)'; + btn.style.color = 'var(--text-dim)'; + btn.style.border = '1px solid var(--border)'; + btn.disabled = false; + installing = null; + progressWrap.remove(); + }, 2100); + } + }, +}; diff --git a/js/apps/terminal.js b/js/apps/terminal.js new file mode 100644 index 0000000..e95e0a5 --- /dev/null +++ b/js/apps/terminal.js @@ -0,0 +1,316 @@ +export default { + id: 'terminal', + title: 'Terminal', + render: function (container, api) { + var commandHistory = []; + var historyIndex = -1; + var outputLines = []; + var bus = api.bus; + + // ── DOM Structure ────────────────────────────────────── + container.style.cssText = 'height:100%;width:100%;background:#0a0b0d;color:#cfd3d8;display:flex;flex-direction:column;font-family:ui-monospace,"JetBrains Mono",Menlo,monospace;overflow:hidden;cursor:text;'; + + var outputEl = document.createElement('div'); + outputEl.style.cssText = 'flex:1;overflow-y:auto;padding:12px;font-size:14px;line-height:1.5;white-space:pre-wrap;word-break:break-word;'; + + var inputRow = document.createElement('div'); + inputRow.style.cssText = 'display:flex;align-items:center;padding:4px 12px 12px;'; + + var promptEl = document.createElement('span'); + promptEl.style.cssText = 'color:#d4a24c;font-size:14px;line-height:1.5;white-space:pre;user-select:none;'; + promptEl.textContent = 'tarnished@meridian:~$ '; + + var inputEl = document.createElement('input'); + inputEl.style.cssText = 'flex:1;background:transparent;border:none;color:inherit;outline:none;font-family:inherit;font-size:14px;line-height:1.5;cursor:text;'; + inputEl.setAttribute('type', 'text'); + inputEl.setAttribute('autocomplete', 'off'); + inputEl.setAttribute('autocorrect', 'off'); + inputEl.setAttribute('autocapitalize', 'off'); + inputEl.setAttribute('spellcheck', 'false'); + + inputRow.appendChild(promptEl); + inputRow.appendChild(inputEl); + container.appendChild(outputEl); + container.appendChild(inputRow); + + // ── Helpers ──────────────────────────────────────────── + function appendLine(text, color) { + var line = document.createElement('div'); + line.style.cssText = 'font-size:14px;line-height:1.5;'; + line.style.color = color || '#cfd3d8'; + line.textContent = text; + outputEl.appendChild(line); + outputLines.push(line); + outputEl.scrollTop = outputEl.scrollHeight; + } + + function printBanner() { + appendLine('Meridian Shell 1.0 (kernel 4.19-browser)'); + appendLine("Type 'help' to begin. Try 'praise_the_sun' if you know what you're doing."); + appendLine(''); + } + + // ── Real Commands ────────────────────────────────────── + function cmdHelp() { + appendLine('Available commands:'); + appendLine(' help Show this help message'); + appendLine(' ls List directory contents'); + appendLine(' whoami Show current user'); + appendLine(' date Show current date'); + appendLine(' uname Show system info'); + appendLine(' echo Print text'); + appendLine(' clear Clear terminal output'); + appendLine(' cat Read a file'); + appendLine(' history Show command history'); + appendLine(' achievements Show unlocked achievements'); + appendLine(' exit Close terminal'); + appendLine(''); + appendLine("Try 'praise_the_sun' if you know what you're doing."); + } + + function cmdLs() { + appendLine('Documents Downloads Music Pictures save_files.dat'); + } + + function cmdWhoami() { + var user = api.state.get('user'); + appendLine(user ? user.name : 'tarnished'); + } + + function cmdDate() { + appendLine(new Date().toString()); + } + + function cmdUname() { + appendLine('Meridian 1.0 (browser-kernel)'); + } + + function cmdEcho(args) { + if (args.length === 0) return; + appendLine(args.join(' ')); + } + + function cmdClear() { + outputEl.innerHTML = ''; + outputLines = []; + } + + function cmdCat(file) { + if (!file) { + appendLine('cat: missing operand', '#c85d5d'); + return; + } + if (file === 'save_files.dat') { + appendLine("Ah, you want to read your save file? Not today. Some mysteries are better left unsolved.", '#d4a24c'); + return; + } + appendLine('cat: ' + file + ': No such file or directory', '#c85d5d'); + } + + function cmdHistory() { + if (commandHistory.length === 0) { + appendLine('No commands in history.'); + return; + } + commandHistory.forEach(function (cmd, i) { + appendLine(' ' + String(i + 1).padStart(4) + ' ' + cmd); + }); + } + + function cmdAchievements() { + var achievements = api.state.get('achievements'); + if (!achievements || achievements.length === 0) { + appendLine('No achievements unlocked yet.'); + return; + } + appendLine('Unlocked achievements (' + achievements.length + '):'); + achievements.forEach(function (a) { + appendLine(' [x] ' + a); + }); + } + + function cmdExit() { + api.close(); + } + + // ── Easter Eggs ──────────────────────────────────────── + var easterEggs = { + 'praise_the_sun': { + msg: 'A grace of Gold is bestowed upon you.\nMay it guide you in the dark times ahead.', + slug: 'er_grace' + }, + 'tarnished': { + msg: 'Well then, tarnished. Seek strength. For that is your duty.', + slug: 'er_tarnished' + }, + 'try finger, but hole': { + msg: 'Message left by a fellow traveler. Rating: brilliant. Rating: inappropriate.', + slug: 'er_bong_hits' + }, + 'dropshot': { + msg: 'Hitmarker. Clean drop.', + slug: 'er_dropshot' + }, + '360_noscope': { + msg: ' |\n /|\\\n-------\n |/\nmontage material acquired.', + slug: 'er_360noscope' + }, + 'mw2': { + msg: 'A true classic. Soap would be proud.', + slug: 'er_mw2' + }, + 'siuuu': { + msg: 'SIUUUUU! The keeper had no chance.', + slug: 'er_siuuu' + }, + 'golazo': { + msg: 'Top bins. What a strike. What a moment.', + slug: 'er_golazo' + }, + 'wonderkid': { + msg: 'Regen detected. Potential: 5 stars.', + slug: 'er_wonderkid' + }, + 'konami': { + msg: 'Cheat mode enabled. (it does nothing. you are already a legend.)', + slug: 'er_konami' + }, + 'matrix': { + msg: null, + slug: 'er_matrix' + }, + 'sudo rm -rf /': { + msg: 'nice try, tarnished. root is a state of mind.', + slug: 'er_sudo_rm_rf' + } + }; + + function triggerEasterEgg(cmdName, egg) { + bus.emit('term:cmd', { cmd: cmdName }); + bus.emit('easter:found', { id: egg.slug }); + + if (cmdName === 'matrix') { + appendLine('Entering the matrix...', '#d4a24c'); + var mlines = []; + for (var mi = 0; mi < 8; mi++) { + var row = ''; + for (var mj = 0; mj < 40; mj++) { + row += Math.random() < 0.5 ? '0' : '1'; + } + mlines.push(row); + } + var mlIdx = 0; + function typeNext() { + if (mlIdx < mlines.length) { + appendLine(mlines[mlIdx], '#41c35a'); + mlIdx++; + setTimeout(typeNext, 30); + } + } + setTimeout(typeNext, 30); + return; + } + + if (cmdName === 'sudo rm -rf /') { + appendLine('...', '#d4a24c'); + setTimeout(function () { + outputEl.removeChild(outputEl.lastChild); + appendLine('nice try, tarnished. root is a state of mind.', '#d4a24c'); + }, 900); + return; + } + + if (egg.msg && egg.msg.indexOf('\n') !== -1) { + egg.msg.split('\n').forEach(function (ln) { + appendLine(ln, '#d4a24c'); + }); + } else if (egg.msg) { + appendLine(egg.msg, '#d4a24c'); + } + } + + // ── Command Processing ───────────────────────────────── + function emitRealCmd(cmd) { + bus.emit('term:cmd', { cmd: cmd }); + } + + function processInput(raw) { + var trimmed = raw.trim(); + if (!trimmed) return; + + commandHistory.push(trimmed); + if (commandHistory.length > 50) { + commandHistory.shift(); + } + historyIndex = commandHistory.length; + + appendLine(promptEl.textContent + trimmed, '#d4a24c'); + + if (easterEggs[trimmed]) { + triggerEasterEgg(trimmed, easterEggs[trimmed]); + return; + } + + var parts = trimmed.split(/\s+/); + var cmd = parts[0].toLowerCase(); + + if (easterEggs[cmd]) { + triggerEasterEgg(cmd, easterEggs[cmd]); + return; + } + + emitRealCmd(cmd); + switch (cmd) { + case 'help': cmdHelp(); break; + case 'ls': cmdLs(); break; + case 'whoami': cmdWhoami(); break; + case 'date': cmdDate(); break; + case 'uname': cmdUname(); break; + case 'echo': cmdEcho(parts.slice(1)); break; + case 'clear': cmdClear(); break; + case 'cat': cmdCat(parts[1]); break; + case 'history': cmdHistory(); break; + case 'achievements': cmdAchievements(); break; + case 'exit': cmdExit(); break; + default: + appendLine('Command not found: ' + cmd, '#c85d5d'); + } + } + + // ── Keyboard Handling ────────────────────────────────── + inputEl.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + processInput(inputEl.value); + inputEl.value = ''; + historyIndex = commandHistory.length; + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (historyIndex > 0) { + historyIndex--; + inputEl.value = commandHistory[historyIndex]; + } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (historyIndex < commandHistory.length - 1) { + historyIndex++; + inputEl.value = commandHistory[historyIndex]; + } else { + historyIndex = commandHistory.length; + inputEl.value = ''; + } + } else if (e.ctrlKey && e.key.toLowerCase() === 'l') { + e.preventDefault(); + cmdClear(); + } + }); + + // ── Click to Focus ───────────────────────────────────── + container.addEventListener('click', function () { + inputEl.focus(); + }); + + // ── Initial Banner ───────────────────────────────────── + printBanner(); + inputEl.focus(); + } +}; diff --git a/js/apps/vscode.js b/js/apps/vscode.js new file mode 100644 index 0000000..f2be9bb --- /dev/null +++ b/js/apps/vscode.js @@ -0,0 +1,214 @@ +// VS Code spoof — three-pane layout with file explorer and syntax-highlighted editor. + +const FILES = [ + { path: 'src/index.ts', content: `import { Engine } from './engine'; + +const engine = new Engine({ + title: 'Meridian OS', + version: '0.1.0', + antialias: true, + vsync: true, +}); + +// Entry point +engine.start(); +console.log('Meridian OS initialized'); + +export default engine;` }, + { path: 'src/engine.ts', content: `export interface EngineConfig { + title: string; + version: string; + antialias: boolean; + vsync: boolean; +} + +export class Engine { + private config: EngineConfig; + private running: boolean = false; + + constructor(config: EngineConfig) { + this.config = config; + this.running = false; + } + + start(): void { + if (this.running) return; + this.running = true; + console.log(\`Starting \${this.config.title} v\${this.config.version}\`); + this.renderLoop(); + } + + private renderLoop(): void { + while (this.running) { + this.update(); + this.draw(); + } + } + + private update(): void { + // Game loop update + } + + private draw(): void { + // Game loop render + } + + stop(): void { + this.running = false; + } +}` }, + { path: 'README.md', content: `# Meridian OS + +## Overview +A browser-based desktop environment for a video game. +Elegant, restrained, gamer-literate without being loud. + +## Features +- Window manager with drag, focus, minimize, close +- Multiple apps: Notepad, Music, Terminal +- Spoof apps: Steam, VS Code, Postman, Chrome +- Achievement system +- localStorage persistence + +## Tech Stack +- Vanilla JS +- CSS custom properties +- No frameworks. No excuses.` }, +]; + +function highlightTS(code) { + // Very basic "syntax highlighting" — just colour types, strings, keywords + return code + .replace(/(import|export|from|const|let|var|function|class|private|public|return|if|while|interface|type|new|void|false|true|this|typeof|instanceof|extends)/g, '$1') + .replace(/(\/\/.*)/g, '$1') + .replace(/('.*?'|".*?"|`.*?`)/g, '$1') + .replace(/\b(\d+)\b/g, '$1') + .replace(/(\w+)(?=\s*\()/g, '$1') + .replace(/(:\s*)(\w+)/g, '$1$2'); +} + +export default { + id: 'vscode', + title: 'VS Code', + render(container, api) { + let activeFile = 0; + + const root = document.createElement('div'); + root.style.cssText = 'height:100%;display:flex;flex-direction:column;background:#1e1e1e;font-family:var(--font-mono,monospace);font-size:13px;color:#d4d4d4;'; + + // Activity bar (leftmost, 40px wide) + const actBar = document.createElement('div'); + actBar.style.cssText = 'width:40px;background:#333333;display:flex;flex-direction:column;align-items:center;padding-top:8px;border-right:1px solid #444444;flex-shrink:0;'; + + // 5 geometric icons as divs + const icons = [ + { shape: 'square', id: 'explorer' }, + { shape: 'hline', id: 'search' }, + { shape: 'dots', id: 'git' }, + { shape: 'circle', id: 'debug' }, + { shape: 'grid', id: 'extensions' }, + ]; + + const iconStyle = 'width:24px;height:24px;margin-bottom:16px;cursor:pointer;opacity:0.5;transition:opacity 150ms;'; + icons.forEach((icon) => { + const el = document.createElement('div'); + el.style.cssText = iconStyle; + el.id = `vscode-icon-${icon.id}`; + + if (icon.shape === 'square') { + el.innerHTML = '
'; + } else if (icon.shape === 'hline') { + el.innerHTML = '
'; + } else if (icon.shape === 'dots') { + el.innerHTML = '
' + + '
' + + '
' + + '
'; + } else if (icon.shape === 'circle') { + el.innerHTML = '
'; + } else if (icon.shape === 'grid') { + el.innerHTML = '
' + + '
' + + '
'; + } + + if (icon.id === 'explorer') { + el.style.opacity = '1'; + el.style.borderLeft = '2px solid var(--accent)'; + el.style.marginLeft = '-2px'; + } + + el.addEventListener('click', () => { + icons.forEach((ic) => { + const e = document.getElementById(`vscode-icon-${ic.id}`); + if (e) { e.style.opacity = '0.5'; e.style.borderLeft = 'none'; } + }); + el.style.opacity = '1'; + el.style.borderLeft = '2px solid var(--accent)'; + el.style.marginLeft = '-2px'; + }); + + actBar.appendChild(el); + }); + + // File explorer (200px wide) + const explorer = document.createElement('div'); + explorer.style.cssText = 'width:200px;background:#252526;border-right:1px solid #444444;display:flex;flex-direction:column;flex-shrink:0;'; + + const explLabel = document.createElement('div'); + explLabel.style.cssText = 'padding:8px 12px;font-size:11px;font-weight:600;color:#bbbbbb;text-transform:uppercase;letter-spacing:0.05em;'; + explLabel.textContent = 'Explorer'; + explorer.appendChild(explLabel); + + const folderLabel = document.createElement('div'); + folderLabel.style.cssText = 'padding:4px 12px;font-size:12px;color:#9cdcfe;font-weight:600;cursor:pointer;'; + folderLabel.textContent = 'MERIDIAN-OS'; + explorer.appendChild(folderLabel); + + FILES.forEach((file, i) => { + const row = document.createElement('div'); + row.style.cssText = 'padding:4px 12px 4px 24px;font-size:13px;color:#cccccc;cursor:pointer;display:flex;align-items:center;transition:background 100ms;'; + const ext = file.path.split('.').pop(); + const tsColor = ext === 'ts' ? '#519aba' : (ext === 'md' ? '#82aaff' : '#cccccc'); + row.innerHTML = `${ext === 'md' ? 'M' : '{ }'}${file.path}`; + row.addEventListener('click', () => { + activeFile = i; + updateEditor(); + }); + explorer.appendChild(row); + }); + + // Editor area + const editor = document.createElement('div'); + editor.style.cssText = 'flex:1;overflow:auto;padding:16px 20px;background:#1e1e1e;'; + + const editorContent = document.createElement('pre'); + editorContent.style.cssText = 'margin:0;font-family:Consolas,Menlo,Monaco,monospace;font-size:13px;line-height:1.6;white-space:pre;tab-size:2;'; + + function updateEditor() { + editorContent.innerHTML = '' + + Array.from({ length: FILES[activeFile].content.split('\n').length }, (_, i) => i + 1).join('\n') + + ' ' + highlightTS(FILES[activeFile].content); + } + + updateEditor(); + editor.appendChild(editorContent); + + // Body container + const body = document.createElement('div'); + body.style.cssText = 'flex:1;display:flex;overflow:hidden;'; + body.appendChild(explorer); + body.appendChild(editor); + + // Status bar + const statusBar = document.createElement('div'); + statusBar.style.cssText = 'height:22px;background:#007acc;display:flex;align-items:center;padding:0 12px;font-size:12px;color:#ffffff;flex-shrink:0;gap:16px;'; + statusBar.innerHTML = 'main\u{2713} 0 problemsUTF-8TypeScript'; + + root.appendChild(actBar); + root.appendChild(body); + root.appendChild(statusBar); + + container.appendChild(root); + }, +}; diff --git a/js/core/auth.js b/js/core/auth.js new file mode 100644 index 0000000..87c8e2d --- /dev/null +++ b/js/core/auth.js @@ -0,0 +1,71 @@ +import State from './state.js'; +import Bus from './bus.js'; + +const Auth = (() => { + function login(name) { + const trimmed = name.trim(); + if (!trimmed) return; + const initials = trimmed.slice(0, 2).toUpperCase(); + State.set('user', { name: trimmed, initials }); + document.getElementById('login').classList.add('hidden'); + const desktop = document.getElementById('desktop'); + desktop.classList.remove('hidden'); + desktop.hidden = false; + Bus.emit('user:login', { name: trimmed }); + // Persist login session + localStorage.setItem('meridian:session', trimmed); + } + + function logout() { + State.set('user', null); + localStorage.removeItem('meridian:session'); + location.reload(); + } + + function init() { + const session = localStorage.getItem('meridian:session'); + if (session) { + const trimmed = session.trim(); + if (trimmed) { + const initials = trimmed.slice(0, 2).toUpperCase(); + State.set('user', { name: trimmed, initials }); + const loginEl = document.getElementById('login'); + if (loginEl) loginEl.classList.add('hidden'); + const desktop = document.getElementById('desktop'); + if (desktop) { + desktop.classList.remove('hidden'); + desktop.hidden = false; + } + return; + } + } + // Wire login form + const btn = document.getElementById('login-btn'); + const input = document.getElementById('login-input'); + const submit = () => { if (input && input.value.trim()) login(input.value); }; + if (btn) btn.addEventListener('click', submit); + if (input) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); submit(); } + }); + setTimeout(() => input.focus(), 50); + } + // Live clock on login screen + const loginTime = document.getElementById('login-time'); + if (loginTime) { + const tick = () => { + const d = new Date(); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + loginTime.textContent = `${hh}:${mm}`; + }; + tick(); + setInterval(tick, 1000 * 30); + } + } + + return { login, logout, init }; +})(); + +export default Auth; +export { Auth }; diff --git a/js/core/bus.js b/js/core/bus.js new file mode 100644 index 0000000..ebceecc --- /dev/null +++ b/js/core/bus.js @@ -0,0 +1,36 @@ +/** + * Simple pub/sub event bus. + * on(evt, cb) - subscribe + * emit(evt, data) - publish + */ +const Bus = (() => { + const listeners = new Map(); + + return { + on(evt, cb) { + if (!listeners.has(evt)) listeners.set(evt, new Set()); + listeners.get(evt).add(cb); + return () => this.off(evt, cb); + }, + + off(evt, cb) { + const set = listeners.get(evt); + if (set) { + set.delete(cb); + if (set.size === 0) listeners.delete(evt); + } + }, + + emit(evt, data) { + const set = listeners.get(evt); + if (set) { + for (const cb of set) { + try { cb(data); } catch (e) { console.error(`Bus emit error (${evt}):`, e); } + } + } + } + }; +})(); + +export default Bus; +export { Bus }; diff --git a/js/core/registry.js b/js/core/registry.js new file mode 100644 index 0000000..8094b95 --- /dev/null +++ b/js/core/registry.js @@ -0,0 +1,148 @@ +import MeridianWM from './wm.js'; +import State from './state.js'; +import Bus from './bus.js'; + +const Registry = (() => { + const APPS = {}; + let _launcherOpen = false; + + function registerApp(def) { + if (def.id) { + APPS[def.id] = def; + } + } + + function unregisterApp(id) { + delete APPS[id]; + } + + function getApp(id) { + return APPS[id] || null; + } + + function launchApp(id) { + const def = APPS[id]; + if (!def) { + console.warn(`App ${id} not registered`); + return; + } + + const title = def.title || id; + const w = def.width || 640; + const h = def.height || 480; + + // Create window with a render-callback; wm will mount container then call it. + const win = MeridianWM.openWindow({ + app: id, + title, + w, h, + content: (container) => { + // Tag container for per-app CSS scoping + container.setAttribute('data-app', id); + const api = { + state: State, + bus: Bus, + close: () => MeridianWM.closeWindow(win && win.id), + setTitle: (t) => { + const el = document.getElementById(win && win.id); + if (el) { + const titleEl = el.querySelector('.m-window-title'); + if (titleEl) titleEl.textContent = t; + } + }, + }; + try { + def.render(container, api); + } catch (e) { + container.textContent = `Error loading ${id}: ${e.message}`; + console.error(`[${id}] render error`, e); + } + }, + }); + closeLauncher(); + } + + function getRegisteredApps() { + return Object.values(APPS); + } + + function toggleLauncher() { + _launcherOpen = !_launcherOpen; + const popover = document.getElementById('launcher-popover'); + const btn = document.getElementById('launcher'); + if (popover) { + if (_launcherOpen) { + renderLauncherApps(popover); + popover.classList.remove('hidden'); + } else { + popover.classList.add('hidden'); + } + } + if (btn) { + btn.classList.toggle('active', _launcherOpen); + } + return _launcherOpen; + } + + function closeLauncher() { + _launcherOpen = false; + const popover = document.getElementById('launcher-popover'); + const btn = document.getElementById('launcher'); + if (popover) popover.classList.add('hidden'); + if (btn) btn.classList.remove('active'); + } + + function renderLauncherApps(popover) { + const apps = getRegisteredApps(); + popover.innerHTML = ''; + if (apps.length === 0) { + popover.innerHTML = '
No apps registered
'; + return; + } + for (const app of apps) { + const btn = document.createElement('button'); + btn.className = 'launcher-app-btn'; + btn.setAttribute('aria-label', `Launch ${app.title || app.id}`); + btn.innerHTML = ` +
${app.icon || '◆'}
+
${escapeHtml(app.title || app.id)}
+ `; + btn.addEventListener('click', () => launchApp(app.id)); + popover.appendChild(btn); + } + } + + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function init() { + const btn = document.getElementById('launcher'); + if (btn) { + btn.addEventListener('click', toggleLauncher); + } + + // Close launcher when clicking elsewhere + document.addEventListener('mousedown', (e) => { + if (_launcherOpen && !e.target.closest('#launcher') && !e.target.closest('#launcher-popover')) { + closeLauncher(); + } + }); + } + + return { + APPS, + registerApp, + unregisterApp, + getApp, + launchApp, + getRegisteredApps, + toggleLauncher, + init + }; +})(); + +export default Registry; +export { Registry }; diff --git a/js/core/state.js b/js/core/state.js new file mode 100644 index 0000000..2327e81 --- /dev/null +++ b/js/core/state.js @@ -0,0 +1,107 @@ +/** + * Central state store with persistence. + * Keys: user, windows, wallpaper, achievements, connection + */ +const STORAGE_KEY = 'meridian:state'; + +const State = (() => { + const listeners = new Map(); + let _state = loadState(); + + function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + // achievements is stored as array, reconstruct as Set + if (parsed.achievements) { + parsed._achievementSet = new Set(parsed.achievements); + } + return parsed; + } + } catch (e) { + console.warn('State load failed:', e); + } + return { + user: null, + windows: [], + wallpaper: 'graphite', + achievements: [], + _achievementSet: new Set(), + connection: navigator.onLine + }; + } + + function saveState() { + try { + const toSave = { + user: _state.user, + windows: _state.windows, + wallpaper: _state.wallpaper, + achievements: Array.from(_state._achievementSet || _state.achievements) + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); + } catch (e) { + console.warn('State save failed:', e); + } + } + + function set(key, val) { + _state[key] = val; + saveState(); + notify(key); + } + + function get(key) { + return _state[key]; + } + + function subscribe(key, cb) { + if (!listeners.has(key)) listeners.set(key, new Set()); + listeners.get(key).add(cb); + // immediate call with current value + try { cb(_state[key]); } catch (e) { console.error('State subscribe error:', e); } + return () => off(key, cb); + } + + function off(key, cb) { + const set = listeners.get(key); + if (set) { + set.delete(cb); + if (set.size === 0) listeners.delete(key); + } + } + + function notify(key) { + const set = listeners.get(key); + if (set) { + for (const cb of set) { + try { cb(_state[key]); } catch (e) { console.error('State notify error:', e); } + } + } + } + + function addAchievement(id) { + if (!_state._achievementSet) { + _state._achievementSet = new Set(_state.achievements || []); + _state.achievements = Array.from(_state._achievementSet); + } + _state._achievementSet.add(id); + _state.achievements = Array.from(_state._achievementSet); + saveState(); + notify('achievements'); + } + + function initConnection() { + _state.connection = navigator.onLine; + window.addEventListener('online', () => { _state.connection = true; notify('connection'); saveState(); }); + window.addEventListener('offline', () => { _state.connection = false; notify('connection'); saveState(); }); + } + + return { + set, get, subscribe, off, addAchievement, initConnection + }; +})(); + +export default State; +export { State }; diff --git a/js/core/wm.js b/js/core/wm.js new file mode 100644 index 0000000..be5e053 --- /dev/null +++ b/js/core/wm.js @@ -0,0 +1,284 @@ +import State from './state.js'; +import Bus from './bus.js'; + +let windowIdCounter = 0; + +function createWindow(options) { + const { app, title, content, w = 640, h = 480 } = options; + const id = 'win-' + (++windowIdCounter); + + // Cascade position + const count = State.get('windows').length; + const offsetX = 40 + (count * 30) % 200; + const offsetY = 30 + (count * 30) % 150; + + const layer = document.getElementById('windows-layer'); + if (!layer) return null; + + const el = document.createElement('div'); + el.className = 'm-window focused'; + el.id = id; + el.style.left = offsetX + 'px'; + el.style.top = offsetY + 'px'; + el.style.width = w + 'px'; + el.style.height = h + 'px'; + el.style.zIndex = 10 + count; + + el.innerHTML = ` +
+ ${escapeHtml(title)} +
+ + +
+
+
+
+ `; + + layer.appendChild(el); + + // Render content + const body = el.querySelector('.m-window-body'); + if (typeof content === 'string') { + body.innerHTML = content; + } else if (content instanceof HTMLElement) { + body.innerHTML = ''; + body.appendChild(content); + } else if (typeof content === 'function') { + body.innerHTML = ''; + content(body); + } + + // Setup drag + setupDrag(el, id); + // Setup resize + setupResize(el, id); + // Setup focus + el.addEventListener('mousedown', () => focusWindow(id)); + // Setup header button clicks + el.querySelector('.minimize-btn').addEventListener('click', (e) => { + e.stopPropagation(); + minimizeWindow(id); + }); + el.querySelector('.close-btn').addEventListener('click', (e) => { + e.stopPropagation(); + closeWindow(id); + }); + + // Track in state + const windows = State.get('windows'); + windows.push({ id, app, title, minimized: false }); + State.set('windows', windows); + + Bus.emit('app:open', { app, id }); + + return id; +} + +function closeWindow(id) { + const el = document.getElementById(id); + if (!el) return; + + // Find window data + const windows = State.get('windows'); + const idx = windows.findIndex(w => w.id === id); + if (idx !== -1) { + const win = windows[idx]; + windows.splice(idx, 1); + State.set('windows', windows); + Bus.emit('app:close', { app: win.app, id }); + } + + el.remove(); +} + +function minimizeWindow(id) { + const el = document.getElementById(id); + if (!el) return; + + el.classList.add('minimized'); + const windows = State.get('windows'); + const win = windows.find(w => w.id === id); + if (win) win.minimized = true; + State.set('windows', windows); + + // If this was the focused window, focus the next available + const focusable = windows.filter(w => !w.minimized); + if (focusable.length > 0) { + focusWindow(focusable[focusable.length - 1].id); + } +} + +function focusWindow(id) { + const el = document.getElementById(id); + if (!el) return; + + // Remove focus from all + document.querySelectorAll('.m-window.focused').forEach(w => w.classList.remove('focused')); + el.classList.add('focused'); + + // Bring to front + const maxZ = Math.max(0, ...Array.from(document.querySelectorAll('.m-window')).map(w => + parseInt(w.style.zIndex || 0) + )); + el.style.zIndex = maxZ + 1; + + const windows = State.get('windows'); + const win = windows.find(w => w.id === id); + if (win) { + win.minimized = false; + State.set('windows', windows); + } + + Bus.emit('window:focused', { id }); +} + +function setupDrag(el, id) { + const header = el.querySelector('.m-window-header'); + if (!header) return; + + let isDragging = false; + let startX, startY, startLeft, startTop; + + header.addEventListener('mousedown', (e) => { + if (e.target.closest('.m-window-controls')) return; + isDragging = true; + startX = e.clientX; + startY = e.clientY; + startLeft = el.offsetLeft; + startTop = el.offsetTop; + document.body.style.cursor = 'move'; + + const onMove = (e2) => { + if (!isDragging) return; + const dx = e2.clientX - startX; + const dy = e2.clientY - startY; + el.style.left = Math.max(0, startLeft + dx) + 'px'; + el.style.top = Math.max(0, startTop + dy) + 'px'; + }; + + const onUp = () => { + isDragging = false; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + // Touch support + header.addEventListener('touchstart', (e) => { + if (e.target.closest('.m-window-controls')) return; + const touch = e.touches[0]; + isDragging = true; + startX = touch.clientX; + startY = touch.clientY; + startLeft = el.offsetLeft; + startTop = el.offsetTop; + + const onMove = (e2) => { + if (!isDragging) return; + const touch2 = e2.touches[0]; + const dx = touch2.clientX - startX; + const dy = touch2.clientY - startY; + el.style.left = Math.max(0, startLeft + dx) + 'px'; + el.style.top = Math.max(0, startTop + dy) + 'px'; + }; + + const onEnd = () => { + isDragging = false; + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + }; + + document.addEventListener('touchmove', onMove); + document.addEventListener('touchend', onEnd); + }, { passive: true }); +} + +function setupResize(el, id) { + const handle = el.querySelector('.m-window-resize'); + if (!handle) return; + + let isResizing = false; + let startX, startY, startW, startH; + + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + isResizing = true; + startX = e.clientX; + startY = e.clientY; + startW = el.offsetWidth; + startH = el.offsetHeight; + + const onMove = (e2) => { + if (!isResizing) return; + const dx = e2.clientX - startX; + const dy = e2.clientY - startY; + const newW = Math.max(320, startW + dx); + const newH = Math.max(240, startH + dy); + el.style.width = newW + 'px'; + el.style.height = newH + 'px'; + }; + + const onUp = () => { + isResizing = false; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + // Touch support for resize + handle.addEventListener('touchstart', (e) => { + e.preventDefault(); + const touch = e.touches[0]; + isResizing = true; + startX = touch.clientX; + startY = touch.clientY; + startW = el.offsetWidth; + startH = el.offsetHeight; + + const onMove = (e2) => { + if (!isResizing) return; + const touch2 = e2.touches[0]; + const dx = touch2.clientX - startX; + const dy = touch2.clientY - startY; + const newW = Math.max(320, startW + dx); + const newH = Math.max(240, startH + dy); + el.style.width = newW + 'px'; + el.style.height = newH + 'px'; + }; + + const onEnd = () => { + isResizing = false; + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + }; + + document.addEventListener('touchmove', onMove); + document.addEventListener('touchend', onEnd); + }, { passive: false }); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +const MeridianWM = { + openWindow: createWindow, + closeWindow, + minimizeWindow, + focusWindow +}; + +export default MeridianWM; +export { MeridianWM }; diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..85197fd --- /dev/null +++ b/js/main.js @@ -0,0 +1,85 @@ +import State from './core/state.js'; +import Auth from './core/auth.js'; +import Bus from './core/bus.js'; +import MeridianWM from './core/wm.js'; +import Registry from './core/registry.js'; + +// Import widgets +import './widgets/clock.js'; +import './widgets/active-apps.js'; +import './widgets/achievements.js'; + +// Import wallpaper +import './widgets/wallpaper.js'; + +// Import apps +import notepad from './apps/notepad.js'; +import music from './apps/music.js'; +import terminal from './apps/terminal.js'; +import steam from './apps/steam.js'; +import vscode from './apps/vscode.js'; +import postman from './apps/postman.js'; +import chrome from './apps/chrome.js'; + +// Import achievements engine +import AchievementsEngine from './achievements/engine.js'; + +// Initialize +function init() { + State.initConnection(); + Auth.init(); + Registry.init(); + + // Register all 7 apps + [notepad, music, terminal, steam, vscode, postman, chrome].forEach(app => Registry.registerApp(app)); + + // Initialize achievements engine + AchievementsEngine.init(); + + setupTaskbarApps(); +} + +function setupTaskbarApps() { + // Status bar user display + State.subscribe('user', (user) => { + const statusBar = document.getElementById('status-bar'); + if (!statusBar) return; + const existing = statusBar.querySelector('.status-user'); + if (existing) existing.remove(); + + if (user) { + const div = document.createElement('div'); + div.className = 'status-user'; + div.innerHTML = ` +
${escapeHtml(user.initials)}
+ ${escapeHtml(user.name)} + + + + + `; + statusBar.appendChild(div); + } + }); + + // Connection status + State.subscribe('connection', (connected) => { + const dot = document.querySelector('.connection-dot'); + if (dot) { + dot.classList.toggle('online', !!connected); + } + }); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// Initialize on DOM ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/js/widgets/achievements.js b/js/widgets/achievements.js new file mode 100644 index 0000000..85cf2df --- /dev/null +++ b/js/widgets/achievements.js @@ -0,0 +1,76 @@ +import State from '../core/state.js'; +import { Bus } from '../core/bus.js'; + +function renderAchievements(achievements) { + const container = document.getElementById('achievements-list'); + if (!container) return; + + if (!achievements || achievements.length === 0) { + container.innerHTML = '
No achievements yet
'; + return; + } + + // Show last 3 + const last3 = achievements.slice(-3).reverse(); + container.innerHTML = ''; + for (const achId of last3) { + const item = document.createElement('div'); + item.className = 'achievement-item'; + item.innerHTML = ` +
${getInitial(achId)}
+
+ ${escapeHtml(achId)} + Unlocked +
+ `; + container.appendChild(item); + } +} + +function getInitial(id) { + return id.charAt(0).toUpperCase(); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function showToast({ id, title, desc }) { + const layer = document.getElementById('toast-layer'); + if (!layer) return; + + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.innerHTML = ` +
+
Achievement Unlocked
+
${escapeHtml(title || desc || id)}
+
+ + `; + + layer.appendChild(toast); + + // Close button + toast.querySelector('.toast-close').addEventListener('click', () => removeToast(toast)); + + // Auto dismiss + setTimeout(() => removeToast(toast), 4000); +} + +function removeToast(toast) { + if (!toast.parentNode) return; + toast.classList.add('toast-removing'); + setTimeout(() => toast.remove(), 300); +} + +function init() { + State.subscribe('achievements', renderAchievements); + + // Listen for achievement unlocks + Bus.on('achievement:unlocked', showToast); +} + +init(); diff --git a/js/widgets/active-apps.js b/js/widgets/active-apps.js new file mode 100644 index 0000000..f0849df --- /dev/null +++ b/js/widgets/active-apps.js @@ -0,0 +1,33 @@ +import State from '../core/state.js'; +import MeridianWM from '../core/wm.js'; +import Registry from '../core/registry.js'; + +function renderApps(apps) { + const container = document.getElementById('active-apps-list'); + if (!container) return; + + if (!apps || apps.length === 0) { + container.innerHTML = '
No apps open
'; + return; + } + + container.innerHTML = ''; + for (const win of apps) { + const btn = document.createElement('button'); + btn.className = 'active-app-chip'; + btn.setAttribute('aria-label', `Focus ${win.title}`); + btn.textContent = win.title; + + btn.addEventListener('click', () => { + MeridianWM.focusWindow(win.id); + }); + + container.appendChild(btn); + } +} + +function init() { + State.subscribe('windows', renderApps); +} + +init(); diff --git a/js/widgets/clock.js b/js/widgets/clock.js new file mode 100644 index 0000000..f4b8db6 --- /dev/null +++ b/js/widgets/clock.js @@ -0,0 +1,39 @@ +import State from '../core/state.js'; + +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function formatTime(date) { + let h = date.getHours(); + const m = date.getMinutes().toString().padStart(2, '0'); + const ampm = h >= 12 ? 'PM' : 'AM'; + h = h % 12 || 12; + return `${h}:${m} ${ampm}`; +} + +function formatDate(date) { + return `${DAYS[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()}`; +} + +function updateClock() { + const now = new Date(); + const timeEl = document.getElementById('clock-time'); + const dateEl = document.getElementById('clock-date'); + if (timeEl) timeEl.textContent = formatTime(now); + if (dateEl) dateEl.textContent = formatDate(now); + + // Update login screen time + const loginTimeEl = document.getElementById('login-time'); + if (loginTimeEl) loginTimeEl.textContent = formatTime(now); + + // Update taskbar time + const statusTimeEl = document.getElementById('status-time'); + if (statusTimeEl) statusTimeEl.textContent = formatTime(now); +} + +function init() { + updateClock(); + setInterval(updateClock, 1000); +} + +init(); diff --git a/js/widgets/wallpaper.js b/js/widgets/wallpaper.js new file mode 100644 index 0000000..60f10a4 --- /dev/null +++ b/js/widgets/wallpaper.js @@ -0,0 +1,138 @@ +import State from '../core/state.js'; +import { Bus } from '../core/bus.js'; + +const WALLPAPERS = { + graphite: { + name: 'Graphite', + gradient: 'radial-gradient(ellipse at center, #1e2126 0%, #0f1114 100%)' + }, + ember: { + name: 'Ember', + gradient: 'linear-gradient(135deg, #1a1d21 0%, #2a1f15 40%, #d4a24c33 100%)' + }, + tide: { + name: 'Tide', + gradient: 'linear-gradient(160deg, #0a1628 0%, #0f2847 40%, #14425a 70%, #1a5c6e 100%)' + }, + vellum: { + name: 'Vellum', + gradient: 'linear-gradient(145deg, #1e1c1a 0%, #2a2520 50%, #332e28 100%)' + }, + eclipse: { + name: 'Eclipse', + gradient: 'linear-gradient(160deg, #0a0a0f 0%, #150f20 50%, #1a1030 100%)' + }, + terminal: { + name: 'Terminal', + gradient: 'radial-gradient(ellipse at 20% 50%, #0a1a0a 0%, #0f1410 40%, #0a0f0a 100%)' + } +}; + +let currentWp = null; +let applying = false; + +function applyWallpaper(id) { + if (applying) return; + const wp = WALLPAPERS[id]; + if (!wp) return; + + const layer = document.getElementById('wallpaper-layer'); + if (!layer) return; + + applying = true; + + // Crossfade + const overlay = document.createElement('div'); + overlay.style.position = 'absolute'; + overlay.style.inset = '0'; + overlay.style.background = wp.gradient; + overlay.style.opacity = '0'; + overlay.style.transition = 'opacity 250ms ease'; + overlay.style.zIndex = '1'; + layer.appendChild(overlay); + + requestAnimationFrame(() => { + overlay.style.opacity = '1'; + if (currentWp && currentWp !== overlay) { + const old = currentWp; + old.style.opacity = '0'; + setTimeout(() => old.remove(), 300); + } + currentWp = overlay; + }); + + if (State.get('wallpaper') !== id) { + State.set('wallpaper', id); + } + applying = false; + + // Update picker swatches + document.querySelectorAll('.wallpaper-swatch').forEach(s => { + s.classList.toggle('active', s.dataset.id === id); + }); +} + +function initPicker() { + const picker = document.getElementById('wallpaper-picker'); + const grid = document.getElementById('wallpaper-picker-grid'); + if (!picker || !grid) return; + + // Populate swatches + for (const [id, wp] of Object.entries(WALLPAPERS)) { + const swatch = document.createElement('div'); + swatch.className = 'wallpaper-swatch'; + swatch.dataset.id = id; + swatch.setAttribute('role', 'button'); + swatch.setAttribute('aria-label', `Select ${wp.name} wallpaper`); + swatch.style.background = wp.gradient; + swatch.innerHTML = `${wp.name}`; + swatch.addEventListener('click', () => applyWallpaper(id)); + grid.appendChild(swatch); + } + + // Apply saved wallpaper + const saved = State.get('wallpaper'); + if (saved && WALLPAPERS[saved]) { + applyWallpaper(saved); + } + + // Toggle button in status bar + const toggleBtn = document.getElementById('wallpaper-toggle'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + picker.classList.toggle('hidden'); + }); + } + + // Close picker on backdrop click + picker.addEventListener('click', (e) => { + if (e.target === picker) { + picker.classList.add('hidden'); + } + }); + + // Close on Escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !picker.classList.contains('hidden')) { + picker.classList.add('hidden'); + } + }); +} + +function init() { + // Listen for state changes to wallpaper (external triggers only; applying flag prevents recursion) + State.subscribe('wallpaper', (id) => { + if (id && WALLPAPERS[id]) { + applyWallpaper(id); + } + }); + + // Apply saved or default + const saved = State.get('wallpaper'); + const wpId = (saved && WALLPAPERS[saved]) ? saved : 'graphite'; + applyWallpaper(wpId); + + initPicker(); +} + +init(); diff --git a/serve.sh b/serve.sh new file mode 100755 index 0000000..b6729f0 --- /dev/null +++ b/serve.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Meridian OS — start local server on a stable port. +# Usage: ./serve.sh [port] (default 8921) +set -e +PORT="${1:-8921}" +cd "$(dirname "$0")" +# Kill any stale server on this port +lsof -ti:"$PORT" 2>/dev/null | xargs -r kill 2>/dev/null || true +echo "Serving Meridian OS at http://localhost:$PORT" +exec python3 -m http.server "$PORT"