Bootstrap NebulaOS repo and desktop shell UI
Initialize the NebulaOS monorepo layout with top-level documentation, ignore rules, placeholder service/package directories, compositor docs plus a baseline Sway config, and session startup/desktop entry files. Add the first desktop shell prototype under `shells/desktop/ui` using React + Vite, including themed surface components (desktop, top bar, launcher, preview), mock Nebula APIs and shell bridge state wiring, reusable UI controls/icons, and supporting styles/config needed for local development.
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# CMake
|
||||||
|
build/
|
||||||
|
cmake-build-*/
|
||||||
|
|
||||||
|
# Compiled objects
|
||||||
|
*.o
|
||||||
|
*.so
|
||||||
|
*.a
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# NebulaOS
|
||||||
|
|
||||||
|
A Linux desktop environment built around a custom compositor, shell, and service stack.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `core/nebula-core` | Core runtime and platform APIs |
|
||||||
|
| `compositor/` | Compositor configuration (Sway) |
|
||||||
|
| `shells/` | Desktop and bigscreen shell implementations |
|
||||||
|
| `packages/` | Shared UI libraries, icons, API, and design tokens |
|
||||||
|
| `services/` | System services (audio, network, bluetooth, power, accounts) |
|
||||||
|
| `session/` | Session manager and desktop entry |
|
||||||
|
| `packaging/` | Distribution packaging (Ubuntu) |
|
||||||
|
| `branding/` | Boot assets, wallpapers, and sounds |
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Documentation and build instructions will be added as components land.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Compositor
|
||||||
|
|
||||||
|
NebulaOS uses Sway as its Wayland compositor. Configuration lives in `sway/nebula-sway.conf`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# NebulaOS Sway configuration
|
||||||
|
|
||||||
|
include /etc/sway/config.d/*
|
||||||
|
|
||||||
|
set $mod Mod4
|
||||||
|
|
||||||
|
input type:keyboard {
|
||||||
|
xkb_layout us
|
||||||
|
}
|
||||||
|
|
||||||
|
output * bg #1a1a2e solid_color
|
||||||
|
|
||||||
|
bindsym $mod+Return exec $term
|
||||||
|
bindsym $mod+Shift+q kill
|
||||||
|
bindsym $mod+d exec $menu
|
||||||
|
|
||||||
|
exec_always nebula-session
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# NebulaOS session startup
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
export XDG_CURRENT_DESKTOP=NebulaOS
|
||||||
|
export XDG_SESSION_DESKTOP=nebula
|
||||||
|
|
||||||
|
# Shell and services are wired here as they land
|
||||||
|
# exec nebula-shell-desktop
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=NebulaOS
|
||||||
|
Comment=NebulaOS desktop session
|
||||||
|
Exec=nebula-session
|
||||||
|
Type=Application
|
||||||
|
DesktopNames=NebulaOS
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Nebula Desktop native host
|
||||||
|
|
||||||
|
This directory will contain the Linux native shell host for Nebula Desktop.
|
||||||
|
|
||||||
|
The host is a C++ process that creates **several** GTK4 windows, each backed by WebKitGTK 6.0 and placed with **gtk4-layer-shell**. It does not composite application windows. Those remain ordinary Wayland / XWayland clients of the compositor.
|
||||||
|
|
||||||
|
Planned stack:
|
||||||
|
|
||||||
|
- C++
|
||||||
|
- GTK4
|
||||||
|
- gtk4-layer-shell
|
||||||
|
- WebKitGTK 6.0
|
||||||
|
- Wayland
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
The React UI in `shells/desktop/ui` can render each shell layer independently via a query parameter. The native host will create one WebKitGTK view per surface and append that parameter to the load URL.
|
||||||
|
|
||||||
|
| Surface | Query | Layer Shell layer | Placement |
|
||||||
|
|-----------|-----------------------|-------------------|-----------|
|
||||||
|
| Desktop | `?surface=desktop` | `BACKGROUND` | Anchored to all monitor edges |
|
||||||
|
| Top Bar | `?surface=topbar` | `TOP` | Anchored top/left/right, fixed shell-defined height, exclusive zone enabled |
|
||||||
|
| Launcher | `?surface=launcher` | `OVERLAY` | Hidden/unmapped when closed |
|
||||||
|
|
||||||
|
The host must not hardcode a Vite port. It loads a base URL and appends the surface parameter.
|
||||||
|
|
||||||
|
## Stacking
|
||||||
|
|
||||||
|
```text
|
||||||
|
OVERLAY
|
||||||
|
Nebula Launcher
|
||||||
|
notifications
|
||||||
|
OSDs
|
||||||
|
|
||||||
|
TOP
|
||||||
|
Nebula Top Bar ← exclusive zone; apps do not go under it
|
||||||
|
|
||||||
|
NORMAL APPLICATION WINDOWS
|
||||||
|
Firefox, Steam, Blender, games, terminals, …
|
||||||
|
|
||||||
|
BACKGROUND
|
||||||
|
Nebula Desktop
|
||||||
|
wallpaper
|
||||||
|
```
|
||||||
|
|
||||||
|
React draws **shell chrome only**. Applications and games render through the compositor, not through WebKit.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Application / game
|
||||||
|
→ Wayland / XWayland
|
||||||
|
→ compositor
|
||||||
|
→ GPU
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading modes
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
The host will load a configurable Vite origin, conceptually:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NEBULA_SHELL_DEV_URL=http://127.0.0.1:5174
|
||||||
|
```
|
||||||
|
|
||||||
|
and request:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{NEBULA_SHELL_DEV_URL}/?surface=desktop
|
||||||
|
{NEBULA_SHELL_DEV_URL}/?surface=topbar
|
||||||
|
{NEBULA_SHELL_DEV_URL}/?surface=launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
The port is environment configuration, not architecture. A combined browser preview (`/?surface=preview`, also the default) is for Windows UI work only; the native host should load the three surfaces separately.
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
The host will load the compiled assets from `npm run build` in `shells/desktop/ui/dist`, still using the same `?surface=` parameters. It will not depend on a Vite server.
|
||||||
|
|
||||||
|
## Shell bridge
|
||||||
|
|
||||||
|
Each WebKit view talks to the host through a JavaScript bridge (`shellBridge` in the React UI). The development build uses an in-page mock. The native host will inject the real implementation so the top bar can toggle the launcher, and so launching an app can unmap the overlay and update the top-bar context.
|
||||||
|
|
||||||
|
`nebula.*` remains the API boundary for system services (apps, audio, network, power). Those stay mocked until Linux services land.
|
||||||
|
|
||||||
|
## Current development compositor
|
||||||
|
|
||||||
|
Sway is the temporary compositor. See `compositor/sway/nebula-sway.conf`.
|
||||||
|
|
||||||
|
## Build notes
|
||||||
|
|
||||||
|
This host is Linux-only. Do not expect it to compile on Windows. Implementation is the next Linux-side task.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Nebula Desktop UI
|
||||||
|
|
||||||
|
React UI for Nebula Desktop shell chrome. It does not host application or game windows. Those render as Wayland / XWayland surfaces. The native host in `shells/desktop/native` will load this UI in WebKitGTK.
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
The same codebase can render each Wayland layer on its own:
|
||||||
|
|
||||||
|
| URL | Renders |
|
||||||
|
|-----|---------|
|
||||||
|
| `/?surface=preview` (default) | Combined browser mockup |
|
||||||
|
| `/?surface=desktop` | Background layer only |
|
||||||
|
| `/?surface=topbar` | Top bar only |
|
||||||
|
| `/?surface=launcher` | Launcher only |
|
||||||
|
|
||||||
|
The native host chooses the origin. Surface selection is a relative query parameter, not a hardcoded port.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Oxlint is the linter for this project.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>Nebula Desktop</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1316
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "nebula-desktop-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
"oxlint": "^1.79.0",
|
||||||
|
"vite": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#0b0f16"/>
|
||||||
|
<circle cx="16" cy="16" r="5" fill="#7ea0c4"/>
|
||||||
|
<ellipse
|
||||||
|
cx="16"
|
||||||
|
cy="16"
|
||||||
|
rx="12"
|
||||||
|
ry="5"
|
||||||
|
stroke="#9bb6d4"
|
||||||
|
stroke-width="1.5"
|
||||||
|
transform="rotate(-26 16 16)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 325 B |
@@ -0,0 +1,18 @@
|
|||||||
|
import { getSurfaceName, SURFACES } from './lib/surface.js';
|
||||||
|
import { DesktopSurface } from './surfaces/DesktopSurface.jsx';
|
||||||
|
import { LauncherSurface } from './surfaces/LauncherSurface.jsx';
|
||||||
|
import { PreviewSurface } from './surfaces/PreviewSurface.jsx';
|
||||||
|
import { TopBarSurface } from './surfaces/TopBarSurface.jsx';
|
||||||
|
|
||||||
|
const SURFACE_VIEWS = {
|
||||||
|
[SURFACES.DESKTOP]: DesktopSurface,
|
||||||
|
[SURFACES.TOPBAR]: TopBarSurface,
|
||||||
|
[SURFACES.LAUNCHER]: LauncherSurface,
|
||||||
|
[SURFACES.PREVIEW]: PreviewSurface,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const surface = getSurfaceName();
|
||||||
|
const View = SURFACE_VIEWS[surface] ?? PreviewSurface;
|
||||||
|
return <View />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
.desktop {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS stand-in for the future wallpaper service. Swap this layer later. */
|
||||||
|
.desktop-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--nebula-bg);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 90% 70% at 12% -8%,
|
||||||
|
rgba(110, 140, 180, 0.16),
|
||||||
|
transparent 52%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 55% 45% at 88% 108%,
|
||||||
|
rgba(42, 68, 98, 0.28),
|
||||||
|
transparent 58%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 40% 32% at 62% 38%,
|
||||||
|
rgba(72, 92, 120, 0.08),
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-backdrop::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(8, 11, 17, 0.22) 0%,
|
||||||
|
transparent 16%,
|
||||||
|
transparent 78%,
|
||||||
|
rgba(8, 11, 17, 0.38) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import './Desktop.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop / wallpaper layer only.
|
||||||
|
* Top bar and launcher are separate shell surfaces.
|
||||||
|
*/
|
||||||
|
export function Desktop() {
|
||||||
|
return (
|
||||||
|
<div className="desktop">
|
||||||
|
<div className="desktop-backdrop" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
function Svg({ children, size = 16, ...props }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NebulaMark({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="4.2" fill="currentColor" />
|
||||||
|
<ellipse
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
rx="10"
|
||||||
|
ry="4.15"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.35"
|
||||||
|
transform="rotate(-26 12 12)"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchIcon({ size = 16 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<circle cx="11" cy="11" r="6.25" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="M15.8 15.8 20 20"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WifiIcon({ strength = 3, size = 16 }) {
|
||||||
|
const bars = [
|
||||||
|
'M4.8 15.2c4-4 10.4-4 14.4 0',
|
||||||
|
'M7.4 17.4c2.6-2.5 6.6-2.5 9.2 0',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
{bars.map((d, index) => (
|
||||||
|
<path
|
||||||
|
key={d}
|
||||||
|
d={d}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={strength > index ? 1 : 0.28}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<circle cx="12" cy="20" r="1.15" fill="currentColor" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpeakerIcon({ size = 16 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<path
|
||||||
|
d="M4.5 9.4h3.1L12 6.2v11.6L7.6 14.6H4.5V9.4Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M15.2 9.4c1.3 1.3 1.3 3.9 0 5.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M17.6 7.4c2.4 2.4 2.4 7 0 9.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatteryIcon({ percent = 84, size = 16 }) {
|
||||||
|
const fillWidth = Math.max(1.5, (14 * percent) / 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<rect
|
||||||
|
x="2.5"
|
||||||
|
y="7.25"
|
||||||
|
width="16.5"
|
||||||
|
height="9.5"
|
||||||
|
rx="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<rect x="19.5" y="10" width="2" height="4" rx="0.6" fill="currentColor" />
|
||||||
|
<rect
|
||||||
|
x="4.2"
|
||||||
|
y="9"
|
||||||
|
width={fillWidth}
|
||||||
|
height="6"
|
||||||
|
rx="1"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PowerIcon({ size = 16 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<path
|
||||||
|
d="M12 4.5v7.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8.1 6.7a6.2 6.2 0 1 0 7.8 0"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestartIcon({ size = 16 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<path
|
||||||
|
d="M19 12a7 7 0 1 1-2-4.9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19 5.2v4.4h-4.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderGlyph({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<path
|
||||||
|
d="M3.5 8.2c0-1 .8-1.8 1.8-1.8h4.1L11 8.2h7.7c1 0 1.8.8 1.8 1.8v6.8c0 1-.8 1.8-1.8 1.8H5.3c-1 0-1.8-.8-1.8-1.8V8.2Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobeGlyph({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="7.25" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<ellipse cx="12" cy="12" rx="3.1" ry="7.25" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
<path d="M5.2 12h13.6" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalGlyph({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<path
|
||||||
|
d="M7 9.2 10.4 12 7 14.8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12.2 15.2H17"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsGlyph({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<circle cx="12" cy="12" r="2.4" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M12 5.2v1.6M12 17.2v1.6M5.2 12h1.6M17.2 12h1.6M7.2 7.2l1.1 1.1M15.7 15.7l1.1 1.1M16.8 7.2l-1.1 1.1M8.3 15.7l-1.1 1.1"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameGlyph({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<path
|
||||||
|
d="M5.2 10.4h13.6c.9 0 1.6.8 1.5 1.7l-.5 3.2c-.2 1.1-1.2 1.9-2.3 1.9h-2.2l-1.3-1.8H10l-1.3 1.8H6.5c-1.1 0-2.1-.8-2.3-1.9l-.5-3.2c-.1-.9.6-1.7 1.5-1.7Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 12.4v3.2M6.4 14h3.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="15.4" cy="13.2" r="0.9" fill="currentColor" />
|
||||||
|
<circle cx="17.2" cy="15" r="0.9" fill="currentColor" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoGlyph({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="6.2"
|
||||||
|
width="16"
|
||||||
|
height="11.6"
|
||||||
|
rx="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<circle cx="9" cy="10.2" r="1.3" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="m7.2 15.8 3.4-3.4 2.6 2.6 2.2-2.1 3.4 2.9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MusicGlyph({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<path
|
||||||
|
d="M10 16.6V7.4l8-1.6v9.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="8.2" cy="16.6" r="2.2" fill="currentColor" />
|
||||||
|
<circle cx="16.2" cy="15.2" r="2.2" fill="currentColor" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentGlyph({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<Svg size={size}>
|
||||||
|
<path
|
||||||
|
d="M7 5.5h6.2L17.5 10v8.5c0 .8-.7 1.5-1.5 1.5H7c-.8 0-1.5-.7-1.5-1.5v-12c0-.8.7-1.5 1.5-1.5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M13.2 5.5V10H17.5" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path
|
||||||
|
d="M8.8 13.4h6.4M8.8 16.2h4.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_GLYPHS = {
|
||||||
|
folder: FolderGlyph,
|
||||||
|
globe: GlobeGlyph,
|
||||||
|
terminal: TerminalGlyph,
|
||||||
|
settings: SettingsGlyph,
|
||||||
|
game: GameGlyph,
|
||||||
|
photo: PhotoGlyph,
|
||||||
|
music: MusicGlyph,
|
||||||
|
document: DocumentGlyph,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppGlyph({ name, size = 18 }) {
|
||||||
|
const Glyph = APP_GLYPHS[name] ?? FolderGlyph;
|
||||||
|
return <Glyph size={size} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { AppGlyph } from '../icons.jsx';
|
||||||
|
|
||||||
|
export function AppItem({ item, variant = 'tile', onSelect }) {
|
||||||
|
const isRow = variant === 'row';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`app-item app-item--${variant}`}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="app-item-icon"
|
||||||
|
style={{ color: item.tint ?? 'var(--nebula-accent)' }}
|
||||||
|
>
|
||||||
|
<AppGlyph name={item.glyph ?? item.kind} size={isRow ? 16 : 18} />
|
||||||
|
</span>
|
||||||
|
<span className="app-item-copy">
|
||||||
|
<span className="app-item-name">{item.name}</span>
|
||||||
|
{item.subtitle ? (
|
||||||
|
<span className="app-item-subtitle">{item.subtitle}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
.launcher {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(420px, calc(100vw - 20px));
|
||||||
|
max-height: min(640px, calc(100vh - 24px));
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 14px 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-section h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--nebula-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-recent,
|
||||||
|
.launcher-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-recent {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-projects {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-empty {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--nebula-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: var(--nebula-radius-medium);
|
||||||
|
transition: background var(--nebula-transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item:hover,
|
||||||
|
.app-item:focus-visible {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item--tile,
|
||||||
|
.app-item--recent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item--row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item--recent .app-item-icon,
|
||||||
|
.app-item--row .app-item-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item-name {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item--tile .app-item-name,
|
||||||
|
.app-item--recent .app-item-name {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item-subtitle {
|
||||||
|
color: var(--nebula-text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--nebula-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nebula-accent-soft);
|
||||||
|
color: var(--nebula-accent-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-user-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-user-meta {
|
||||||
|
color: var(--nebula-text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-power {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { PowerIcon, RestartIcon } from '../icons.jsx';
|
||||||
|
import { nebula } from '../../lib/nebula.js';
|
||||||
|
import { IconButton } from '../ui/IconButton.jsx';
|
||||||
|
import { SearchField } from '../ui/SearchField.jsx';
|
||||||
|
import { AppItem } from './AppItem.jsx';
|
||||||
|
import './Launcher.css';
|
||||||
|
|
||||||
|
function matchesQuery(app, query) {
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const haystack = `${app.name} ${app.comment ?? ''}`.toLowerCase();
|
||||||
|
return haystack.includes(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Launcher({
|
||||||
|
open,
|
||||||
|
apps,
|
||||||
|
recents,
|
||||||
|
projects,
|
||||||
|
user,
|
||||||
|
onLaunch,
|
||||||
|
onOpenProject,
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [wasOpen, setWasOpen] = useState(open);
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
|
||||||
|
if (open !== wasOpen) {
|
||||||
|
setWasOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
searchRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const filteredApps = useMemo(() => {
|
||||||
|
const normalised = query.trim().toLowerCase();
|
||||||
|
return apps.filter((app) => matchesQuery(app, normalised));
|
||||||
|
}, [apps, query]);
|
||||||
|
|
||||||
|
const searching = query.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`launcher nebula-surface${open ? ' is-open' : ''}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Launcher"
|
||||||
|
aria-hidden={!open}
|
||||||
|
inert={!open ? true : undefined}
|
||||||
|
>
|
||||||
|
<SearchField
|
||||||
|
inputRef={searchRef}
|
||||||
|
value={query}
|
||||||
|
onChange={setQuery}
|
||||||
|
placeholder="Search applications"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="launcher-body">
|
||||||
|
{!searching && recents.length > 0 ? (
|
||||||
|
<section className="launcher-section">
|
||||||
|
<h2>Recent</h2>
|
||||||
|
<div className="launcher-recent">
|
||||||
|
{recents.map((app) => (
|
||||||
|
<AppItem
|
||||||
|
key={app.id}
|
||||||
|
item={app}
|
||||||
|
variant="recent"
|
||||||
|
onSelect={onLaunch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="launcher-section">
|
||||||
|
<h2>{searching ? 'Results' : 'Applications'}</h2>
|
||||||
|
{filteredApps.length > 0 ? (
|
||||||
|
<div className="launcher-grid">
|
||||||
|
{filteredApps.map((app) => (
|
||||||
|
<AppItem
|
||||||
|
key={app.id}
|
||||||
|
item={app}
|
||||||
|
variant="tile"
|
||||||
|
onSelect={onLaunch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="launcher-empty">No matching applications</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!searching && projects.length > 0 ? (
|
||||||
|
<section className="launcher-section">
|
||||||
|
<h2>Recent projects</h2>
|
||||||
|
<div className="launcher-projects">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<AppItem
|
||||||
|
key={project.id}
|
||||||
|
item={project}
|
||||||
|
variant="row"
|
||||||
|
onSelect={onOpenProject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="launcher-footer">
|
||||||
|
<div className="launcher-user">
|
||||||
|
<span className="launcher-avatar" aria-hidden="true">
|
||||||
|
{user?.initials ?? 'N'}
|
||||||
|
</span>
|
||||||
|
<span className="launcher-user-copy">
|
||||||
|
<span className="launcher-user-name">{user?.name ?? 'User'}</span>
|
||||||
|
<span className="launcher-user-meta">Local session</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="launcher-power">
|
||||||
|
<IconButton
|
||||||
|
label="Restart"
|
||||||
|
onClick={() => {
|
||||||
|
nebula.power.restart();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RestartIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
className="nebula-icon-button--danger"
|
||||||
|
label="Shut down"
|
||||||
|
onClick={() => {
|
||||||
|
nebula.power.shutdown();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PowerIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { nebula } from '../../lib/nebula.js';
|
||||||
|
|
||||||
|
export function Clock() {
|
||||||
|
const [now, setNow] = useState(() => nebula.time.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setNow(nebula.time.now());
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<time
|
||||||
|
className="clock"
|
||||||
|
dateTime={now.toISOString()}
|
||||||
|
title={nebula.time.formatFull(now)}
|
||||||
|
>
|
||||||
|
{nebula.time.formatClock(now)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.system-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 7.4em;
|
||||||
|
height: 28px;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: var(--nebula-radius-small);
|
||||||
|
color: var(--nebula-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { BatteryIcon, SpeakerIcon, WifiIcon } from '../icons.jsx';
|
||||||
|
import { nebula } from '../../lib/nebula.js';
|
||||||
|
import { IconButton } from '../ui/IconButton.jsx';
|
||||||
|
import { Clock } from './Clock.jsx';
|
||||||
|
import './SystemArea.css';
|
||||||
|
|
||||||
|
export function SystemArea() {
|
||||||
|
const [network, setNetwork] = useState(null);
|
||||||
|
const [volume, setVolume] = useState(null);
|
||||||
|
const [power, setPower] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
nebula.network.getState(),
|
||||||
|
nebula.audio.getVolume(),
|
||||||
|
nebula.power.getState(),
|
||||||
|
]).then(([networkState, volumeState, powerState]) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNetwork(networkState);
|
||||||
|
setVolume(volumeState);
|
||||||
|
setPower(powerState);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const networkLabel = network?.connected
|
||||||
|
? `Network connected, ${network.name}`
|
||||||
|
: 'Network disconnected';
|
||||||
|
const volumeLabel =
|
||||||
|
volume == null ? 'Audio' : `Volume ${Math.round(volume)}%`;
|
||||||
|
const powerLabel =
|
||||||
|
power == null
|
||||||
|
? 'Power'
|
||||||
|
: `Battery ${Math.round(power.percent)}%${power.charging ? ', charging' : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="system-area">
|
||||||
|
<IconButton label={networkLabel}>
|
||||||
|
<WifiIcon strength={network?.strength ?? 0} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label={volumeLabel}>
|
||||||
|
<SpeakerIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label={powerLabel}>
|
||||||
|
<BatteryIcon percent={power?.percent ?? 0} />
|
||||||
|
</IconButton>
|
||||||
|
<Clock />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
.topbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--nebula-topbar-height);
|
||||||
|
padding: 0 10px 0 6px;
|
||||||
|
background: rgba(10, 13, 18, 0.55);
|
||||||
|
border-bottom: 1px solid var(--nebula-border);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.15);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.15);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-menu-button {
|
||||||
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px 0 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--nebula-radius-small);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nebula-text);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition: background var(--nebula-transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-menu-button:hover,
|
||||||
|
.nebula-menu-button.is-open {
|
||||||
|
background: var(--nebula-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-menu-button.is-open {
|
||||||
|
color: var(--nebula-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--nebula-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-context {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--nebula-text-secondary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NebulaMark } from '../icons.jsx';
|
||||||
|
import { SystemArea } from '../system/SystemArea.jsx';
|
||||||
|
import './TopBar.css';
|
||||||
|
|
||||||
|
export function TopBar({ launcherOpen, onToggleLauncher, contextName }) {
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`nebula-menu-button${launcherOpen ? ' is-open' : ''}`}
|
||||||
|
aria-label="Nebula"
|
||||||
|
aria-expanded={launcherOpen}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={onToggleLauncher}
|
||||||
|
>
|
||||||
|
<NebulaMark />
|
||||||
|
<span>Nebula</span>
|
||||||
|
</button>
|
||||||
|
<span className="topbar-divider" aria-hidden="true" />
|
||||||
|
<span className="topbar-context">{contextName}</span>
|
||||||
|
</div>
|
||||||
|
<SystemArea />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import './controls.css';
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
label,
|
||||||
|
active = false,
|
||||||
|
className = '',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const classes = [
|
||||||
|
'nebula-icon-button',
|
||||||
|
active ? 'is-active' : '',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classes}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { SearchIcon } from '../icons.jsx';
|
||||||
|
import './controls.css';
|
||||||
|
|
||||||
|
export function SearchField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Search',
|
||||||
|
inputRef,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="nebula-search">
|
||||||
|
<SearchIcon />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck="false"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.nebula-icon-button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nebula-text-secondary);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--nebula-radius-small);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition:
|
||||||
|
background var(--nebula-transition-fast),
|
||||||
|
color var(--nebula-transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-icon-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--nebula-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-icon-button.is-active {
|
||||||
|
background: var(--nebula-accent-soft);
|
||||||
|
color: var(--nebula-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-icon-button--danger:hover {
|
||||||
|
color: var(--nebula-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--nebula-radius-medium);
|
||||||
|
background: rgba(8, 11, 17, 0.45);
|
||||||
|
border: 1px solid var(--nebula-border);
|
||||||
|
color: var(--nebula-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-search:focus-within {
|
||||||
|
border-color: var(--nebula-border-strong);
|
||||||
|
color: var(--nebula-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-search input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nebula-text);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-search input::placeholder {
|
||||||
|
color: var(--nebula-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-search input::-webkit-search-decoration,
|
||||||
|
.nebula-search input::-webkit-search-cancel-button {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Placeholder application catalogue for the desktop shell prototype.
|
||||||
|
*
|
||||||
|
* Real application discovery will later replace this module via
|
||||||
|
* `nebula.apps.list()` from native Nebula Core.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const mockApps = [
|
||||||
|
{
|
||||||
|
id: 'files',
|
||||||
|
name: 'Files',
|
||||||
|
comment: 'Browse files and folders',
|
||||||
|
category: 'system',
|
||||||
|
recent: true,
|
||||||
|
glyph: 'folder',
|
||||||
|
tint: '#c4a574',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'firefox',
|
||||||
|
name: 'Firefox',
|
||||||
|
comment: 'Web browser',
|
||||||
|
category: 'internet',
|
||||||
|
recent: true,
|
||||||
|
glyph: 'globe',
|
||||||
|
tint: '#d08a5a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'terminal',
|
||||||
|
name: 'Terminal',
|
||||||
|
comment: 'Command line',
|
||||||
|
category: 'system',
|
||||||
|
recent: true,
|
||||||
|
glyph: 'terminal',
|
||||||
|
tint: '#7eaa8e',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
name: 'Settings',
|
||||||
|
comment: 'System preferences',
|
||||||
|
category: 'system',
|
||||||
|
recent: false,
|
||||||
|
glyph: 'settings',
|
||||||
|
tint: '#8e99ab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'steam',
|
||||||
|
name: 'Steam',
|
||||||
|
comment: 'Games',
|
||||||
|
category: 'games',
|
||||||
|
recent: true,
|
||||||
|
glyph: 'game',
|
||||||
|
tint: '#6f8fb8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'photos',
|
||||||
|
name: 'Photos',
|
||||||
|
comment: 'Pictures and albums',
|
||||||
|
category: 'media',
|
||||||
|
recent: false,
|
||||||
|
glyph: 'photo',
|
||||||
|
tint: '#8aa8c4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'music',
|
||||||
|
name: 'Music',
|
||||||
|
comment: 'Listen and organise',
|
||||||
|
category: 'media',
|
||||||
|
recent: false,
|
||||||
|
glyph: 'music',
|
||||||
|
tint: '#b48a9a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'text-editor',
|
||||||
|
name: 'Text Editor',
|
||||||
|
comment: 'Edit documents',
|
||||||
|
category: 'productivity',
|
||||||
|
recent: false,
|
||||||
|
glyph: 'document',
|
||||||
|
tint: '#9aa7bc',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockProjects = [
|
||||||
|
{
|
||||||
|
id: 'nebulaos',
|
||||||
|
name: 'NebulaOS',
|
||||||
|
subtitle: 'Repository',
|
||||||
|
kind: 'folder',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'website',
|
||||||
|
name: 'Website',
|
||||||
|
subtitle: 'Project',
|
||||||
|
kind: 'folder',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'session-notes',
|
||||||
|
name: 'Session notes',
|
||||||
|
subtitle: 'Document',
|
||||||
|
kind: 'document',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Nebula JavaScript API
|
||||||
|
*
|
||||||
|
* Boundary between the React shell UI and the future native Nebula Core:
|
||||||
|
*
|
||||||
|
* React UI → nebula.* → native host / Nebula Core
|
||||||
|
*
|
||||||
|
* Every method here is a placeholder. Implementations must not call Node
|
||||||
|
* APIs, spawn processes, or talk to D-Bus, PipeWire, NetworkManager, or
|
||||||
|
* Wayland. The WebKitGTK host will later inject a real bridge.
|
||||||
|
*
|
||||||
|
* The shell is chrome only. Applications and games render as compositor
|
||||||
|
* surfaces, not inside this React tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mockApps, mockProjects } from '../data/mockApps.js';
|
||||||
|
import { formatClock, formatFullDateTime } from './time.js';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
volume: 72,
|
||||||
|
network: {
|
||||||
|
connected: true,
|
||||||
|
type: 'wifi',
|
||||||
|
name: 'Nebula-Net',
|
||||||
|
strength: 3,
|
||||||
|
},
|
||||||
|
power: {
|
||||||
|
onBattery: true,
|
||||||
|
charging: false,
|
||||||
|
percent: 84,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
user: {
|
||||||
|
name: 'User',
|
||||||
|
initials: 'N',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nebula = {
|
||||||
|
apps: {
|
||||||
|
async list() {
|
||||||
|
return mockApps.map((app) => ({ ...app }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async recents() {
|
||||||
|
return mockApps.filter((app) => app.recent).map((app) => ({ ...app }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async launch(id) {
|
||||||
|
console.info(`[nebula] launch ${id}`);
|
||||||
|
return { ok: true, id };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: {
|
||||||
|
async recents() {
|
||||||
|
return mockProjects.map((project) => ({ ...project }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async open(id) {
|
||||||
|
console.info(`[nebula] open project ${id}`);
|
||||||
|
return { ok: true, id };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
audio: {
|
||||||
|
async getVolume() {
|
||||||
|
return state.volume;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setVolume(value) {
|
||||||
|
const next = Math.min(100, Math.max(0, Number(value)));
|
||||||
|
state.volume = next;
|
||||||
|
console.info(`[nebula] volume ${next}`);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
network: {
|
||||||
|
async getState() {
|
||||||
|
return { ...state.network };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
power: {
|
||||||
|
async getState() {
|
||||||
|
return { ...state.power };
|
||||||
|
},
|
||||||
|
|
||||||
|
async shutdown() {
|
||||||
|
console.info('[nebula] shutdown requested');
|
||||||
|
},
|
||||||
|
|
||||||
|
async restart() {
|
||||||
|
console.info('[nebula] restart requested');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
session: {
|
||||||
|
async getUser() {
|
||||||
|
return { ...state.session.user };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
time: {
|
||||||
|
now() {
|
||||||
|
return new Date();
|
||||||
|
},
|
||||||
|
|
||||||
|
formatClock(date = new Date()) {
|
||||||
|
return formatClock(date);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatFull(date = new Date()) {
|
||||||
|
return formatFullDateTime(date);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nebula;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Shell coordination bridge.
|
||||||
|
*
|
||||||
|
* Top bar, launcher, and desktop will later live in separate WebKitGTK views.
|
||||||
|
* They must not share React component state. The native host will replace this
|
||||||
|
* module with an injected C++ bridge.
|
||||||
|
*
|
||||||
|
* This development mock keeps in-page state and broadcasts it with window
|
||||||
|
* CustomEvents. No networking, WebSockets, or Node APIs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SHELL_EVENT = 'nebula:shell-state';
|
||||||
|
|
||||||
|
let state = {
|
||||||
|
launcherOpen: false,
|
||||||
|
contextName: 'Desktop',
|
||||||
|
};
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
return {
|
||||||
|
launcherOpen: state.launcherOpen,
|
||||||
|
contextName: state.contextName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish() {
|
||||||
|
const next = snapshot();
|
||||||
|
window.dispatchEvent(new CustomEvent(SHELL_EVENT, { detail: next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shellBridge = {
|
||||||
|
getState() {
|
||||||
|
return snapshot();
|
||||||
|
},
|
||||||
|
|
||||||
|
getLauncherOpen() {
|
||||||
|
return state.launcherOpen;
|
||||||
|
},
|
||||||
|
|
||||||
|
getContextName() {
|
||||||
|
return state.contextName;
|
||||||
|
},
|
||||||
|
|
||||||
|
openLauncher() {
|
||||||
|
if (state.launcherOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = { ...state, launcherOpen: true };
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
|
||||||
|
closeLauncher() {
|
||||||
|
if (!state.launcherOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = { ...state, launcherOpen: false };
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleLauncher() {
|
||||||
|
state = { ...state, launcherOpen: !state.launcherOpen };
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
|
||||||
|
setContext(name) {
|
||||||
|
if (state.contextName === name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = { ...state, contextName: name };
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe(callback) {
|
||||||
|
function onShellState(event) {
|
||||||
|
callback(event.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(SHELL_EVENT, onShellState);
|
||||||
|
return () => window.removeEventListener(SHELL_EVENT, onShellState);
|
||||||
|
},
|
||||||
|
|
||||||
|
onLauncherChanged(callback) {
|
||||||
|
return shellBridge.subscribe((next) => {
|
||||||
|
callback(next.launcherOpen);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onContextChanged(callback) {
|
||||||
|
return shellBridge.subscribe((next) => {
|
||||||
|
callback(next.contextName);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default shellBridge;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Shell surface routing.
|
||||||
|
*
|
||||||
|
* The native host will load this UI in separate WebKitGTK views, each with a
|
||||||
|
* surface query parameter. Never hardcode a host or port — the loader decides
|
||||||
|
* the URL; this module only reads the current location.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SURFACES = {
|
||||||
|
DESKTOP: 'desktop',
|
||||||
|
TOPBAR: 'topbar',
|
||||||
|
LAUNCHER: 'launcher',
|
||||||
|
PREVIEW: 'preview',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KNOWN_SURFACES = new Set(Object.values(SURFACES));
|
||||||
|
|
||||||
|
const SURFACE_TITLES = {
|
||||||
|
[SURFACES.DESKTOP]: 'Nebula Desktop',
|
||||||
|
[SURFACES.TOPBAR]: 'Nebula Top Bar',
|
||||||
|
[SURFACES.LAUNCHER]: 'Nebula Launcher',
|
||||||
|
[SURFACES.PREVIEW]: 'Nebula Desktop',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSurfaceName(location = window.location) {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
const value = url.searchParams.get('surface');
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return SURFACES.PREVIEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (KNOWN_SURFACES.has(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SURFACES.PREVIEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySurfaceToDocument(
|
||||||
|
surface = getSurfaceName(),
|
||||||
|
doc = document,
|
||||||
|
) {
|
||||||
|
doc.documentElement.dataset.surface = surface;
|
||||||
|
doc.title = SURFACE_TITLES[surface] ?? SURFACE_TITLES[SURFACES.PREVIEW];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Locale-aware clock formatting.
|
||||||
|
*
|
||||||
|
* Isolated from the Clock component so a future Nebula system API can
|
||||||
|
* replace Intl without touching the shell UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function getUserLocale() {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||||
|
return navigator.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatClock(date, locale = getUserLocale()) {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
weekday: 'short',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFullDateTime(date, locale = getUserLocale()) {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { shellBridge } from './shellBridge.js';
|
||||||
|
|
||||||
|
export function useShellState() {
|
||||||
|
const [state, setState] = useState(() => shellBridge.getState());
|
||||||
|
|
||||||
|
useEffect(() => shellBridge.subscribe(setState), []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import { applySurfaceToDocument } from './lib/surface.js';
|
||||||
|
import './styles/globals.css';
|
||||||
|
|
||||||
|
applySurfaceToDocument();
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
@import './tokens.css';
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-surface='desktop'],
|
||||||
|
html[data-surface='preview'] {
|
||||||
|
background: var(--nebula-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--nebula-font);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--nebula-text);
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--nebula-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nebula-surface {
|
||||||
|
background: var(--nebula-surface);
|
||||||
|
border: 1px solid var(--nebula-border);
|
||||||
|
border-radius: var(--nebula-radius-large);
|
||||||
|
box-shadow: var(--nebula-shadow);
|
||||||
|
backdrop-filter: blur(var(--nebula-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--nebula-blur));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
:root {
|
||||||
|
--nebula-bg: #0b0f16;
|
||||||
|
--nebula-bg-deep: #080b11;
|
||||||
|
--nebula-surface: rgba(20, 26, 36, 0.78);
|
||||||
|
--nebula-surface-raised: rgba(32, 40, 54, 0.9);
|
||||||
|
--nebula-border: rgba(232, 236, 242, 0.08);
|
||||||
|
--nebula-border-strong: rgba(232, 236, 242, 0.14);
|
||||||
|
--nebula-text: #e8ecf2;
|
||||||
|
--nebula-text-secondary: #8e99ab;
|
||||||
|
--nebula-text-tertiary: #6b7587;
|
||||||
|
--nebula-accent: #7ea0c4;
|
||||||
|
--nebula-accent-strong: #9bb6d4;
|
||||||
|
--nebula-accent-soft: rgba(126, 160, 196, 0.16);
|
||||||
|
--nebula-danger: #c98888;
|
||||||
|
--nebula-focus: rgba(126, 160, 196, 0.55);
|
||||||
|
|
||||||
|
--nebula-radius-small: 6px;
|
||||||
|
--nebula-radius-medium: 10px;
|
||||||
|
--nebula-radius-large: 14px;
|
||||||
|
|
||||||
|
--nebula-spacing-small: 8px;
|
||||||
|
--nebula-spacing-medium: 16px;
|
||||||
|
--nebula-spacing-large: 24px;
|
||||||
|
|
||||||
|
--nebula-transition-fast: 120ms ease;
|
||||||
|
--nebula-transition-normal: 200ms ease;
|
||||||
|
|
||||||
|
--nebula-topbar-height: 40px;
|
||||||
|
--nebula-blur: 24px;
|
||||||
|
--nebula-shadow: 0 18px 50px rgba(0, 0, 0, 0.42);
|
||||||
|
--nebula-font: "Segoe UI Variable Text", "Segoe UI", "Inter", "SF Pro Text",
|
||||||
|
"Ubuntu", system-ui, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Desktop } from '../components/desktop/Desktop.jsx';
|
||||||
|
import './surfaces.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BACKGROUND layer surface.
|
||||||
|
* Native host: GTK_LAYER_SHELL_LAYER_BACKGROUND, anchored to all edges.
|
||||||
|
*/
|
||||||
|
export function DesktopSurface() {
|
||||||
|
return (
|
||||||
|
<div className="desktop-surface">
|
||||||
|
<Desktop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Launcher } from '../components/launcher/Launcher.jsx';
|
||||||
|
import { nebula } from '../lib/nebula.js';
|
||||||
|
import { shellBridge } from '../lib/shellBridge.js';
|
||||||
|
import { useShellState } from '../lib/useShellState.js';
|
||||||
|
import './surfaces.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OVERLAY layer surface.
|
||||||
|
* Native host maps/unmaps this view. Standalone browser loads always show
|
||||||
|
* the launcher so the surface can be inspected without the preview shell.
|
||||||
|
*/
|
||||||
|
export function LauncherSurface({ embedded = false }) {
|
||||||
|
const shell = useShellState();
|
||||||
|
const [apps, setApps] = useState([]);
|
||||||
|
const [recents, setRecents] = useState([]);
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
|
||||||
|
const open = embedded ? shell.launcherOpen : true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
nebula.apps.list(),
|
||||||
|
nebula.apps.recents(),
|
||||||
|
nebula.projects.recents(),
|
||||||
|
nebula.session.getUser(),
|
||||||
|
]).then(([appList, recentList, projectList, sessionUser]) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setApps(appList);
|
||||||
|
setRecents(recentList);
|
||||||
|
setProjects(projectList);
|
||||||
|
setUser(sessionUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (embedded) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
shellBridge.closeLauncher();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [embedded]);
|
||||||
|
|
||||||
|
async function handleLaunch(app) {
|
||||||
|
await nebula.apps.launch(app.id);
|
||||||
|
shellBridge.setContext(app.name);
|
||||||
|
shellBridge.closeLauncher();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenProject(project) {
|
||||||
|
await nebula.projects.open(project.id);
|
||||||
|
shellBridge.setContext(project.name);
|
||||||
|
shellBridge.closeLauncher();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="launcher-surface">
|
||||||
|
<Launcher
|
||||||
|
open={open}
|
||||||
|
apps={apps}
|
||||||
|
recents={recents}
|
||||||
|
projects={projects}
|
||||||
|
user={user}
|
||||||
|
onLaunch={handleLaunch}
|
||||||
|
onOpenProject={handleOpenProject}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
.preview-shell {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shell .desktop-surface {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shell .topbar-surface {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
height: var(--nebula-topbar-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shell .launcher-surface {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
padding: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shell .launcher-surface .launcher {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(var(--nebula-topbar-height) + 10px);
|
||||||
|
left: 10px;
|
||||||
|
max-height: min(640px, calc(100vh - var(--nebula-topbar-height) - 24px));
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
transition:
|
||||||
|
opacity var(--nebula-transition-normal),
|
||||||
|
transform var(--nebula-transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shell .launcher-surface .launcher.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-dismiss {
|
||||||
|
position: absolute;
|
||||||
|
inset: var(--nebula-topbar-height) 0 0;
|
||||||
|
z-index: 25;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { shellBridge } from '../lib/shellBridge.js';
|
||||||
|
import { useShellState } from '../lib/useShellState.js';
|
||||||
|
import { DesktopSurface } from './DesktopSurface.jsx';
|
||||||
|
import { LauncherSurface } from './LauncherSurface.jsx';
|
||||||
|
import { TopBarSurface } from './TopBarSurface.jsx';
|
||||||
|
import './PreviewSurface.css';
|
||||||
|
import './surfaces.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-only composition of the three shell surfaces.
|
||||||
|
* Production loads each surface in its own WebKitGTK view.
|
||||||
|
*/
|
||||||
|
export function PreviewSurface() {
|
||||||
|
const { launcherOpen } = useShellState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
shellBridge.closeLauncher();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="preview-shell">
|
||||||
|
<DesktopSurface />
|
||||||
|
<TopBarSurface />
|
||||||
|
{launcherOpen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="launcher-dismiss"
|
||||||
|
aria-label="Close launcher"
|
||||||
|
onClick={() => {
|
||||||
|
shellBridge.closeLauncher();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<LauncherSurface embedded />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { TopBar } from '../components/topbar/TopBar.jsx';
|
||||||
|
import { shellBridge } from '../lib/shellBridge.js';
|
||||||
|
import { useShellState } from '../lib/useShellState.js';
|
||||||
|
import './surfaces.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOP layer surface.
|
||||||
|
* Native host: GTK_LAYER_SHELL_LAYER_TOP, exclusive zone, fixed height.
|
||||||
|
*/
|
||||||
|
export function TopBarSurface() {
|
||||||
|
const { launcherOpen, contextName } = useShellState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
shellBridge.closeLauncher();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="topbar-surface">
|
||||||
|
<TopBar
|
||||||
|
launcherOpen={launcherOpen}
|
||||||
|
contextName={contextName}
|
||||||
|
onToggleLauncher={() => {
|
||||||
|
shellBridge.toggleLauncher();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.desktop-surface,
|
||||||
|
.topbar-surface,
|
||||||
|
.launcher-surface,
|
||||||
|
.preview-shell {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-surface,
|
||||||
|
.launcher-surface,
|
||||||
|
.preview-shell {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-surface,
|
||||||
|
.topbar-surface,
|
||||||
|
.launcher-surface {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-surface {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-surface .topbar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-surface {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-surface .launcher {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
z-index: auto;
|
||||||
|
max-height: min(640px, 100%);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user