Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="theme-color" content="#06111d" />
<meta name="theme-color" content="#090909" />
<title>KNeuron</title>
</head>
<body>
Expand Down
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"quality:full": "npm run quality && cargo check --manifest-path src-tauri/Cargo.toml"
},
"dependencies": {
"@fontsource/barlow": "^5.3.0",
"@fontsource/barlow-semi-condensed": "^5.3.0",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-shell": "^2.4.0",
"lucide-react": "^0.468.0",
Expand Down
Binary file added public/modules/cortex/thumbnail.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/modules/miner/thumbnail.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/modules/neuorrun/thumbnail.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"decorations": false,
"shadow": true,
"theme": "Dark",
"backgroundColor": "#030A12"
"backgroundColor": "#090909"
}
],
"security": {
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export default function App() {
}
}

const activeSidebarPage: ShellPage = route.kind === "shell" ? route.page : "dashboard";
const activeSidebarPage: ShellPage | null = route.kind === "shell" ? route.page : null;

const isModuleOpen = route.kind === "module";

Expand Down
58 changes: 32 additions & 26 deletions src/components/DevicePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,69 +1,75 @@
import { MonitorOff } from "lucide-react";

import { useDevice } from "../features/device/useDevice";

import { ModuleThumbnail } from "./ModuleThumbnail";

