Add Qt/QML desktop shell and Nebula.UI

Introduce a new production desktop shell at `shells/desktop/shell` built with C++20, Qt Quick/QML, and LayerShellQt, including top bar, launcher, desktop surfaces, shell state, and mock app data. Add the shared `packages/nebula-ui` QML module (theme, controls, focus, icons) and wire it into the shell build.

Retire the old GTK/WebKit native host by removing `shells/desktop/native`, move the React shell UI to `shells/desktop/prototype-react` as an archived design reference, and update root/compositor/session documentation to reflect the new Linux-only production architecture and temporary Sway role.
This commit is contained in:
2026-08-26 16:34:40 +12:00
parent 5504d3cfb8
commit befed8ff96
80 changed files with 2128 additions and 609 deletions
+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?
@@ -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 }]
}
}
+48
View File
@@ -0,0 +1,48 @@
# Nebula Desktop UI — prototype (archived)
```text
DESIGN REFERENCE ONLY
NOT THE PRODUCTION NEBULA SHELL
```
This directory is the original React + Vite desktop chrome. It remains only as a visual and behavioural reference while the Qt Quick/QML shell catches up.
**Do not add production functionality here.**
The production Nebula Desktop shell is:
```text
shells/desktop/shell
C++20 + Qt 6 + Qt Quick/QML + LayerShellQt
```
This prototype must not be launched as the OS shell. It does not talk to Wayland layer-shell, and it is not wired to Nebula Core.
## What this is for
- comparing layout, colour, spacing, and motion against `shells/desktop/shell`
- checking launcher search, mock launch, and top-bar treatment
- discarding this tree later, once Qt parity is accepted
## Surfaces (browser preview only)
| URL | Renders |
|-----|---------|
| `/?surface=preview` (default) | Combined mockup |
| `/?surface=desktop` | Background only |
| `/?surface=topbar` | Top bar only |
| `/?surface=launcher` | Launcher only |
## Preview on a development machine
```powershell
npm install
npm run dev
```
```powershell
npm run lint
npm run build
```
Oxlint applies to this JavaScript prototype only. It is not used for the Qt shell.
+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>
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;
}
+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
*
* Placeholder APIs for the archived React prototype.
*
* Archived React prototype → nebula.* placeholders
*
* Production system APIs will be C++ services exposed to QML, not this
* JavaScript module. Methods here must not call Node APIs, spawn processes,
* or talk to D-Bus, PipeWire, NetworkManager, or Wayland.
*
* 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;
+97
View File
@@ -0,0 +1,97 @@
/**
* Shell coordination bridge.
*
* In-page mock for coordinating the archived browser preview.
* Production shell state is the QML ShellState singleton, not this 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;
+45
View File
@@ -0,0 +1,45 @@
/**
* Shell surface routing.
*
* Browser preview routing for the archived React prototype.
* Production surfaces are separate Qt Quick windows, not WebKit views.
*/
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);
}
+10
View File
@@ -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,47 @@
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.
* Browser-only composition of the three shell surfaces.
* Archived design reference — production chrome is Qt Quick/QML.
*/
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()],
})