# Nebula Core

Nebula Core is a controller-first, SteamOS-friendly foundation for building living-room and handheld applications.
It focuses on predictable navigation, input abstraction, and UI patterns that work without a mouse or keyboard.

This repository is a monorepo of small, independent packages. Each package solves one problem well and stays UI-agnostic where possible.

## Goals

- Controller and gamepad first
- Steam Deck and SteamOS as a primary target
- Large, readable UI defaults
- Minimal dependencies and no framework lock-in
- ES Modules, Node 18+

## Packages

- @nebula/core-utils — shared helpers and small utilities
- @nebula/core-input — unified, action-based input abstraction
- @nebula/core-navigation — focus management and spatial navigation primitives
- @nebula/core-theme — tokens for color, spacing, typography, and motion
- @nebula/core-glyphs — controller glyph mappings and lookup helpers
- @nebula/core-ui — optional, minimal UI primitives for controller-first apps

## Installation

Install only the packages you need:

```bash
npm install @nebula/core-input @nebula/core-navigation @nebula/core-ui @nebula/core-theme @nebula/core-glyphs @nebula/core-utils
```

## Quick start

```js
import { createActionMapper } from "@nebula/core-input";
import { pickBestCandidate } from "@nebula/core-navigation";
import { getFocusableAttributes, focusRing, hitTarget } from "@nebula/core-ui";
import { createTheme } from "@nebula/core-theme";
import { getGlyph } from "@nebula/core-glyphs";

const theme = createTheme({ colors: { accent: "#44d3ff" } });
const glyph = getGlyph("steam-deck", "confirm");

const mapper = createActionMapper({
	bindings: {
		confirm: [{ source: "gamepad", control: "a" }],
		back: [{ source: "gamepad", control: "b" }]
	}
});

mapper.onAction((update) => {
	if (update.action === "confirm" && update.active) {
		console.log("Confirm pressed", glyph, theme.colors.accent);
	}
});

const current = { id: "settings", x: 100, y: 100, width: 180, height: 80 };
const candidates = [
	{ id: "play", x: 100, y: 10, width: 180, height: 80 },
	{ id: "help", x: 320, y: 100, width: 180, height: 80 }
];

const next = pickBestCandidate(current, candidates, "up");

const focusableProps = getFocusableAttributes({ role: "button", focusKey: "play" });
const styles = {
	minHeight: hitTarget.minHeight,
	minWidth: hitTarget.minWidth,
	outline: `${focusRing.outlineWidth}px solid ${focusRing.outlineColor}`,
	outlineOffset: focusRing.outlineOffset
};
```

## Architecture overview

Nebula Core is designed as small, composable packages:

1. **Input**: Normalize device events into actions.
2. **Navigation**: Choose the next focus target with spatial heuristics.
3. **UI**: Provide hit-target and focus-ring defaults for controller use.
4. **Theme**: Provide tokens tuned for couch and handheld readability.
5. **Glyphs**: Map logical actions to controller glyphs and assets.
6. **Utils**: Small reusable helpers used by other packages.

## Package documentation

### @nebula/core-utils

Small, dependency-free helpers. No DOM or platform assumptions.

#### API

##### clamp(value, min, max)
Clamp a number between a minimum and maximum.

##### lerp(from, to, t)
Linear interpolation between two numbers.

##### roundTo(value, decimals)
Round a number to a specific decimal precision.

##### createEmitter()
Create a tiny event emitter with `on`, `emit`, and `clear`.

#### Example

```js
import { clamp, createEmitter } from "@nebula/core-utils";

const value = clamp(12, 0, 10);
const events = createEmitter();
const off = events.on((payload) => console.log(payload));
events.emit({ action: "confirm" });
off();
```

### @nebula/core-input

Action-based input abstraction for controller-first apps. Convert raw device events into app-level actions.

#### Key concepts

- **InputEvent**: Normalized input from any device (`gamepad`, `keyboard`, `mouse`, `touch`).
- **InputBinding**: Maps a physical control to an action name.
- **ActionUpdate**: Emitted update with `action`, `value`, and `active` state.

#### API

##### createActionMapper(options)
Creates an action mapper instance.

**Options**
- `bindings`: `Record<string, InputBinding[]>`
- `deadzone?`: `number` (default: `0.2`)

