mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
refactor: move frontend/ to examples/frontend/
The Lit + Vite HOMECORE web UI is an example consumer of the sensing stack, not a top-level deliverable — relocate it under examples/ alongside the other sensor and dashboard demos. Add an entry to examples/README.md so it's discoverable. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -54,3 +54,17 @@ python examples/environment/room_monitor.py --csi-port COM7 --mmwave-port COM4
|
||||
# CSI only (no mmWave)
|
||||
python examples/ruview_live.py --csi COM7 --mmwave none
|
||||
```
|
||||
|
||||
## Web UI
|
||||
|
||||
| Example | Stack | What It Does |
|
||||
|---------|-------|-------------|
|
||||
| [**frontend/**](frontend/) | Lit 3 + TypeScript + Vite | HOMECORE web UI — Home Assistant–style dashboard for the sensing stack (ADR-131). Mirrors the cognitum-v0 appliance design system. |
|
||||
|
||||
```bash
|
||||
cd examples/frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173 — proxies /api → http://localhost:8123
|
||||
```
|
||||
|
||||
See [examples/frontend/README.md](frontend/README.md) for the full layout and design tokens.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
@@ -0,0 +1,69 @@
|
||||
# @ruvnet/homecore-frontend
|
||||
|
||||
HOMECORE web UI — built with Lit 3, TypeScript, and Vite.
|
||||
Design system mirrors the cognitum-v0 / v0-appliance dashboard (ADR-131).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/api` → `http://localhost:8123`, so you need a
|
||||
`homecore-api-server` (or the `wifi-densepose-sensing-server` crate) running on `:8123`.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run dev` | Start Vite dev server on port 5173 |
|
||||
| `npm run build` | TypeScript compile + Vite production bundle → `dist/` |
|
||||
| `npm run lint` | ESLint on `src/` |
|
||||
| `npm test` | Vitest unit tests (3 suites, jsdom) |
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
src/
|
||||
api/
|
||||
client.ts # fetch + WebSocket client (REST + WS)
|
||||
types.ts # TypeScript types matching homecore-api JSON shapes
|
||||
components/
|
||||
AppShell.ts # <hc-app-shell> — header + nav + content slot
|
||||
StateCard.ts # <hc-state-card> — single entity state card
|
||||
icons/
|
||||
lucide.ts # Tree-shaken Lucide icon wrapper
|
||||
styles/
|
||||
tokens.css # 16 CSS custom properties (--hc-*)
|
||||
base.css # Typography reset, page shell, nav layout
|
||||
__tests__/ # Vitest unit tests
|
||||
index.html # Shell loading src/main.ts
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
```
|
||||
|
||||
## Design system
|
||||
|
||||
Colors, typography, and components mirror the cognitum-v0 dashboard
|
||||
(`http://cognitum-v0:9000/`). Dark-only; no light-mode. Key tokens:
|
||||
|
||||
- `--hc-primary` `#19d4e5` — teal (active nav, focus ring, CTA borders)
|
||||
- `--hc-accent` `#26d867` — green (success, secondary CTA)
|
||||
- `--hc-bg` `#0b0e13` — near-black navy page root
|
||||
- Font: Outfit (display) + JetBrains Mono (mono)
|
||||
- Icons: Lucide (SVG, `stroke: currentColor`, no icon font)
|
||||
|
||||
See `docs/design/HOMECORE-FRONTEND-design-recon.md` for the full recon.
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- Components are standard Lit `LitElement` custom elements — compatible with
|
||||
any HTML page and with Home Assistant's Lit-based frontend.
|
||||
- The REST client uses `fetch`; the WS client uses `WebSocket`. Both accept a
|
||||
bearer token and are fully typed against the Rust `homecore-api` JSON shapes.
|
||||
- WASM: `vite.config.ts` enables `.wasm` asset import. Hook up via dynamic
|
||||
`import('/path/to/module.wasm?init')` when WASM bindings are ready.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>HOMECORE</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<hc-app-shell></hc-app-shell>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4429
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@ruvnet/homecore-frontend",
|
||||
"version": "0.1.0-alpha.0",
|
||||
"description": "HOMECORE web UI — Lit + TypeScript + Vite, cognitum-v0 design system",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.2.1",
|
||||
"lucide": "^0.474.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"eslint": "^9.17.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Unit tests for <hc-state-card>.
|
||||
* Verifies that the component renders entity_id and state value into the DOM.
|
||||
*
|
||||
* Uses jsdom (via vitest environment) — no real browser required.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import type { StateView } from '../api/types.js';
|
||||
|
||||
// Register the custom element before tests run
|
||||
beforeAll(async () => {
|
||||
// jsdom does not support Lit's adoptedStyleSheets; suppress the error.
|
||||
if (typeof document !== 'undefined' && !document.adoptedStyleSheets) {
|
||||
Object.defineProperty(document, 'adoptedStyleSheets', { value: [], writable: true });
|
||||
}
|
||||
await import('../components/StateCard.js');
|
||||
});
|
||||
|
||||
function makeState(overrides: Partial<StateView> = {}): StateView {
|
||||
return {
|
||||
entity_id: 'light.living_room',
|
||||
state: 'on',
|
||||
attributes: { brightness: 255 },
|
||||
last_changed: '2026-05-25T10:00:00Z',
|
||||
last_updated: '2026-05-25T10:00:00Z',
|
||||
context: { id: 'abc123', user_id: null, parent_id: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('StateCard', () => {
|
||||
it('renders entity_id in the DOM', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState();
|
||||
document.body.appendChild(el);
|
||||
|
||||
// Lit renders synchronously into shadow root after a microtask
|
||||
await el.updateComplete;
|
||||
|
||||
const shadowRoot = el.shadowRoot!;
|
||||
const entityEl = shadowRoot.querySelector('.entity-id');
|
||||
expect(entityEl).not.toBeNull();
|
||||
expect(entityEl!.textContent).toContain('light.living_room');
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
|
||||
it('renders the state value', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState({ state: 'off' });
|
||||
document.body.appendChild(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
const stateEl = el.shadowRoot!.querySelector('.state-value');
|
||||
expect(stateEl).not.toBeNull();
|
||||
expect(stateEl!.textContent).toBe('off');
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
|
||||
it('applies .off badge class for unavailable state', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState({ state: 'unavailable' });
|
||||
document.body.appendChild(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
const badge = el.shadowRoot!.querySelector('.badge.off');
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
});
|
||||
|
||||
// Augment for updateComplete
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
updateComplete: Promise<boolean>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Unit tests for HomecoreClient REST methods.
|
||||
* Mocks global `fetch` and asserts correct URL + Authorization header.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
|
||||
describe('HomecoreClient', () => {
|
||||
const token = 'test-bearer-token';
|
||||
let client: HomecoreClient;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new HomecoreClient({ token });
|
||||
fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('getStates() GETs /api/states with the bearer header', async () => {
|
||||
await client.getStates();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
expect(url).toBe('/api/states');
|
||||
expect((init.headers as Record<string, string>)['Authorization']).toBe(`Bearer ${token}`);
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('getState() GETs /api/states/:entity_id with the bearer header', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ entity_id: 'light.living', state: 'on', attributes: {}, last_changed: '', last_updated: '', context: { id: 'x', user_id: null, parent_id: null } }),
|
||||
} as Response);
|
||||
|
||||
await client.getState('light.living');
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/states/light.living');
|
||||
});
|
||||
|
||||
it('getConfig() GETs /api/config', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ location_name: 'Home', version: '0.1.0', state: 'RUNNING', components: [] }),
|
||||
} as Response);
|
||||
|
||||
await client.getConfig();
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/config');
|
||||
});
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' } as Response);
|
||||
|
||||
await expect(client.getStates()).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Validates that tokens.css contains all 16 documented HOMECORE design tokens.
|
||||
* Reads the file from disk and checks for each CSS custom property name.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const tokensPath = resolve(__dirname, '../styles/tokens.css');
|
||||
const css = readFileSync(tokensPath, 'utf-8');
|
||||
|
||||
/**
|
||||
* The 16 design tokens from ADR-131 §9 / HOMECORE-FRONTEND-design-recon.md §1.
|
||||
* 4 surfaces + 2 text + 6 accent/status + 2 border/ring + 2 radius = 16 tokens.
|
||||
*/
|
||||
const REQUIRED_TOKENS = [
|
||||
// Surfaces (4)
|
||||
'--hc-bg',
|
||||
'--hc-surface-card',
|
||||
'--hc-surface-elevated',
|
||||
'--hc-surface-overlay',
|
||||
// Text (2)
|
||||
'--hc-text',
|
||||
'--hc-text-muted',
|
||||
// Accent palette (6)
|
||||
'--hc-primary',
|
||||
'--hc-primary-fg',
|
||||
'--hc-accent',
|
||||
'--hc-accent-fg',
|
||||
'--hc-destructive',
|
||||
'--hc-warning',
|
||||
// Borders & rings (2)
|
||||
'--hc-border',
|
||||
'--hc-ring',
|
||||
// Radii (2)
|
||||
'--hc-radius',
|
||||
'--hc-radius-sm',
|
||||
] as const;
|
||||
|
||||
describe('tokens.css', () => {
|
||||
it('contains all 16 documented design tokens', () => {
|
||||
for (const token of REQUIRED_TOKENS) {
|
||||
expect(css, `Missing token: ${token}`).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('has exactly 16 (or more) --hc- custom properties', () => {
|
||||
const matches = css.match(/--hc-[\w-]+\s*:/g) ?? [];
|
||||
// De-duplicate (token may appear in comments)
|
||||
const unique = new Set(matches.map(m => m.replace(/\s*:/, '')));
|
||||
expect(unique.size).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('defines the teal primary token with the correct hue value', () => {
|
||||
// --hc-primary must reference HSL hue 185 (teal, from cognitum-v0)
|
||||
expect(css).toMatch(/--hc-primary\s*:\s*hsl\(185/);
|
||||
});
|
||||
|
||||
it('defines the green accent token (#26d867)', () => {
|
||||
// --hc-accent must reference HSL 142 70% 50%
|
||||
expect(css).toMatch(/--hc-accent\s*:\s*hsl\(142/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* HOMECORE API client.
|
||||
*
|
||||
* REST: fetch-based, bearer token auth. Base URL defaults to window.location.origin
|
||||
* so the Vite dev-server proxy handles the `/api` → `:8123` rewrite.
|
||||
* WS: native WebSocket, mirrors HA's ws handshake protocol (auth_required → auth → auth_ok).
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiConfig,
|
||||
ServiceDomainView,
|
||||
StateView,
|
||||
WsAuthOk,
|
||||
WsAuthRequired,
|
||||
WsServerMessage,
|
||||
} from './types.js';
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export class HomecoreClient {
|
||||
private readonly base: string;
|
||||
private readonly token: string;
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
this.base = options.baseUrl ?? '';
|
||||
this.token = options.token;
|
||||
}
|
||||
|
||||
// ── REST helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private headers(): HeadersInit {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${this.base}${path}`, {
|
||||
method: 'GET',
|
||||
headers: this.headers(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`GET ${path} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(`${this.base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`POST ${path} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── REST endpoints (mirrors rest.rs) ─────────────────────────────────────
|
||||
|
||||
getConfig(): Promise<ApiConfig> {
|
||||
return this.get<ApiConfig>('/api/config');
|
||||
}
|
||||
|
||||
getStates(): Promise<StateView[]> {
|
||||
return this.get<StateView[]>('/api/states');
|
||||
}
|
||||
|
||||
getState(entityId: string): Promise<StateView> {
|
||||
return this.get<StateView>(`/api/states/${encodeURIComponent(entityId)}`);
|
||||
}
|
||||
|
||||
setState(entityId: string, state: string, attributes?: Record<string, unknown>): Promise<StateView> {
|
||||
return this.post<StateView>(`/api/states/${encodeURIComponent(entityId)}`, {
|
||||
state,
|
||||
attributes: attributes ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
getServices(): Promise<ServiceDomainView[]> {
|
||||
return this.get<ServiceDomainView[]>('/api/services');
|
||||
}
|
||||
|
||||
callService(domain: string, service: string, data?: unknown): Promise<unknown> {
|
||||
return this.post<unknown>(`/api/services/${domain}/${service}`, data ?? {});
|
||||
}
|
||||
|
||||
// ── WebSocket ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open an authenticated WebSocket connection.
|
||||
* Resolves once `auth_ok` is received; rejects on auth failure or network error.
|
||||
* Returns the live socket; caller is responsible for `.close()`.
|
||||
*/
|
||||
openWebSocket(wsBase?: string): Promise<WebSocket> {
|
||||
const resolved = wsBase ?? this.base.replace(/^http/, 'ws');
|
||||
const origin = resolved || window.location.origin.replace(/^http/, 'ws');
|
||||
const url = `${origin}/api/websocket`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onmessage = (evt: MessageEvent<string>) => {
|
||||
const msg = JSON.parse(evt.data) as WsServerMessage;
|
||||
|
||||
if ((msg as WsAuthRequired).type === 'auth_required') {
|
||||
ws.send(JSON.stringify({ type: 'auth', access_token: this.token }));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((msg as WsAuthOk).type === 'auth_ok') {
|
||||
ws.onmessage = null;
|
||||
resolve(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_invalid') {
|
||||
ws.close();
|
||||
reject(new Error(`WS auth_invalid`));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => reject(new Error('WebSocket connection error'));
|
||||
ws.onclose = () => reject(new Error('WebSocket closed before auth_ok'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* TypeScript types mirroring the JSON shapes from homecore-api/src/rest.rs and ws.rs.
|
||||
* Keep in sync with Rust `StateView`, `ApiConfig`, `ServiceDomainView`.
|
||||
*/
|
||||
|
||||
/** Context for a state change — mirrors Rust `ContextView`. */
|
||||
export interface ContextView {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
parent_id: string | null;
|
||||
}
|
||||
|
||||
/** Snapshot of a single entity state — mirrors Rust `StateView`. */
|
||||
export interface StateView {
|
||||
entity_id: string;
|
||||
state: string;
|
||||
/** Arbitrary JSON attributes attached to the entity. */
|
||||
attributes: Record<string, unknown>;
|
||||
/** RFC 3339 timestamp of last state value change. */
|
||||
last_changed: string;
|
||||
/** RFC 3339 timestamp of last update (attributes may have changed). */
|
||||
last_updated: string;
|
||||
context: ContextView;
|
||||
}
|
||||
|
||||
/** HOMECORE configuration — mirrors Rust `ApiConfig`. */
|
||||
export interface ApiConfig {
|
||||
location_name: string;
|
||||
version: string;
|
||||
state: 'RUNNING' | 'STARTING' | 'STOPPING';
|
||||
components: string[];
|
||||
}
|
||||
|
||||
/** Services grouped by domain — mirrors Rust `ServiceDomainView`. */
|
||||
export interface ServiceDomainView {
|
||||
domain: string;
|
||||
/** Keyed by service name; value is the service schema (may be empty `{}`). */
|
||||
services: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── WebSocket protocol types ──────────────────────────────────────────────────
|
||||
|
||||
/** Sent by server immediately upon WS upgrade. */
|
||||
export interface WsAuthRequired {
|
||||
type: 'auth_required';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/** Sent by client to authenticate. */
|
||||
export interface WsAuth {
|
||||
type: 'auth';
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
/** Sent by server on successful auth. */
|
||||
export interface WsAuthOk {
|
||||
type: 'auth_ok';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/** Sent by server on failed auth. */
|
||||
export interface WsAuthInvalid {
|
||||
type: 'auth_invalid';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Generic result message from server. */
|
||||
export interface WsResult<T = unknown> {
|
||||
id: number;
|
||||
type: 'result';
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
/** State-changed event pushed by server via `subscribe_events`. */
|
||||
export interface WsStateChangedEvent {
|
||||
id: number;
|
||||
type: 'event';
|
||||
event: {
|
||||
event_type: 'state_changed';
|
||||
data: {
|
||||
entity_id: string;
|
||||
old_state: StateView | null;
|
||||
new_state: StateView | null;
|
||||
};
|
||||
origin: 'LOCAL' | 'REMOTE';
|
||||
time_fired: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Union of all inbound WS server messages. */
|
||||
export type WsServerMessage =
|
||||
| WsAuthRequired
|
||||
| WsAuthOk
|
||||
| WsAuthInvalid
|
||||
| WsResult
|
||||
| WsStateChangedEvent;
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* `<hc-app-shell>` — top-level layout: sticky header + horizontal sidenav + content slot.
|
||||
* Page shell mirrors cognitum-v0's appbar + wrap layout (ADR-131 §3).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Raw SVG string for the icon */
|
||||
iconSvg?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NAV: NavItem[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'states', label: 'States' },
|
||||
{ id: 'services', label: 'Services' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
@customElement('hc-app-shell')
|
||||
export class AppShell extends LitElement {
|
||||
@property({ type: String }) locationName = 'HOMECORE';
|
||||
@property({ type: String }) version = '0.1.0';
|
||||
@property({ type: Array }) navItems: NavItem[] = DEFAULT_NAV;
|
||||
@state() private activeId = 'dashboard';
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; min-height: 100dvh; background: var(--hc-bg, #0b0e13); }
|
||||
|
||||
/* ── Appbar ── */
|
||||
.appbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: hsl(220 25% 6% / 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid hsl(220 15% 18% / 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary-fg, #0b0e13);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 150ms, background 150ms;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--hc-text, #e6eaee);
|
||||
background: hsl(220 20% 14%);
|
||||
}
|
||||
|
||||
.nav-link:focus-visible {
|
||||
outline: 2px solid hsl(185 80% 50% / 0.6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.nav-link:active { transform: translateY(1px); }
|
||||
|
||||
.nav-link.active { color: var(--hc-primary, #19d4e5); }
|
||||
|
||||
.nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0.7rem;
|
||||
right: 0.7rem;
|
||||
height: 2px;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.version-chip {
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Main content ── */
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
padding-block: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
border-top: 1px solid hsl(220 15% 18%);
|
||||
text-align: center;
|
||||
padding: 1rem 1.25rem;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.75rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
}
|
||||
`;
|
||||
|
||||
private onNavClick(id: string) {
|
||||
this.activeId = id;
|
||||
this.dispatchEvent(new CustomEvent('hc-navigate', { detail: { id }, bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<header class="appbar" part="appbar">
|
||||
<div class="brand">
|
||||
<div class="brand-icon" aria-hidden="true">H</div>
|
||||
${this.locationName}
|
||||
</div>
|
||||
<nav class="nav" aria-label="Primary navigation">
|
||||
${this.navItems.map(item => html`
|
||||
<button
|
||||
class="nav-link ${this.activeId === item.id ? 'active' : ''}"
|
||||
@click=${() => this.onNavClick(item.id)}
|
||||
aria-current=${this.activeId === item.id ? 'page' : 'false'}
|
||||
>${item.label}</button>
|
||||
`)}
|
||||
</nav>
|
||||
<span class="version-chip">v${this.version}</span>
|
||||
</header>
|
||||
|
||||
<main part="content">
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
<footer part="footer">
|
||||
HOMECORE — ${this.locationName} — v${this.version}
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'hc-app-shell': AppShell;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* `<hc-entity-form>` — create / edit form for a single entity.
|
||||
*
|
||||
* Props:
|
||||
* .entityId — pre-populated when editing; empty for create
|
||||
* .state — pre-populated state value
|
||||
* .attributes — pre-populated JSON object
|
||||
* .editing — true to lock entity_id (HA wire-compat doesn't rename)
|
||||
*
|
||||
* Emits:
|
||||
* hc-entity-submit detail: { entity_id, state, attributes }
|
||||
* hc-entity-cancel
|
||||
*
|
||||
* Validation (client-side; backend validates again):
|
||||
* - entity_id matches /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/
|
||||
* - state is non-empty
|
||||
* - attributes parses as a JSON object (not array, not scalar)
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
const ENTITY_ID_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
|
||||
|
||||
/**
|
||||
* Known Home Assistant domain prefixes. We don't reject unknown domains
|
||||
* (the API accepts any matching the regex), but unknown ones get a
|
||||
* warning so the operator sees what's standard. Add new domains here
|
||||
* as integrations land.
|
||||
*/
|
||||
const KNOWN_DOMAINS = new Set([
|
||||
'sensor', 'binary_sensor', 'switch', 'light', 'climate', 'cover',
|
||||
'fan', 'media_player', 'lock', 'camera', 'vacuum', 'humidifier',
|
||||
'water_heater', 'scene', 'script', 'automation', 'input_boolean',
|
||||
'input_number', 'input_text', 'input_select', 'input_datetime',
|
||||
'person', 'device_tracker', 'zone', 'sun', 'weather', 'calendar',
|
||||
'remote', 'siren', 'select', 'number', 'text', 'button',
|
||||
'homeassistant', 'homecore', 'group', 'notify', 'tts', 'alarm_control_panel',
|
||||
]);
|
||||
|
||||
type FieldValidity = { ok: true } | { ok: false; level: 'err' | 'warn'; msg: string };
|
||||
|
||||
function validateEntityId(id: string): FieldValidity {
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) return { ok: false, level: 'err', msg: 'required' };
|
||||
if (!ENTITY_ID_RE.test(trimmed)) {
|
||||
return {
|
||||
ok: false,
|
||||
level: 'err',
|
||||
msg: 'must match domain.snake_case (lowercase, digits, underscores)',
|
||||
};
|
||||
}
|
||||
const domain = trimmed.split('.')[0]!;
|
||||
if (!KNOWN_DOMAINS.has(domain)) {
|
||||
return {
|
||||
ok: false,
|
||||
level: 'warn',
|
||||
msg: `unknown domain "${domain}" — HA-standard domains include sensor / light / switch / binary_sensor / climate`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateState(s: string): FieldValidity {
|
||||
if (!s.trim()) return { ok: false, level: 'err', msg: 'required' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function validateAttrs(raw: string): FieldValidity {
|
||||
if (!raw.trim()) return { ok: true }; // empty = {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
return { ok: false, level: 'err', msg: 'must be a JSON object (not array, not scalar)' };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, level: 'err', msg: `JSON parse: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('hc-entity-form')
|
||||
export class EntityForm extends LitElement {
|
||||
@property({ type: String }) entityId = '';
|
||||
@property({ type: String }) state = '';
|
||||
@property({ type: Object }) entityAttrs: Record<string, unknown> = {};
|
||||
@property({ type: Boolean }) editing = false;
|
||||
|
||||
@state() private _attrs = '';
|
||||
@state() private _err: string | null = null;
|
||||
/** Per-field live validity. `null` = haven't typed yet (no decoration). */
|
||||
@state() private _idValid: FieldValidity | null = null;
|
||||
@state() private _stateValid: FieldValidity | null = null;
|
||||
@state() private _attrsValid: FieldValidity | null = null;
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); color: var(--hc-text, #e6eaee); }
|
||||
label { display: block; margin: 12px 0 4px; font-size: 12px; color: var(--hc-text-muted, #7b899d); }
|
||||
input, textarea {
|
||||
width: 100%; box-sizing: border-box;
|
||||
padding: 8px 10px; background: hsl(220 25% 10%);
|
||||
border: 1px solid var(--hc-border, #2a323e); border-radius: 6px;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 13px;
|
||||
}
|
||||
input:focus, textarea:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); }
|
||||
input[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
input.invalid, textarea.invalid { border-color: hsl(0 60% 50%); }
|
||||
input.warn, textarea.warn { border-color: hsl(38 80% 55%); }
|
||||
.field-status { font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px; }
|
||||
.field-status.ok { color: hsl(150 60% 55%); }
|
||||
.field-status.err { color: hsl(0 70% 70%); }
|
||||
.field-status.warn { color: hsl(38 80% 65%); }
|
||||
.field-status .sigil { display: inline-block; width: 12px; text-align: center; font-weight: 700; }
|
||||
button.primary[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); border-color: var(--hc-border, #2a323e); cursor: not-allowed; }
|
||||
textarea { min-height: 90px; resize: vertical; }
|
||||
.hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; }
|
||||
.err { margin-top: 10px; padding: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; }
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 6px;
|
||||
background: hsl(220 25% 14%);
|
||||
color: var(--hc-text, #e6eaee);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
|
||||
button:hover { background: hsl(220 20% 18%); }
|
||||
button.primary:hover { background: hsl(185 80% 55%); }
|
||||
`;
|
||||
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (changed.has('entityAttrs')) {
|
||||
this._attrs = JSON.stringify(this.entityAttrs, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** Allow the host (Dashboard) to surface a server-side error inline. */
|
||||
public setSubmitError(msg: string | null): void {
|
||||
this._err = msg;
|
||||
}
|
||||
|
||||
/** True iff every field is valid (warnings are OK, errors block). Public so the host can bind a disabled state on the submit button. */
|
||||
public isValid(): boolean {
|
||||
const checks = [
|
||||
validateEntityId(this.entityId),
|
||||
validateState(this.state),
|
||||
validateAttrs(this._attrs),
|
||||
];
|
||||
return !checks.some((c) => !c.ok && c.level === 'err');
|
||||
}
|
||||
|
||||
private _onIdInput(v: string) {
|
||||
this.entityId = v;
|
||||
this._idValid = validateEntityId(v);
|
||||
}
|
||||
private _onStateInput(v: string) {
|
||||
this.state = v;
|
||||
this._stateValid = validateState(v);
|
||||
}
|
||||
private _onAttrsInput(v: string) {
|
||||
this._attrs = v;
|
||||
this._attrsValid = validateAttrs(v);
|
||||
}
|
||||
|
||||
private _statusLine(label: string, v: FieldValidity | null) {
|
||||
if (v === null) return html``;
|
||||
if (v.ok) return html`<div class="field-status ok"><span class="sigil">✓</span>${label} OK</div>`;
|
||||
return html`<div class="field-status ${v.level}">
|
||||
<span class="sigil">${v.level === 'warn' ? '!' : '✗'}</span>${v.msg}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _fieldClass(v: FieldValidity | null): string {
|
||||
if (v === null || v.ok) return '';
|
||||
return v.level;
|
||||
}
|
||||
|
||||
/** Public — call from host to trigger validation + emit submit event. */
|
||||
public requestSubmit(): void { this._submit(); }
|
||||
|
||||
/** Public — call from host to dispatch cancel. */
|
||||
public requestCancel(): void { this._cancel(); }
|
||||
|
||||
private _submit() {
|
||||
const id = this.entityId.trim();
|
||||
if (!ENTITY_ID_RE.test(id)) {
|
||||
this._err = `entity_id must match domain.snake_case (got "${id}")`;
|
||||
return;
|
||||
}
|
||||
const stateVal = this.state.trim();
|
||||
if (!stateVal) {
|
||||
this._err = 'state must not be empty';
|
||||
return;
|
||||
}
|
||||
let attrs: Record<string, unknown> = {};
|
||||
if (this._attrs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(this._attrs);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
this._err = 'attributes must be a JSON object (not array, not scalar)';
|
||||
return;
|
||||
}
|
||||
attrs = parsed as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
this._err = `attributes JSON parse failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._err = null;
|
||||
this.dispatchEvent(new CustomEvent('hc-entity-submit', {
|
||||
detail: { entity_id: id, state: stateVal, attributes: attrs },
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private _cancel() {
|
||||
this._err = null;
|
||||
this.dispatchEvent(new CustomEvent('hc-entity-cancel', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<form @submit=${(e: Event) => { e.preventDefault(); this._submit(); }}>
|
||||
<label for="eid">entity_id</label>
|
||||
<input id="eid" .value=${this.entityId}
|
||||
class=${this._fieldClass(this._idValid)}
|
||||
?disabled=${this.editing}
|
||||
@input=${(e: Event) => this._onIdInput((e.target as HTMLInputElement).value)}
|
||||
placeholder="light.kitchen_ceiling" />
|
||||
<div class="hint">format: <code>domain.snake_case</code> — domain like sensor / light / switch / binary_sensor</div>
|
||||
${this._statusLine('entity_id', this._idValid)}
|
||||
|
||||
<label for="state">state</label>
|
||||
<input id="state" .value=${this.state}
|
||||
class=${this._fieldClass(this._stateValid)}
|
||||
@input=${(e: Event) => this._onStateInput((e.target as HTMLInputElement).value)}
|
||||
placeholder="on / off / 42 / 14.5 / detected" />
|
||||
${this._statusLine('state', this._stateValid)}
|
||||
|
||||
<label for="attrs">attributes (JSON object)</label>
|
||||
<textarea id="attrs" .value=${this._attrs}
|
||||
class=${this._fieldClass(this._attrsValid)}
|
||||
@input=${(e: Event) => this._onAttrsInput((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder='{ "friendly_name": "Kitchen Ceiling", "brightness": 230 }'></textarea>
|
||||
<div class="hint">optional; leave blank for <code>{}</code></div>
|
||||
${this._statusLine('attributes', this._attrsValid)}
|
||||
|
||||
${this._err ? html`<div class="err">${this._err}</div>` : ''}
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'hc-entity-form': EntityForm; } }
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* `<hc-modal>` — minimal accessible overlay modal.
|
||||
*
|
||||
* Open / close by setting the `open` property. Closes on Escape and
|
||||
* on backdrop click. Content goes in the default slot; an optional
|
||||
* named "footer" slot is rendered below the content.
|
||||
*
|
||||
* Emits `hc-modal-close` on close so the host can clean up.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
@customElement('hc-modal')
|
||||
export class Modal extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) open = false;
|
||||
@property({ type: String }) heading = '';
|
||||
|
||||
static styles = css`
|
||||
:host { display: contents; }
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: hsl(220 25% 4% / 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 16px;
|
||||
}
|
||||
.dialog {
|
||||
background: var(--hc-bg, #0b0e13);
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 24px 64px hsl(220 25% 2% / 0.6);
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
color: var(--hc-text, #e6eaee);
|
||||
}
|
||||
header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--hc-border, #2a323e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
button.close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button.close:hover { background: hsl(220 20% 14%); color: var(--hc-text, #e6eaee); }
|
||||
.body { padding: 16px 18px; overflow-y: auto; }
|
||||
.footer {
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--hc-border, #2a323e);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._onKey = this._onKey.bind(this);
|
||||
window.addEventListener('keydown', this._onKey);
|
||||
}
|
||||
disconnectedCallback(): void {
|
||||
window.removeEventListener('keydown', this._onKey);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private _onKey(e: KeyboardEvent) {
|
||||
if (this.open && e.key === 'Escape') this._close();
|
||||
}
|
||||
|
||||
private _close() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new CustomEvent('hc-modal-close', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.open) return html``;
|
||||
return html`
|
||||
<div class="backdrop" @click=${(e: Event) => { if (e.target === e.currentTarget) this._close(); }}>
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label=${this.heading}>
|
||||
<header>
|
||||
<span>${this.heading}</span>
|
||||
<button class="close" @click=${this._close} aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="body"><slot></slot></div>
|
||||
<div class="footer"><slot name="footer"></slot></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'hc-modal': Modal; } }
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* `<hc-state-card>` — renders one HOMECORE entity state in the cognitum-v0 card style.
|
||||
* Uses Lit 3 (LitElement + html/css template tags).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import type { StateView } from '../api/types.js';
|
||||
|
||||
@customElement('hc-state-card')
|
||||
export class StateCard extends LitElement {
|
||||
// `delegatesFocus` lets Tab key traversal from the light DOM reach the
|
||||
// role="button" element inside this card's shadow root. Without it the
|
||||
// user can only activate the card via mouse click or by JS-focusing the
|
||||
// inner div; with it, the natural tab sequence flows through every card.
|
||||
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
|
||||
|
||||
@property({ type: Object }) state!: StateView;
|
||||
/** Optional: icon SVG string (use `iconSvg()` from lucide.ts) */
|
||||
@property({ type: String }) iconSvg?: string;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--hc-gradient-card, linear-gradient(180deg, #181c24 0%, #111318 100%));
|
||||
border: 1px solid hsl(220 15% 18% / 0.5);
|
||||
border-radius: var(--hc-radius, 0.75rem);
|
||||
box-shadow: var(--hc-shadow-card, 0 8px 32px -8px hsl(220 25% 2% / 0.8));
|
||||
padding: 1.25rem;
|
||||
transition: transform 200ms, border-color 200ms;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: hsl(185 80% 50% / 0.4);
|
||||
}
|
||||
|
||||
.card { cursor: pointer; position: relative; }
|
||||
.card:focus-visible { outline: 2px solid var(--hc-primary, #19d4e5); outline-offset: 2px; }
|
||||
button.delete {
|
||||
position: absolute;
|
||||
top: 0.5rem; right: 0.5rem;
|
||||
width: 24px; height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms, background 150ms, color 150ms;
|
||||
}
|
||||
.card:hover button.delete,
|
||||
.card:focus-within button.delete { opacity: 1; }
|
||||
button.delete:hover { background: hsl(0 50% 30%); color: hsl(0 80% 88%); }
|
||||
button.delete:focus-visible { opacity: 1; outline: 2px solid hsl(0 60% 55%); }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--hc-radius-sm, 0.4rem);
|
||||
background: hsl(220 20% 14%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary, #19d4e5);
|
||||
}
|
||||
|
||||
.meta { flex: 1; min-width: 0; }
|
||||
|
||||
.entity-id {
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.state-value {
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--hc-border, #272b34);
|
||||
font-family: var(--hc-font-mono, monospace);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.on { color: #26d867; border-color: hsl(142 70% 50% / 0.4); }
|
||||
.badge.off { color: #d22c2c; border-color: hsl(0 65% 50% / 0.4); }
|
||||
|
||||
.timestamp {
|
||||
font-family: var(--hc-font-mono, monospace);
|
||||
font-size: 0.625rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
`;
|
||||
|
||||
private badgeClass(state: string): string {
|
||||
const s = state.toLowerCase();
|
||||
if (s === 'on' || s === 'open' || s === 'home' || s === 'running') return 'on';
|
||||
if (s === 'off' || s === 'closed' || s === 'away' || s === 'unavailable') return 'off';
|
||||
return '';
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state) return nothing;
|
||||
const { entity_id, state, last_updated } = this.state;
|
||||
const badge = this.badgeClass(state);
|
||||
|
||||
return html`
|
||||
<div class="card" part="card" role="button" tabindex="0"
|
||||
@click=${this._onClick}
|
||||
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }}
|
||||
aria-label="Edit ${entity_id}">
|
||||
<button class="delete" type="button"
|
||||
@click=${this._onDelete}
|
||||
@keydown=${(e: KeyboardEvent) => { e.stopPropagation(); }}
|
||||
aria-label="Delete ${entity_id}"
|
||||
title="Delete ${entity_id}">×</button>
|
||||
<div class="header">
|
||||
${this.iconSvg
|
||||
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
|
||||
: nothing}
|
||||
<div class="meta">
|
||||
<div class="entity-id" title=${entity_id}>${entity_id}</div>
|
||||
<div class="state-value">${state}</div>
|
||||
</div>
|
||||
<span class="badge ${badge}">${state}</span>
|
||||
</div>
|
||||
<div class="timestamp">updated ${new Date(last_updated).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _onClick() {
|
||||
this.dispatchEvent(new CustomEvent('hc-state-card-click', {
|
||||
detail: { state: this.state }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private _onDelete(e: Event) {
|
||||
// Stop propagation so the parent card's click handler (which would
|
||||
// open the edit modal) doesn't also fire.
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent('hc-state-card-delete', {
|
||||
detail: { state: this.state }, bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'hc-state-card': StateCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Minimal Lucide icon wrapper.
|
||||
* Import only the icons used by HOMECORE components — Vite tree-shakes the rest.
|
||||
*/
|
||||
|
||||
export {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Book,
|
||||
ChevronRight,
|
||||
Grid2X2,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
Wifi,
|
||||
Zap,
|
||||
} from 'lucide';
|
||||
|
||||
/** Re-export the icon node type for consumers that need it. */
|
||||
export type { IconNode as LucideIconNode } from 'lucide';
|
||||
|
||||
/**
|
||||
* Render a Lucide icon as an SVG string suitable for Lit's `unsafeHTML`.
|
||||
* Each icon is 24×24, no fill, stroke = currentColor, stroke-width = 2.
|
||||
*/
|
||||
export function iconSvg(
|
||||
paths: string,
|
||||
{ size = 24, label }: { size?: number; label?: string } = {},
|
||||
): string {
|
||||
const ariaAttrs = label
|
||||
? `role="img" aria-label="${label}"`
|
||||
: `aria-hidden="true"`;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
${ariaAttrs}>${paths}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* HOMECORE frontend entry point.
|
||||
* Imports global styles, registers Lit components, and mounts the app shell.
|
||||
*/
|
||||
|
||||
import './styles/tokens.css';
|
||||
import './styles/base.css';
|
||||
|
||||
// Register custom elements
|
||||
import './components/AppShell.js';
|
||||
import './components/StateCard.js';
|
||||
import './pages/Dashboard.js';
|
||||
import './pages/States.js';
|
||||
import './pages/Services.js';
|
||||
import './pages/Settings.js';
|
||||
|
||||
// Tiny router: the AppShell dispatches `hc-navigate` on every nav
|
||||
// click. We swap whichever page element is sitting in its <slot>
|
||||
// based on the new active id. Default page on first paint = dashboard.
|
||||
const NAV_TO_TAG: Record<string, string> = {
|
||||
dashboard: 'hc-dashboard',
|
||||
states: 'hc-states',
|
||||
services: 'hc-services',
|
||||
settings: 'hc-settings',
|
||||
};
|
||||
|
||||
function mountPage(shell: Element, tag: string): void {
|
||||
// Remove any existing page (everything that isn't itself the shell).
|
||||
Array.from(shell.children).forEach((c) => c.remove());
|
||||
shell.appendChild(document.createElement(tag));
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const shell = document.querySelector('hc-app-shell');
|
||||
if (!shell) return;
|
||||
mountPage(shell, 'hc-dashboard');
|
||||
shell.addEventListener('hc-navigate', (ev) => {
|
||||
const id = (ev as CustomEvent<{ id: string }>).detail?.id;
|
||||
const tag = id ? NAV_TO_TAG[id] : undefined;
|
||||
if (tag) mountPage(shell, tag);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Dashboard page — fetches HOMECORE state + config from the backend and
|
||||
* populates the `<hc-app-shell>` slot with a grid of `<hc-state-card>`.
|
||||
*
|
||||
* Auth: reads bearer from `localStorage["homecore.token"]`, the
|
||||
* `?token=` query string, or `HOMECORE_TOKEN` `<meta>` tag — in that
|
||||
* order. Falls back to the literal "dev-token" in DEV-mode backends
|
||||
* (any non-empty bearer is accepted when HOMECORE_TOKENS is unset).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
import type { ApiConfig, StateView } from '../api/types.js';
|
||||
import '../components/Modal.js';
|
||||
import '../components/EntityForm.js';
|
||||
import type { EntityForm } from '../components/EntityForm.js';
|
||||
|
||||
function resolveToken(): string {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem('homecore.token');
|
||||
if (stored) return stored;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
const qs = url.searchParams.get('token');
|
||||
if (qs) return qs;
|
||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="homecore-token"]');
|
||||
if (meta?.content) return meta.content;
|
||||
return 'dev-token';
|
||||
}
|
||||
|
||||
@customElement('hc-dashboard')
|
||||
export class Dashboard extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
color: var(--hc-fg, #e6e9ec);
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--hc-fg-dim, #8a93a0);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.meta strong { color: var(--hc-fg, #e6e9ec); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.empty,
|
||||
.err {
|
||||
padding: 24px;
|
||||
border: 1px dashed var(--hc-border, #2a323e);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: var(--hc-fg-dim, #8a93a0);
|
||||
}
|
||||
.err {
|
||||
border-color: #b35a5a;
|
||||
color: #f0c0c0;
|
||||
text-align: left;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||
.toolbar .grow { flex: 1; }
|
||||
button.add {
|
||||
padding: 7px 14px;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
color: var(--hc-primary-fg, #0b0e13);
|
||||
border: none; border-radius: 6px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
}
|
||||
button.add:hover { background: hsl(185 80% 55%); }
|
||||
button.btn {
|
||||
padding: 7px 14px;
|
||||
background: hsl(220 25% 14%);
|
||||
color: var(--hc-text, #e6eaee);
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
}
|
||||
button.btn:hover { background: hsl(220 20% 18%); }
|
||||
button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
|
||||
.toast { padding: 8px 12px; background: hsl(165 60% 16%); color: hsl(165 60% 80%); border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
|
||||
`;
|
||||
|
||||
@state() private states: StateView[] = [];
|
||||
@state() private config: ApiConfig | null = null;
|
||||
@state() private error: string | null = null;
|
||||
@state() private loading = true;
|
||||
@state() private modalOpen = false;
|
||||
@state() private submitToast: string | null = null;
|
||||
@state() private editingState: StateView | null = null; // null = create mode
|
||||
@state() private deletingState: StateView | null = null; // null = no confirm
|
||||
|
||||
@query('hc-entity-form') private _form?: EntityForm;
|
||||
|
||||
private client = new HomecoreClient({ token: resolveToken() });
|
||||
private pollTimer: number | undefined;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.refresh();
|
||||
this.pollTimer = window.setInterval(() => void this.refresh(), 5000);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.pollTimer !== undefined) window.clearInterval(this.pollTimer);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
try {
|
||||
const [cfg, states] = await Promise.all([
|
||||
this.client.getConfig(),
|
||||
this.client.getStates(),
|
||||
]);
|
||||
this.config = cfg;
|
||||
this.states = states;
|
||||
this.error = null;
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _openCreate() {
|
||||
this.editingState = null;
|
||||
this.modalOpen = true;
|
||||
}
|
||||
|
||||
private _openEdit(e: CustomEvent<{ state: StateView }>) {
|
||||
this.editingState = e.detail.state;
|
||||
this.modalOpen = true;
|
||||
}
|
||||
|
||||
private _openDeleteConfirm(e: CustomEvent<{ state: StateView }>) {
|
||||
this.deletingState = e.detail.state;
|
||||
}
|
||||
|
||||
private async _confirmDelete() {
|
||||
const target = this.deletingState;
|
||||
if (!target) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/states/${encodeURIComponent(target.entity_id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${resolveToken()}` },
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
||||
this.deletingState = null;
|
||||
this.submitToast = `Deleted ${target.entity_id}`;
|
||||
window.setTimeout(() => (this.submitToast = null), 3000);
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : String(err);
|
||||
this.deletingState = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
|
||||
const { entity_id, state, attributes } = e.detail;
|
||||
const wasEditing = this.editingState !== null;
|
||||
// Clear any previous server-side error before the next attempt.
|
||||
this._form?.setSubmitError(null);
|
||||
try {
|
||||
const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${resolveToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ state, attributes }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
// Surface the server message inline in the form, not at
|
||||
// the top of the page — the form is what the user is
|
||||
// looking at.
|
||||
const body = await resp.text();
|
||||
this._form?.setSubmitError(`server rejected (${resp.status}): ${body || resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
this.modalOpen = false;
|
||||
this.editingState = null;
|
||||
this.submitToast = `${wasEditing ? 'Updated' : 'Created'} ${entity_id} = ${state}`;
|
||||
window.setTimeout(() => (this.submitToast = null), 3000);
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this._form?.setSubmitError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.error && this.states.length === 0) {
|
||||
return html`<div class="err">backend unreachable — ${this.error}\n\n
|
||||
hint: make sure homecore-server is running on :8123 and that
|
||||
the token in localStorage["homecore.token"] is accepted.
|
||||
</div>`;
|
||||
}
|
||||
if (this.loading) {
|
||||
return html`<div class="empty">loading HOMECORE state…</div>`;
|
||||
}
|
||||
const v = this.config?.version ?? '?';
|
||||
const loc = this.config?.location_name ?? 'Home';
|
||||
return html`
|
||||
${this.submitToast ? html`<div class="toast">${this.submitToast}</div>` : ''}
|
||||
<div class="toolbar">
|
||||
<span class="grow"></span>
|
||||
<button class="add" @click=${this._openCreate}>+ Add entity</button>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span><strong>${loc}</strong></span>
|
||||
<span>HOMECORE v<strong>${v}</strong></span>
|
||||
<span><strong>${this.states.length}</strong> entities</span>
|
||||
</div>
|
||||
${this.states.length === 0
|
||||
? html`<div class="empty">
|
||||
No entities registered yet. Click <strong>+ Add entity</strong>
|
||||
above, run <code>bash scripts/homecore-seed.sh</code>,
|
||||
or boot <code>homecore-server</code> without
|
||||
<code>--no-seed-entities</code>.
|
||||
</div>`
|
||||
: html`<div class="grid"
|
||||
@hc-state-card-click=${(e: Event) => this._openEdit(e as CustomEvent)}
|
||||
@hc-state-card-delete=${(e: Event) => this._openDeleteConfirm(e as CustomEvent)}>
|
||||
${this.states.map(
|
||||
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
|
||||
)}
|
||||
</div>`}
|
||||
|
||||
<hc-modal .open=${this.deletingState !== null}
|
||||
heading="Delete entity"
|
||||
@hc-modal-close=${() => (this.deletingState = null)}>
|
||||
<p style="margin:0 0 12px 0; line-height:1.5;">
|
||||
Permanently remove
|
||||
<code style="background:hsl(220 25% 14%); padding:2px 6px; border-radius:4px;">${this.deletingState?.entity_id ?? ''}</code>
|
||||
from the state machine?
|
||||
<br>
|
||||
<span style="color:var(--hc-text-muted,#7b899d); font-size:12px;">
|
||||
This is immediate. To restore, re-create the entity via "+ Add entity".
|
||||
</span>
|
||||
</p>
|
||||
<button slot="footer" class="btn" @click=${() => (this.deletingState = null)}>Cancel</button>
|
||||
<button slot="footer" class="btn"
|
||||
style="background:hsl(0 50% 25%); border-color:hsl(0 50% 35%); color:hsl(0 60% 88%);"
|
||||
@click=${this._confirmDelete}>Delete</button>
|
||||
</hc-modal>
|
||||
|
||||
<hc-modal .open=${this.modalOpen}
|
||||
heading=${this.editingState ? `Edit ${this.editingState.entity_id}` : 'Add entity'}
|
||||
@hc-modal-close=${() => { this.modalOpen = false; this.editingState = null; }}>
|
||||
<hc-entity-form
|
||||
.entityId=${this.editingState?.entity_id ?? ''}
|
||||
.state=${this.editingState?.state ?? ''}
|
||||
.entityAttrs=${this.editingState?.attributes ?? {}}
|
||||
.editing=${this.editingState !== null}
|
||||
@hc-entity-submit=${(e: Event) => this._onSubmit(e as CustomEvent)}
|
||||
@hc-entity-cancel=${() => { this.modalOpen = false; this.editingState = null; }}></hc-entity-form>
|
||||
<button slot="footer" class="btn" @click=${() => this._form?.requestCancel()}>Cancel</button>
|
||||
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>${this.editingState ? 'Save' : 'Create'}</button>
|
||||
</hc-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'hc-dashboard': Dashboard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Services page — lists every registered service grouped by domain,
|
||||
* and lets the operator call any of them with a JSON service_data
|
||||
* payload (POST /api/services/<domain>/<service>).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
import type { ServiceDomainView } from '../api/types.js';
|
||||
import '../components/Modal.js';
|
||||
|
||||
function resolveToken(): string {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem('homecore.token');
|
||||
if (stored) return stored;
|
||||
}
|
||||
const qs = new URL(window.location.href).searchParams.get('token');
|
||||
return qs ?? 'dev-token';
|
||||
}
|
||||
|
||||
@customElement('hc-services')
|
||||
export class ServicesPage extends LitElement {
|
||||
static styles = css`
|
||||
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
|
||||
.domain { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; margin-bottom: 12px; padding: 14px 16px; }
|
||||
.domain h2 { font-size: 14px; font-weight: 600; margin: 0 0 8px 0; color: var(--hc-primary, #19d4e5); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
|
||||
ul { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
li {
|
||||
background: hsl(220 25% 14%);
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 12px;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
li .name { padding: 4px 10px; }
|
||||
li button.call {
|
||||
background: hsl(220 25% 18%);
|
||||
color: var(--hc-primary, #19d4e5);
|
||||
border: none;
|
||||
border-left: 1px solid var(--hc-border, #2a323e);
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
font-weight: 600;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
li button.call:hover { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); }
|
||||
.empty { padding: 24px; border: 1px dashed var(--hc-border, #2a323e); border-radius: 8px; text-align: center; color: var(--hc-text-muted, #7b899d); }
|
||||
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; }
|
||||
.toast { padding: 8px 12px; background: hsl(165 60% 16%); color: hsl(165 60% 80%); border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
|
||||
|
||||
/* Service-call modal contents */
|
||||
.form label { display: block; margin: 6px 0 4px; font-size: 12px; color: var(--hc-text-muted, #7b899d); }
|
||||
.form code.target { color: var(--hc-primary, #19d4e5); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
|
||||
.form textarea {
|
||||
width: 100%; box-sizing: border-box;
|
||||
padding: 8px 10px; background: hsl(220 25% 10%);
|
||||
border: 1px solid var(--hc-border, #2a323e); border-radius: 6px;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 13px;
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
}
|
||||
.form textarea.invalid { border-color: hsl(0 60% 50%); }
|
||||
.form .hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; }
|
||||
.form .field-status { font-size: 11px; margin-top: 4px; }
|
||||
.form .field-status.ok { color: hsl(150 60% 55%); }
|
||||
.form .field-status.err { color: hsl(0 70% 70%); }
|
||||
.form pre {
|
||||
background: hsl(220 25% 8%);
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.form .resp-ok { border-color: hsl(150 50% 35%); }
|
||||
.form .resp-err { border-color: hsl(0 50% 45%); color: #f0c0c0; }
|
||||
.form .err { padding: 10px; margin-top: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; }
|
||||
|
||||
button.btn {
|
||||
padding: 8px 16px;
|
||||
background: hsl(220 25% 14%);
|
||||
color: var(--hc-text, #e6eaee);
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
}
|
||||
button.btn:hover { background: hsl(220 20% 18%); }
|
||||
button.btn.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
|
||||
button.btn.primary[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); border-color: var(--hc-border, #2a323e); cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
@state() private domains: ServiceDomainView[] = [];
|
||||
@state() private error: string | null = null;
|
||||
@state() private loading = true;
|
||||
@state() private calling: { domain: string; service: string } | null = null;
|
||||
@state() private callBody = '{}';
|
||||
@state() private callResp: { ok: boolean; text: string } | null = null;
|
||||
@state() private callErr: string | null = null;
|
||||
@state() private callPending = false;
|
||||
@state() private callToast: string | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
try {
|
||||
const r = await fetch('/api/services', { headers: { 'Authorization': `Bearer ${resolveToken()}` } });
|
||||
if (!r.ok) throw new Error(`/api/services -> HTTP ${r.status}`);
|
||||
this.domains = await r.json();
|
||||
this.error = null;
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _openCall(domain: string, service: string) {
|
||||
this.calling = { domain, service };
|
||||
this.callBody = '{}';
|
||||
this.callResp = null;
|
||||
this.callErr = null;
|
||||
}
|
||||
|
||||
private _closeCall() {
|
||||
this.calling = null;
|
||||
this.callBody = '{}';
|
||||
this.callResp = null;
|
||||
this.callErr = null;
|
||||
this.callPending = false;
|
||||
}
|
||||
|
||||
private _validateBody(): { ok: boolean; data?: unknown; msg?: string } {
|
||||
const raw = this.callBody.trim();
|
||||
if (!raw) return { ok: true, data: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
return { ok: false, msg: 'service_data must be a JSON object (not array, not scalar)' };
|
||||
}
|
||||
return { ok: true, data: parsed };
|
||||
} catch (e) {
|
||||
return { ok: false, msg: `JSON parse: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
private async _doCall() {
|
||||
if (!this.calling) return;
|
||||
const v = this._validateBody();
|
||||
if (!v.ok) {
|
||||
this.callErr = v.msg ?? 'invalid';
|
||||
this.callResp = null;
|
||||
return;
|
||||
}
|
||||
this.callPending = true;
|
||||
this.callErr = null;
|
||||
const { domain, service } = this.calling;
|
||||
try {
|
||||
const r = await fetch(`/api/services/${encodeURIComponent(domain)}/${encodeURIComponent(service)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${resolveToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(v.data ?? {}),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (r.ok) {
|
||||
let pretty = text;
|
||||
try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch { /* leave raw */ }
|
||||
this.callResp = { ok: true, text: pretty };
|
||||
this.callToast = `Called ${domain}.${service} → 200`;
|
||||
window.setTimeout(() => (this.callToast = null), 3000);
|
||||
} else {
|
||||
this.callResp = { ok: false, text: `HTTP ${r.status}\n${text}` };
|
||||
}
|
||||
} catch (e) {
|
||||
this.callErr = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
this.callPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.error) return html`<div class="err">backend unreachable — ${this.error}</div>`;
|
||||
if (this.loading) return html`<div>loading services…</div>`;
|
||||
if (this.domains.length === 0) {
|
||||
return html`
|
||||
<h1>Services (0 domains)</h1>
|
||||
<div class="empty">
|
||||
No services registered. Services are registered by plugins
|
||||
(Wasmtime or InProcess) or by integrations that call
|
||||
<code>services::register()</code> on boot.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const validity = this._validateBody();
|
||||
return html`
|
||||
${this.callToast ? html`<div class="toast">${this.callToast}</div>` : ''}
|
||||
<h1>Services (${this.domains.length} domain${this.domains.length === 1 ? '' : 's'})</h1>
|
||||
${this.domains.map(d => html`
|
||||
<div class="domain">
|
||||
<h2>${d.domain}</h2>
|
||||
<ul>
|
||||
${Object.keys(d.services).map(name => html`
|
||||
<li>
|
||||
<span class="name">${name}</span>
|
||||
<button class="call"
|
||||
@click=${() => this._openCall(d.domain, name)}
|
||||
title="Call ${d.domain}.${name}">▶ Call</button>
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
</div>
|
||||
`)}
|
||||
|
||||
<hc-modal .open=${this.calling !== null}
|
||||
heading=${this.calling ? `Call ${this.calling.domain}.${this.calling.service}` : ''}
|
||||
@hc-modal-close=${this._closeCall}>
|
||||
<div class="form">
|
||||
<label>target</label>
|
||||
<div><code class="target">POST /api/services/${this.calling?.domain ?? ''}/${this.calling?.service ?? ''}</code></div>
|
||||
|
||||
<label for="body">service_data (JSON object)</label>
|
||||
<textarea id="body"
|
||||
class=${validity.ok ? '' : 'invalid'}
|
||||
.value=${this.callBody}
|
||||
@input=${(e: Event) => (this.callBody = (e.target as HTMLTextAreaElement).value)}
|
||||
placeholder='{ "entity_id": "light.kitchen_ceiling", "brightness": 200 }'></textarea>
|
||||
<div class="hint">leave blank for <code>{}</code> — these handlers are no-op echoes, they round-trip whatever you send</div>
|
||||
${validity.ok
|
||||
? (this.callBody.trim()
|
||||
? html`<div class="field-status ok">✓ service_data OK</div>`
|
||||
: html`<div class="hint">empty → will send <code>{}</code></div>`)
|
||||
: html`<div class="field-status err">✗ ${validity.msg}</div>`}
|
||||
|
||||
${this.callErr ? html`<div class="err">${this.callErr}</div>` : ''}
|
||||
${this.callResp
|
||||
? html`<label>response</label>
|
||||
<pre class=${this.callResp.ok ? 'resp-ok' : 'resp-err'}>${this.callResp.text}</pre>`
|
||||
: ''}
|
||||
</div>
|
||||
<button slot="footer" class="btn" @click=${this._closeCall}>Close</button>
|
||||
<button slot="footer" class="btn primary"
|
||||
?disabled=${!validity.ok || this.callPending}
|
||||
@click=${this._doCall}>
|
||||
${this.callPending ? 'Calling…' : 'Call'}
|
||||
</button>
|
||||
</hc-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'hc-services': ServicesPage; } }
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Settings page — backend config + bearer-token editor with
|
||||
* probe-before-persist validation.
|
||||
*
|
||||
* The save flow probes `/api/config` with the new token BEFORE writing
|
||||
* it to localStorage. If the probe fails (401 wrong token, network
|
||||
* error, etc.) the bad token is NOT persisted and the operator sees
|
||||
* an inline error. This avoids the foot-gun where saving a typo'd
|
||||
* token would lock the UI out of the backend until the operator
|
||||
* cleared localStorage by hand.
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
import type { ApiConfig } from '../api/types.js';
|
||||
|
||||
const TOKEN_LS_KEY = 'homecore.token';
|
||||
|
||||
function resolveToken(): string {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem(TOKEN_LS_KEY);
|
||||
if (stored) return stored;
|
||||
}
|
||||
const qs = new URL(window.location.href).searchParams.get('token');
|
||||
return qs ?? 'dev-token';
|
||||
}
|
||||
|
||||
function maskToken(t: string): string {
|
||||
if (!t) return '(empty)';
|
||||
if (t.length <= 8) return '•'.repeat(t.length);
|
||||
return t.slice(0, 4) + '…' + t.slice(-3) + ' (' + t.length + ' chars)';
|
||||
}
|
||||
|
||||
type ProbeResult =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'probing' }
|
||||
| { kind: 'ok'; ms: number; serverVersion: string }
|
||||
| { kind: 'err'; status?: number; msg: string };
|
||||
|
||||
@customElement('hc-settings')
|
||||
export class SettingsPage extends LitElement {
|
||||
static styles = css`
|
||||
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
|
||||
section { background: hsl(220 20% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
h2 { font-size: 14px; font-weight: 600; margin: 0 0 12px 0; color: var(--hc-primary, #19d4e5); }
|
||||
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; margin: 0; font-size: 13px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
|
||||
dt { color: var(--hc-text-muted, #7b899d); }
|
||||
dd { margin: 0; word-break: break-all; }
|
||||
label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--hc-text-muted, #7b899d); }
|
||||
input {
|
||||
width: 100%; box-sizing: border-box;
|
||||
padding: 8px 12px;
|
||||
background: hsl(220 25% 14%);
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
border-radius: 6px;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 13px;
|
||||
}
|
||||
input:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); }
|
||||
input.invalid { border-color: hsl(0 60% 50%); }
|
||||
.actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--hc-border, #2a323e);
|
||||
background: hsl(220 25% 14%);
|
||||
color: var(--hc-text, #e6eaee);
|
||||
font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: hsl(220 20% 18%); }
|
||||
button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; }
|
||||
button.primary:hover { background: hsl(185 80% 55%); }
|
||||
button[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); cursor: not-allowed; }
|
||||
.hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 6px; }
|
||||
.field-status { font-size: 12px; margin-top: 6px; display: flex; align-items: center; gap: 6px; }
|
||||
.field-status.ok { color: hsl(150 60% 55%); }
|
||||
.field-status.err { color: hsl(0 70% 70%); }
|
||||
.field-status.probing { color: var(--hc-text-muted, #7b899d); }
|
||||
.toast { font-size: 12px; color: var(--hc-primary, #19d4e5); margin-top: 8px; }
|
||||
.err { padding: 12px; border: 1px solid #b35a5a; border-radius: 6px; color: #f0c0c0; background: hsl(0 35% 12%); font-size: 13px; margin-top: 8px; }
|
||||
.saved-meta { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
|
||||
`;
|
||||
|
||||
@state() private config: ApiConfig | null = null;
|
||||
@state() private configErr: string | null = null;
|
||||
@state() private token = resolveToken();
|
||||
@state() private storedToken = resolveToken();
|
||||
@state() private probe: ProbeResult = { kind: 'idle' };
|
||||
@state() private savedAt = 0;
|
||||
|
||||
private client = new HomecoreClient({ token: resolveToken() });
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.refreshConfig();
|
||||
}
|
||||
|
||||
private async refreshConfig(): Promise<void> {
|
||||
try {
|
||||
this.config = await this.client.getConfig();
|
||||
this.configErr = null;
|
||||
} catch (e) {
|
||||
this.configErr = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hit /api/config with the given token; return success or 4xx/5xx kind. */
|
||||
private async _probe(token: string): Promise<ProbeResult> {
|
||||
if (!token.trim()) return { kind: 'err', msg: 'token must not be empty' };
|
||||
const started = performance.now();
|
||||
try {
|
||||
const r = await fetch('/api/config', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (!r.ok) {
|
||||
return { kind: 'err', status: r.status, msg: r.statusText || `HTTP ${r.status}` };
|
||||
}
|
||||
const cfg = await r.json() as ApiConfig;
|
||||
return { kind: 'ok', ms: Math.round(performance.now() - started), serverVersion: cfg.version };
|
||||
} catch (e) {
|
||||
return { kind: 'err', msg: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
private async _testToken() {
|
||||
this.probe = { kind: 'probing' };
|
||||
this.probe = await this._probe(this.token);
|
||||
}
|
||||
|
||||
private async _saveToken() {
|
||||
const result = await this._probe(this.token);
|
||||
this.probe = result;
|
||||
if (result.kind !== 'ok') return; // refuse to persist a bad token
|
||||
localStorage.setItem(TOKEN_LS_KEY, this.token);
|
||||
this.storedToken = this.token;
|
||||
this.savedAt = Date.now();
|
||||
// Rebuild the client with the new token + refresh the config readout.
|
||||
this.client = new HomecoreClient({ token: this.token });
|
||||
await this.refreshConfig();
|
||||
}
|
||||
|
||||
private _clearToken() {
|
||||
localStorage.removeItem(TOKEN_LS_KEY);
|
||||
this.storedToken = '';
|
||||
this.token = '';
|
||||
this.probe = { kind: 'idle' };
|
||||
this.savedAt = 0;
|
||||
}
|
||||
|
||||
private _renderProbe() {
|
||||
switch (this.probe.kind) {
|
||||
case 'idle':
|
||||
return html`<div class="hint">click Test token to probe /api/config with the value above</div>`;
|
||||
case 'probing':
|
||||
return html`<div class="field-status probing">⋯ probing /api/config…</div>`;
|
||||
case 'ok':
|
||||
return html`<div class="field-status ok">✓ token accepted (${this.probe.ms} ms) — server v${this.probe.serverVersion}</div>`;
|
||||
case 'err':
|
||||
return html`<div class="field-status err">✗ ${this.probe.status ? `HTTP ${this.probe.status}: ` : ''}${this.probe.msg}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isEmpty = !this.token.trim();
|
||||
const inputClass = isEmpty || this.probe.kind === 'err' ? 'invalid' : '';
|
||||
return html`
|
||||
<h1>Settings</h1>
|
||||
<section>
|
||||
<h2>backend</h2>
|
||||
${this.configErr
|
||||
? html`<div class="err">unreachable — ${this.configErr}</div>`
|
||||
: this.config
|
||||
? html`<dl>
|
||||
<dt>location</dt><dd>${this.config.location_name}</dd>
|
||||
<dt>version</dt><dd>${this.config.version}</dd>
|
||||
<dt>state</dt><dd>${this.config.state}</dd>
|
||||
<dt>components</dt><dd>${this.config.components.join(', ')}</dd>
|
||||
</dl>`
|
||||
: html`loading…`}
|
||||
</section>
|
||||
<section>
|
||||
<h2>auth — bearer token</h2>
|
||||
<label for="tok">localStorage["homecore.token"] — must be accepted by /api/config before save is allowed</label>
|
||||
<input id="tok" type="password" .value=${this.token}
|
||||
class=${inputClass}
|
||||
@input=${(e: Event) => { this.token = (e.target as HTMLInputElement).value; this.probe = { kind: 'idle' }; }} />
|
||||
<div class="saved-meta">currently stored: ${maskToken(this.storedToken)}</div>
|
||||
${this._renderProbe()}
|
||||
<div class="actions">
|
||||
<button @click=${this._testToken} ?disabled=${isEmpty}>Test token</button>
|
||||
<button class="primary" @click=${this._saveToken} ?disabled=${isEmpty}>Probe & Save</button>
|
||||
<button @click=${this._clearToken}>Clear</button>
|
||||
</div>
|
||||
${this.savedAt > 0
|
||||
? html`<div class="toast">✓ saved at ${new Date(this.savedAt).toLocaleTimeString()} — backend config refreshed with new token</div>`
|
||||
: ''}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'hc-settings': SettingsPage; } }
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* States page — full table view of every entity in the state machine.
|
||||
* Mirrors Home Assistant's `/developer-tools/state` view (read-only).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
import type { StateView } from '../api/types.js';
|
||||
|
||||
function resolveToken(): string {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem('homecore.token');
|
||||
if (stored) return stored;
|
||||
}
|
||||
const qs = new URL(window.location.href).searchParams.get('token');
|
||||
return qs ?? 'dev-token';
|
||||
}
|
||||
|
||||
@customElement('hc-states')
|
||||
export class StatesPage extends LitElement {
|
||||
static styles = css`
|
||||
:host { display: block; padding: 24px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); }
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px 0; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--hc-border, #2a323e); color: var(--hc-text-muted, #7b899d); font-weight: 500; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid hsl(220 15% 14%); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); }
|
||||
td.attrs { color: var(--hc-text-muted, #7b899d); font-size: 12px; max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
tr:hover td { background: hsl(220 20% 10%); }
|
||||
.state { color: var(--hc-primary, #19d4e5); }
|
||||
.err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; }
|
||||
`;
|
||||
|
||||
@state() private states: StateView[] = [];
|
||||
@state() private error: string | null = null;
|
||||
@state() private loading = true;
|
||||
|
||||
private client = new HomecoreClient({ token: resolveToken() });
|
||||
private timer?: number;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.refresh();
|
||||
this.timer = window.setInterval(() => void this.refresh(), 5000);
|
||||
}
|
||||
disconnectedCallback(): void {
|
||||
if (this.timer !== undefined) window.clearInterval(this.timer);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
try {
|
||||
this.states = await this.client.getStates();
|
||||
this.error = null;
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.error) return html`<div class="err">backend unreachable — ${this.error}</div>`;
|
||||
if (this.loading) return html`<div>loading…</div>`;
|
||||
return html`
|
||||
<h1>States (${this.states.length})</h1>
|
||||
<table>
|
||||
<thead><tr><th>entity_id</th><th>state</th><th>last_changed</th><th>attributes</th></tr></thead>
|
||||
<tbody>
|
||||
${this.states.map(s => html`
|
||||
<tr>
|
||||
<td>${s.entity_id}</td>
|
||||
<td class="state">${s.state}</td>
|
||||
<td>${s.last_changed.replace('T', ' ').replace(/\..*$/, '')}</td>
|
||||
<td class="attrs" title=${JSON.stringify(s.attributes)}>${JSON.stringify(s.attributes)}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'hc-states': StatesPage; } }
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* HOMECORE base styles — typography reset, page shell, nav layout.
|
||||
* Component vocabulary mirrors cognitum-v0 (ADR-131 §3–4).
|
||||
*/
|
||||
|
||||
@import './tokens.css';
|
||||
|
||||
/* ── Reset ── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
font-family: var(--hc-font-display);
|
||||
font-size: 16px;
|
||||
background: var(--hc-bg);
|
||||
color: var(--hc-text);
|
||||
}
|
||||
|
||||
body { min-height: 100dvh; }
|
||||
|
||||
/* ── Typography scale ── */
|
||||
h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
h2 { font-size: 1.125rem; font-weight: 700; letter-spacing: -0.02em; }
|
||||
h3 { font-size: 0.9375rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
h4 { font-size: 0.875rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
p { font-size: 0.875rem; line-height: 1.45; }
|
||||
|
||||
.mono { font-family: var(--hc-font-mono); }
|
||||
|
||||
/* ── Page shell ── */
|
||||
.hc-wrap {
|
||||
max-width: 1400px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
padding-block: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Appbar ── */
|
||||
.hc-appbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: hsl(220 25% 6% / 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--hc-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.hc-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
color: var(--hc-text);
|
||||
}
|
||||
|
||||
.hc-brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--hc-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary-fg);
|
||||
}
|
||||
|
||||
.hc-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hc-nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.hc-nav-link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: var(--hc-radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--hc-text-muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 150ms, background 150ms;
|
||||
}
|
||||
|
||||
.hc-nav-link:hover {
|
||||
color: var(--hc-text);
|
||||
background: hsl(220 20% 14%);
|
||||
}
|
||||
|
||||
.hc-nav-link:focus-visible {
|
||||
outline: 2px solid hsl(185 80% 50% / 0.6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.hc-nav-link:active { transform: translateY(1px); transition-duration: 50ms; }
|
||||
|
||||
.hc-nav-link.active {
|
||||
color: var(--hc-primary);
|
||||
}
|
||||
|
||||
.hc-nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0.7rem;
|
||||
right: 0.7rem;
|
||||
height: 2px;
|
||||
background: var(--hc-primary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.hc-card {
|
||||
background: var(--hc-gradient-card);
|
||||
border: 1px solid hsl(220 15% 18% / 0.5);
|
||||
border-radius: var(--hc-radius);
|
||||
box-shadow: var(--hc-shadow-card);
|
||||
padding: 1.25rem;
|
||||
transition: transform 200ms, border-color 200ms;
|
||||
}
|
||||
|
||||
.hc-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: hsl(185 80% 50% / 0.4);
|
||||
}
|
||||
|
||||
/* ── Badge ── */
|
||||
.hc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--hc-radius-pill);
|
||||
border: 1px solid var(--hc-border);
|
||||
font-family: var(--hc-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.hc-badge.online { color: var(--hc-accent); border-color: hsl(142 70% 50% / 0.4); }
|
||||
.hc-badge.offline { color: var(--hc-destructive); border-color: hsl(0 65% 50% / 0.4); }
|
||||
.hc-badge.warning { color: var(--hc-warning); border-color: hsl(38 80% 60% / 0.4); }
|
||||
|
||||
/* ── Button ── */
|
||||
.hc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: var(--hc-radius-sm);
|
||||
font-family: var(--hc-font-display);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--hc-border);
|
||||
background: hsl(220 20% 14%);
|
||||
color: var(--hc-text);
|
||||
cursor: pointer;
|
||||
transition: background 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.hc-btn:hover { background: hsl(220 20% 18%); }
|
||||
|
||||
.hc-btn.primary {
|
||||
background: var(--hc-primary);
|
||||
color: var(--hc-primary-fg);
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--hc-shadow-glow);
|
||||
}
|
||||
|
||||
.hc-btn.primary:hover { background: hsl(185 80% 55%); }
|
||||
|
||||
/* ── Section ── */
|
||||
.hc-section { margin-bottom: 1.5rem; }
|
||||
|
||||
.hc-section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--hc-text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Grid helpers ── */
|
||||
.hc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hc-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.hc-footer {
|
||||
border-top: 1px solid var(--hc-border);
|
||||
text-align: center;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--hc-text-muted);
|
||||
font-family: var(--hc-font-mono);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* HOMECORE design tokens — sourced from cognitum-v0 (ADR-131 §9).
|
||||
* 16 CSS custom properties: 4 surfaces + 2 text + 6 accent/status + 2 border/ring + 2 radius.
|
||||
* Dark-only; no light-mode overrides.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Surfaces (darkest → lightest within dark palette) ── */
|
||||
--hc-bg: hsl(220 25% 6%); /* #0b0e13 — page root */
|
||||
--hc-surface-card: hsl(220 20% 10%); /* #14171e — card fill */
|
||||
--hc-surface-elevated: hsl(220 20% 12%); /* #181c24 — raised panel */
|
||||
--hc-surface-overlay: hsl(220 20% 8%); /* #111318 — modal / sticky nav base */
|
||||
|
||||
/* ── Text ── */
|
||||
--hc-text: hsl(210 20% 92%); /* #e6eaee — primary body text */
|
||||
--hc-text-muted: hsl(215 15% 55%); /* #7b899d — secondary / labels / timestamps */
|
||||
|
||||
/* ── Accent palette ── */
|
||||
--hc-primary: hsl(185 80% 50%); /* #19d4e5 — teal: active nav, CTA border, focus ring */
|
||||
--hc-primary-fg: hsl(220 25% 6%); /* #0b0e13 — text on filled primary buttons */
|
||||
--hc-accent: hsl(142 70% 50%); /* #26d867 — green: success / secondary CTA */
|
||||
--hc-accent-fg: hsl(220 25% 6%); /* #0b0e13 — text on filled accent buttons */
|
||||
--hc-destructive: hsl(0 65% 50%); /* #d22c2c — error / danger */
|
||||
--hc-warning: hsl(38 80% 60%); /* #e69940 — warning / amber (elevated from inline) */
|
||||
|
||||
/* ── Borders & rings ── */
|
||||
--hc-border: hsl(220 15% 18%); /* #272b34 — subtle 1px border */
|
||||
--hc-ring: hsl(185 80% 50%); /* #19d4e5 — focus ring (same hue as primary) */
|
||||
|
||||
/* ── Radii ── */
|
||||
--hc-radius: 0.75rem; /* cards, modals */
|
||||
--hc-radius-sm: 0.4rem; /* buttons, inputs, chips */
|
||||
--hc-radius-pill: 9999px; /* badges, CTA pills */
|
||||
|
||||
/* ── Typography ── */
|
||||
--hc-font-display: 'Outfit', system-ui, sans-serif;
|
||||
--hc-font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* ── Shadows ── */
|
||||
--hc-shadow-card: 0 8px 32px -8px hsl(220 25% 2% / 0.8);
|
||||
--hc-shadow-glow: 0 0 60px -10px hsl(185 80% 50% / 0.3);
|
||||
|
||||
/* ── Gradients ── */
|
||||
--hc-gradient-card: linear-gradient(180deg, hsl(220 20% 12%) 0%, hsl(220 20% 8%) 100%);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8123',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Allow WASM async import via dynamic import()
|
||||
exclude: [],
|
||||
},
|
||||
// WASM async import support: vite handles .wasm?init natively
|
||||
assetsInclude: ['**/*.wasm'],
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: false,
|
||||
include: ['src/__tests__/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user