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 OS v0 – Windows-first Development Plan
|
||||||
|
|
||||||
|
## Nebula Shell Prototype (Current)
|
||||||
|
|
||||||
|
This repository now includes a working v0 Nebula Shell prototype in the Tauri frontend (`src/`) with:
|
||||||
|
|
||||||
|
- Lock Screen with controller/keyboard PIN keypad (`1234` for v0)
|
||||||
|
- Home dashboard tile grid (Library, Settings, Power)
|
||||||
|
- Settings split-pane stub (category list + content panel)
|
||||||
|
- Library stub view with controller back behavior
|
||||||
|
- Start/Menu power overlay that traps focus and closes with Back
|
||||||
|
- Unified input actions (`up/down/left/right/accept/back/menu`) from keyboard + gamepad
|
||||||
|
|
||||||
|
### Dashboard refresh (Xbox-inspired Nebula)
|
||||||
|
|
||||||
|
The shell now uses a premium horizontal dashboard language inspired by console UI patterns:
|
||||||
|
|
||||||
|
- Left-aligned horizontal app tile rail (Library, Settings, Power)
|
||||||
|
- Dynamic nebula background stack (gradient, starfield, fog, vignette)
|
||||||
|
- Shared top bar with reactive accent line and profile/time status
|
||||||
|
- Layered tile focus states (scale, cyan glow, ripple, elevation)
|
||||||
|
- Smooth page/focus transitions with cubic-bezier motion curves
|
||||||
|
- Immersive lock screen with large clock/date and input-revealed PIN panel
|
||||||
|
- Settings redesign with top category rail + card-based content panel
|
||||||
|
|
||||||
|
Animation and component architecture notes are in [src/styles/shell-guidelines.md](src/styles/shell-guidelines.md).
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run (Windows dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controller testing notes (Windows)
|
||||||
|
|
||||||
|
- Connect an Xbox-compatible controller before launching dev mode.
|
||||||
|
- Navigation: D-pad or left stick.
|
||||||
|
- Actions: `A` = Accept, `B` = Back, `Start` = Menu.
|
||||||
|
- Keyboard mirror for development: arrow keys, Enter, Escape/Backspace.
|
||||||
|
|
||||||
|
### Nebula Core integration status
|
||||||
|
|
||||||
|
`@nebulaproject/core` is installed and used through runtime adapters in:
|
||||||
|
|
||||||
|
- `src/core/input.js`
|
||||||
|
- `src/core/nav.js`
|
||||||
|
- `src/core/state.js`
|
||||||
|
|
||||||
|
If Nebula Core exports are available, the shell uses them for input/navigation/glyphs/theme.
|
||||||
|
If not, local fallback adapters keep the shell fully functional.
|
||||||
|
|
||||||
|
### Local Nebula Core development (Windows-safe)
|
||||||
|
|
||||||
|
Current npm package `@nebulaproject/core@0.1.3` re-exports internal `@nebula/*` packages that are not published, so local linking is recommended for active core development.
|
||||||
|
|
||||||
|
1. Clone Nebula Core monorepo next to this repo.
|
||||||
|
2. Build Nebula Core packages.
|
||||||
|
3. Link from the core repo and consume in this repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In Nebula-Core repo
|
||||||
|
npm link
|
||||||
|
|
||||||
|
# In Nebula-OS repo
|
||||||
|
npm link @nebulaproject/core
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative (more deterministic): use `file:` dependency in `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@nebulaproject/core": "file:../Nebula-Core/packages/core"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux VM build (high-level)
|
||||||
|
|
||||||
|
- Install Rust, Node.js, and Tauri Linux prerequisites in the VM.
|
||||||
|
- Clone this repo in Linux VM.
|
||||||
|
- Run `npm install`.
|
||||||
|
- Run `npm run dev` for integration checks in Linux session.
|
||||||
|
- Run `npm run build` to produce Linux artifacts.
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
|
|
||||||
Nebula OS is a **controller-first, open source operating system experience** built on Linux.
|
Nebula OS is a **controller-first, open source operating system experience** built on Linux.
|
||||||
@@ -14,7 +115,7 @@ It is an independent UI layer that:
|
|||||||
|
|
||||||
* Acts as the primary **controller-first shell** for the OS
|
* Acts as the primary **controller-first shell** for the OS
|
||||||
* Provides a unified Home experience for **games, apps, and media**
|
* Provides a unified Home experience for **games, apps, and media**
|
||||||
* Ships with core Nebula apps such as **Nebula Browser**
|
* Integrates first-install Nebula apps such as **Nebula Browser** and **Nebula Launcher**
|
||||||
* Exposes **system settings** through controller-friendly panels
|
* Exposes **system settings** through controller-friendly panels
|
||||||
* Manages game libraries directly (Steam, GOG, Epic, and others)
|
* Manages game libraries directly (Steam, GOG, Epic, and others)
|
||||||
|
|
||||||
@@ -78,7 +179,6 @@ Game UI Mode contains:
|
|||||||
|
|
||||||
* Lock screen with controller PIN entry
|
* Lock screen with controller PIN entry
|
||||||
* Nebula Home dashboard
|
* Nebula Home dashboard
|
||||||
* Nebula Browser
|
|
||||||
* Nebula Library
|
* Nebula Library
|
||||||
* Controller-friendly Settings
|
* Controller-friendly Settings
|
||||||
* Power and session controls
|
* Power and session controls
|
||||||
@@ -147,7 +247,7 @@ It is the primary interface in Game UI Mode and should feel like a console dashb
|
|||||||
Nebula Shell responsibilities:
|
Nebula Shell responsibilities:
|
||||||
|
|
||||||
* Home dashboard (games, apps, recent activity)
|
* Home dashboard (games, apps, recent activity)
|
||||||
* App launcher for Nebula apps (Nebula Browser, Nebula Library, Settings)
|
* App launcher for integrated Nebula apps (Nebula Browser, Nebula Launcher, Nebula Library, Settings)
|
||||||
* System navigation (network, audio, display, storage, accounts)
|
* System navigation (network, audio, display, storage, accounts)
|
||||||
* Power menu and session switching (Game UI ↔ Desktop)
|
* Power menu and session switching (Game UI ↔ Desktop)
|
||||||
* Notifications and downloads (later)
|
* Notifications and downloads (later)
|
||||||
|
|||||||
@@ -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",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"build": "tauri build",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nebulaproject/core": "^0.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="/styles/theme.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/base.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/components.css" />
|
||||||
|
<link rel="stylesheet" href="/views/lock/lock.css" />
|
||||||
|
<link rel="stylesheet" href="/views/home/home.css" />
|
||||||
|
<link rel="stylesheet" href="/views/settings/settings.css" />
|
||||||
|
<link rel="stylesheet" href="/views/library/library.css" />
|
||||||
|
<link rel="stylesheet" href="/views/overlays/powerMenu.css" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri App</title>
|
<title>Nebula Shell</title>
|
||||||
<script type="module" src="/main.js" defer></script>
|
<script type="module" src="/main.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<main class="container">
|
<div id="nebula-background" aria-hidden="true">
|
||||||
<h1>Welcome to Tauri</h1>
|
<div class="nebula-layer gradient"></div>
|
||||||
|
<div class="nebula-layer starfield"></div>
|
||||||
|
<div class="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">
|
<template id="global-hints-template">
|
||||||
<a href="https://tauri.app" target="_blank">
|
<div class="hint-row">
|
||||||
<img src="/assets/tauri.svg" class="logo tauri" alt="Tauri logo" />
|
<span class="hint"><span data-glyph="accept"></span> Select</span>
|
||||||
</a>
|
<span class="hint"><span data-glyph="back"></span> Back</span>
|
||||||
<a
|
<span class="hint"><span data-glyph="menu"></span> Menu</span>
|
||||||
href="https://developer.mozilla.org/en-US/docs/Web/JavaScript"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/assets/javascript.svg"
|
|
||||||
class="logo vanilla"
|
|
||||||
alt="JavaScript logo"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<p>Click on the Tauri logo to learn more about the framework</p>
|
</template>
|
||||||
|
|
||||||
<form class="row" id="greet-form">
|
<template id="minimal-hints-template">
|
||||||
<input id="greet-input" placeholder="Enter a name..." />
|
<div class="hint-row">
|
||||||
<button type="submit">Greet</button>
|
<span class="hint"><span data-glyph="accept"></span> Open</span>
|
||||||
</form>
|
<span class="hint"><span data-glyph="menu"></span> Power Menu</span>
|
||||||
<p id="greet-msg"></p>
|
</div>
|
||||||
</main>
|
</template>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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;
|
const appRoot = document.querySelector("#app");
|
||||||
let greetMsgEl;
|
const overlayRoot = document.querySelector("#overlay-root");
|
||||||
|
const footer = document.querySelector("#app-footer");
|
||||||
|
|
||||||
async function greet() {
|
const state = createAppState();
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
const nav = createNavigationManager();
|
||||||
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });
|
const router = createRouter(appRoot);
|
||||||
}
|
const powerMenu = createPowerMenuOverlay({ mountRoot: overlayRoot, state });
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
let currentViewContract = null;
|
||||||
greetInputEl = document.querySelector("#greet-input");
|
|
||||||
greetMsgEl = document.querySelector("#greet-msg");
|
const emitUiHook = (type, payload = {}) => {
|
||||||
document.querySelector("#greet-form").addEventListener("submit", (e) => {
|
window.dispatchEvent(
|
||||||
e.preventDefault();
|
new CustomEvent("nebula-ui-hook", {
|
||||||
greet();
|
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