mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
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:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
@@ -0,0 +1,69 @@
|
||||
# @ruvnet/homecore-frontend
|
||||
|
||||
HOMECORE web UI — built with Lit 3, TypeScript, and Vite.
|
||||
Design system mirrors the cognitum-v0 / v0-appliance dashboard (ADR-131).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/api` → `http://localhost:8123`, so you need a
|
||||
`homecore-api-server` (or the `wifi-densepose-sensing-server` crate) running on `:8123`.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm run dev` | Start Vite dev server on port 5173 |
|
||||
| `npm run build` | TypeScript compile + Vite production bundle → `dist/` |
|
||||
| `npm run lint` | ESLint on `src/` |
|
||||
| `npm test` | Vitest unit tests (3 suites, jsdom) |
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
src/
|
||||
api/
|
||||
client.ts # fetch + WebSocket client (REST + WS)
|
||||
types.ts # TypeScript types matching homecore-api JSON shapes
|
||||
components/
|
||||
AppShell.ts # <hc-app-shell> — header + nav + content slot
|
||||
StateCard.ts # <hc-state-card> — single entity state card
|
||||
icons/
|
||||
lucide.ts # Tree-shaken Lucide icon wrapper
|
||||
styles/
|
||||
tokens.css # 16 CSS custom properties (--hc-*)
|
||||
base.css # Typography reset, page shell, nav layout
|
||||
__tests__/ # Vitest unit tests
|
||||
index.html # Shell loading src/main.ts
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
```
|
||||
|
||||
## Design system
|
||||
|
||||
Colors, typography, and components mirror the cognitum-v0 dashboard
|
||||
(`http://cognitum-v0:9000/`). Dark-only; no light-mode. Key tokens:
|
||||
|
||||
- `--hc-primary` `#19d4e5` — teal (active nav, focus ring, CTA borders)
|
||||
- `--hc-accent` `#26d867` — green (success, secondary CTA)
|
||||
- `--hc-bg` `#0b0e13` — near-black navy page root
|
||||
- Font: Outfit (display) + JetBrains Mono (mono)
|
||||
- Icons: Lucide (SVG, `stroke: currentColor`, no icon font)
|
||||
|
||||
See `docs/design/HOMECORE-FRONTEND-design-recon.md` for the full recon.
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- Components are standard Lit `LitElement` custom elements — compatible with
|
||||
any HTML page and with Home Assistant's Lit-based frontend.
|
||||
- The REST client uses `fetch`; the WS client uses `WebSocket`. Both accept a
|
||||
bearer token and are fully typed against the Rust `homecore-api` JSON shapes.
|
||||
- WASM: `vite.config.ts` enables `.wasm` asset import. Hook up via dynamic
|
||||
`import('/path/to/module.wasm?init')` when WASM bindings are ready.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>HOMECORE</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<hc-app-shell></hc-app-shell>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4429
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@ruvnet/homecore-frontend",
|
||||
"version": "0.1.0-alpha.0",
|
||||
"description": "HOMECORE web UI — Lit + TypeScript + Vite, cognitum-v0 design system",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.2.1",
|
||||
"lucide": "^0.474.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"eslint": "^9.17.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Unit tests for <hc-state-card>.
|
||||
* Verifies that the component renders entity_id and state value into the DOM.
|
||||
*
|
||||
* Uses jsdom (via vitest environment) — no real browser required.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import type { StateView } from '../api/types.js';
|
||||
|
||||
// Register the custom element before tests run
|
||||
beforeAll(async () => {
|
||||
// jsdom does not support Lit's adoptedStyleSheets; suppress the error.
|
||||
if (typeof document !== 'undefined' && !document.adoptedStyleSheets) {
|
||||
Object.defineProperty(document, 'adoptedStyleSheets', { value: [], writable: true });
|
||||
}
|
||||
await import('../components/StateCard.js');
|
||||
});
|
||||
|
||||
function makeState(overrides: Partial<StateView> = {}): StateView {
|
||||
return {
|
||||
entity_id: 'light.living_room',
|
||||
state: 'on',
|
||||
attributes: { brightness: 255 },
|
||||
last_changed: '2026-05-25T10:00:00Z',
|
||||
last_updated: '2026-05-25T10:00:00Z',
|
||||
context: { id: 'abc123', user_id: null, parent_id: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('StateCard', () => {
|
||||
it('renders entity_id in the DOM', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState();
|
||||
document.body.appendChild(el);
|
||||
|
||||
// Lit renders synchronously into shadow root after a microtask
|
||||
await el.updateComplete;
|
||||
|
||||
const shadowRoot = el.shadowRoot!;
|
||||
const entityEl = shadowRoot.querySelector('.entity-id');
|
||||
expect(entityEl).not.toBeNull();
|
||||
expect(entityEl!.textContent).toContain('light.living_room');
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
|
||||
it('renders the state value', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState({ state: 'off' });
|
||||
document.body.appendChild(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
const stateEl = el.shadowRoot!.querySelector('.state-value');
|
||||
expect(stateEl).not.toBeNull();
|
||||
expect(stateEl!.textContent).toBe('off');
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
|
||||
it('applies .off badge class for unavailable state', async () => {
|
||||
const el = document.createElement('hc-state-card') as HTMLElement & { state: StateView };
|
||||
el.state = makeState({ state: 'unavailable' });
|
||||
document.body.appendChild(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
const badge = el.shadowRoot!.querySelector('.badge.off');
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
});
|
||||
|
||||
// Augment for updateComplete
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
updateComplete: Promise<boolean>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Unit tests for HomecoreClient REST methods.
|
||||
* Mocks global `fetch` and asserts correct URL + Authorization header.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HomecoreClient } from '../api/client.js';
|
||||
|
||||
describe('HomecoreClient', () => {
|
||||
const token = 'test-bearer-token';
|
||||
let client: HomecoreClient;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new HomecoreClient({ token });
|
||||
fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('getStates() GETs /api/states with the bearer header', async () => {
|
||||
await client.getStates();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
expect(url).toBe('/api/states');
|
||||
expect((init.headers as Record<string, string>)['Authorization']).toBe(`Bearer ${token}`);
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('getState() GETs /api/states/:entity_id with the bearer header', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ entity_id: 'light.living', state: 'on', attributes: {}, last_changed: '', last_updated: '', context: { id: 'x', user_id: null, parent_id: null } }),
|
||||
} as Response);
|
||||
|
||||
await client.getState('light.living');
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/states/light.living');
|
||||
});
|
||||
|
||||
it('getConfig() GETs /api/config', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ location_name: 'Home', version: '0.1.0', state: 'RUNNING', components: [] }),
|
||||
} as Response);
|
||||
|
||||
await client.getConfig();
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('/api/config');
|
||||
});
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized' } as Response);
|
||||
|
||||
await expect(client.getStates()).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Validates that tokens.css contains all 16 documented HOMECORE design tokens.
|
||||
* Reads the file from disk and checks for each CSS custom property name.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const tokensPath = resolve(__dirname, '../styles/tokens.css');
|
||||
const css = readFileSync(tokensPath, 'utf-8');
|
||||
|
||||
/**
|
||||
* The 16 design tokens from ADR-131 §9 / HOMECORE-FRONTEND-design-recon.md §1.
|
||||
* 4 surfaces + 2 text + 6 accent/status + 2 border/ring + 2 radius = 16 tokens.
|
||||
*/
|
||||
const REQUIRED_TOKENS = [
|
||||
// Surfaces (4)
|
||||
'--hc-bg',
|
||||
'--hc-surface-card',
|
||||
'--hc-surface-elevated',
|
||||
'--hc-surface-overlay',
|
||||
// Text (2)
|
||||
'--hc-text',
|
||||
'--hc-text-muted',
|
||||
// Accent palette (6)
|
||||
'--hc-primary',
|
||||
'--hc-primary-fg',
|
||||
'--hc-accent',
|
||||
'--hc-accent-fg',
|
||||
'--hc-destructive',
|
||||
'--hc-warning',
|
||||
// Borders & rings (2)
|
||||
'--hc-border',
|
||||
'--hc-ring',
|
||||
// Radii (2)
|
||||
'--hc-radius',
|
||||
'--hc-radius-sm',
|
||||
] as const;
|
||||
|
||||
describe('tokens.css', () => {
|
||||
it('contains all 16 documented design tokens', () => {
|
||||
for (const token of REQUIRED_TOKENS) {
|
||||
expect(css, `Missing token: ${token}`).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('has exactly 16 (or more) --hc- custom properties', () => {
|
||||
const matches = css.match(/--hc-[\w-]+\s*:/g) ?? [];
|
||||
// De-duplicate (token may appear in comments)
|
||||
const unique = new Set(matches.map(m => m.replace(/\s*:/, '')));
|
||||
expect(unique.size).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('defines the teal primary token with the correct hue value', () => {
|
||||
// --hc-primary must reference HSL hue 185 (teal, from cognitum-v0)
|
||||
expect(css).toMatch(/--hc-primary\s*:\s*hsl\(185/);
|
||||
});
|
||||
|
||||
it('defines the green accent token (#26d867)', () => {
|
||||
// --hc-accent must reference HSL 142 70% 50%
|
||||
expect(css).toMatch(/--hc-accent\s*:\s*hsl\(142/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* HOMECORE API client.
|
||||
*
|
||||
* REST: fetch-based, bearer token auth. Base URL defaults to window.location.origin
|
||||
* so the Vite dev-server proxy handles the `/api` → `:8123` rewrite.
|
||||
* WS: native WebSocket, mirrors HA's ws handshake protocol (auth_required → auth → auth_ok).
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiConfig,
|
||||
ServiceDomainView,
|
||||
StateView,
|
||||
WsAuthOk,
|
||||
WsAuthRequired,
|
||||
WsServerMessage,
|
||||
} from './types.js';
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export class HomecoreClient {
|
||||
private readonly base: string;
|
||||
private readonly token: string;
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
this.base = options.baseUrl ?? '';
|
||||
this.token = options.token;
|
||||
}
|
||||
|
||||
// ── REST helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private headers(): HeadersInit {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${this.base}${path}`, {
|
||||
method: 'GET',
|
||||
headers: this.headers(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`GET ${path} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const resp = await fetch(`${this.base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`POST ${path} → ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── REST endpoints (mirrors rest.rs) ─────────────────────────────────────
|
||||
|
||||
getConfig(): Promise<ApiConfig> {
|
||||
return this.get<ApiConfig>('/api/config');
|
||||
}
|
||||
|
||||
getStates(): Promise<StateView[]> {
|
||||
return this.get<StateView[]>('/api/states');
|
||||
}
|
||||
|
||||
getState(entityId: string): Promise<StateView> {
|
||||
return this.get<StateView>(`/api/states/${encodeURIComponent(entityId)}`);
|
||||
}
|
||||
|
||||
setState(entityId: string, state: string, attributes?: Record<string, unknown>): Promise<StateView> {
|
||||
return this.post<StateView>(`/api/states/${encodeURIComponent(entityId)}`, {
|
||||
state,
|
||||
attributes: attributes ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
getServices(): Promise<ServiceDomainView[]> {
|
||||
return this.get<ServiceDomainView[]>('/api/services');
|
||||
}
|
||||
|
||||
callService(domain: string, service: string, data?: unknown): Promise<unknown> {
|
||||
return this.post<unknown>(`/api/services/${domain}/${service}`, data ?? {});
|
||||
}
|
||||
|
||||
// ── WebSocket ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open an authenticated WebSocket connection.
|
||||
* Resolves once `auth_ok` is received; rejects on auth failure or network error.
|
||||
* Returns the live socket; caller is responsible for `.close()`.
|
||||
*/
|
||||
openWebSocket(wsBase?: string): Promise<WebSocket> {
|
||||
const resolved = wsBase ?? this.base.replace(/^http/, 'ws');
|
||||
const origin = resolved || window.location.origin.replace(/^http/, 'ws');
|
||||
const url = `${origin}/api/websocket`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onmessage = (evt: MessageEvent<string>) => {
|
||||
const msg = JSON.parse(evt.data) as WsServerMessage;
|
||||
|
||||
if ((msg as WsAuthRequired).type === 'auth_required') {
|
||||
ws.send(JSON.stringify({ type: 'auth', access_token: this.token }));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((msg as WsAuthOk).type === 'auth_ok') {
|
||||
ws.onmessage = null;
|
||||
resolve(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'auth_invalid') {
|
||||
ws.close();
|
||||
reject(new Error(`WS auth_invalid`));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => reject(new Error('WebSocket connection error'));
|
||||
ws.onclose = () => reject(new Error('WebSocket closed before auth_ok'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* TypeScript types mirroring the JSON shapes from homecore-api/src/rest.rs and ws.rs.
|
||||
* Keep in sync with Rust `StateView`, `ApiConfig`, `ServiceDomainView`.
|
||||
*/
|
||||
|
||||
/** Context for a state change — mirrors Rust `ContextView`. */
|
||||
export interface ContextView {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
parent_id: string | null;
|
||||
}
|
||||
|
||||
/** Snapshot of a single entity state — mirrors Rust `StateView`. */
|
||||
export interface StateView {
|
||||
entity_id: string;
|
||||
state: string;
|
||||
/** Arbitrary JSON attributes attached to the entity. */
|
||||
attributes: Record<string, unknown>;
|
||||
/** RFC 3339 timestamp of last state value change. */
|
||||
last_changed: string;
|
||||
/** RFC 3339 timestamp of last update (attributes may have changed). */
|
||||
last_updated: string;
|
||||
context: ContextView;
|
||||
}
|
||||
|
||||
/** HOMECORE configuration — mirrors Rust `ApiConfig`. */
|
||||
export interface ApiConfig {
|
||||
location_name: string;
|
||||
version: string;
|
||||
state: 'RUNNING' | 'STARTING' | 'STOPPING';
|
||||
components: string[];
|
||||
}
|
||||
|
||||
/** Services grouped by domain — mirrors Rust `ServiceDomainView`. */
|
||||
export interface ServiceDomainView {
|
||||
domain: string;
|
||||
/** Keyed by service name; value is the service schema (may be empty `{}`). */
|
||||
services: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── WebSocket protocol types ──────────────────────────────────────────────────
|
||||
|
||||
/** Sent by server immediately upon WS upgrade. */
|
||||
export interface WsAuthRequired {
|
||||
type: 'auth_required';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/** Sent by client to authenticate. */
|
||||
export interface WsAuth {
|
||||
type: 'auth';
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
/** Sent by server on successful auth. */
|
||||
export interface WsAuthOk {
|
||||
type: 'auth_ok';
|
||||
ha_version: string;
|
||||
}
|
||||
|
||||
/** Sent by server on failed auth. */
|
||||
export interface WsAuthInvalid {
|
||||
type: 'auth_invalid';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Generic result message from server. */
|
||||
export interface WsResult<T = unknown> {
|
||||
id: number;
|
||||
type: 'result';
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
/** State-changed event pushed by server via `subscribe_events`. */
|
||||
export interface WsStateChangedEvent {
|
||||
id: number;
|
||||
type: 'event';
|
||||
event: {
|
||||
event_type: 'state_changed';
|
||||
data: {
|
||||
entity_id: string;
|
||||
old_state: StateView | null;
|
||||
new_state: StateView | null;
|
||||
};
|
||||
origin: 'LOCAL' | 'REMOTE';
|
||||
time_fired: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Union of all inbound WS server messages. */
|
||||
export type WsServerMessage =
|
||||
| WsAuthRequired
|
||||
| WsAuthOk
|
||||
| WsAuthInvalid
|
||||
| WsResult
|
||||
| WsStateChangedEvent;
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* `<hc-app-shell>` — top-level layout: sticky header + horizontal sidenav + content slot.
|
||||
* Page shell mirrors cognitum-v0's appbar + wrap layout (ADR-131 §3).
|
||||
*/
|
||||
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Raw SVG string for the icon */
|
||||
iconSvg?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NAV: NavItem[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'states', label: 'States' },
|
||||
{ id: 'services', label: 'Services' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
@customElement('hc-app-shell')
|
||||
export class AppShell extends LitElement {
|
||||
@property({ type: String }) locationName = 'HOMECORE';
|
||||
@property({ type: String }) version = '0.1.0';
|
||||
@property({ type: Array }) navItems: NavItem[] = DEFAULT_NAV;
|
||||
@state() private activeId = 'dashboard';
|
||||
|
||||
static styles = css`
|
||||
:host { display: block; min-height: 100dvh; background: var(--hc-bg, #0b0e13); }
|
||||
|
||||
/* ── Appbar ── */
|
||||
.appbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: hsl(220 25% 6% / 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid hsl(220 15% 18% / 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--hc-text, #e6eaee);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hc-primary-fg, #0b0e13);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
font-family: var(--hc-font-display, 'Outfit', system-ui, sans-serif);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 150ms, background 150ms;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--hc-text, #e6eaee);
|
||||
background: hsl(220 20% 14%);
|
||||
}
|
||||
|
||||
.nav-link:focus-visible {
|
||||
outline: 2px solid hsl(185 80% 50% / 0.6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.nav-link:active { transform: translateY(1px); }
|
||||
|
||||
.nav-link.active { color: var(--hc-primary, #19d4e5); }
|
||||
|
||||
.nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0.7rem;
|
||||
right: 0.7rem;
|
||||
height: 2px;
|
||||
background: var(--hc-primary, #19d4e5);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.version-chip {
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Main content ── */
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
padding-block: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
border-top: 1px solid hsl(220 15% 18%);
|
||||
text-align: center;
|
||||
padding: 1rem 1.25rem;
|
||||
font-family: var(--hc-font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.75rem;
|
||||
color: var(--hc-text-muted, #7b899d);
|
||||
}
|
||||
`;
|
||||
|
||||
private onNavClick(id: string) {
|
||||
this.activeId = id;
|
||||
this.dispatchEvent(new CustomEvent('hc-navigate', { detail: { id }, bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<header class="appbar" part="appbar">
|
||||
<div class="brand">
|
||||
<div class="brand-icon" aria-hidden="true">H</div>
|
||||
${this.locationName}
|
||||
</div>
|
||||
<nav class="nav" aria-label="Primary navigation">
|
||||
${this.navItems.map(item => html`
|
||||
<button
|
||||
class="nav-link ${this.activeId === item.id ? 'active' : ''}"
|
||||
@click=${() => this.onNavClick(item.id)}
|
||||
aria-current=${this.activeId === item.id ? 'page' : 'false'}
|
||||
>${item.label}</button>
|
||||
`)}
|
||||
</nav>
|
||||
<span class="version-chip">v${this.version}</span>
|
||||
</header>
|
||||
|
||||
<main part="content">
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
<footer part="footer">
|
||||
HOMECORE — ${this.locationName} — v${this.version}
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'hc-app-shell': AppShell;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user