/**
* Right-hand panel with the active device.
*/
export function DevicePanel() {
const { snapshot, streamInfoByDeviceId } = useDevice();

const activeDevice = snapshot.activeDeviceInfo;

const activeStatus = snapshot.activeStatus;

const state = activeStatus?.state ?? "disconnected";

const streamInfo = snapshot.activeDeviceId
? streamInfoByDeviceId[snapshot.activeDeviceId]
: undefined;

return (
<aside className="device-panel">
<div className="device-panel__header">
<span className="eyebrow">DEVICE</span>

<aside className="device-panel" aria-label="Device">
<header className="device-panel__header">
<span className="eyebrow">Device</span>
<h2>Device</h2>
</div>
</header>

{!activeDevice ? (
<div className="device-panel__empty">
<span className="device-panel__dot" />
<MonitorOff size={64} strokeWidth={1.25} />

<strong>No device connected</strong>

<p>Select a device from the Device page.</p>
<span>Select a device from the Device page.</span>
</div>
) : (
<div className="device-panel__active">
<div className="device-panel__status">
<span
className={`device-panel__dot device-panel__dot--${activeStatus?.state ?? "disconnected"}`}
/>

<span>{activeStatus?.state ?? "disconnected"}</span>
</div>
<ModuleThumbnail
src={activeDevice.thumbnail}
name={activeDevice.name}
className="device-panel__thumbnail"
/>

<strong className="device-panel__name">{activeDevice.name}</strong>

{activeDevice.model && <span className="device-panel__model">{activeDevice.model}</span>}
<span className="device-panel__state">
<span className={`device-panel__dot device-panel__dot--${state}`} />
{state}
</span>

<div className="device-panel__details">
<dl className="device-panel__details">
<div>
<span>Type</span>

<strong>{activeDevice.kind}</strong>
<dt>Type</dt>
<dd>{activeDevice.kind}</dd>
</div>

{streamInfo && (
<>
<div>
<span>Rate</span>

<strong>{streamInfo.sampleRateHz} Hz</strong>
<dt>Rate</dt>
<dd>{streamInfo.sampleRateHz} Hz</dd>
</div>

<div>
<span>Channels</span>

<strong>{streamInfo.channels.length}</strong>
<dt>Channels</dt>
<dd>{streamInfo.channels.length}</dd>
</div>
</>
)}
</div>
</dl>
</div>
)}
</aside>
Expand Down
26 changes: 11 additions & 15 deletions src/components/ModuleThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,28 @@ import { useState } from "react";
interface ModuleThumbnailProps {
src?: string;
name: string;
/**
* Extra class for placement, e.g. on device cards.
*/
className?: string;
}

/**
* Displays a module thumbnail with a deterministic fallback.
* Displays a module or device thumbnail.
*
* A broken or missing thumbnail must never break the launcher layout.
* Renders nothing when no image is set or it fails to load, so the layout
* closes the gap instead of showing a placeholder.
*/
export function ModuleThumbnail({ src, name }: ModuleThumbnailProps) {
export function ModuleThumbnail({ src, name, className }: ModuleThumbnailProps) {
const [imageFailed, setImageFailed] = useState(false);

const showFallback = !src || imageFailed;

if (showFallback) {
return (
<div
className="module-thumbnail module-thumbnail--fallback"
aria-label={`${name} thumbnail unavailable`}
>
<span>{name.charAt(0).toUpperCase()}</span>
</div>
);
if (!src || imageFailed) {
return null;
}

return (
<img
className="module-thumbnail"
className={["module-thumbnail", className].filter(Boolean).join(" ")}
src={src}
alt={`${name} thumbnail`}
draggable={false}
Expand Down
44 changes: 23 additions & 21 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { Cpu, LayoutDashboard, Settings } from "lucide-react";
import type { ShellPage } from "../types/navigation";
import { LayoutGrid, Monitor, Settings } from "lucide-react";
import type { LucideIcon } from "lucide-react";

import { APP_CONFIG } from "../config/appConfig";
import type { ShellPage } from "../types/navigation";

const items: Array<{ id: ShellPage; label: string; icon: typeof LayoutDashboard }> = [
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
{ id: "device", label: "Device", icon: Cpu },
const items: Array<{ id: ShellPage; label: string; icon: LucideIcon }> = [
{ id: "dashboard", label: "Dashboard", icon: LayoutGrid },
{ id: "device", label: "Device", icon: Monitor },
{ id: "settings", label: "Settings", icon: Settings },
];

/**
* Left navigation with the app version at the bottom.
*
* `page` is null while a module is open, so no item is marked current.
*/
export function Sidebar({
page,
onNavigate,
}: {
page: ShellPage;
page: ShellPage | null;
onNavigate: (page: ShellPage) => void;
}) {
return (
Expand All @@ -21,28 +28,23 @@ export function Sidebar({
{items.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
className={page === id ? "nav-item active" : "nav-item"}
aria-current={page === id ? "page" : undefined}
onClick={() => onNavigate(id)}
>
<Icon size={21} />
<span>{label}</span>
<Icon size={24} strokeWidth={1.5} />
{label}
</button>
))}
</nav>
<div className="wave" aria-hidden="true">
<svg viewBox="0 0 240 170" preserveAspectRatio="none">
{Array.from({ length: 13 }, (_, i) => (
<path
key={i}
d={`M-10 ${72 + i * 5} C 45 ${25 + i * 4}, 82 ${145 - i * 2}, 145 ${91 + i * 2} S 225 ${58 + i * 3}, 255 ${95 + i * 2}`}
/>
))}
</svg>

<div className="sidebar__footer">
<strong>
{APP_CONFIG.name} v{APP_CONFIG.version}
</strong>
<span>Built for Brain-Computer Interface</span>
</div>
<strong>
{APP_CONFIG.name} v{APP_CONFIG.version}
</strong>
<span>Built for Brain-Computer Interface</span>
</aside>
);
}
22 changes: 13 additions & 9 deletions src/components/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { APP_CONFIG } from "../config/appConfig";

const isTauri = () => "__TAURI_INTERNALS__" in window;

/**
* Top bar: wordmark and window controls.
*/
export function TitleBar() {
const run = async (action: "minimize" | "maximize" | "close") => {
if (!isTauri()) return;
Expand All @@ -17,23 +20,24 @@ export function TitleBar() {
return (
<header className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region>
<span className="brand-logo">
<BrainLogo size={46} />
</span>
<div data-tauri-drag-region>
<strong>{APP_CONFIG.name}</strong>
<span>Modular Brain-Computer Interface</span>
<BrainLogo size={44} />
<div className="brand__text" data-tauri-drag-region>
<strong data-tauri-drag-region>{APP_CONFIG.name}</strong>
<span data-tauri-drag-region>Modular Brain-Computer Interface</span>
</div>
</div>

<div className="titlebar__drag" data-tauri-drag-region />

<div className="window-controls">
<button aria-label="Minimize" onClick={() => void run("minimize")}>
<Minus size={18} />
<Minus size={16} />
</button>
<button aria-label="Maximize" onClick={() => void run("maximize")}>
<Square size={15} />
<Square size={13} />
</button>
<button aria-label="Close" className="close" onClick={() => void run("close")}>
<X size={18} />
<X size={16} />
</button>
</div>
</header>
Expand Down
6 changes: 6 additions & 0 deletions src/config/appConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export const APP_CONFIG = {
*/
enableDevelopmentModules: import.meta.env.DEV,

/**
* Simulated EEG / brain-metrics devices for working without hardware.
* Never registered in production builds.
*/
enableSimulationDevices: import.meta.env.DEV,

/**
* Development-only diagnostics must never be exposed in production UI.
*/
Expand Down
Loading