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:
ruv
2026-05-27 12:20:49 -04:00
parent 224689a5bc
commit 04f205a05e
26 changed files with 14 additions and 0 deletions
+14
View File
@@ -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 Assistantstyle 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.
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.vite/
*.tsbuildinfo
coverage/
+69
View File
@@ -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.
+18
View File
@@ -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>
+4429
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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/);
});
});
+132
View File
@@ -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'));
});
}
}
+98
View File
@@ -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 &mdash; ${this.locationName} &mdash; 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; } }
+112
View File
@@ -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;
}
}
+39
View File
@@ -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>`;
}
+42
View File
@@ -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);
});
});
+282
View File
@@ -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;
}
}
+272
View File
@@ -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; } }
+208
View File
@@ -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 &amp; 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; } }
+85
View File
@@ -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; } }
+224
View File
@@ -0,0 +1,224 @@
/**
* HOMECORE base styles — typography reset, page shell, nav layout.
* Component vocabulary mirrors cognitum-v0 (ADR-131 §34).
*/
@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);
}
+45
View File
@@ -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%);
}
+23
View File
@@ -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"]
}
+25
View File
@@ -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'],
});
+13
View File
@@ -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'],
},
},
});