**Returns**
- `mapEvent(event)`: map a single `InputEvent` to action updates.
- `getActionState(action)`: get current `value` and `active`.
- `onAction(listener)`: subscribe to action updates.
- `reset()`: clear all action states.

#### Example

```js
import { createActionMapper } from "@nebula/core-input";

const mapper = createActionMapper({
	bindings: {
		confirm: [{ source: "gamepad", control: "a" }],
		back: [{ source: "gamepad", control: "b" }]
	}
});

mapper.onAction((update) => {
	if (update.action === "confirm" && update.active) {
		console.log("Confirm action");
	}
});

mapper.mapEvent({
	source: "gamepad",
	control: "a",
	type: "pressed",
	value: 1
});
```

#### InputEvent shape

```ts
type InputEvent = {
	source: "gamepad" | "keyboard" | "mouse" | "touch" | "unknown";
	control: string;
	type: "pressed" | "released" | "moved" | "axis";
	value: number; // 0..1 for digital, -1..1 for analog
	timestamp?: number;
};
```

### @nebula/core-navigation

Focus management and spatial navigation primitives for controller-first UIs.

#### API

##### getRectCenter(rect)
Return the center point of a rectangle.

##### getDirectionalCandidates(current, candidates, direction)
Filter candidates in the requested direction (`up`, `down`, `left`, `right`).

##### pickBestCandidate(current, candidates, direction)
Pick the nearest candidate using a weighted distance score.

#### Example

```js
import { pickBestCandidate } from "@nebula/core-navigation";

const current = { id: "settings", x: 100, y: 100, width: 180, height: 80 };
const candidates = [
	{ id: "play", x: 100, y: 10, width: 180, height: 80 },
	{ id: "help", x: 320, y: 100, width: 180, height: 80 }
];

const next = pickBestCandidate(current, candidates, "up");
```

### @nebula/core-theme

Theme tokens tuned for readable, couch-friendly UI. Exports plain objects and helpers only.

#### API

##### baseTheme
Default theme tokens: `colors`, `spacing`, `radius`, `typography`, `motion`.

##### createTheme(overrides)
Merge overrides with the base theme. Returns a full theme object.

#### Example

```js
import { baseTheme, createTheme } from "@nebula/core-theme";

const theme = createTheme({ colors: { accent: "#44d3ff" } });
console.log(baseTheme.typography.display, theme.colors.accent);
```

### @nebula/core-glyphs

Controller glyph mappings and lookup helpers. Data-only, no rendering logic.

#### API

##### glyphMap
Mapping of logical actions to glyph labels per controller.

##### glyphAssetMap
Mapping of logical actions to asset paths per controller.

##### getGlyph(controller, action)
Return the glyph label for a controller and action.

##### getGlyphAssetPath(controller, action)
Return the asset path for a controller and action.

#### Example

```js
import { getGlyph, getGlyphAssetPath } from "@nebula/core-glyphs";

const glyph = getGlyph("steam-deck", "confirm");
const asset = getGlyphAssetPath("steam-deck", "confirm");
```

### @nebula/core-ui

Minimal UI helpers for controller-first applications. No framework lock-in.

#### API

##### hitTarget
Recommended hit target sizes (pixels at 100% scale).

##### focusRing
Focus ring style tokens for UI libraries or CSS-in-JS.

##### getFocusableAttributes(options)
Build a minimal set of focusable attributes for controller navigation.

#### Example

```js
import { focusRing, hitTarget, getFocusableAttributes } from "@nebula/core-ui";

const props = getFocusableAttributes({ role: "button", focusKey: "play" });
const styles = {
	minHeight: hitTarget.minHeight,
	minWidth: hitTarget.minWidth,
	outline: `${focusRing.outlineWidth}px solid ${focusRing.outlineColor}`,
	outlineOffset: focusRing.outlineOffset
};
```

## SteamOS / Steam Deck notes

- Favor large hit targets (48px+ at 100% scale) and strong focus rings.
- Keep text contrast high and use `title`/`display` sizes for primary UI.
- Map actions to glyphs rather than hard-coded button labels.
- Provide a thin input adapter that converts raw device events into `InputEvent` objects.

## Development

- Node 18+
- JavaScript only (ES Modules)
- JSDoc for all public APIs

Run tests from the repo root:

```
npm test
```

## License

MIT