feat(homecore-frontend/p1): @ruvnet/homecore-frontend Lit+TS+Vite scaffold (3 tests)
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
68ae6c0bd6
commit
d91ffce1ad
|
|
@ -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>
|
||||
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,132 @@
|
|||
/**
|
||||
* `<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 {
|
||||
@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);
|
||||
}
|
||||
|
||||
.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">
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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,11 @@
|
|||
/**
|
||||
* 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';
|
||||
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -21,7 +21,7 @@ path = "src/lib.rs"
|
|||
|
||||
[features]
|
||||
default = []
|
||||
ruvector = []
|
||||
ruvector = ["dep:ruvector-core", "dep:sha2"]
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
|
@ -53,5 +53,9 @@ tracing = "0.1"
|
|||
# Trait objects for SemanticIndex
|
||||
async-trait = "0.1"
|
||||
|
||||
# P2: ruvector-core HNSW index + sha2 for hash-based embeddings (ruvector feature)
|
||||
ruvector-core = { version = "2.2.0", optional = true, default-features = false }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use async_trait::async_trait;
|
|||
use chrono::{DateTime, Utc};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::debug;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
|
|
@ -41,12 +42,30 @@ pub enum RecorderError {
|
|||
///
|
||||
/// The no-op [`NullSemanticIndex`] is used in P1. P2 ships a ruvector-backed
|
||||
/// implementation behind the `ruvector` feature flag.
|
||||
///
|
||||
/// ## P2 API change
|
||||
///
|
||||
/// The `insert_state` method now accepts a `state_id` (SQLite rowid) so the
|
||||
/// HNSW index can map vector results back to SQLite rows. `search` embeds a
|
||||
/// free-text query and returns `(state_id, score)` pairs.
|
||||
#[async_trait]
|
||||
pub trait SemanticIndex: Send + Sync {
|
||||
/// Index a new state write. Called after the SQLite insert succeeds.
|
||||
/// Implementations must be infallible from the caller's perspective:
|
||||
/// if the index is unavailable the recorder keeps running.
|
||||
async fn index_state(&self, state: &Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Insert an embedding for `state` keyed by its SQLite `state_id`.
|
||||
/// Called after the SQLite insert succeeds. Must not propagate errors
|
||||
/// back to the recorder — failure is logged, not fatal.
|
||||
async fn insert_state(
|
||||
&mut self,
|
||||
state_id: i64,
|
||||
state: &State,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
/// Search for the `k` nearest states to the free-text `query`.
|
||||
/// Returns `(state_id, score)` pairs sorted by ascending distance.
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<(i64, f32)>, Box<dyn std::error::Error + Send + Sync>>;
|
||||
}
|
||||
|
||||
/// No-op `SemanticIndex`. Used by default when the `ruvector` feature is off.
|
||||
|
|
@ -54,17 +73,33 @@ pub struct NullSemanticIndex;
|
|||
|
||||
#[async_trait]
|
||||
impl SemanticIndex for NullSemanticIndex {
|
||||
async fn index_state(&self, _state: &Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
async fn insert_state(
|
||||
&mut self,
|
||||
_state_id: i64,
|
||||
_state: &State,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
_query: &str,
|
||||
_k: usize,
|
||||
) -> Result<Vec<(i64, f32)>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorder. Cheap to clone (Arc-backed pool). Pass copies to the
|
||||
/// `RecorderListener` and the API history handler.
|
||||
///
|
||||
/// The `semantic` field is wrapped in `Arc<RwLock<...>>` so that
|
||||
/// `insert_state` (which takes `&mut self` on the trait) can be called
|
||||
/// without requiring `&mut Recorder` from callers.
|
||||
#[derive(Clone)]
|
||||
pub struct Recorder {
|
||||
pool: SqlitePool,
|
||||
semantic: Arc<dyn SemanticIndex>,
|
||||
semantic: Arc<RwLock<dyn SemanticIndex>>,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
|
|
@ -75,13 +110,13 @@ impl Recorder {
|
|||
/// The schema DDL uses `CREATE TABLE IF NOT EXISTS` so calling this on an
|
||||
/// existing database is safe.
|
||||
pub async fn open(path: &str) -> Result<Self, RecorderError> {
|
||||
Self::open_with_index(path, Arc::new(NullSemanticIndex)).await
|
||||
Self::open_with_index(path, Arc::new(RwLock::new(NullSemanticIndex))).await
|
||||
}
|
||||
|
||||
/// Open with a custom `SemanticIndex` (P2 entry point).
|
||||
pub async fn open_with_index(
|
||||
path: &str,
|
||||
semantic: Arc<dyn SemanticIndex>,
|
||||
semantic: Arc<RwLock<dyn SemanticIndex>>,
|
||||
) -> Result<Self, RecorderError> {
|
||||
let options = path
|
||||
.parse::<SqliteConnectOptions>()
|
||||
|
|
@ -172,13 +207,80 @@ impl Recorder {
|
|||
let state_id = result.last_insert_rowid();
|
||||
|
||||
// Best-effort semantic indexing — failure is logged, not propagated.
|
||||
if let Err(e) = self.semantic.index_state(new_state).await {
|
||||
tracing::warn!(error = %e, entity_id = %new_state.entity_id, "semantic indexing failed");
|
||||
if let Err(e) = self
|
||||
.semantic
|
||||
.write()
|
||||
.await
|
||||
.insert_state(state_id, new_state)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
entity_id = %new_state.entity_id,
|
||||
"semantic indexing failed"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Some(state_id))
|
||||
}
|
||||
|
||||
/// Search for state history rows that semantically match `query`.
|
||||
///
|
||||
/// Uses the HNSW index to find the top-`k` nearest state embeddings,
|
||||
/// then fetches the full `StateRow` from SQLite for each result.
|
||||
/// Returns rows in ascending score (distance) order.
|
||||
///
|
||||
/// With the default `NullSemanticIndex` (no `ruvector` feature) this
|
||||
/// always returns an empty `Vec`.
|
||||
pub async fn search_semantic(
|
||||
&self,
|
||||
query: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<StateRow>, RecorderError> {
|
||||
let hits = self
|
||||
.semantic
|
||||
.read()
|
||||
.await
|
||||
.search(query, k)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut rows = Vec::with_capacity(hits.len());
|
||||
for (state_id, _score) in hits {
|
||||
let row: Option<(String, String, Option<String>, f64, f64, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT s.entity_id, s.state, sa.shared_attrs, \
|
||||
s.last_changed_ts, s.last_updated_ts, s.context_id \
|
||||
FROM states s \
|
||||
LEFT JOIN state_attributes sa ON s.attributes_id = sa.attributes_id \
|
||||
WHERE s.state_id = ?",
|
||||
)
|
||||
.bind(state_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some((entity_id, state, shared_attrs, last_changed_ts, last_updated_ts, context_id)) = row {
|
||||
let eid = EntityId::parse(&entity_id)
|
||||
.unwrap_or_else(|_| EntityId::parse("unknown.unknown").unwrap());
|
||||
let attributes = shared_attrs
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()?
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
rows.push(StateRow {
|
||||
state_id,
|
||||
entity_id: eid,
|
||||
state,
|
||||
attributes,
|
||||
last_changed_ts,
|
||||
last_updated_ts,
|
||||
context_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Persist a `DomainEvent`. Returns the `event_id`.
|
||||
pub async fn record_event(&self, event: &DomainEvent) -> Result<i64, RecorderError> {
|
||||
let data_json = serde_json::to_string(&event.event_data)?;
|
||||
|
|
|
|||
|
|
@ -1,54 +1,273 @@
|
|||
//! Semantic indexing for state attributes — P2 ruvector integration point.
|
||||
//! Ruvector-backed semantic index — ADR-132 P2.
|
||||
//!
|
||||
//! This module is **feature-gated** (`--features ruvector`). The trait
|
||||
//! `SemanticIndex` is defined in [`crate::db`] so it is always available.
|
||||
//! This module provides the ruvector-backed implementation that will ship
|
||||
//! once the embedding model boundary is finalised in P2.
|
||||
//! ## Embedding strategy (P2 — hash-based)
|
||||
//!
|
||||
//! ## P2 plan
|
||||
//! To keep the recorder self-contained and avoid an ML model dependency at P2,
|
||||
//! state attributes are embedded by a deterministic SHA-256 hash procedure:
|
||||
//!
|
||||
//! 1. Add `ruvector-core` + `ruvector-attention` as optional dependencies.
|
||||
//! 2. Implement `RuvectorSemanticIndex` here, embedding the serialised
|
||||
//! `State.attributes` JSON into a fixed-dimension vector and inserting
|
||||
//! it into a ruvector HNSW index keyed by `state_id`.
|
||||
//! 3. Expose a `search(query: &str, k: usize) -> Vec<StateRow>` helper on
|
||||
//! `Recorder` that converts the query string to an embedding and calls
|
||||
//! `ruvector_core::HnswIndex::search`.
|
||||
//! 1. Canonicalise the state as `"{entity_id}={state}|{attributes_json}"`.
|
||||
//! 2. SHA-256 hash → 32 bytes.
|
||||
//! 3. Interpret the 32 bytes as 8 × `i32` (big-endian), cast to `f32`.
|
||||
//! 4. L2-normalise the resulting 8-element vector.
|
||||
//!
|
||||
//! ## Why deferred
|
||||
//! This gives stable, reproducible 8-dimensional unit vectors suitable for
|
||||
//! cosine-distance HNSW search. Semantic similarity is **not** captured (two
|
||||
//! states with the same value but different entity IDs will differ). P3 will
|
||||
//! replace this with a learned sentence-embedding via `ruvector-attention`.
|
||||
//!
|
||||
//! The embedding model boundary (which model, what dimension, cosine vs
|
||||
//! dot-product) is still TBD as of ADR-132. Shipping a concrete
|
||||
//! implementation now would couple the recorder to a specific ruvector
|
||||
//! version that may need to change once the embedding model is chosen.
|
||||
//! The no-op `NullSemanticIndex` in P1 keeps the interface stable without
|
||||
//! locking in that choice.
|
||||
|
||||
use std::sync::Arc;
|
||||
//! ## P3 plan
|
||||
//!
|
||||
//! Replace `embed_bytes` with a call to
|
||||
//! `ruvector_attention::SentenceEmbedding::encode(&text)` for true semantic
|
||||
//! similarity. Increase `EMBEDDING_DIM` to 384 at that point.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use homecore::entity::State;
|
||||
use ruvector_core::{
|
||||
types::{DbOptions, DistanceMetric, HnswConfig, SearchQuery, VectorEntry},
|
||||
VectorDB,
|
||||
};
|
||||
|
||||
use crate::db::SemanticIndex;
|
||||
|
||||
/// Stub ruvector-backed semantic index.
|
||||
/// Dimensionality of the hash-based embedding vectors.
|
||||
///
|
||||
/// Will be replaced by a real implementation in P2 once the embedding
|
||||
/// model boundary is confirmed. Currently logs and no-ops.
|
||||
pub struct RuvectorSemanticIndex;
|
||||
/// 8 dimensions: each SHA-256 chunk of 4 bytes becomes one `f32` component.
|
||||
/// Increase to 384 in P3 when switching to learned embeddings.
|
||||
pub const EMBEDDING_DIM: usize = 8;
|
||||
|
||||
/// Ruvector-backed `SemanticIndex` using in-memory HNSW and hash embeddings.
|
||||
///
|
||||
/// The index lives entirely in process memory. A restart clears it; P3 will
|
||||
/// add persistence via `ruvector-core`'s `storage` feature.
|
||||
pub struct RuvectorSemanticIndex {
|
||||
db: VectorDB,
|
||||
}
|
||||
|
||||
impl RuvectorSemanticIndex {
|
||||
/// Create a new in-memory HNSW index with the given `max_elements` capacity.
|
||||
///
|
||||
/// Uses cosine distance to match the unit-normalised hash embeddings.
|
||||
pub fn new(max_elements: usize) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let options = DbOptions {
|
||||
dimensions: EMBEDDING_DIM,
|
||||
distance_metric: DistanceMetric::Cosine,
|
||||
// storage path is ignored when the `storage` feature is off
|
||||
storage_path: ":memory:".to_string(),
|
||||
hnsw_config: Some(HnswConfig {
|
||||
m: 16,
|
||||
ef_construction: 100,
|
||||
ef_search: 50,
|
||||
max_elements,
|
||||
}),
|
||||
quantization: None,
|
||||
};
|
||||
let db = VectorDB::new(options)?;
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
/// Embed a `State` to a deterministic 8-dimensional unit vector.
|
||||
///
|
||||
/// Canonical form: `"{entity_id}={state}|{attributes_json}"`
|
||||
/// The attributes JSON is sorted-key (via `serde_json`'s default ordering
|
||||
/// of `Map`, which preserves insertion order). For strict canonicalisation
|
||||
/// at P3, sort keys explicitly.
|
||||
pub fn embed_state(state: &State) -> Vec<f32> {
|
||||
let attrs = state.attributes.to_string();
|
||||
let input = format!("{}={}|{}", state.entity_id, state.state, attrs);
|
||||
Self::embed_str(&input)
|
||||
}
|
||||
|
||||
/// Embed an arbitrary string to a deterministic 8-dimensional unit vector.
|
||||
pub fn embed_str(input: &str) -> Vec<f32> {
|
||||
embed_bytes(input.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 → 8 × f32 unit vector.
|
||||
///
|
||||
/// Split the 32-byte digest into 8 chunks of 4 bytes. Interpret each chunk
|
||||
/// as a big-endian `i32`, cast to `f32`, then L2-normalise.
|
||||
fn embed_bytes(data: &[u8]) -> Vec<f32> {
|
||||
let digest = Sha256::digest(data);
|
||||
let mut raw: Vec<f32> = digest
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| {
|
||||
let bytes: [u8; 4] = chunk.try_into().expect("chunk is exactly 4 bytes");
|
||||
i32::from_be_bytes(bytes) as f32
|
||||
})
|
||||
.collect();
|
||||
|
||||
// L2-normalise
|
||||
let norm = raw.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-10 {
|
||||
for v in &mut raw {
|
||||
*v /= norm;
|
||||
}
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SemanticIndex for RuvectorSemanticIndex {
|
||||
async fn index_state(
|
||||
&self,
|
||||
state: &Arc<State>,
|
||||
async fn insert_state(
|
||||
&mut self,
|
||||
state_id: i64,
|
||||
state: &State,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// P2 TODO: embed state.attributes JSON → f32 vector → ruvector insert.
|
||||
tracing::debug!(
|
||||
entity_id = %state.entity_id,
|
||||
"ruvector semantic index: P2 stub — not yet implemented"
|
||||
);
|
||||
let vector = Self::embed_state(state);
|
||||
let entry = VectorEntry {
|
||||
id: Some(state_id.to_string()),
|
||||
vector,
|
||||
metadata: None,
|
||||
};
|
||||
self.db.insert(entry)?;
|
||||
tracing::debug!(state_id, entity_id = %state.entity_id, "semantic index: inserted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<(i64, f32)>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let vector = Self::embed_str(query);
|
||||
let results = self.db.search(SearchQuery {
|
||||
vector,
|
||||
k,
|
||||
filter: None,
|
||||
ef_search: None,
|
||||
})?;
|
||||
let hits = results
|
||||
.into_iter()
|
||||
.filter_map(|r| r.id.parse::<i64>().ok().map(|id| (id, r.score)))
|
||||
.collect();
|
||||
Ok(hits)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use homecore::entity::{EntityId, State};
|
||||
use homecore::event::Context;
|
||||
|
||||
use super::*;
|
||||
use crate::db::{Recorder, SemanticIndex};
|
||||
|
||||
fn make_state(entity_id: &str, state_val: &str, attrs: serde_json::Value) -> State {
|
||||
let eid = EntityId::parse(entity_id).unwrap();
|
||||
let ctx = Context::new();
|
||||
State::new(eid, state_val, attrs, ctx)
|
||||
}
|
||||
|
||||
// ── embed_state ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn embed_state_is_deterministic() {
|
||||
let s = make_state("light.kitchen", "on", serde_json::json!({"brightness": 200}));
|
||||
let v1 = RuvectorSemanticIndex::embed_state(&s);
|
||||
let v2 = RuvectorSemanticIndex::embed_state(&s);
|
||||
assert_eq!(v1, v2, "same input must produce identical embedding");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_state_is_unit_norm() {
|
||||
let s = make_state("sensor.temp", "22.5", serde_json::json!({"unit": "C"}));
|
||||
let v = RuvectorSemanticIndex::embed_state(&s);
|
||||
let norm_sq: f32 = v.iter().map(|x| x * x).sum();
|
||||
assert!(
|
||||
(norm_sq - 1.0).abs() < 1e-5,
|
||||
"embedding must be unit-norm, got norm^2={norm_sq}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_state_dim_is_correct() {
|
||||
let s = make_state("binary_sensor.door", "off", serde_json::json!({}));
|
||||
let v = RuvectorSemanticIndex::embed_state(&s);
|
||||
assert_eq!(v.len(), EMBEDDING_DIM);
|
||||
}
|
||||
|
||||
// ── RuvectorSemanticIndex insert + search ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_then_search_finds_state() {
|
||||
let mut idx = RuvectorSemanticIndex::new(1000).unwrap();
|
||||
let state = make_state("light.living_room", "on", serde_json::json!({"brightness": 255}));
|
||||
idx.insert_state(42, &state).await.unwrap();
|
||||
|
||||
// Query the same canonical string used by embed_state
|
||||
let query = format!(
|
||||
"{}={}|{}",
|
||||
state.entity_id, state.state, state.attributes
|
||||
);
|
||||
let hits = idx.search(&query, 5).await.unwrap();
|
||||
assert!(!hits.is_empty(), "search must return at least one hit");
|
||||
assert_eq!(hits[0].0, 42, "top hit must be the inserted state_id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_ordering_closer_entity_ranks_first() {
|
||||
let mut idx = RuvectorSemanticIndex::new(1000).unwrap();
|
||||
|
||||
let s_a = make_state("light.office", "on", serde_json::json!({"brightness": 100}));
|
||||
let s_b = make_state("switch.fan", "off", serde_json::json!({}));
|
||||
|
||||
idx.insert_state(1, &s_a).await.unwrap();
|
||||
idx.insert_state(2, &s_b).await.unwrap();
|
||||
|
||||
// Query identical to s_a's canonical form → s_a must rank first
|
||||
let query_a = format!("{}={}|{}", s_a.entity_id, s_a.state, s_a.attributes);
|
||||
let hits = idx.search(&query_a, 2).await.unwrap();
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert_eq!(
|
||||
hits[0].0, 1,
|
||||
"state matching the query must rank first; got {:?}",
|
||||
hits
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recorder end-to-end with RuvectorSemanticIndex ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn recorder_search_semantic_returns_recorded_state() {
|
||||
use homecore::event::StateChangedEvent;
|
||||
use chrono::Utc;
|
||||
|
||||
let idx = Arc::new(RwLock::new(
|
||||
RuvectorSemanticIndex::new(1000).unwrap(),
|
||||
));
|
||||
let semantic: Arc<RwLock<dyn SemanticIndex>> = idx;
|
||||
let recorder = Recorder::open_with_index("sqlite::memory:", semantic)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = Arc::new(make_state(
|
||||
"sensor.humidity",
|
||||
"65",
|
||||
serde_json::json!({"unit": "%"}),
|
||||
));
|
||||
let event = StateChangedEvent {
|
||||
entity_id: state.entity_id.clone(),
|
||||
old_state: None,
|
||||
new_state: Some(state.clone()),
|
||||
fired_at: Utc::now(),
|
||||
};
|
||||
let state_id = recorder.record_state(&event).await.unwrap().unwrap();
|
||||
|
||||
// Query using the entity prefix — close enough embedding to find it
|
||||
let query = format!("{}={}|{}", state.entity_id, state.state, state.attributes);
|
||||
let rows = recorder.search_semantic(&query, 5).await.unwrap();
|
||||
assert!(!rows.is_empty(), "search_semantic must return at least one row");
|
||||
assert_eq!(
|
||||
rows[0].state_id, state_id,
|
||||
"returned row must match the recorded state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue