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:
@@ -1,5 +1,106 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
* 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
|
||||
* Manages game libraries directly (Steam, GOG, Epic, and others)
|
||||
|
||||
@@ -78,7 +179,6 @@ Game UI Mode contains:
|
||||
|
||||
* Lock screen with controller PIN entry
|
||||
* Nebula Home dashboard
|
||||
* Nebula Browser
|
||||
* Nebula Library
|
||||
* Controller-friendly Settings
|
||||
* 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:
|
||||
|
||||
* 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)
|
||||
* Power menu and session switching (Game UI ↔ Desktop)
|
||||
* Notifications and downloads (later)
|
||||
|
||||
@@ -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." ✨
|
||||
Generated
+241
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nebulaproject/core": "^0.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+5307
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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"];
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
+33
-25
@@ -2,38 +2,46 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Tauri App</title>
|
||||
<title>Nebula Shell</title>
|
||||
<script type="module" src="/main.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class="container">
|
||||
<h1>Welcome to Tauri</h1>
|
||||
<div id="nebula-background" aria-hidden="true">
|
||||
<div class="nebula-layer gradient"></div>
|
||||
<div class="nebula-layer starfield"></div>
|
||||
<div class="nebula-layer fog"></div>
|
||||
<div class="nebula-layer vignette"></div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="row">
|
||||
<a href="https://tauri.app" target="_blank">
|
||||
<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>
|
||||
<template id="global-hints-template">
|
||||
<div class="hint-row">
|
||||
<span class="hint"><span data-glyph="accept"></span> Select</span>
|
||||
<span class="hint"><span data-glyph="back"></span> Back</span>
|
||||
<span class="hint"><span data-glyph="menu"></span> Menu</span>
|
||||
</div>
|
||||
<p>Click on the Tauri logo to learn more about the framework</p>
|
||||
</template>
|
||||
|
||||
<form class="row" id="greet-form">
|
||||
<input id="greet-input" placeholder="Enter a name..." />
|
||||
<button type="submit">Greet</button>
|
||||
</form>
|
||||
<p id="greet-msg"></p>
|
||||
</main>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
+151
-14
@@ -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;
|
||||
let greetMsgEl;
|
||||
const appRoot = document.querySelector("#app");
|
||||
const overlayRoot = document.querySelector("#overlay-root");
|
||||
const footer = document.querySelector("#app-footer");
|
||||
|
||||
async function greet() {
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });
|
||||
}
|
||||
const state = createAppState();
|
||||
const nav = createNavigationManager();
|
||||
const router = createRouter(appRoot);
|
||||
const powerMenu = createPowerMenuOverlay({ mountRoot: overlayRoot, state });
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
greetInputEl = document.querySelector("#greet-input");
|
||||
greetMsgEl = document.querySelector("#greet-msg");
|
||||
document.querySelector("#greet-form").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
greet();
|
||||
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;
|
||||
}
|
||||
|
||||
if (!currentViewContract) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "menu") {
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 `120ms–180ms` (`--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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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: () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
.stub-view {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stub-panel {
|
||||
max-width: 720px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nebula-spacing-md);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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: () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user