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:
2026-08-26 15:31:54 +12:00
parent bf5686cebf
commit dd9b43bd27
61 changed files with 3471 additions and 0 deletions
+31
View File
@@ -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
+20
View File
@@ -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.
View File
View File
View File
+3
View File
@@ -0,0 +1,3 @@
# Compositor
NebulaOS uses Sway as its Wayland compositor. Configuration lives in `sway/nebula-sway.conf`.
+17
View File
@@ -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
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+10
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
[Desktop Entry]
Name=NebulaOS
Comment=NebulaOS desktop session
Exec=nebula-session
Type=Application
DesktopNames=NebulaOS
View File
View File
+91
View File
@@ -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.
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+30
View File
@@ -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.
+14
View File
@@ -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>
+1316
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+13
View File
@@ -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

+18
View File
@@ -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>
);
}
+309
View File
@@ -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;
}
+102
View File
@@ -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',
},
];
+121
View File
@@ -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;
+98
View File
@@ -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;
+46
View File
@@ -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];
}
+34
View File
@@ -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;
}
+13
View File
@@ -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>,
);
+79
View File
@@ -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;
}
}
+33
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})