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:
@@ -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,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.
|
||||
@@ -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
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
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,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;
|
||||
}
|
||||
@@ -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