Add Nebula Shell v0 prototype and redesign

Introduce a working v0 Nebula Shell prototype (Tauri frontend) and redesign docs. Adds a complete frontend under src/ (views: home, lock, library, settings, overlays), core adapters (input, nav, passkey, router, state), global styles and shell guidelines, and view-specific HTML/CSS/JS. Update README with prototype notes, install/run/build instructions and controller testing tips. Update package.json to add dev/build scripts and add @nebulaproject/core as a dependency. Also include a detailed REDESIGN_DOCUMENTATION.md describing the Xbox-inspired horizontal dashboard, component patterns, and developer notes.
This commit is contained in:
2026-02-15 22:52:35 +13:00
parent 0102d06bba
commit 89eedf5522
31 changed files with 9242 additions and 42 deletions
+103 -3
View File
@@ -1,5 +1,106 @@
# Nebula OS v0 Windows-first Development Plan # Nebula OS v0 Windows-first Development Plan
## Nebula Shell Prototype (Current)
This repository now includes a working v0 Nebula Shell prototype in the Tauri frontend (`src/`) with:
- Lock Screen with controller/keyboard PIN keypad (`1234` for v0)
- Home dashboard tile grid (Library, Settings, Power)
- Settings split-pane stub (category list + content panel)
- Library stub view with controller back behavior
- Start/Menu power overlay that traps focus and closes with Back
- Unified input actions (`up/down/left/right/accept/back/menu`) from keyboard + gamepad
### Dashboard refresh (Xbox-inspired Nebula)
The shell now uses a premium horizontal dashboard language inspired by console UI patterns:
- Left-aligned horizontal app tile rail (Library, Settings, Power)
- Dynamic nebula background stack (gradient, starfield, fog, vignette)
- Shared top bar with reactive accent line and profile/time status
- Layered tile focus states (scale, cyan glow, ripple, elevation)
- Smooth page/focus transitions with cubic-bezier motion curves
- Immersive lock screen with large clock/date and input-revealed PIN panel
- Settings redesign with top category rail + card-based content panel
Animation and component architecture notes are in [src/styles/shell-guidelines.md](src/styles/shell-guidelines.md).
### Install
```bash
npm install
```
### Run (Windows dev)
```bash
npm run dev
```
### Build
```bash
npm run build
```
### Controller testing notes (Windows)
- Connect an Xbox-compatible controller before launching dev mode.
- Navigation: D-pad or left stick.
- Actions: `A` = Accept, `B` = Back, `Start` = Menu.
- Keyboard mirror for development: arrow keys, Enter, Escape/Backspace.
### Nebula Core integration status
`@nebulaproject/core` is installed and used through runtime adapters in:
- `src/core/input.js`
- `src/core/nav.js`
- `src/core/state.js`
If Nebula Core exports are available, the shell uses them for input/navigation/glyphs/theme.
If not, local fallback adapters keep the shell fully functional.
### Local Nebula Core development (Windows-safe)
Current npm package `@nebulaproject/core@0.1.3` re-exports internal `@nebula/*` packages that are not published, so local linking is recommended for active core development.
1. Clone Nebula Core monorepo next to this repo.
2. Build Nebula Core packages.
3. Link from the core repo and consume in this repo:
```bash
# In Nebula-Core repo
npm link
# In Nebula-OS repo
npm link @nebulaproject/core
```
Alternative (more deterministic): use `file:` dependency in `package.json`:
```json
{
"dependencies": {
"@nebulaproject/core": "file:../Nebula-Core/packages/core"
}
}
```
Then run:
```bash
npm install
```
### Linux VM build (high-level)
- Install Rust, Node.js, and Tauri Linux prerequisites in the VM.
- Clone this repo in Linux VM.
- Run `npm install`.
- Run `npm run dev` for integration checks in Linux session.
- Run `npm run build` to produce Linux artifacts.
## Vision ## Vision
Nebula OS is a **controller-first, open source operating system experience** built on Linux. Nebula OS is a **controller-first, open source operating system experience** built on Linux.
@@ -14,7 +115,7 @@ It is an independent UI layer that:
* Acts as the primary **controller-first shell** for the OS * Acts as the primary **controller-first shell** for the OS
* Provides a unified Home experience for **games, apps, and media** * Provides a unified Home experience for **games, apps, and media**
* Ships with core Nebula apps such as **Nebula Browser** * Integrates first-install Nebula apps such as **Nebula Browser** and **Nebula Launcher**
* Exposes **system settings** through controller-friendly panels * Exposes **system settings** through controller-friendly panels
* Manages game libraries directly (Steam, GOG, Epic, and others) * Manages game libraries directly (Steam, GOG, Epic, and others)
@@ -78,7 +179,6 @@ Game UI Mode contains:
* Lock screen with controller PIN entry * Lock screen with controller PIN entry
* Nebula Home dashboard * Nebula Home dashboard
* Nebula Browser
* Nebula Library * Nebula Library
* Controller-friendly Settings * Controller-friendly Settings
* Power and session controls * Power and session controls
@@ -147,7 +247,7 @@ It is the primary interface in Game UI Mode and should feel like a console dashb
Nebula Shell responsibilities: Nebula Shell responsibilities:
* Home dashboard (games, apps, recent activity) * Home dashboard (games, apps, recent activity)
* App launcher for Nebula apps (Nebula Browser, Nebula Library, Settings) * App launcher for integrated Nebula apps (Nebula Browser, Nebula Launcher, Nebula Library, Settings)
* System navigation (network, audio, display, storage, accounts) * System navigation (network, audio, display, storage, accounts)
* Power menu and session switching (Game UI ↔ Desktop) * Power menu and session switching (Game UI ↔ Desktop)
* Notifications and downloads (later) * Notifications and downloads (later)
+382
View File
@@ -0,0 +1,382 @@
# Nebula OS - Xbox Series X Inspired Redesign Documentation
## Overview
Nebula OS has been redesigned with inspiration from the Xbox Series X dashboard while maintaining its unique space-themed identity. The result is a horizontal, content-first dashboard with large tiles, smooth motion, layered depth, and a premium, console-native feel.
---
## Core Layout Changes
### 1. Home Dashboard Structure
- **Layout**: Left-aligned horizontal tile grid with smooth scrolling
- **Tiles**: 4 primary apps (Library, Browser, Settings, Power)
- **Navigation**: Horizontal controller input (left/right) with smooth transitions
- **Focus Effects**:
- Tiles scale to 1.06x on focus
- Cyan glow and accent bar appears on focused tile
- Smooth parallax depth between layers
### 2. Dynamic Background System
The background now features multiple animated layers for depth and immersion:
#### Gradient Layer
- Deep navy → midnight blue → subtle purple gradient
- Animated drift with rotation for dynamic feel
- Duration: 28s
- Multiple radial gradients for light sources
#### Starfield Layer
- Three layers of stars with varying sizes and colors
- Creates sense of depth and space
- Slow parallax movement
- Duration: 45s linear
#### Fog Layer
- Soft drifting nebula clouds
- Heavy blur (52px) for ethereal effect
- Multiple elliptical gradients
- Duration: 34s
#### Vignette Layer
- Subtle darkening at edges
- Focuses attention on center content
- Static overlay
### 3. Top Bar (Nebula Style)
- **Left**: "Nebula OS" brand with cyan glow effect
- **Right**: User avatar (with glow) + system time
- **Accent Line**: Animated line that moves with focus
- **Structure**: Flexbox with `.shell-topbar-content` wrapper
### 4. Tile Design
Tiles now feature Xbox-inspired rectangular design with depth:
#### Visual Layers
1. **Background**: Layered gradient with soft radial accent
2. **Content**: Icon, title, and subtitle flexbox layout
3. **Accent Bar**: Bottom highlight that animates on focus
4. **Hover Glow**: Radial gradient overlay on focus
#### On Focus Effects
- Scale: 1.06x transform
- Border: Cyan outline with glow
- Shadow: Enhanced depth shadow
- Text: Subtle translateX(3px) animation
- Icon: Scale 1.08 + translateY(-2px)
### 5. Controller Navigation Feedback
- **Focus Transitions**: 180ms smooth animations
- **Movement**: Animated position changes, not instant jumps
- **Press Animation**: Scale down to 0.96 then back
- **Easing**: Custom console-style cubic-bezier curves
- **Sound Hooks**: Structure in place for UI audio (not implemented)
### 6. Lock Screen Redesign
A more immersive authentication experience:
#### Time Display
- Large gradient-filled text (96-160px)
- Cyan glow shadow
- Modern weight (760)
- Gradient from white to light blue
#### PIN Keypad
- **Design**: Circular buttons (50% border-radius)
- **Size**: Aspect ratio 1:1, min 92px
- **Material**: Radial gradient with inner/outer shadows
- **Focus**: Scale 1.08 with cyan border and glow
- **Press**: Scale 0.95 feedback
#### PIN Dots
- Larger design (18px)
- Cyan border that fills on input
- Glowing effect when filled
### 7. Settings Page Redesign
Horizontal category navigation with card-based content:
#### Category Bar
- Horizontal list at top
- Active category gets:
- Color change to cyan
- Bottom accent bar with glow
- Scale animation
- Smooth underline animation
#### Content Panel
- Large panel with layered background
- Card-based settings layout
- Responsive grid (auto-fit)
- Cards scale 1.04x on focus
### 8. Animation Style
All animations follow these principles:
#### Easing Functions
```css
--nebula-ease-standard: cubic-bezier(0.25, 0.1, 0.25, 1)
--nebula-ease-console: cubic-bezier(0.19, 0.82, 0.18, 1)
--nebula-ease-snap: cubic-bezier(0.32, 0.94, 0.18, 1)
```
#### Timing
- Fast: 120ms (micro-interactions)
- Nav: 180ms (focus transitions)
- Slow: 340ms (page transitions)
#### Performance
- Use `transform` instead of position changes
- Avoid heavy shadow stacking
- GPU-accelerated with `translateZ(0)`
- `will-change` on focusable elements
---
## Component Breakdown
### TopBar Component
**Location**: `.shell-topbar`
**Structure**:
```html
<header class="shell-topbar">
<div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p>
<div class="shell-status">
<span class="shell-avatar"></span>
<p class="shell-time"></p>
</div>
</div>
<div class="shell-accent-line"></div>
</header>
```
**Features**:
- Brand glow effect
- Avatar with cyan border glow
- Animated accent line that follows focus
- Responsive layout
---
### TileRow Component
**Location**: `.tile-rail`
**Structure**:
```html
<section class="tile-rail" data-focus-root>
<!-- Tiles go here -->
</section>
```
**Features**:
- Horizontal scroll with hidden scrollbar
- Smooth scroll behavior
- Parallax transform on navigation
- Flexible gap spacing
---
### Tile Component
**Location**: `.tile.dashboard-tile`
**Variants**:
- `.tile-large` - Larger featured tile
- Standard - Default size
**Structure**:
```html
<button class="focusable tile dashboard-tile">
<div class="tile-content">
<span class="tile-icon">📚</span>
<div class="tile-text">
<p class="tile-label">Title</p>
<p class="tile-meta">Subtitle</p>
</div>
</div>
<div class="tile-accent-bar"></div>
</button>
```
**States**:
- Default
- `.is-focused` - Scale, glow, accent bar
- `.is-pressed` - Press animation
---
### BackgroundLayer Component
**Location**: `#nebula-background`
**Layers**:
1. `.nebula-layer.gradient` - Base gradient
2. `.nebula-layer.starfield` - Animated stars
3. `.nebula-layer.fog` - Blurred nebula clouds
4. `.nebula-layer.vignette` - Edge darkening
**Features**:
- Independent animations per layer
- Parallax on navigation
- Smooth transitions
---
### FocusManager
**Location**: `.focusable` class
**Features**:
- Automatic border/shadow on focus
- Radial gradient ripple effect
- Press pulse animation
- Smooth transitions between states
**States**:
- `.is-focused` - Cyan border, glow, scaled
- `.is-pressed` - Animation pulse
---
## Design Language
### Colors
- **Accent**: Nebula cyan (#4fd8ff)
- **Background**: Deep space blue (#050a17#1a1342)
- **Text**: Off-white (#f2f7ff)
- **Muted**: Light blue-gray (#a8bdd8)
### Typography
- **Modern, clean sans-serif** (Segoe UI)
- **Weights**: 600-760 range for premium feel
- **Sizes**: Responsive with clamp()
- **Spacing**: Slightly bold for console readability
### Depth & Layers
- **Multiple shadow layers** on focus
- **Inset shadows** for inner depth
- **Gradient overlays** for material feel
- **Backdrop blur** for depth separation
### Premium Feel
- **No flat design** - everything has depth
- **Smooth animations** throughout
- **Glow effects** on accents
- **Inner/outer shadows** for dimensions
---
## Files Modified
### View Files
- `src/views/home/home.html`
- `src/views/home/home.js`
- `src/views/home/home.css`
- `src/views/lock/lock.html`
- `src/views/lock/lock.js`
- `src/views/lock/lock.css`
- `src/views/settings/settings.html`
- `src/views/settings/settings.js`
- `src/views/settings/settings.css`
- `src/views/library/library.html`
- `src/views/library/library.js`
### Core Style Files
- `src/styles/theme.css` - Design tokens
- `src/styles/base.css` - Background, topbar, animations
- `src/styles/components.css` - Tiles, focus system
---
## Navigation Grid Changes
### Home View
- Changed from 3 columns to 4 columns
- Added Browser tile
- Updated tile sizing for better proportions
### Settings View
- 5 horizontal categories (Network, Audio, Display, Storage, System)
- Card grid with auto-fit responsive layout
- 2-row navigation (categories + cards)
### Lock Screen
- 4x3 keypad grid
- Circular button design
- Center-focused layout
---
## Performance Optimizations
1. **Transform-based animations** - No layout recalculation
2. **will-change hints** - GPU acceleration where needed
3. **Selective backdrop-filter** - Only on depth blur layer
4. **CSS containment** - Component isolation
5. **Efficient selectors** - Class-based, minimal nesting
---
## Future Enhancements
### Audio System
Structure is in place for UI sound hooks:
- Focus change sounds
- Press/click sounds
- Navigation swoosh
- Error/success tones
### Additional Animations
- Page slide transitions between views
- Content fade-in on load
- Tile stagger animations on home view entry
### Advanced Features
- Dynamic tile backgrounds (game art)
- Video backgrounds for featured content
- Achievement/notification popups
- Quick resume tiles with screenshots
---
## Console-First Design Principles
1. **Large hit targets** - Easy to navigate with controller
2. **Clear focus states** - Always visible what's selected
3. **Smooth motion** - No jarring transitions
4. **Depth perception** - Layered UI for spatial understanding
5. **Content first** - Minimal chrome, maximum content
6. **Premium materials** - Glows, shadows, gradients for quality feel
---
## Developer Notes
### Adding New Tiles
1. Add button to `.tile-rail` in home.js
2. Update `data-row` and `data-col` attributes
3. Update navigation contract cols count
4. Ensure proper `data-target` attribute
### Adding New Settings Categories
1. Add button to `.settings-category-bar`
2. Add to CATEGORIES object in settings.js
3. Update navigation contract
4. Create category content panel
### Customizing Animations
All animation timings are in CSS variables in `theme.css`:
- `--nebula-duration-fast`
- `--nebula-duration-nav`
- `--nebula-duration-slow`
Easing curves are also variables:
- `--nebula-ease-standard`
- `--nebula-ease-console`
- `--nebula-ease-snap`
---
## Conclusion
The redesign transforms Nebula OS into a premium, console-first experience that feels fast, immersive, and modern. The Xbox Series X dashboard inspiration is clear in the horizontal layout, large tiles, and smooth animations, while the space-themed "Nebula" identity is maintained through the cyan accent colors, starfield backgrounds, and cosmic naming.
**Result**: "Xbox Series X dashboard reimagined for a space-themed, controller-first OS." ✨
+241
View File
@@ -0,0 +1,241 @@
{
"name": "nebula-os",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nebula-os",
"version": "0.1.0",
"dependencies": {
"@nebulaproject/core": "^0.1.3"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
},
"node_modules/@nebulaproject/core": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nebulaproject/core/-/core-0.1.3.tgz",
"integrity": "sha512-sOjH10J1qSyqRNNi0yM3GAhkQk6lLGgmPPoyEljGfPC2Ty3iBoY44ML0sfepiiedVtedbkeDPpDcyx/UbVis6g==",
"license": "MIT"
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
"integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.0",
"@tauri-apps/cli-darwin-x64": "2.10.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz",
"integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+5
View File
@@ -4,9 +4,14 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tauri dev",
"build": "tauri build",
"tauri": "tauri" "tauri": "tauri"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"
},
"dependencies": {
"@nebulaproject/core": "^0.1.3"
} }
} }
+5307
View File
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
const KEYBOARD_MAP = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
KeyY: "y",
KeyX: "clear",
KeyQ: "l1",
KeyE: "r1",
KeyZ: "l2",
KeyC: "r2",
Enter: "accept",
Escape: "back",
Backspace: "back",
};
const BUTTON_MAP = {
0: "accept",
1: "back",
2: "clear",
3: "y",
8: "menu",
9: "menu",
4: "l1",
5: "r1",
6: "l2",
7: "r2",
12: "up",
13: "down",
14: "left",
15: "right",
};
const createFallbackEmitter = () => {
const listeners = new Set();
return {
emit: (payload) => listeners.forEach((listener) => listener(payload)),
on: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
};
export const createInputManager = ({ onAction, actions }) => {
const heldButtons = new Set();
const heldVirtual = new Set();
let rafId = 0;
let mapper = null;
let unsubscribeMapper = null;
let axisLatched = false;
let lastAxisEmitAt = 0;
let active = false;
const emitter = createFallbackEmitter();
const emitAction = (action) => {
if (!actions.includes(action)) {
return;
}
onAction(action);
emitter.emit(action);
};
const mapKeyboard = async () => {
try {
const coreInput = await import("@nebulaproject/core/input");
if (typeof coreInput.createActionMapper !== "function") {
return;
}
mapper = coreInput.createActionMapper({
bindings: {
up: [
{ source: "keyboard", control: "ArrowUp" },
{ source: "gamepad", control: "dpad-up" },
{ source: "gamepad", control: "axis-up" },
],
down: [
{ source: "keyboard", control: "ArrowDown" },
{ source: "gamepad", control: "dpad-down" },
{ source: "gamepad", control: "axis-down" },
],
left: [
{ source: "keyboard", control: "ArrowLeft" },
{ source: "gamepad", control: "dpad-left" },
{ source: "gamepad", control: "axis-left" },
],
right: [
{ source: "keyboard", control: "ArrowRight" },
{ source: "gamepad", control: "dpad-right" },
{ source: "gamepad", control: "axis-right" },
],
accept: [
{ source: "keyboard", control: "Enter" },
{ source: "gamepad", control: "a" },
],
back: [
{ source: "keyboard", control: "Escape" },
{ source: "keyboard", control: "Backspace" },
{ source: "gamepad", control: "b" },
],
menu: [
{ source: "keyboard", control: "KeyM" },
{ source: "gamepad", control: "start" },
],
clear: [
{ source: "keyboard", control: "KeyX" },
{ source: "gamepad", control: "x" },
],
y: [
{ source: "keyboard", control: "KeyY" },
{ source: "gamepad", control: "y" },
],
l1: [{ source: "gamepad", control: "lb" }],
r1: [{ source: "gamepad", control: "rb" }],
l2: [
{ source: "keyboard", control: "KeyZ" },
{ source: "gamepad", control: "lt" },
],
r2: [
{ source: "keyboard", control: "KeyC" },
{ source: "gamepad", control: "rt" },
],
},
});
unsubscribeMapper = mapper.onAction((update) => {
if (update?.active && update.action) {
emitAction(update.action);
}
});
} catch (_error) {
mapper = null;
}
};
const mapEventToMapper = (event) => {
mapper?.mapEvent?.(event);
};
const onKeyDown = (event) => {
const action = KEYBOARD_MAP[event.code] ?? (event.code === "KeyM" ? "menu" : null);
if (!action) {
return;
}
event.preventDefault();
if (mapper) {
mapEventToMapper({
source: "keyboard",
control: event.code,
type: "pressed",
value: 1,
});
} else if (!heldVirtual.has(event.code)) {
heldVirtual.add(event.code);
emitAction(action);
}
};
const onKeyUp = (event) => {
if (mapper) {
mapEventToMapper({
source: "keyboard",
control: event.code,
type: "released",
value: 0,
});
}
heldVirtual.delete(event.code);
};
const processAxis = (axisX, axisY) => {
const threshold = 0.6;
const now = performance.now();
let axisAction = null;
if (Math.abs(axisX) > Math.abs(axisY)) {
if (axisX <= -threshold) axisAction = "left";
if (axisX >= threshold) axisAction = "right";
} else {
if (axisY <= -threshold) axisAction = "up";
if (axisY >= threshold) axisAction = "down";
}
if (!axisAction) {
axisLatched = false;
return;
}
if (axisLatched || now - lastAxisEmitAt < 120) {
return;
}
if (mapper) {
mapEventToMapper({
source: "gamepad",
control: `axis-${axisAction}`,
type: "axis",
value: 1,
});
} else {
emitAction(axisAction);
}
axisLatched = true;
lastAxisEmitAt = now;
};
const pollGamepad = () => {
if (!active) {
return;
}
const [pad] = navigator.getGamepads?.() ?? [];
if (pad) {
pad.buttons.forEach((button, index) => {
const action = BUTTON_MAP[index];
if (!action) {
return;
}
if (button.pressed && !heldButtons.has(index)) {
heldButtons.add(index);
if (mapper) {
const controlMap = {
accept: "a",
back: "b",
clear: "x",
y: "y",
menu: "start",
l1: "lb",
r1: "rb",
l2: "lt",
r2: "rt",
up: "dpad-up",
down: "dpad-down",
left: "dpad-left",
right: "dpad-right",
};
mapEventToMapper({
source: "gamepad",
control: controlMap[action] ?? action,
type: "pressed",
value: 1,
});
} else {
emitAction(action);
}
}
if (!button.pressed && heldButtons.has(index)) {
heldButtons.delete(index);
if (mapper) {
const releaseControlMap = {
accept: "a",
back: "b",
clear: "x",
y: "y",
menu: "start",
l1: "lb",
r1: "rb",
l2: "lt",
r2: "rt",
up: "dpad-up",
down: "dpad-down",
left: "dpad-left",
right: "dpad-right",
};
mapEventToMapper({
source: "gamepad",
control: releaseControlMap[action] ?? action,
type: "released",
value: 0,
});
}
}
});
processAxis(pad.axes?.[0] ?? 0, pad.axes?.[1] ?? 0);
}
rafId = requestAnimationFrame(pollGamepad);
};
const start = async () => {
if (active) {
return;
}
active = true;
await mapKeyboard();
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
rafId = requestAnimationFrame(pollGamepad);
};
const stop = () => {
active = false;
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
unsubscribeMapper?.();
unsubscribeMapper = null;
};
return {
start,
stop,
onAction: emitter.on,
};
};
+192
View File
@@ -0,0 +1,192 @@
const getRect = (element) => {
const rect = element.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
};
};
const scoreCandidate = (source, target, direction) => {
const horizontal = target.col - source.col;
const vertical = target.row - source.row;
if (direction === "up" && vertical >= 0) return Number.POSITIVE_INFINITY;
if (direction === "down" && vertical <= 0) return Number.POSITIVE_INFINITY;
if (direction === "left" && horizontal >= 0) return Number.POSITIVE_INFINITY;
if (direction === "right" && horizontal <= 0) return Number.POSITIVE_INFINITY;
const primary = direction === "up" || direction === "down" ? Math.abs(vertical) : Math.abs(horizontal);
const secondary = direction === "up" || direction === "down" ? Math.abs(horizontal) : Math.abs(vertical);
return primary * 100 + secondary;
};
export const createNavigationManager = () => {
let contract = null;
let focusables = [];
let focusedIndex = -1;
const decorateFocusable = (element) => {
element.classList.remove("is-focused");
element.tabIndex = -1;
};
const applyFocus = (index) => {
if (!focusables.length) {
focusedIndex = -1;
return;
}
const clamped = Math.max(0, Math.min(index, focusables.length - 1));
focusables.forEach((focusable) => {
focusable.element.classList.remove("is-focused");
focusable.element.setAttribute("aria-selected", "false");
});
focusedIndex = clamped;
const focused = focusables[focusedIndex]?.element;
if (focused) {
focused.classList.add("is-focused");
focused.setAttribute("aria-selected", "true");
focused.focus({ preventScroll: true });
const col = Number(focused.dataset.col ?? 0);
const row = Number(focused.dataset.row ?? 0);
document.documentElement.style.setProperty("--nebula-accent-line-x", `${20 + col * 110}px`);
document.documentElement.style.setProperty("--nebula-focus-strength", "1");
window.dispatchEvent(
new CustomEvent("nebula-focus-change", {
detail: {
key: focused.dataset.focusKey ?? null,
row,
col,
},
}),
);
}
};
const buildFocusables = () => {
if (!contract?.focusRoot) {
focusables = [];
return;
}
const nodes = Array.from(contract.focusRoot.querySelectorAll("[data-focusable='true']"));
focusables = nodes
.map((element, index) => {
decorateFocusable(element);
return {
index,
element,
row: Number(element.dataset.row ?? 0),
col: Number(element.dataset.col ?? 0),
key: element.dataset.focusKey ?? String(index),
};
})
.sort((left, right) => {
if (left.row === right.row) {
return left.col - right.col;
}
return left.row - right.row;
});
};
const resolveDefaultFocus = () => {
if (!focusables.length) {
return -1;
}
if (contract?.defaultFocus) {
const defaultIndex = focusables.findIndex((focusable) => focusable.element === contract.defaultFocus);
if (defaultIndex >= 0) {
return defaultIndex;
}
}
return 0;
};
const moveWithNebula = (direction) => {
const picker = contract?.nebulaNavigation?.pickBestCandidate;
if (typeof picker !== "function") {
return null;
}
const source = focusables[focusedIndex];
if (!source) {
return null;
}
const sourceRect = getRect(source.element);
const candidates = focusables
.filter((item) => item.index !== source.index)
.map((item) => ({
id: item.key,
...getRect(item.element),
}));
const picked = picker(
{ id: source.key, ...sourceRect },
candidates,
direction,
);
if (!picked?.id) {
return null;
}
const nextIndex = focusables.findIndex((item) => item.key === picked.id);
return nextIndex >= 0 ? nextIndex : null;
};
const move = (direction) => {
if (!focusables.length || focusedIndex < 0) {
return;
}
const nebulaIndex = moveWithNebula(direction);
if (nebulaIndex !== null) {
applyFocus(nebulaIndex);
return;
}
const source = focusables[focusedIndex];
let bestIndex = focusedIndex;
let bestScore = Number.POSITIVE_INFINITY;
focusables.forEach((candidate, index) => {
if (index === focusedIndex) {
return;
}
const score = scoreCandidate(source, candidate, direction);
if (score < bestScore) {
bestScore = score;
bestIndex = index;
}
});
if (bestIndex !== focusedIndex) {
applyFocus(bestIndex);
}
};
const mount = (nextContract) => {
contract = nextContract;
buildFocusables();
applyFocus(resolveDefaultFocus());
};
const getFocusedElement = () => focusables[focusedIndex]?.element ?? null;
return {
mount,
move,
getFocusedElement,
};
};
+173
View File
@@ -0,0 +1,173 @@
const PASSKEY_STORAGE_KEY = "nebula.passkey.v1";
const PASSKEY_VERSION = 1;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const FALLBACK_CONFIG = {
enabled: true,
length: 6,
requireConfirm: false,
keyboardSupport: true,
maxAttempts: 5,
cooldownSeconds: 30,
animationSpeed: "normal",
highContrast: false,
hash: "",
salt: "",
};
const arrayToHex = (bytes) =>
Array.from(bytes)
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
const createSalt = () => {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return arrayToHex(bytes);
};
const normalizeSequence = (sequence) => sequence.join("|");
export const hashPasskeySequence = async (sequence, salt) => {
const source = `${salt}::${normalizeSequence(sequence)}`;
const encoded = new TextEncoder().encode(source);
const digest = await crypto.subtle.digest("SHA-256", encoded);
return arrayToHex(new Uint8Array(digest));
};
export const loadPasskeyConfig = () => {
try {
const raw = window.localStorage.getItem(PASSKEY_STORAGE_KEY);
if (!raw) {
return {
...FALLBACK_CONFIG,
};
}
const parsed = JSON.parse(raw);
return {
...FALLBACK_CONFIG,
...parsed,
length: clamp(Number(parsed.length ?? FALLBACK_CONFIG.length), 4, 8),
maxAttempts: clamp(Number(parsed.maxAttempts ?? FALLBACK_CONFIG.maxAttempts), 1, 10),
cooldownSeconds: clamp(Number(parsed.cooldownSeconds ?? FALLBACK_CONFIG.cooldownSeconds), 5, 120),
requireConfirm: Boolean(parsed.requireConfirm ?? FALLBACK_CONFIG.requireConfirm),
keyboardSupport: Boolean(parsed.keyboardSupport ?? FALLBACK_CONFIG.keyboardSupport),
enabled: Boolean(parsed.enabled ?? FALLBACK_CONFIG.enabled),
highContrast: Boolean(parsed.highContrast ?? FALLBACK_CONFIG.highContrast),
animationSpeed: ["slow", "normal", "fast"].includes(parsed.animationSpeed)
? parsed.animationSpeed
: FALLBACK_CONFIG.animationSpeed,
hash: typeof parsed.hash === "string" ? parsed.hash : "",
salt: typeof parsed.salt === "string" ? parsed.salt : "",
version: PASSKEY_VERSION,
};
} catch (_error) {
return {
...FALLBACK_CONFIG,
version: PASSKEY_VERSION,
};
}
};
export const savePasskeyConfig = (config) => {
const safeConfig = {
...FALLBACK_CONFIG,
...config,
version: PASSKEY_VERSION,
length: clamp(Number(config.length ?? FALLBACK_CONFIG.length), 4, 8),
maxAttempts: clamp(Number(config.maxAttempts ?? FALLBACK_CONFIG.maxAttempts), 1, 10),
cooldownSeconds: clamp(Number(config.cooldownSeconds ?? FALLBACK_CONFIG.cooldownSeconds), 5, 120),
};
window.localStorage.setItem(PASSKEY_STORAGE_KEY, JSON.stringify(safeConfig));
return safeConfig;
};
export const createPasskeyController = () => {
const config = loadPasskeyConfig();
let failedAttempts = 0;
let lockoutUntil = 0;
const persist = () => savePasskeyConfig(config);
const inLockout = () => Date.now() < lockoutUntil;
const getLockoutRemainingMs = () => Math.max(0, lockoutUntil - Date.now());
const verifySequence = async (sequence) => {
if (!config.enabled) {
return { ok: true, reason: "disabled" };
}
if (inLockout()) {
return {
ok: false,
reason: "lockout",
lockoutRemainingMs: getLockoutRemainingMs(),
};
}
if (!config.hash || !config.salt) {
return { ok: false, reason: "setup-required" };
}
const hash = await hashPasskeySequence(sequence, config.salt);
if (hash === config.hash) {
failedAttempts = 0;
return { ok: true, reason: "match" };
}
failedAttempts += 1;
const attemptsLeft = Math.max(0, config.maxAttempts - failedAttempts);
if (attemptsLeft <= 0) {
lockoutUntil = Date.now() + config.cooldownSeconds * 1000;
failedAttempts = 0;
return {
ok: false,
reason: "lockout",
lockoutRemainingMs: getLockoutRemainingMs(),
};
}
return {
ok: false,
reason: "mismatch",
attemptsLeft,
};
};
const setSequence = async (sequence) => {
const nextSalt = createSalt();
const nextHash = await hashPasskeySequence(sequence, nextSalt);
config.salt = nextSalt;
config.hash = nextHash;
failedAttempts = 0;
lockoutUntil = 0;
persist();
};
const updateConfig = (partial) => {
Object.assign(config, partial);
config.length = clamp(Number(config.length), 4, 8);
config.maxAttempts = clamp(Number(config.maxAttempts), 1, 10);
config.cooldownSeconds = clamp(Number(config.cooldownSeconds), 5, 120);
persist();
return { ...config };
};
const getConfig = () => ({ ...config });
return {
getConfig,
updateConfig,
verifySequence,
setSequence,
inLockout,
getLockoutRemainingMs,
hasPasskey: () => Boolean(config.hash && config.salt),
};
};
export const PASSKEY_ACTIONS = ["up", "down", "left", "right", "l1", "r1", "l2", "r2"];
+34
View File
@@ -0,0 +1,34 @@
export const createRouter = (outlet) => {
const views = new Map();
let current = null;
const register = (view) => {
views.set(view.id, view);
};
const navigate = (id) => {
const view = views.get(id);
if (!view) {
throw new Error(`Unknown view: ${id}`);
}
current = id;
outlet.innerHTML = view.render();
const nextView = outlet.querySelector(".view");
if (nextView) {
requestAnimationFrame(() => {
nextView.classList.add("view-entered");
});
}
view.mount?.(outlet);
return view.getNavigationContract();
};
const getCurrent = () => current;
return {
register,
navigate,
getCurrent,
};
};
+141
View File
@@ -0,0 +1,141 @@
import { createPasskeyController } from "./passkey.js";
const FALLBACK_THEME = {
colors: {
bg: "#0b1020",
panel: "#141c33",
panelAlt: "#1b2747",
text: "#eef4ff",
muted: "#9eb1d3",
accent: "#50d6ff",
danger: "#ff6b88",
success: "#7dff9e",
focus: "#50d6ff",
overlay: "rgba(5, 8, 18, 0.78)",
},
spacing: {
xs: 6,
sm: 10,
md: 16,
lg: 24,
xl: 36,
},
radius: {
sm: 10,
md: 16,
lg: 22,
},
typography: {
body: 18,
title: 24,
display: 34,
},
};
const FALLBACK_GLYPHS = {
accept: "A",
back: "B",
menu: "≡",
up: "↑",
down: "↓",
left: "←",
right: "→",
l1: "LB",
r1: "RB",
l2: "LT",
r2: "RT",
y: "Y",
};
export const createAppState = () => {
const passkey = createPasskeyController();
const state = {
passkey,
locked: true,
activeView: "lock",
nebula: {
coreReady: false,
source: "local-fallback",
navigation: null,
input: null,
glyphs: null,
theme: null,
ui: null,
},
theme: FALLBACK_THEME,
glyphs: { ...FALLBACK_GLYPHS },
settingsCategory: "system",
settingsValues: {
network: true,
audio: true,
display: false,
storage: true,
system: false,
},
passkeySetupRequired: !passkey.hasPasskey(),
};
const applyThemeToDocument = () => {
const root = document.documentElement;
const { colors, spacing, radius, typography } = state.theme;
Object.entries(colors).forEach(([key, value]) => root.style.setProperty(`--nebula-color-${key}`, value));
Object.entries(spacing).forEach(([key, value]) => root.style.setProperty(`--nebula-spacing-${key}`, `${value}px`));
Object.entries(radius).forEach(([key, value]) => root.style.setProperty(`--nebula-radius-${key}`, `${value}px`));
Object.entries(typography).forEach(([key, value]) => root.style.setProperty(`--nebula-type-${key}`, `${value}px`));
};
const initializeNebulaCore = async () => {
try {
const [input, navigation, glyphs, theme, ui] = await Promise.all([
import("@nebulaproject/core/input"),
import("@nebulaproject/core/navigation"),
import("@nebulaproject/core/glyphs"),
import("@nebulaproject/core/theme"),
import("@nebulaproject/core/ui"),
]);
state.nebula = {
coreReady: true,
source: "@nebulaproject/core",
input,
navigation,
glyphs,
theme,
ui,
};
state.theme = typeof theme.createTheme === "function" ? theme.createTheme({}) : FALLBACK_THEME;
if (typeof glyphs.getGlyph === "function") {
state.glyphs = {
accept: glyphs.getGlyph("xbox", "confirm") ?? FALLBACK_GLYPHS.accept,
back: glyphs.getGlyph("xbox", "back") ?? FALLBACK_GLYPHS.back,
menu: glyphs.getGlyph("xbox", "menu") ?? FALLBACK_GLYPHS.menu,
up: glyphs.getGlyph("xbox", "dpad-up") ?? FALLBACK_GLYPHS.up,
down: glyphs.getGlyph("xbox", "dpad-down") ?? FALLBACK_GLYPHS.down,
left: glyphs.getGlyph("xbox", "dpad-left") ?? FALLBACK_GLYPHS.left,
right: glyphs.getGlyph("xbox", "dpad-right") ?? FALLBACK_GLYPHS.right,
l1: glyphs.getGlyph("xbox", "lb") ?? FALLBACK_GLYPHS.l1,
r1: glyphs.getGlyph("xbox", "rb") ?? FALLBACK_GLYPHS.r1,
l2: glyphs.getGlyph("xbox", "lt") ?? FALLBACK_GLYPHS.l2,
r2: glyphs.getGlyph("xbox", "rt") ?? FALLBACK_GLYPHS.r2,
y: glyphs.getGlyph("xbox", "y") ?? FALLBACK_GLYPHS.y,
};
}
} catch (_error) {
state.nebula = {
...state.nebula,
coreReady: false,
source: "local-fallback",
};
state.theme = FALLBACK_THEME;
state.glyphs = { ...FALLBACK_GLYPHS };
}
applyThemeToDocument();
};
state.initializeNebulaCore = initializeNebulaCore;
return state;
};
+34 -26
View File
@@ -2,38 +2,46 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="/styles/theme.css" />
<link rel="stylesheet" href="/styles/base.css" />
<link rel="stylesheet" href="/styles/components.css" />
<link rel="stylesheet" href="/views/lock/lock.css" />
<link rel="stylesheet" href="/views/home/home.css" />
<link rel="stylesheet" href="/views/settings/settings.css" />
<link rel="stylesheet" href="/views/library/library.css" />
<link rel="stylesheet" href="/views/overlays/powerMenu.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri App</title> <title>Nebula Shell</title>
<script type="module" src="/main.js" defer></script> <script type="module" src="/main.js" defer></script>
</head> </head>
<body> <body>
<main class="container"> <div id="nebula-background" aria-hidden="true">
<h1>Welcome to Tauri</h1> <div class="nebula-layer gradient"></div>
<div class="nebula-layer starfield"></div>
<div class="row"> <div class="nebula-layer fog"></div>
<a href="https://tauri.app" target="_blank"> <div class="nebula-layer vignette"></div>
<img src="/assets/tauri.svg" class="logo tauri" alt="Tauri logo" />
</a>
<a
href="https://developer.mozilla.org/en-US/docs/Web/JavaScript"
target="_blank"
>
<img
src="/assets/javascript.svg"
class="logo vanilla"
alt="JavaScript logo"
/>
</a>
</div> </div>
<p>Click on the Tauri logo to learn more about the framework</p> <div class="shell-chrome" aria-hidden="true">
<div class="shell-depth-blur"></div>
</div>
<main id="app" class="app-shell"></main>
<div id="overlay-root"></div>
<footer class="app-footer" id="app-footer"></footer>
<form class="row" id="greet-form"> <template id="global-hints-template">
<input id="greet-input" placeholder="Enter a name..." /> <div class="hint-row">
<button type="submit">Greet</button> <span class="hint"><span data-glyph="accept"></span> Select</span>
</form> <span class="hint"><span data-glyph="back"></span> Back</span>
<p id="greet-msg"></p> <span class="hint"><span data-glyph="menu"></span> Menu</span>
</main> </div>
</template>
<template id="minimal-hints-template">
<div class="hint-row">
<span class="hint"><span data-glyph="accept"></span> Open</span>
<span class="hint"><span data-glyph="menu"></span> Power Menu</span>
</div>
</template>
</body> </body>
</html> </html>
+150 -13
View File
@@ -1,18 +1,155 @@
const { invoke } = window.__TAURI__.core; import { createInputManager } from "./core/input.js";
import { createNavigationManager } from "./core/nav.js";
import { createRouter } from "./core/router.js";
import { createAppState } from "./core/state.js";
import { createHomeView } from "./views/home/home.js";
import { createLibraryView } from "./views/library/library.js";
import { createLockView } from "./views/lock/lock.js";
import { createPowerMenuOverlay } from "./views/overlays/powerMenu.js";
import { createSettingsView } from "./views/settings/settings.js";
let greetInputEl; const appRoot = document.querySelector("#app");
let greetMsgEl; const overlayRoot = document.querySelector("#overlay-root");
const footer = document.querySelector("#app-footer");
async function greet() { const state = createAppState();
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ const nav = createNavigationManager();
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value }); const router = createRouter(appRoot);
const powerMenu = createPowerMenuOverlay({ mountRoot: overlayRoot, state });
let currentViewContract = null;
const emitUiHook = (type, payload = {}) => {
window.dispatchEvent(
new CustomEvent("nebula-ui-hook", {
detail: { type, ...payload },
}),
);
};
const setFooterHints = (templateId, glyphs) => {
const template = document.querySelector(templateId);
if (!template) {
footer.innerHTML = "";
return;
}
footer.innerHTML = template.innerHTML;
footer.querySelectorAll("[data-glyph]").forEach((element) => {
const action = element.dataset.glyph;
element.textContent = glyphs[action] ?? action;
});
};
const updateClockLabels = () => {
const now = new Date();
const formattedTime = now.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const formattedDate = now.toLocaleDateString([], {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
document.querySelectorAll("[data-clock]").forEach((clock) => {
clock.textContent = formattedTime;
});
document.querySelectorAll("[data-date]").forEach((date) => {
date.textContent = formattedDate;
});
};
const renderView = (viewId) => {
const contract = router.navigate(viewId);
currentViewContract = contract;
if (!contract) {
return;
}
nav.mount(contract);
setFooterHints(contract.hintsTemplate ?? "#global-hints-template", state.glyphs);
updateClockLabels();
};
const registerViews = () => {
const context = { state, renderView, powerMenu, openPowerMenu };
router.register(createLockView(context));
router.register(createHomeView(context));
router.register(createSettingsView(context));
router.register(createLibraryView(context));
};
const openPowerMenu = () => {
powerMenu.open({
onClose: () => {
nav.mount(currentViewContract);
setFooterHints(currentViewContract?.hintsTemplate ?? "#global-hints-template", state.glyphs);
},
});
};
const handleAction = (action) => {
if (powerMenu.isOpen()) {
powerMenu.handleAction(action);
return;
} }
window.addEventListener("DOMContentLoaded", () => { if (!currentViewContract) {
greetInputEl = document.querySelector("#greet-input"); return;
greetMsgEl = document.querySelector("#greet-msg"); }
document.querySelector("#greet-form").addEventListener("submit", (e) => {
e.preventDefault(); if (action === "menu") {
greet(); const handled = currentViewContract.onMenu?.();
}); if (handled !== false) {
openPowerMenu();
}
return;
}
if (action === "up" || action === "down" || action === "left" || action === "right") {
if (currentViewContract.captureDirectionalInput) {
const handled = currentViewContract.onAction?.(action, nav.getFocusedElement());
if (handled) {
return;
}
}
nav.move(action);
emitUiHook("move", { action });
return;
}
if (action === "accept") {
const focused = nav.getFocusedElement();
focused?.classList.add("is-pressed");
window.setTimeout(() => focused?.classList.remove("is-pressed"), 180);
emitUiHook("accept", { focusKey: focused?.dataset.focusKey ?? null });
currentViewContract.onAccept?.(focused);
return;
}
if (action === "back") {
emitUiHook("back");
currentViewContract.onBack?.();
return;
}
currentViewContract.onAction?.(action, nav.getFocusedElement());
};
const initialize = async () => {
await state.initializeNebulaCore();
registerViews();
renderView("lock");
updateClockLabels();
window.setInterval(updateClockLabels, 1000);
const input = createInputManager({
onAction: handleAction,
actions: ["up", "down", "left", "right", "accept", "back", "menu", "clear", "y", "l1", "r1", "l2", "r2"],
}); });
input.start();
};
initialize();
+262
View File
@@ -0,0 +1,262 @@
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
background: var(--nebula-color-bg);
color: var(--nebula-color-text);
font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
body {
display: grid;
grid-template-rows: 1fr auto;
overflow: hidden;
position: relative;
isolation: isolate;
}
#nebula-background {
position: fixed;
inset: 0;
z-index: -2;
overflow: hidden;
transform: translate3d(var(--bg-parallax-x, 0px), 0, 0);
transition: transform var(--nebula-duration-slow) var(--nebula-ease-standard);
}
.nebula-layer {
position: absolute;
inset: -6%;
will-change: transform, opacity, filter;
}
.nebula-layer.gradient {
background:
radial-gradient(circle at 16% 12%, rgba(82, 116, 218, 0.26), transparent 48%),
radial-gradient(circle at 82% 76%, rgba(147, 79, 188, 0.22), transparent 46%),
radial-gradient(circle at 48% 88%, rgba(79, 216, 255, 0.14), transparent 38%),
linear-gradient(135deg, #050a17 0%, #090f28 24%, #0d1435 48%, #1a1542 78%, #23173c 100%);
animation: nebulaGradientDrift 28s var(--nebula-ease-standard) infinite alternate;
}
.nebula-layer.starfield {
background-image:
radial-gradient(circle, rgba(255, 255, 255, 0.9) 0.8px, transparent 1.6px),
radial-gradient(circle, rgba(79, 216, 255, 0.6) 0.6px, transparent 1.4px),
radial-gradient(circle, rgba(147, 79, 188, 0.5) 1px, transparent 1.8px);
background-size: 180px 180px, 260px 260px, 320px 320px;
background-position: 0 0, 140px 110px, 60px 180px;
opacity: 0.24;
animation: starfieldShift 45s linear infinite;
}
.nebula-layer.fog {
background:
radial-gradient(ellipse at 28% 72%, rgba(82, 182, 255, 0.22), transparent 52%),
radial-gradient(ellipse at 72% 28%, rgba(147, 77, 203, 0.22), transparent 48%),
radial-gradient(ellipse at 45% 50%, rgba(79, 216, 255, 0.16), transparent 46%);
filter: blur(52px);
opacity: 0.76;
animation: fogDrift 34s var(--nebula-ease-standard) infinite alternate;
}
.nebula-layer.vignette {
background: radial-gradient(circle at center, transparent 45%, rgba(2, 6, 20, 0.55) 100%);
inset: 0;
}
.shell-chrome {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
}
.shell-depth-blur {
position: absolute;
inset: 80px 40px 120px;
backdrop-filter: blur(calc(8px + var(--nebula-focus-strength, 0) * 6px));
opacity: calc(0.22 + var(--nebula-focus-strength, 0) * 0.35);
border-radius: 32px;
transition:
opacity var(--nebula-duration-nav) var(--nebula-ease-console),
backdrop-filter var(--nebula-duration-nav) var(--nebula-ease-console);
}
.app-shell {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
padding: 24px var(--nebula-spacing-xl) 0;
overflow: hidden;
}
.view {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: var(--nebula-spacing-lg);
opacity: 0;
transform: translateX(32px);
}
.view.view-entered {
opacity: 1;
transform: translateX(0);
transition:
transform var(--nebula-duration-slow) var(--nebula-ease-console),
opacity var(--nebula-duration-nav) var(--nebula-ease-standard);
}
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.shell-topbar {
display: flex;
flex-direction: column;
min-height: 64px;
padding-bottom: var(--nebula-spacing-sm);
position: relative;
}
.shell-topbar-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.shell-brand {
margin: 0;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 15px;
font-weight: 680;
color: color-mix(in srgb, var(--nebula-color-text) 92%, var(--nebula-color-accent));
text-shadow: 0 0 24px rgba(79, 216, 255, 0.38), 0 0 6px rgba(79, 216, 255, 0.2);
}
.shell-status {
display: inline-flex;
align-items: center;
gap: var(--nebula-spacing-md);
}
.shell-time {
letter-spacing: 0.05em;
font-size: 16px;
font-weight: 560;
color: color-mix(in srgb, var(--nebula-color-text) 90%, var(--nebula-color-muted));
}
.shell-avatar {
width: 32px;
height: 32px;
border-radius: var(--nebula-radius-pill);
border: 2px solid rgba(79, 216, 255, 0.4);
background:
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.85), transparent 46%),
linear-gradient(145deg, rgba(108, 180, 255, 0.7), rgba(75, 81, 155, 0.6));
box-shadow:
0 0 12px rgba(79, 216, 255, 0.25),
0 2px 8px rgba(0, 0, 0, 0.3);
}
.shell-accent-line {
position: absolute;
inset: auto 0 0;
height: 3px;
overflow: hidden;
background: linear-gradient(90deg,
transparent,
rgba(79, 216, 255, 0.12) 25%,
rgba(79, 216, 255, 0.08) 75%,
transparent
);
}
.shell-accent-line::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: clamp(120px, 14vw, 200px);
height: 100%;
background: linear-gradient(90deg,
transparent,
rgba(79, 216, 255, 0.6) 30%,
var(--nebula-color-accent) 50%,
rgba(79, 216, 255, 0.6) 70%,
transparent
);
box-shadow: 0 0 16px rgba(79, 216, 255, 0.5);
transform: translateX(var(--nebula-accent-line-x, 0px));
transition: transform var(--nebula-duration-nav) var(--nebula-ease-console);
}
.view-title {
margin: 0;
font-size: var(--nebula-type-display);
line-height: 1.15;
letter-spacing: 0.01em;
}
.muted {
color: var(--nebula-color-muted);
}
.app-footer {
min-height: 56px;
padding: 0 var(--nebula-spacing-xl) var(--nebula-spacing-md);
}
.hint-row {
display: flex;
align-items: center;
gap: var(--nebula-spacing-lg);
color: var(--nebula-color-muted);
}
.hint {
display: inline-flex;
gap: var(--nebula-spacing-xs);
align-items: center;
font-size: 15px;
}
@keyframes nebulaGradientDrift {
0% {
transform: translate3d(-2%, -1.5%, 0) scale(1.03) rotate(0deg);
}
100% {
transform: translate3d(1.5%, 2%, 0) scale(1.05) rotate(0.5deg);
}
}
@keyframes starfieldShift {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(-140px, -110px, 0);
}
}
@keyframes fogDrift {
0% {
transform: translate3d(-2%, 1.2%, 0) scale(1.02);
}
100% {
transform: translate3d(2.2%, -1.4%, 0) scale(1.04);
}
}
+164
View File
@@ -0,0 +1,164 @@
.panel {
background: var(--nebula-color-panel);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--nebula-radius-md);
padding: var(--nebula-spacing-lg);
box-shadow: var(--nebula-depth-shadow);
backdrop-filter: blur(12px);
}
.focusable {
border: 1px solid transparent;
border-radius: var(--nebula-radius-md);
outline: none;
position: relative;
overflow: hidden;
will-change: transform, box-shadow, border-color;
transform: translateZ(0);
transition:
transform var(--nebula-duration-nav) var(--nebula-ease-console),
border-color var(--nebula-duration-nav) var(--nebula-ease-console),
box-shadow var(--nebula-duration-nav) var(--nebula-ease-console),
background-color var(--nebula-duration-nav) var(--nebula-ease-standard);
}
.focusable.is-focused {
border-color: rgba(79, 216, 255, 0.5);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--nebula-color-focus) 45%, transparent),
0 0 28px color-mix(in srgb, var(--nebula-color-focus) 35%, transparent),
0 4px 16px rgba(2, 6, 18, 0.3),
var(--nebula-depth-shadow-focus);
}
.focusable::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(
circle at var(--ripple-x, 50%) var(--ripple-y, 50%),
rgba(79, 216, 255, 0.28),
rgba(79, 216, 255, 0.12) 40%,
transparent 65%
);
opacity: 0;
transform: scale(0.75);
transition:
opacity var(--nebula-duration-nav) var(--nebula-ease-console),
transform var(--nebula-duration-slow) var(--nebula-ease-console);
pointer-events: none;
}
.focusable.is-focused::before {
opacity: 1;
transform: scale(1.12);
}
.focusable.is-pressed {
animation: uiPressPulse var(--nebula-duration-fast) var(--nebula-ease-snap);
}
.tile {
min-height: 188px;
min-width: 320px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: var(--nebula-spacing-xs);
background:
linear-gradient(160deg, rgba(65, 108, 189, 0.32), rgba(24, 36, 72, 0.90)),
radial-gradient(circle at 75% 25%, rgba(79, 216, 255, 0.12), transparent 48%),
var(--nebula-color-panelAlt);
color: var(--nebula-color-text);
border-radius: var(--nebula-radius-md);
padding: var(--nebula-spacing-lg);
transform-origin: center;
box-shadow:
0 8px 24px rgba(2, 6, 18, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
position: relative;
overflow: hidden;
}
.tile::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(79, 216, 255, 0.08), transparent 60%);
opacity: 0;
transition: opacity var(--nebula-duration-nav) var(--nebula-ease-console);
pointer-events: none;
}
.tile.is-focused::after {
opacity: 1;
}
.tile-icon {
font-size: 42px;
line-height: 1;
opacity: 0.94;
filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.32));
transition: transform var(--nebula-duration-nav) var(--nebula-ease-console);
}
.tile.is-focused .tile-icon {
transform: scale(1.08) translateY(-2px);
}
.tile-label {
margin: 0;
font-size: clamp(22px, 2vw, 28px);
font-weight: 720;
letter-spacing: -0.01em;
z-index: 1;
transition: transform var(--nebula-duration-fast) var(--nebula-ease-console);
}
.tile.is-focused .tile-label {
transform: translateX(3px);
}
.tile-meta {
margin: 0;
font-size: 15px;
color: var(--nebula-color-muted);
z-index: 1;
opacity: 0.88;
transition:
transform var(--nebula-duration-fast) var(--nebula-ease-console),
opacity var(--nebula-duration-fast) var(--nebula-ease-console);
}
.tile.is-focused .tile-meta {
transform: translateX(3px);
opacity: 1;
}
.tile.is-focused {
transform: scale(1.06) translateZ(0);
box-shadow:
0 16px 40px rgba(2, 6, 18, 0.5),
0 0 0 2px rgba(79, 216, 255, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.button-like {
background: var(--nebula-color-panelAlt);
color: var(--nebula-color-text);
padding: var(--nebula-spacing-md) var(--nebula-spacing-lg);
border-radius: var(--nebula-radius-sm);
}
@keyframes uiPressPulse {
0% {
transform: scale(1);
}
40% {
transform: scale(0.96);
}
100% {
transform: scale(1);
}
}
+53
View File
@@ -0,0 +1,53 @@
# Nebula Shell UI Guidelines (Dashboard Refresh)
## Layout Structure
- Home uses a left-aligned horizontal tile rail (`.tile-rail`) with one primary row of large app tiles.
- Top bar is shared across shell views with brand left and time/profile right.
- Settings uses a horizontal category bar at top and card panel content below.
- Lock screen keeps immersive centered time/date and reveals PIN panel on first input.
## Animation Guidelines
- Use cubic-bezier curves only:
- `--nebula-ease-console`: focus travel and panel transitions.
- `--nebula-ease-standard`: opacity and ambient motion.
- `--nebula-ease-snap`: press pulse feedback.
- Focus transitions target `120ms180ms` (`--nebula-duration-fast` / `--nebula-duration-nav`).
- Use transform/opacity for movement and avoid layout-triggering transitions.
- Page changes use `.view` to `.view-entered` slide-in transitions.
- Press feedback uses `.is-pressed` and `uiPressPulse` keyframes.
## Component Breakdown
### TopBar
- Class roots: `.shell-topbar`, `.shell-brand`, `.shell-status`, `.shell-time`, `.shell-avatar`, `.shell-accent-line`.
- Purpose: persistent identity + status + navigation-reactive accent line.
- Reactive token: `--nebula-accent-line-x` updates from focus manager.
### TileRow
- Class root: `.tile-rail`.
- Purpose: horizontal app rail with controller-first left/right travel and smooth scroll.
- Behavior: focus auto-centers via `scrollTo` and updates parallax variables.
### Tile
- Class roots: `.tile`, `.dashboard-tile`, `.tile-icon`, `.tile-label`, `.tile-meta`.
- Focus state: `.is-focused` scales tile and adds cyan glow outline.
- Press state: `.is-pressed` triggers pulse animation and hook events.
### BackgroundLayer
- DOM root: `#nebula-background` with `.nebula-layer` children (`gradient`, `starfield`, `fog`, `vignette`).
- Purpose: animated nebula depth stack with subtle star motion and fog drift.
- Parallax token: `--bg-parallax-x` supports focus-driven depth shift.
### FocusManager
- Implementation root: `src/core/nav.js`.
- Responsibilities:
- Maintain focused element and directional navigation.
- Apply `.is-focused` state and `aria-selected`.
- Publish focus telemetry events (`nebula-focus-change`).
- Update CSS vars (`--nebula-accent-line-x`, `--nebula-focus-strength`).
## UI Hook Contract (No Audio Implementation)
- `window` `CustomEvent("nebula-ui-hook")` details:
- `type: "focus" | "move" | "accept" | "back"`
- Optional metadata (`target`, `action`, `focusKey`)
- Intended use: external UI audio/haptics bridge without coupling shell visuals to playback.
+41
View File
@@ -0,0 +1,41 @@
:root {
--nebula-color-bg: #050a17;
--nebula-color-bg-deep: #0a1028;
--nebula-color-bg-purple: #1a1342;
--nebula-color-panel: rgba(18, 30, 58, 0.75);
--nebula-color-panelAlt: rgba(26, 43, 82, 0.88);
--nebula-color-text: #f2f7ff;
--nebula-color-muted: #a8bdd8;
--nebula-color-accent: #4fd8ff;
--nebula-color-accent-soft: rgba(79, 216, 255, 0.4);
--nebula-color-danger: #ff6b88;
--nebula-color-success: #7dff9e;
--nebula-color-focus: #4fd8ff;
--nebula-color-overlay: rgba(5, 8, 20, 0.82);
--nebula-spacing-xs: 6px;
--nebula-spacing-sm: 10px;
--nebula-spacing-md: 16px;
--nebula-spacing-lg: 24px;
--nebula-spacing-xl: 36px;
--nebula-radius-sm: 10px;
--nebula-radius-md: 14px;
--nebula-radius-lg: 20px;
--nebula-radius-pill: 999px;
--nebula-type-body: 18px;
--nebula-type-title: 24px;
--nebula-type-display: 34px;
--nebula-ease-standard: cubic-bezier(0.25, 0.1, 0.25, 1);
--nebula-ease-console: cubic-bezier(0.19, 0.82, 0.18, 1);
--nebula-ease-snap: cubic-bezier(0.32, 0.94, 0.18, 1);
--nebula-duration-fast: 120ms;
--nebula-duration-nav: 180ms;
--nebula-duration-slow: 340ms;
--nebula-depth-shadow: 0 12px 32px rgba(2, 6, 20, 0.48);
--nebula-depth-shadow-focus: 0 20px 48px rgba(2, 12, 38, 0.62), 0 8px 16px rgba(2, 6, 20, 0.3);
}
+90
View File
@@ -0,0 +1,90 @@
.home-view {
justify-content: flex-start;
gap: var(--nebula-spacing-xl);
padding-left: 0;
}
.home-hero {
display: grid;
gap: var(--nebula-spacing-xs);
padding-left: var(--nebula-spacing-xl);
}
.home-hero .muted {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 14px;
font-weight: 600;
opacity: 0.72;
}
.home-hero .view-title {
font-size: clamp(32px, 3.2vw, 44px);
font-weight: 700;
letter-spacing: -0.02em;
}
.tile-rail {
display: flex;
gap: var(--nebula-spacing-lg);
overflow-x: auto;
overflow-y: hidden;
padding: 10px var(--nebula-spacing-xl) 18px;
scroll-behavior: smooth;
scrollbar-width: none;
transform: translate3d(var(--home-parallax-x, 0px), 0, 0);
transition: transform var(--nebula-duration-slow) var(--nebula-ease-console);
}
.tile-rail::-webkit-scrollbar {
display: none;
}
.dashboard-tile {
flex: 0 0 clamp(280px, 22vw, 340px);
min-height: clamp(200px, 18vh, 240px);
position: relative;
overflow: visible;
}
.dashboard-tile.tile-large {
flex: 0 0 clamp(360px, 28vw, 440px);
min-height: clamp(240px, 22vh, 300px);
}
.tile-content {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
gap: var(--nebula-spacing-md);
position: relative;
z-index: 2;
}
.tile-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.tile-accent-bar {
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--nebula-color-accent), transparent);
opacity: 0;
transform: scaleX(0);
transform-origin: left center;
transition:
opacity var(--nebula-duration-nav) var(--nebula-ease-console),
transform var(--nebula-duration-nav) var(--nebula-ease-console);
}
.dashboard-tile.is-focused .tile-accent-bar {
opacity: 1;
transform: scaleX(1);
}
+60
View File
@@ -0,0 +1,60 @@
<section class="view home-view" data-view="home">
<header class="shell-topbar">
<div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p>
<div class="shell-status">
<span class="shell-avatar" aria-hidden="true"></span>
<p class="shell-time" data-clock>--:--</p>
</div>
</div>
<div class="shell-accent-line"></div>
</header>
<section class="home-hero">
<p class="muted">Dashboard</p>
<h1 class="view-title">Jump back in</h1>
</section>
<section class="tile-rail" data-focus-root data-home-rail>
<button class="focusable tile dashboard-tile tile-large" data-focusable="true" data-row="0" data-col="0" data-target="library">
<div class="tile-content">
<span class="tile-icon" aria-hidden="true">📚</span>
<div class="tile-text">
<p class="tile-label">Library</p>
<p class="tile-meta">Your games & apps</p>
</div>
</div>
<div class="tile-accent-bar"></div>
</button>
<button class="focusable tile dashboard-tile" data-focusable="true" data-row="0" data-col="1" data-target="browser">
<div class="tile-content">
<span class="tile-icon" aria-hidden="true">🌐</span>
<div class="tile-text">
<p class="tile-label">Browser</p>
<p class="tile-meta">Explore the web</p>
</div>
</div>
<div class="tile-accent-bar"></div>
</button>
<button class="focusable tile dashboard-tile" data-focusable="true" data-row="0" data-col="2" data-target="settings">
<div class="tile-content">
<span class="tile-icon" aria-hidden="true">⚙️</span>
<div class="tile-text">
<p class="tile-label">Settings</p>
<p class="tile-meta">System configuration</p>
</div>
</div>
<div class="tile-accent-bar"></div>
</button>
<button class="focusable tile dashboard-tile" data-focusable="true" data-row="0" data-col="3" data-target="power">
<div class="tile-content">
<span class="tile-icon" aria-hidden="true"></span>
<div class="tile-text">
<p class="tile-label">Power</p>
<p class="tile-meta">Sleep, restart, shut down</p>
</div>
</div>
<div class="tile-accent-bar"></div>
</button>
</section>
</section>
+132
View File
@@ -0,0 +1,132 @@
const HOME_TEMPLATE = `
<section class="view home-view" data-view="home">
<header class="shell-topbar">
<div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p>
<div class="shell-status">
<span class="shell-avatar" aria-hidden="true"></span>
<p class="shell-time" data-clock>--:--</p>
</div>
</div>
<div class="shell-accent-line"></div>
</header>
<section class="home-hero">
<p class="muted">Dashboard</p>
<h1 class="view-title">Jump back in</h1>
</section>
<section class="tile-rail" data-focus-root data-home-rail>
<button class="focusable tile dashboard-tile tile-large" data-focusable="true" data-row="0" data-col="0" data-target="library" data-focus-key="library">
<div class="tile-content">
<span class="tile-icon" aria-hidden="true">📚</span>
<div class="tile-text">
<p class="tile-label">Library</p>
<p class="tile-meta">Your games & apps</p>
</div>
</div>
<div class="tile-accent-bar"></div>
</button>
<button class="focusable tile dashboard-tile" data-focusable="true" data-row="0" data-col="1" data-target="browser" data-focus-key="browser">
<div class="tile-content">
<span class="tile-icon" aria-hidden="true">🌐</span>
<div class="tile-text">
<p class="tile-label">Browser</p>
<p class="tile-meta">Explore the web</p>
</div>
</div>
<div class="tile-accent-bar"></div>
</button>
<button class="focusable tile dashboard-tile" data-focusable="true" data-row="0" data-col="2" data-target="settings" data-focus-key="settings">
<div class="tile-content">
<span class="tile-icon" aria-hidden="true">⚙️</span>
<div class="tile-text">
<p class="tile-label">Settings</p>
<p class="tile-meta">System configuration</p>
</div>
</div>
<div class="tile-accent-bar"></div>
</button>
<button class="focusable tile dashboard-tile" data-focusable="true" data-row="0" data-col="3" data-target="power" data-focus-key="power">
<div class="tile-content">
<span class="tile-icon" aria-hidden="true">⏻</span>
<div class="tile-text">
<p class="tile-label">Power</p>
<p class="tile-meta">Sleep, restart, shut down</p>
</div>
</div>
<div class="tile-accent-bar"></div>
</button>
</section>
</section>
`;
export const createHomeView = ({ state, renderView, openPowerMenu }) => ({
id: "home",
render: () => HOME_TEMPLATE,
mount: () => {
const rail = document.querySelector("[data-home-rail]");
const background = document.querySelector("#nebula-background");
if (!rail) {
return;
}
const focusTile = (tile) => {
if (!tile) {
return;
}
const targetScroll = tile.offsetLeft - rail.clientWidth * 0.22;
rail.scrollTo({
left: Math.max(0, targetScroll),
behavior: "smooth",
});
const focusColumn = Number(tile.dataset.col ?? 0);
document.documentElement.style.setProperty("--nebula-focus-strength", "1");
document.documentElement.style.setProperty("--nebula-accent-line-x", `${20 + focusColumn * 112}px`);
rail.style.setProperty("--home-parallax-x", `${focusColumn * -10}px`);
background?.style.setProperty("--bg-parallax-x", `${focusColumn * -6}px`);
};
rail.addEventListener("focusin", (event) => {
const tile = event.target.closest("[data-focusable='true']");
focusTile(tile);
if (tile) {
window.dispatchEvent(
new CustomEvent("nebula-ui-hook", {
detail: { type: "focus", target: tile.dataset.target },
}),
);
}
});
},
getNavigationContract: () => {
const root = document.querySelector("[data-home-rail]");
return {
focusRoot: root,
defaultFocus: root?.querySelector("[data-target='library']") ?? null,
layout: { type: "grid", cols: 4, rows: 1 },
hintsTemplate: "#global-hints-template",
nebulaNavigation: state.nebula.navigation,
onAccept: (element) => {
if (!element) {
return;
}
const target = element.dataset.target;
if (target === "power") {
openPowerMenu();
return;
}
state.activeView = target;
renderView(target);
},
onBack: () => {
state.locked = true;
state.activeView = "lock";
renderView("lock");
},
onMenu: () => {},
};
},
});
+10
View File
@@ -0,0 +1,10 @@
.stub-view {
justify-content: center;
}
.stub-panel {
max-width: 720px;
display: flex;
flex-direction: column;
gap: var(--nebula-spacing-md);
}
+22
View File
@@ -0,0 +1,22 @@
<section class="view stub-view" data-view="library">
<header class="shell-topbar">
<div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p>
<div class="shell-status">
<span class="shell-avatar" aria-hidden="true"></span>
<p class="shell-time" data-clock>--:--</p>
</div>
</div>
<div class="shell-accent-line"></div>
</header>
<section class="view-header">
<div>
<p class="muted">Nebula App</p>
<h1 class="view-title">Library</h1>
</div>
</section>
<section class="panel stub-panel" data-focus-root>
<button class="focusable button-like" data-focusable="true" data-row="0" data-col="0" data-action="back">Back to Home</button>
<p class="muted">Library integration stub for v0 shell navigation.</p>
</section>
</section>
+48
View File
@@ -0,0 +1,48 @@
const LIBRARY_TEMPLATE = `
<section class="view stub-view" data-view="library">
<header class="shell-topbar">
<div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p>
<div class="shell-status">
<span class="shell-avatar" aria-hidden="true"></span>
<p class="shell-time" data-clock>--:--</p>
</div>
</div>
<div class="shell-accent-line"></div>
</header>
<section class="view-header">
<div>
<p class="muted">Nebula App</p>
<h1 class="view-title">Library</h1>
</div>
</section>
<section class="panel stub-panel" data-focus-root>
<button class="focusable button-like" data-focusable="true" data-row="0" data-col="0" data-action="back" data-focus-key="back">Back to Home</button>
<p class="muted">Library integration stub for v0 shell navigation.</p>
</section>
</section>
`;
export const createLibraryView = ({ state, renderView }) => ({
id: "library",
render: () => LIBRARY_TEMPLATE,
getNavigationContract: () => {
const root = document.querySelector("[data-focus-root]");
return {
focusRoot: root,
defaultFocus: root?.querySelector("[data-action='back']") ?? null,
layout: { type: "list", rows: 1 },
hintsTemplate: "#minimal-hints-template",
nebulaNavigation: state.nebula.navigation,
onAccept: () => {
state.activeView = "home";
renderView("home");
},
onBack: () => {
state.activeView = "home";
renderView("home");
},
onMenu: () => {},
};
},
});
+233
View File
@@ -0,0 +1,233 @@
.lock-view {
justify-content: center;
align-items: center;
background: radial-gradient(circle at 55% 48%, rgba(79, 216, 255, 0.1), transparent 62%);
}
.lock-layout {
width: min(1240px, 96vw);
min-height: min(720px, 82vh);
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(360px, 520px);
gap: clamp(24px, 4vw, 58px);
background:
linear-gradient(165deg, rgba(56, 82, 128, 0.18), rgba(19, 30, 56, 0.74)),
radial-gradient(circle at 65% 35%, rgba(79, 216, 255, 0.08), transparent 52%),
rgba(13, 18, 31, 0.78);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 24px 62px rgba(0, 0, 0, 0.38),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
backdrop-filter: blur(14px);
padding: clamp(28px, 4vw, 48px);
}
.lock-view.is-success .lock-layout {
box-shadow:
0 0 0 1px rgba(79, 216, 255, 0.3),
0 24px 62px rgba(0, 0, 0, 0.38),
0 0 48px rgba(79, 216, 255, 0.24);
}
.lock-left {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--nebula-spacing-lg);
}
.lock-user {
display: inline-flex;
align-items: center;
gap: 12px;
}
.lock-avatar {
width: 42px;
height: 42px;
border-radius: 999px;
border: 2px solid rgba(79, 216, 255, 0.44);
background:
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.85), transparent 45%),
linear-gradient(145deg, rgba(112, 189, 255, 0.66), rgba(66, 78, 142, 0.8));
}
.lock-username {
margin: 0;
font-weight: 640;
font-size: 18px;
}
.lock-title {
margin: 0;
font-size: clamp(36px, 5vw, 52px);
font-weight: 540;
letter-spacing: -0.01em;
}
.lock-copy {
margin: 0;
color: var(--nebula-color-muted);
font-size: 20px;
line-height: 1.45;
max-width: 540px;
}
.lock-dots {
display: flex;
align-items: center;
gap: clamp(14px, 1.6vw, 22px);
min-height: 30px;
transition: transform var(--nebula-duration-fast) var(--nebula-ease-console);
}
.lock-dot {
width: 22px;
height: 22px;
border-radius: 999px;
border: 2px solid rgba(191, 206, 230, 0.38);
background: rgba(255, 255, 255, 0.05);
opacity: 0.75;
transform: scale(0.92);
transition:
transform var(--nebula-duration-fast) var(--nebula-ease-console),
border-color var(--nebula-duration-fast) var(--nebula-ease-console),
background-color var(--nebula-duration-fast) var(--nebula-ease-console),
box-shadow var(--nebula-duration-fast) var(--nebula-ease-console);
}
.lock-dot.active {
border-color: rgba(79, 216, 255, 0.72);
box-shadow: 0 0 16px rgba(79, 216, 255, 0.34);
}
.lock-dot.filled {
background: rgba(215, 230, 255, 0.9);
border-color: rgba(215, 230, 255, 0.95);
transform: scale(1);
animation: dotFill var(--nebula-duration-fast) var(--nebula-ease-console);
}
.lock-dots.is-success .lock-dot.filled {
border-color: rgba(79, 216, 255, 0.95);
background: rgba(79, 216, 255, 0.9);
box-shadow: 0 0 20px rgba(79, 216, 255, 0.58);
}
.lock-dots.is-error .lock-dot {
border-color: color-mix(in srgb, var(--nebula-color-danger) 72%, rgba(255, 255, 255, 0.3));
box-shadow: 0 0 20px color-mix(in srgb, var(--nebula-color-danger) 38%, transparent);
}
.lock-dots.is-shaking {
animation: dotsShake 420ms var(--nebula-ease-console);
}
.lock-status {
margin: 0;
min-height: 24px;
font-size: 16px;
color: var(--nebula-color-muted);
}
.lock-status.is-danger {
color: var(--nebula-color-danger);
}
.lock-right {
align-self: center;
display: grid;
grid-template-columns: repeat(3, minmax(96px, 1fr));
gap: clamp(10px, 1.4vw, 18px);
justify-items: center;
background: rgba(15, 24, 45, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.lock-num {
width: clamp(82px, 7.3vw, 112px);
height: clamp(82px, 7.3vw, 112px);
border: none;
border-radius: 18px;
font-size: clamp(42px, 3.8vw, 68px);
font-weight: 330;
line-height: 1;
color: color-mix(in srgb, var(--nebula-color-text) 95%, rgba(215, 230, 255, 0.94));
background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.12), transparent 55%),
linear-gradient(162deg, rgba(61, 90, 140, 0.18), rgba(15, 23, 43, 0.6));
box-shadow:
0 10px 22px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
transition:
transform var(--nebula-duration-nav) var(--nebula-ease-console),
box-shadow var(--nebula-duration-nav) var(--nebula-ease-console),
border-color var(--nebula-duration-nav) var(--nebula-ease-console);
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: 8px;
}
.lock-num.is-zero {
grid-column: 2;
}
.lock-num.is-focused {
transform: scale(1.1);
box-shadow:
0 0 0 2px rgba(79, 216, 255, 0.5),
0 0 28px rgba(79, 216, 255, 0.36),
0 12px 30px rgba(0, 0, 0, 0.28);
}
.lock-map {
font-size: 13px;
font-weight: 650;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--nebula-color-muted) 80%, #fff);
transform: translateY(-8px);
}
.lock-num.is-focused .lock-map {
color: color-mix(in srgb, var(--nebula-color-accent) 85%, #fff);
}
@keyframes dotFill {
0% {
opacity: 0.35;
transform: scale(0.62);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes dotsShake {
0% {
transform: translateX(0);
}
20% {
transform: translateX(-9px);
}
40% {
transform: translateX(8px);
}
60% {
transform: translateX(-6px);
}
80% {
transform: translateX(4px);
}
100% {
transform: translateX(0);
}
}
+39
View File
@@ -0,0 +1,39 @@
<section class="view lock-view" data-view="lock">
<header class="shell-topbar">
<div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p>
<div class="shell-status">
<span class="shell-avatar" aria-hidden="true"></span>
<p class="shell-time" data-clock>--:--</p>
</div>
</div>
<div class="shell-accent-line"></div>
</header>
<section class="lock-stage">
<p class="lock-time" data-clock>--:--</p>
<p class="lock-date muted" data-date>-- --- ----</p>
<p class="muted lock-prompt">Press any key to unlock</p>
</section>
<section class="panel lock-panel" data-lock-panel>
<p class="muted">Enter Security PIN</p>
<div class="pin-dots" data-pin-dots></div>
<div class="keypad" data-focus-root>
<button class="focusable keypad-key" data-focusable="true" data-row="0" data-col="0" data-key="1">1</button>
<button class="focusable keypad-key" data-focusable="true" data-row="0" data-col="1" data-key="2">2</button>
<button class="focusable keypad-key" data-focusable="true" data-row="0" data-col="2" data-key="3">3</button>
<button class="focusable keypad-key" data-focusable="true" data-row="1" data-col="0" data-key="4">4</button>
<button class="focusable keypad-key" data-focusable="true" data-row="1" data-col="1" data-key="5">5</button>
<button class="focusable keypad-key" data-focusable="true" data-row="1" data-col="2" data-key="6">6</button>
<button class="focusable keypad-key" data-focusable="true" data-row="2" data-col="0" data-key="7">7</button>
<button class="focusable keypad-key" data-focusable="true" data-row="2" data-col="1" data-key="8">8</button>
<button class="focusable keypad-key" data-focusable="true" data-row="2" data-col="2" data-key="9">9</button>
<button class="focusable keypad-key" data-focusable="true" data-row="3" data-col="0" data-key="delete"></button>
<button class="focusable keypad-key" data-focusable="true" data-row="3" data-col="1" data-key="0">0</button>
<button class="focusable keypad-key" data-focusable="true" data-row="3" data-col="2" data-key="ok">OK</button>
</div>
<p class="muted lock-help">Default v0 PIN: 1234</p>
<p class="lock-error" data-error></p>
</section>
</section>
+385
View File
@@ -0,0 +1,385 @@
const LOCK_TEMPLATE = `
<section class="view lock-view" data-view="lock">
<section class="lock-layout panel">
<section class="lock-left">
<div class="lock-user">
<span class="lock-avatar" aria-hidden="true"></span>
<p class="lock-username" data-username>Nebula User</p>
</div>
<h1 class="lock-title">Enter your passkey</h1>
<p class="lock-copy" data-copy>Using your controller, enter your 6-digit passkey.</p>
<div class="lock-dots" data-passkey-dots></div>
<p class="lock-status" data-status></p>
</section>
<section class="lock-right panel" data-focus-root>
<button class="focusable lock-num" data-focusable="true" data-row="0" data-col="0" data-digit="1" data-focus-key="digit-1">1 <span class="lock-map" data-map="up"></span></button>
<button class="focusable lock-num" data-focusable="true" data-row="0" data-col="1" data-digit="2" data-focus-key="digit-2">2 <span class="lock-map" data-map="left"></span></button>
<button class="focusable lock-num" data-focusable="true" data-row="0" data-col="2" data-digit="3" data-focus-key="digit-3">3 <span class="lock-map" data-map="down"></span></button>
<button class="focusable lock-num" data-focusable="true" data-row="1" data-col="0" data-digit="4" data-focus-key="digit-4">4 <span class="lock-map" data-map="right"></span></button>
<button class="focusable lock-num" data-focusable="true" data-row="1" data-col="1" data-digit="5" data-focus-key="digit-5">5 <span class="lock-map" data-map="l2"></span></button>
<button class="focusable lock-num" data-focusable="true" data-row="1" data-col="2" data-digit="6" data-focus-key="digit-6">6 <span class="lock-map" data-map="r2"></span></button>
<button class="focusable lock-num" data-focusable="true" data-row="2" data-col="0" data-digit="7" data-focus-key="digit-7">7 <span class="lock-map" data-map="l1"></span></button>
<button class="focusable lock-num" data-focusable="true" data-row="2" data-col="1" data-digit="8" data-focus-key="digit-8">8 <span class="lock-map" data-map="r1"></span></button>
<button class="focusable lock-num" data-focusable="true" data-row="2" data-col="2" data-digit="9" data-focus-key="digit-9">9 <span class="lock-map" data-map="y"></span></button>
<button class="focusable lock-num is-zero" data-focusable="true" data-row="3" data-col="1" data-digit="0" data-focus-key="digit-0">0 <span class="lock-map" data-map="clear"></span></button>
</section>
</section>
</section>
`;
export const createLockView = ({ state, renderView }) => {
const ENTRY_DEBOUNCE_MS = 120;
const FAIL_CLEAR_MS = 600;
let digits = [];
let setupDigits = [];
let setupPhase = "create";
let busy = false;
let lastEntryAt = 0;
let keyboardListener = null;
const config = () => state.passkey.getConfig();
const ACTION_TO_DIGIT = {
up: "1",
left: "2",
down: "3",
right: "4",
l2: "5",
r2: "6",
l1: "7",
r1: "8",
y: "9",
clear: "0",
};
const playTone = (frequency = 860, durationMs = 34, gainValue = 0.03) => {
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
if (!AudioContextClass) {
return;
}
try {
const ctx = new AudioContextClass();
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = "triangle";
oscillator.frequency.value = frequency;
gain.gain.value = gainValue;
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.start();
window.setTimeout(() => {
oscillator.stop();
ctx.close();
}, durationMs);
} catch (_error) {
// no-op
}
};
const pulseHaptic = (ms = 10) => {
navigator.vibrate?.(ms);
};
const setStatus = (text = "", danger = false) => {
const status = document.querySelector("[data-status]");
if (!status) {
return;
}
status.textContent = text;
status.classList.toggle("is-danger", danger);
};
const updateCopy = () => {
const copy = document.querySelector("[data-copy]");
if (!copy) {
return;
}
const length = config().length;
if (state.passkeySetupRequired) {
copy.textContent = setupPhase === "confirm"
? `Re-enter the same ${length}-digit passkey to confirm.`
: `Use Xbox passkey controls to enter your ${length}-digit passkey.`;
return;
}
if (config().requireConfirm) {
copy.textContent = `Use Xbox passkey controls to enter ${length} digits, then press Start.`;
return;
}
copy.textContent = `Use Xbox passkey controls to enter your ${length}-digit passkey.`;
};
const updateMapLabels = () => {
document.querySelectorAll("[data-map]").forEach((element) => {
const action = element.dataset.map;
element.textContent = state.glyphs[action] ?? action?.toUpperCase?.() ?? "";
});
};
const renderDots = (stateClass = "") => {
const dots = document.querySelector("[data-passkey-dots]");
if (!dots) {
return;
}
dots.className = `lock-dots ${stateClass}`.trim();
dots.innerHTML = "";
for (let index = 0; index < config().length; index += 1) {
const dot = document.createElement("span");
dot.className = "lock-dot";
if (index < digits.length) {
dot.classList.add("filled");
}
if (index === digits.length && digits.length < config().length) {
dot.classList.add("active");
}
dots.append(dot);
}
};
const clearDigits = () => {
digits = [];
renderDots();
};
const lockoutText = (remainingMs) => `Too many attempts. Retry in ${Math.ceil(remainingMs / 1000)}s.`;
const applyFailure = (text) => {
setStatus(text, true);
renderDots("is-error is-shaking");
playTone(220, 84, 0.05);
window.setTimeout(() => {
clearDigits();
}, FAIL_CLEAR_MS);
};
const applySuccess = () => {
const view = document.querySelector(".lock-view");
setStatus("", false);
renderDots("is-success");
view?.classList.add("is-success");
playTone(1200, 66, 0.04);
pulseHaptic(24);
window.setTimeout(() => {
state.locked = false;
state.activeView = "home";
renderView("home");
}, 240);
};
const submitDigits = async () => {
if (busy) {
return;
}
if (digits.length !== config().length) {
setStatus(`Enter ${config().length} digits.`, true);
return;
}
busy = true;
if (!config().enabled) {
applySuccess();
busy = false;
return;
}
if (state.passkeySetupRequired) {
if (setupPhase === "create") {
setupDigits = [...digits];
clearDigits();
setupPhase = "confirm";
updateCopy();
setStatus("Confirm your new passkey.");
busy = false;
return;
}
const same = setupDigits.length === digits.length && setupDigits.every((digit, index) => digit === digits[index]);
if (!same) {
setupPhase = "create";
setupDigits = [];
updateCopy();
applyFailure("Passkeys did not match. Try again.");
busy = false;
return;
}
await state.passkey.setSequence(setupDigits);
state.passkeySetupRequired = false;
applySuccess();
busy = false;
return;
}
const result = await state.passkey.verifySequence(digits);
if (result.ok) {
applySuccess();
busy = false;
return;
}
if (result.reason === "lockout") {
applyFailure(lockoutText(result.lockoutRemainingMs ?? state.passkey.getLockoutRemainingMs()));
busy = false;
return;
}
if (result.reason === "setup-required") {
state.passkeySetupRequired = true;
setupPhase = "create";
setupDigits = [];
updateCopy();
applyFailure("Passkey setup required.");
busy = false;
return;
}
applyFailure(`Incorrect passkey. ${result.attemptsLeft ?? 0} attempts left.`);
busy = false;
};
const pushDigit = (digit) => {
if (busy || state.passkey.inLockout()) {
if (state.passkey.inLockout()) {
setStatus(lockoutText(state.passkey.getLockoutRemainingMs()), true);
}
return;
}
const now = performance.now();
if (now - lastEntryAt < ENTRY_DEBOUNCE_MS) {
return;
}
lastEntryAt = now;
if (digits.length >= config().length) {
return;
}
digits.push(String(digit));
renderDots();
setStatus("");
playTone();
pulseHaptic(8);
if (digits.length === config().length && !config().requireConfirm) {
submitDigits();
} else if (digits.length === config().length && config().requireConfirm) {
setStatus("Press Start to confirm.");
}
};
const deleteLast = () => {
if (!digits.length || busy) {
return;
}
digits = digits.slice(0, -1);
renderDots();
setStatus("");
};
const clearAll = () => {
if (!digits.length || busy) {
return;
}
clearDigits();
setStatus("");
};
const handleNumberKey = (event) => {
if (!config().keyboardSupport || !state.locked) {
return;
}
const digitMatch = event.code.match(/^(Digit|Numpad)(\d)$/);
if (!digitMatch) {
return;
}
event.preventDefault();
pushDigit(digitMatch[2]);
};
return {
id: "lock",
render: () => LOCK_TEMPLATE,
mount: () => {
setupPhase = "create";
setupDigits = [];
clearDigits();
updateCopy();
setStatus("");
const username = document.querySelector("[data-username]");
if (username) {
username.textContent = state.profileName ?? "Nebula User";
}
document.documentElement.style.setProperty("--nebula-focus-strength", "1");
document.documentElement.dataset.animSpeed = config().animationSpeed;
document.documentElement.classList.toggle("high-contrast", config().highContrast);
updateMapLabels();
if (keyboardListener) {
window.removeEventListener("keydown", keyboardListener);
}
keyboardListener = handleNumberKey;
window.addEventListener("keydown", keyboardListener);
},
getNavigationContract: () => {
const root = document.querySelector("[data-focus-root]");
const defaultFocus = root?.querySelector("[data-digit='5']") ?? root?.querySelector("[data-digit='1']") ?? null;
return {
focusRoot: root,
defaultFocus,
layout: { type: "grid", cols: 3, rows: 4 },
hintsTemplate: "#global-hints-template",
nebulaNavigation: state.nebula.navigation,
captureDirectionalInput: true,
onAccept: (element) => {
const digit = element?.dataset.digit;
if (!digit) {
return;
}
pushDigit(digit);
},
onBack: () => {
deleteLast();
},
onMenu: () => {
if (digits.length === config().length) {
submitDigits();
}
return false;
},
onAction: (action) => {
const mappedDigit = ACTION_TO_DIGIT[action];
if (mappedDigit) {
pushDigit(mappedDigit);
return true;
}
return false;
},
};
},
};
};
+34
View File
@@ -0,0 +1,34 @@
.power-overlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background: var(--nebula-color-overlay);
}
.power-overlay[hidden] {
display: none !important;
}
.power-panel {
width: min(520px, 92vw);
}
.power-title {
margin: 0 0 var(--nebula-spacing-md);
}
.power-options {
display: grid;
gap: var(--nebula-spacing-sm);
}
.power-option {
min-height: 62px;
border: none;
background: var(--nebula-color-panelAlt);
color: var(--nebula-color-text);
text-align: left;
padding: var(--nebula-spacing-md);
}
+12
View File
@@ -0,0 +1,12 @@
<section class="power-overlay" data-power-overlay hidden>
<div class="power-panel panel">
<h2 class="power-title">Power Menu</h2>
<div class="power-options" data-power-focus-root>
<button class="focusable power-option" data-focusable="true" data-row="0" data-col="0" data-action="suspend">Suspend</button>
<button class="focusable power-option" data-focusable="true" data-row="1" data-col="0" data-action="restart">Restart</button>
<button class="focusable power-option" data-focusable="true" data-row="2" data-col="0" data-action="shutdown">Shutdown</button>
<button class="focusable power-option" data-focusable="true" data-row="3" data-col="0" data-action="desktop">Switch to Desktop</button>
<button class="focusable power-option" data-focusable="true" data-row="4" data-col="0" data-action="cancel">Cancel</button>
</div>
</div>
</section>
+107
View File
@@ -0,0 +1,107 @@
const POWER_MENU_TEMPLATE = `
<section class="power-overlay" data-power-overlay hidden>
<div class="power-panel panel">
<h2 class="power-title">Power Menu</h2>
<div class="power-options" data-power-focus-root>
<button class="focusable power-option" data-focusable="true" data-row="0" data-col="0" data-action="suspend">Suspend</button>
<button class="focusable power-option" data-focusable="true" data-row="1" data-col="0" data-action="restart">Restart</button>
<button class="focusable power-option" data-focusable="true" data-row="2" data-col="0" data-action="shutdown">Shutdown</button>
<button class="focusable power-option" data-focusable="true" data-row="3" data-col="0" data-action="desktop">Switch to Desktop</button>
<button class="focusable power-option" data-focusable="true" data-row="4" data-col="0" data-action="cancel">Cancel</button>
</div>
</div>
</section>
`;
const ACTION_LOG = {
suspend: "Suspend requested (stub)",
restart: "Restart requested (stub)",
shutdown: "Shutdown requested (stub)",
desktop: "Switch to Desktop requested (stub)",
};
export const createPowerMenuOverlay = ({ mountRoot }) => {
mountRoot.innerHTML = POWER_MENU_TEMPLATE;
const overlay = mountRoot.querySelector("[data-power-overlay]");
const focusables = Array.from(mountRoot.querySelectorAll("[data-focusable='true']"));
let focusedIndex = 0;
let openState = false;
let onClose = null;
if (overlay) {
overlay.hidden = true;
}
const focusAt = (index) => {
focusables.forEach((element) => {
element.classList.remove("is-focused");
element.setAttribute("aria-selected", "false");
});
focusedIndex = Math.max(0, Math.min(index, focusables.length - 1));
const target = focusables[focusedIndex];
target.classList.add("is-focused");
target.setAttribute("aria-selected", "true");
target.focus({ preventScroll: true });
};
const close = () => {
openState = false;
overlay.hidden = true;
onClose?.();
onClose = null;
};
const open = (options = {}) => {
openState = true;
onClose = options.onClose ?? null;
overlay.hidden = false;
focusAt(0);
};
const runAction = (action) => {
if (action === "cancel") {
close();
return;
}
if (ACTION_LOG[action]) {
console.log(`[PowerMenu] ${ACTION_LOG[action]}`);
close();
}
};
const handleAction = (action) => {
if (!openState) {
return;
}
if (action === "up") {
focusAt(focusedIndex - 1);
return;
}
if (action === "down") {
focusAt(focusedIndex + 1);
return;
}
if (action === "accept") {
const current = focusables[focusedIndex];
runAction(current?.dataset.action ?? "cancel");
return;
}
if (action === "back" || action === "menu") {
close();
}
};
return {
open,
close,
isOpen: () => openState,
handleAction,
};
};
+181
View File
@@ -0,0 +1,181 @@
.settings-view {
gap: var(--nebula-spacing-xl);
}
.settings-header-copy {
display: grid;
gap: var(--nebula-spacing-xs);
padding-left: var(--nebula-spacing-xl);
}
.settings-header-copy .muted {
margin: 0;
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 14px;
font-weight: 600;
opacity: 0.72;
}
.settings-header-copy .view-title {
font-size: clamp(32px, 3.2vw, 44px);
font-weight: 700;
letter-spacing: -0.02em;
}
.settings-body {
display: grid;
gap: var(--nebula-spacing-lg);
padding: 0 var(--nebula-spacing-xl);
}
.settings-category-bar {
display: flex;
gap: 10px;
padding-bottom: var(--nebula-spacing-md);
border-bottom: 2px solid rgba(79, 216, 255, 0.15);
position: relative;
}
.settings-category {
min-height: 56px;
padding: 0 24px;
border-radius: var(--nebula-radius-sm);
border: none;
background: rgba(24, 38, 68, 0.5);
font-weight: 660;
font-size: 16px;
position: relative;
transition:
background-color var(--nebula-duration-nav) var(--nebula-ease-console),
color var(--nebula-duration-nav) var(--nebula-ease-console);
}
.settings-category.is-focused {
background: rgba(38, 58, 98, 0.7);
}
.settings-category.is-active {
background: rgba(50, 78, 128, 0.8);
color: var(--nebula-color-accent);
}
.settings-category::after {
content: "";
position: absolute;
left: 12px;
right: 12px;
bottom: -14px;
height: 3px;
background: linear-gradient(90deg, var(--nebula-color-accent), rgba(79, 216, 255, 0.6));
border-radius: 2px 2px 0 0;
box-shadow: 0 0 12px rgba(79, 216, 255, 0.6);
transform: scaleX(0);
transform-origin: center;
transition: transform var(--nebula-duration-nav) var(--nebula-ease-console);
}
.settings-category.is-active::after {
transform: scaleX(1);
}
.settings-panel {
display: flex;
flex-direction: column;
gap: var(--nebula-spacing-lg);
min-height: 440px;
background:
linear-gradient(160deg, rgba(65, 108, 189, 0.18), rgba(24, 36, 72, 0.65)),
radial-gradient(circle at 75% 25%, rgba(79, 216, 255, 0.08), transparent 48%),
var(--nebula-color-panel);
border-radius: var(--nebula-radius-md);
padding: var(--nebula-spacing-lg);
box-shadow:
0 8px 24px rgba(2, 6, 18, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.settings-panel-head {
display: grid;
gap: 8px;
padding-bottom: var(--nebula-spacing-md);
border-bottom: 1px solid rgba(79, 216, 255, 0.12);
}
.settings-panel-title {
margin: 0;
font-size: 28px;
font-weight: 720;
letter-spacing: -0.01em;
}
.settings-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--nebula-spacing-md);
}
.settings-card {
border: 1px solid rgba(79, 216, 255, 0.15);
border-radius: var(--nebula-radius-md);
padding: var(--nebula-spacing-lg);
min-height: 180px;
background:
linear-gradient(165deg, rgba(70, 108, 186, 0.28), rgba(22, 34, 68, 0.88));
text-align: left;
display: grid;
align-content: space-between;
position: relative;
overflow: hidden;
transition:
transform var(--nebula-duration-nav) var(--nebula-ease-console),
border-color var(--nebula-duration-nav) var(--nebula-ease-console);
}
.settings-card::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(79, 216, 255, 0.12), transparent 60%);
opacity: 0;
transition: opacity var(--nebula-duration-nav) var(--nebula-ease-console);
}
.settings-card.is-focused {
transform: scale(1.04);
border-color: rgba(79, 216, 255, 0.5);
}
.settings-card.is-focused::before {
opacity: 1;
}
.settings-card-title {
margin: 0;
font-size: 20px;
font-weight: 680;
letter-spacing: -0.01em;
position: relative;
z-index: 1;
}
.settings-card p {
margin: 0;
position: relative;
z-index: 1;
}
.settings-card-info {
border-style: dashed;
opacity: 0.85;
}
.settings-card.is-disabled {
opacity: 0.55;
border-style: dashed;
filter: grayscale(0.15);
}
.settings-card.is-disabled.is-focused {
transform: none;
}
+48
View File
@@ -0,0 +1,48 @@
<section class="view settings-view" data-view="settings">
<header class="shell-topbar">
<div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p>
<div class="shell-status">
<span class="shell-avatar" aria-hidden="true"></span>
<p class="shell-time" data-clock>--:--</p>
</div>
</div>
<div class="shell-accent-line"></div>
</header>
<section class="settings-header-copy">
<p class="muted">System</p>
<h1 class="view-title">Settings</h1>
</section>
<section class="settings-body" data-focus-root>
<section class="settings-category-bar">
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="0" data-cat="network">Network</button>
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="1" data-cat="audio">Audio</button>
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="2" data-cat="display">Display</button>
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="3" data-cat="storage">Storage</button>
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="4" data-cat="system">System</button>
</section>
<article class="panel settings-panel">
<div class="settings-panel-head">
<h2 class="settings-panel-title" data-panel-title>Network</h2>
<p class="muted" data-panel-copy>Optimize connection and online routing.</p>
</div>
<div class="settings-card-grid">
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="0" data-toggle="primary">
<p class="settings-card-title">Primary Switch</p>
<p class="muted" data-primary-state>Enabled</p>
</button>
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="1" data-toggle="secondary">
<p class="settings-card-title">Secondary Switch</p>
<p class="muted" data-secondary-state>Enabled</p>
</button>
<div class="settings-card settings-card-info">
<p class="settings-card-title">Status</p>
<p class="muted" data-status-note>Applying Nebula profile</p>
</div>
</div>
</article>
</section>
</section>
+241
View File
@@ -0,0 +1,241 @@
const SETTINGS_TEMPLATE = `
<section class="view settings-view" data-view="settings">
<header class="shell-topbar">
<div class="shell-topbar-content">
<p class="shell-brand">Nebula OS</p>
<div class="shell-status">
<span class="shell-avatar" aria-hidden="true"></span>
<p class="shell-time" data-clock>--:--</p>
</div>
</div>
<div class="shell-accent-line"></div>
</header>
<section class="settings-header-copy">
<p class="muted">System</p>
<h1 class="view-title">Settings</h1>
</section>
<section class="settings-body" data-focus-root>
<section class="settings-category-bar">
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="0" data-cat="network" data-focus-key="network">Network</button>
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="1" data-cat="audio" data-focus-key="audio">Audio</button>
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="2" data-cat="display" data-focus-key="display">Display</button>
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="3" data-cat="storage" data-focus-key="storage">Storage</button>
<button class="focusable settings-category" data-focusable="true" data-row="0" data-col="4" data-cat="system" data-focus-key="system">System</button>
</section>
<article class="panel settings-panel">
<div class="settings-panel-head">
<h2 class="settings-panel-title" data-panel-title>Network</h2>
<p class="muted" data-panel-copy>Optimize connection and online routing.</p>
</div>
<div class="settings-card-grid">
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="0" data-toggle="passkey-enabled" data-focus-key="passkey-enabled">
<p class="settings-card-title">Passkey Lock</p>
<p class="muted" data-passkey-enabled>Enabled</p>
</button>
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="1" data-toggle="passkey-change" data-focus-key="passkey-change">
<p class="settings-card-title">Change Passkey</p>
<p class="muted" data-passkey-change>Open setup on next lock</p>
</button>
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="2" data-toggle="passkey-length" data-focus-key="passkey-length">
<p class="settings-card-title">Required Length</p>
<p class="muted" data-passkey-length>6 inputs</p>
<p class="muted">Use LB / RB to adjust</p>
</button>
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="3" data-toggle="passkey-confirm" data-focus-key="passkey-confirm">
<p class="settings-card-title">Require Confirm</p>
<p class="muted" data-passkey-confirm>Disabled</p>
</button>
<button class="focusable settings-card" data-focusable="true" data-row="1" data-col="4" data-toggle="passkey-keyboard" data-focus-key="passkey-keyboard">
<p class="settings-card-title">Keyboard Support</p>
<p class="muted" data-passkey-keyboard>Enabled</p>
</button>
<div class="settings-card settings-card-info">
<p class="settings-card-title">Status</p>
<p class="muted" data-status-note>Applying Nebula profile</p>
</div>
</div>
</article>
</section>
</section>
`;
const CATEGORIES = {
network: "Network",
audio: "Audio",
display: "Display",
storage: "Storage",
system: "System",
};
export const createSettingsView = ({ state, renderView }) => {
const updatePasskey = (partial) => {
state.passkey.updateConfig(partial);
refreshPanel();
};
const refreshPanel = () => {
const title = document.querySelector("[data-panel-title]");
const copy = document.querySelector("[data-panel-copy]");
const passkeyEnabled = document.querySelector("[data-passkey-enabled]");
const passkeyLength = document.querySelector("[data-passkey-length]");
const passkeyConfirm = document.querySelector("[data-passkey-confirm]");
const passkeyKeyboard = document.querySelector("[data-passkey-keyboard]");
const status = document.querySelector("[data-status-note]");
const passkeyConfig = state.passkey.getConfig();
document.querySelectorAll(".settings-category").forEach((button) => {
button.classList.toggle("is-active", button.dataset.cat === state.settingsCategory);
});
if (title) {
title.textContent = CATEGORIES[state.settingsCategory] ?? "Settings";
}
if (copy) {
if (state.settingsCategory === "system") {
copy.textContent = "Configure passkey security and controller login behavior.";
} else {
copy.textContent = `Tune ${CATEGORIES[state.settingsCategory] ?? "system"} options with controller-first cards.`;
}
}
if (passkeyEnabled) {
passkeyEnabled.textContent = passkeyConfig.enabled ? "Enabled" : "Disabled";
}
if (passkeyLength) {
passkeyLength.textContent = `${passkeyConfig.length} digits`;
}
if (passkeyConfirm) {
passkeyConfirm.textContent = passkeyConfig.requireConfirm ? "Enabled" : "Disabled";
}
if (passkeyKeyboard) {
passkeyKeyboard.textContent = passkeyConfig.keyboardSupport ? "Enabled" : "Disabled";
}
if (status) {
status.textContent = state.settingsCategory === "system"
? `Attempts: ${passkeyConfig.maxAttempts}, cooldown: ${passkeyConfig.cooldownSeconds}s`
: `${CATEGORIES[state.settingsCategory] ?? "System"} profile synced`;
}
document.querySelectorAll("[data-toggle^='passkey']").forEach((button) => {
button.classList.toggle("is-disabled", state.settingsCategory !== "system");
button.setAttribute("aria-disabled", state.settingsCategory === "system" ? "false" : "true");
});
};
const toggleCurrent = (suffix = "") => {
const key = suffix ? `${state.settingsCategory}_${suffix}` : state.settingsCategory;
state.settingsValues[key] = !Boolean(state.settingsValues[key]);
refreshPanel();
};
return {
id: "settings",
render: () => SETTINGS_TEMPLATE,
mount: () => {
const root = document.querySelector("[data-focus-root]");
root?.addEventListener("focusin", (event) => {
const focused = event.target.closest("[data-focusable='true']");
const col = Number(focused?.dataset.col ?? 0);
document.documentElement.style.setProperty("--nebula-accent-line-x", `${24 + col * 110}px`);
});
refreshPanel();
document.documentElement.style.setProperty("--nebula-focus-strength", "1");
},
getNavigationContract: () => {
const root = document.querySelector("[data-focus-root]");
return {
focusRoot: root,
defaultFocus: root?.querySelector("[data-cat='network']") ?? null,
layout: { type: "grid", cols: 5, rows: 2 },
hintsTemplate: "#global-hints-template",
nebulaNavigation: state.nebula.navigation,
onAccept: (element) => {
if (!element) {
return;
}
const category = element.dataset.cat;
const toggle = element.dataset.toggle;
if (category) {
state.settingsCategory = category;
refreshPanel();
return;
}
if (toggle === "primary") {
toggleCurrent();
return;
}
if (toggle === "secondary") {
toggleCurrent("secondary");
return;
}
if (state.settingsCategory !== "system") {
return;
}
if (toggle === "passkey-enabled") {
updatePasskey({ enabled: !state.passkey.getConfig().enabled });
return;
}
if (toggle === "passkey-change") {
state.passkeySetupRequired = true;
state.locked = true;
state.activeView = "lock";
renderView("lock");
return;
}
if (toggle === "passkey-length") {
const current = state.passkey.getConfig().length;
updatePasskey({ length: current >= 8 ? 4 : current + 1 });
return;
}
if (toggle === "passkey-confirm") {
updatePasskey({ requireConfirm: !state.passkey.getConfig().requireConfirm });
return;
}
if (toggle === "passkey-keyboard") {
updatePasskey({ keyboardSupport: !state.passkey.getConfig().keyboardSupport });
}
},
onBack: () => {
state.activeView = "home";
renderView("home");
},
onMenu: () => {},
onAction: (action, element) => {
if (state.settingsCategory !== "system") {
return false;
}
if (element?.dataset.toggle !== "passkey-length") {
return false;
}
if (action === "l1") {
const next = state.passkey.getConfig().length - 1;
updatePasskey({ length: next < 4 ? 8 : next });
return true;
}
if (action === "r1") {
const next = state.passkey.getConfig().length + 1;
updatePasskey({ length: next > 8 ? 4 : next });
return true;
}
return false;
},
};
},
};
};