feat(display): add @circuitforge/display strip-display Vue package
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
New packages/display/ — Vue 3 primitives for products running a secondary 1920x480 landscape / 480x1920 portrait kiosk display, per the strip display spec. First non-Python module in this repo; published as its own npm package (not part of the Python circuitforge-core distribution) so products that don't use a strip display never pull in Vue as a dependency. - DisplayLayout — root wrapper: identity zone, orientation-aware grid (landscape/portrait), dark-default theme, #metrics/#alerts/#macros slots. - DisplayMetric — value/label tile with optional unit, severity colour, sparkline. - DisplayAlert — timestamped, severity-coloured alert row. - DisplayMacroButton — touch target (44px+ min) emitting a shell/url/api/display_switch action payload; execution stays product-side. - theme.ts — central theme file: CSS custom properties (dark/light) plus UnoCSS theme/shortcut fragments for products that already run UnoCSS (Turnstone, Robin) to spread into their own uno.config.ts. - launcher/launcher.html — static, framework-free product switcher reading a sibling launcher.config.json. 37 Vitest tests across all four components; vue-tsc type-checks clean; vite build produces ESM/CJS bundles + CSS + .d.ts. First consumer: Turnstone's sysadmin profile (turnstone#25, currently blocked on this ticket). Closes: #69
This commit is contained in:
parent
11e49067a8
commit
6d82f3561f
20 changed files with 4645 additions and 0 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -9,6 +9,10 @@ dist/
|
||||||
build/
|
build/
|
||||||
"<MagicMock*"
|
"<MagicMock*"
|
||||||
|
|
||||||
|
# packages/display (Vue/npm)
|
||||||
|
node_modules/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# cf-orch private profiles (commit on personal/heimdall branch only)
|
# cf-orch private profiles (commit on personal/heimdall branch only)
|
||||||
circuitforge_core/resources/profiles/private/
|
circuitforge_core/resources/profiles/private/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -88,6 +88,21 @@ pip install circuitforge-core[dev] # All dev dependencies
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Vue package: `@circuitforge/display`
|
||||||
|
|
||||||
|
Strip-display Vue 3 primitives (`packages/display/`) for products running a secondary 1920×480 landscape / 480×1920 portrait kiosk display (Turnstone, Robin). Published as a **separate npm package**, not part of this Python distribution, so products that don't use it never pull in Vue as a dependency.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/display
|
||||||
|
npm install
|
||||||
|
npm test # 37 tests — DisplayLayout, DisplayMetric, DisplayAlert, DisplayMacroButton
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
See `packages/display/README.md` for the component API and theming.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Usage: LLM Router
|
## Usage: LLM Router
|
||||||
|
|
||||||
The LLM router reads a config file at `~/.config/circuitforge/llm.yaml`, tries each backend in fallback order, and skips unreachable or disabled entries transparently.
|
The LLM router reads a config file at `~/.config/circuitforge/llm.yaml`, tries each backend in fallback order, and skips unreachable or disabled entries transparently.
|
||||||
|
|
|
||||||
32
docs/developer/display-package.md
Normal file
32
docs/developer/display-package.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# @circuitforge/display — Vue package
|
||||||
|
|
||||||
|
`packages/display/` is the first non-Python module in circuitforge-core: Vue 3 primitives for CircuitForge products running a secondary strip display (1920×480 landscape / 480×1920 portrait kiosk). Design spec: `circuitforge-plans/circuitforge-core/superpowers/specs/2026-05-17-strip-display-spec.md`.
|
||||||
|
|
||||||
|
## Why a separate npm package, not `circuitforge_core`
|
||||||
|
|
||||||
|
Most products in the menagerie are Python-only and never touch Vue. Publishing `@circuitforge/display` as its own npm package — rather than bundling it into the Python `circuitforge-core` distribution — means:
|
||||||
|
|
||||||
|
- Products that don't use a strip display (most of them) never pull in Vue as a transitive dependency.
|
||||||
|
- Versioning follows npm semver independently of the Python package's release cadence — a Vue component API change doesn't force a `circuitforge-core` PyPI bump, and vice versa.
|
||||||
|
- The build tooling (Vite, vue-tsc, Vitest) stays isolated in `packages/display/`, not mixed into the Python `pyproject.toml`/pytest setup.
|
||||||
|
|
||||||
|
This mirrors the reasoning behind keeping the BSL `resources` module split into the separate `circuitforge-orch` package — different consumers, different release cycles, kept apart even though both live in adjacent repos/directories.
|
||||||
|
|
||||||
|
## Location and consumers
|
||||||
|
|
||||||
|
- Package root: `packages/display/`
|
||||||
|
- First consumer: Turnstone's sysadmin profile (`turnstone#25`) — CPU/RAM/disk/network metrics, live alert feed, service macro buttons
|
||||||
|
- Planned: Robin's proactive assistant overlay
|
||||||
|
|
||||||
|
## Working on it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/display
|
||||||
|
npm install
|
||||||
|
npm test # Vitest — 37 tests across the four components
|
||||||
|
npm run build # vue-tsc --emitDeclarationOnly && vite build → dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Gotcha to watch for: Vite's `build.emptyOutDir` defaults to `true` and will silently wipe the `.d.ts` files `vue-tsc --emitDeclarationOnly` just wrote, since both commands target the same `dist/`. `vite.config.ts` sets `emptyOutDir: false` to prevent this — don't remove it without changing the build order.
|
||||||
|
|
||||||
|
See `packages/display/README.md` for the component API, theming (`src/theme.ts` is the central theme file — CSS custom properties + UnoCSS fragments for products that already run UnoCSS), and the launcher page.
|
||||||
|
|
@ -77,6 +77,7 @@ nav:
|
||||||
- Adding a Module: developer/adding-module.md
|
- Adding a Module: developer/adding-module.md
|
||||||
- Editable Install Pattern: developer/editable-install.md
|
- Editable Install Pattern: developer/editable-install.md
|
||||||
- BSL vs MIT Boundaries: developer/licensing.md
|
- BSL vs MIT Boundaries: developer/licensing.md
|
||||||
|
- "@circuitforge/display (Vue package)": developer/display-package.md
|
||||||
|
|
||||||
extra_javascript:
|
extra_javascript:
|
||||||
- plausible.js
|
- plausible.js
|
||||||
|
|
|
||||||
82
packages/display/README.md
Normal file
82
packages/display/README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# @circuitforge/display
|
||||||
|
|
||||||
|
Vue 3 primitives for CircuitForge products that run on or alongside a secondary strip display (8.8" USB-C touch, 1920×480 landscape / 480×1920 portrait). Design spec: `circuitforge-plans/circuitforge-core/superpowers/specs/2026-05-17-strip-display-spec.md`.
|
||||||
|
|
||||||
|
Published as a separate npm package (not part of the Python `circuitforge-core` distribution) so products that don't use it never pull in Vue as a dependency.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @circuitforge/display
|
||||||
|
```
|
||||||
|
|
||||||
|
`vue@^3.5` is a peer dependency.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DisplayLayout, DisplayMetric, DisplayAlert, DisplayMacroButton } from '@circuitforge/display'
|
||||||
|
import '@circuitforge/display/style.css'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DisplayLayout product="turnstone" profile="sysadmin" orientation="landscape">
|
||||||
|
<template #metrics>
|
||||||
|
<DisplayMetric :value="87" unit="°C" label="CPU temp" severity="warn" />
|
||||||
|
<DisplayMetric :value="42" unit="%" label="RAM" />
|
||||||
|
</template>
|
||||||
|
<template #alerts>
|
||||||
|
<DisplayAlert
|
||||||
|
message="pacman lock detected — another process is using the database"
|
||||||
|
timestamp="2026-01-01T14:22:00"
|
||||||
|
severity="crit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #macros>
|
||||||
|
<DisplayMacroButton
|
||||||
|
icon="🔄"
|
||||||
|
label="Restart nginx"
|
||||||
|
:action="{ type: 'shell', command: 'systemctl restart nginx' }"
|
||||||
|
@trigger="handleMacro"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DisplayLayout>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
A product's `/display` route wraps its content in `DisplayLayout`, filling the `#metrics`, `#alerts`, and `#macros` slots — it doesn't manage zones/orientation manually. `?profile=` and orientation are read from the route by the consuming product and passed in as props (this package doesn't read `window.location` itself, for SSR-safety and testability).
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DisplayLayout` | Root layout — identity zone, orientation-aware grid (landscape/portrait), theme. |
|
||||||
|
| `DisplayMetric` | Single metric tile — value, label, optional unit/sparkline, severity colour. |
|
||||||
|
| `DisplayAlert` | Single alert row — timestamp, message, severity-coloured left border. |
|
||||||
|
| `DisplayMacroButton` | Large touch target (44px+ min) firing a `shell` / `url` / `api` / `display_switch` action. |
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
`src/theme.ts` is the central theme file — CSS custom properties (`--cf-display-*`) with dark-theme defaults, plus `displayUnoTheme`/`displayUnoShortcuts` fragments for products that already run UnoCSS (Turnstone, Robin) to spread into their own `uno.config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// uno.config.ts
|
||||||
|
import { defineConfig } from 'unocss'
|
||||||
|
import { displayUnoTheme, displayUnoShortcuts } from '@circuitforge/display'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
theme: { colors: { ...displayUnoTheme.colors } },
|
||||||
|
shortcuts: { ...displayUnoShortcuts },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Dark is the default (strip displays typically sit adjacent to a bright monitor). Pass `theme="light"` to `DisplayLayout` to override.
|
||||||
|
|
||||||
|
## Launcher
|
||||||
|
|
||||||
|
`launcher/launcher.html` is a static, framework-free page listing configured product display URLs (read from a sibling `launcher.config.json`) and letting the user tap to switch the kiosk window between them. Not part of the npm package's JS API — copy it into a product's static assets or serve it directly.
|
||||||
|
|
||||||
|
## What this package does *not* do
|
||||||
|
|
||||||
|
No live metrics transport (WebSocket/SSE) — that's product-side (`DisplayDataProvider`, per the spec, lives in the consuming product). No macro execution — `DisplayMacroButton` only emits a `trigger` event with the action payload; the consuming product's backend runs it.
|
||||||
151
packages/display/launcher/launcher.html
Normal file
151
packages/display/launcher/launcher.html
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CircuitForge Display Launcher</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--cf-display-surface: #0d1117;
|
||||||
|
--cf-display-surface-raised: #161b22;
|
||||||
|
--cf-display-surface-border: #30363d;
|
||||||
|
--cf-display-accent: #39d353;
|
||||||
|
--cf-display-text-primary: #e6edf3;
|
||||||
|
--cf-display-text-muted: #8b949e;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--cf-display-surface: #ffffff;
|
||||||
|
--cf-display-surface-raised: #f6f8fa;
|
||||||
|
--cf-display-surface-border: #d0d7de;
|
||||||
|
--cf-display-accent: #1f883d;
|
||||||
|
--cf-display-text-primary: #1f2328;
|
||||||
|
--cf-display-text-muted: #59636e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--cf-display-surface);
|
||||||
|
color: var(--cf-display-text-primary);
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.launcher-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 8rem;
|
||||||
|
min-height: 8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--cf-display-surface-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--cf-display-surface-raised);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
.launcher-tile:hover, .launcher-tile:focus-visible {
|
||||||
|
border-color: var(--cf-display-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.launcher-tile__name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.launcher-tile__profile {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--cf-display-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
#empty {
|
||||||
|
color: var(--cf-display-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
cf-core strip display launcher — no framework dependency, per the strip
|
||||||
|
display spec. Config is a small JSON file the user edits once, fetched
|
||||||
|
from ./launcher.config.json next to this HTML file (same-origin — this is
|
||||||
|
meant to be served by a product's own backend, e.g. Turnstone, at a
|
||||||
|
well-known /launcher route, or served as a static file alongside a
|
||||||
|
kiosk browser profile).
|
||||||
|
|
||||||
|
{
|
||||||
|
"products": [
|
||||||
|
{ "name": "Turnstone", "url": "http://localhost:8600/display", "profile": "sysadmin" }
|
||||||
|
],
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Tapping a tile navigates the kiosk window to that product's display URL.
|
||||||
|
On first boot, kiosk mode should open this launcher.
|
||||||
|
-->
|
||||||
|
<div id="app"></div>
|
||||||
|
<div id="empty" style="display: none;">
|
||||||
|
No products configured. Add entries to <code>launcher.config.json</code> next to this file.
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
const empty = document.getElementById('empty');
|
||||||
|
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('./launcher.config.json');
|
||||||
|
config = await resp.json();
|
||||||
|
} catch (err) {
|
||||||
|
config = { products: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = Array.isArray(config.products) ? config.products : [];
|
||||||
|
if (products.length === 0) {
|
||||||
|
empty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
const tile = document.createElement('a');
|
||||||
|
tile.className = 'launcher-tile';
|
||||||
|
tile.href = product.url;
|
||||||
|
tile.setAttribute('aria-label', `Open ${product.name}`);
|
||||||
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'launcher-tile__name';
|
||||||
|
name.textContent = product.name;
|
||||||
|
tile.appendChild(name);
|
||||||
|
|
||||||
|
if (product.profile) {
|
||||||
|
const profile = document.createElement('span');
|
||||||
|
profile.className = 'launcher-tile__profile';
|
||||||
|
profile.textContent = product.profile;
|
||||||
|
tile.appendChild(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.appendChild(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultIndex = Number.isInteger(config.default) ? config.default : null;
|
||||||
|
if (defaultIndex !== null && products[defaultIndex] && location.hash === '#auto') {
|
||||||
|
location.href = products[defaultIndex].url;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3409
packages/display/package-lock.json
generated
Normal file
3409
packages/display/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
48
packages/display/package.json
Normal file
48
packages/display/package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "@circuitforge/display",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Strip-display Vue primitives for CircuitForge products (1920x480 landscape / 480x1920 portrait kiosk displays)",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git",
|
||||||
|
"directory": "packages/display"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"launcher"
|
||||||
|
],
|
||||||
|
"main": "./dist/circuitforge-display.cjs",
|
||||||
|
"module": "./dist/circuitforge-display.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/circuitforge-display.js",
|
||||||
|
"require": "./dist/circuitforge-display.cjs"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/circuitforge-display.css"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --emitDeclarationOnly && vite build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-tsc": "^3.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
packages/display/src/components/DisplayAlert.vue
Normal file
75
packages/display/src/components/DisplayAlert.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { DisplaySeverity } from '../theme'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
message: string
|
||||||
|
timestamp: string | Date
|
||||||
|
severity?: DisplaySeverity
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
severity: 'ok',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const formattedTime = computed(() => {
|
||||||
|
const d = typeof props.timestamp === 'string' ? new Date(props.timestamp) : props.timestamp
|
||||||
|
if (Number.isNaN(d.getTime())) return String(props.timestamp)
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cf-display-alert" :class="`cf-display-alert--${severity}`" role="status">
|
||||||
|
<span class="cf-display-alert__dot" aria-hidden="true" />
|
||||||
|
<span class="cf-display-alert__time">{{ formattedTime }}</span>
|
||||||
|
<span class="cf-display-alert__message">{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cf-display-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-left: 4px solid currentColor;
|
||||||
|
background: var(--cf-display-surface-raised, #161b22);
|
||||||
|
color: var(--cf-display-text-primary, #e6edf3);
|
||||||
|
font-size: clamp(0.65rem, 1.6vw, 0.9rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-alert__dot {
|
||||||
|
width: 0.5em;
|
||||||
|
height: 0.5em;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-alert__time {
|
||||||
|
color: var(--cf-display-text-muted, #8b949e);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-alert__message {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-alert--ok {
|
||||||
|
color: var(--cf-display-text-muted, #8b949e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-alert--warn {
|
||||||
|
color: var(--cf-display-sev-warn, #d29922);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-alert--crit {
|
||||||
|
color: var(--cf-display-sev-crit, #f85149);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
176
packages/display/src/components/DisplayLayout.vue
Normal file
176
packages/display/src/components/DisplayLayout.vue
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { DisplayOrientation, DisplayProfile } from '../theme'
|
||||||
|
import {
|
||||||
|
DISPLAY_THEME_DEFAULTS_DARK,
|
||||||
|
DISPLAY_THEME_DEFAULTS_LIGHT,
|
||||||
|
} from '../theme'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** Product name shown in the identity zone; tap targets the launcher. */
|
||||||
|
product: string
|
||||||
|
/** Persona hint — cosmetic only, products may ignore or extend it. */
|
||||||
|
profile?: DisplayProfile
|
||||||
|
/** Forces a layout; omit to let CSS `@media (orientation:)` decide. */
|
||||||
|
orientation?: DisplayOrientation
|
||||||
|
/** Dark is the default for strip displays regardless of host OS theme. */
|
||||||
|
theme?: 'dark' | 'light'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
profile: 'sysadmin',
|
||||||
|
orientation: 'landscape',
|
||||||
|
theme: 'dark',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'open-launcher': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const themeVars = computed(() => {
|
||||||
|
const vars = props.theme === 'light' ? DISPLAY_THEME_DEFAULTS_LIGHT : DISPLAY_THEME_DEFAULTS_DARK
|
||||||
|
return vars as Record<string, string>
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="cf-display-layout"
|
||||||
|
:class="[`cf-display-layout--${orientation}`, `cf-display-layout--profile-${profile}`]"
|
||||||
|
:style="themeVars"
|
||||||
|
:data-cf-display-theme="theme"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cf-display-layout__identity"
|
||||||
|
:aria-label="`${product} — open display launcher`"
|
||||||
|
@click="emit('open-launcher')"
|
||||||
|
>
|
||||||
|
<slot name="identity">
|
||||||
|
<span class="cf-display-layout__product">{{ product }}</span>
|
||||||
|
<span class="cf-display-layout__profile">{{ profile }}</span>
|
||||||
|
</slot>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="cf-display-layout__metrics">
|
||||||
|
<slot name="metrics" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cf-display-layout__alerts">
|
||||||
|
<slot name="alerts" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cf-display-layout__macros">
|
||||||
|
<slot name="macros" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cf-display-layout {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--cf-display-surface, #0d1117);
|
||||||
|
color: var(--cf-display-text-primary, #e6edf3);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landscape: 1920x480 — identity | metrics | alerts | macros, left to right. */
|
||||||
|
.cf-display-layout--landscape {
|
||||||
|
grid-template-columns: 128px minmax(0, 1fr) 300px 192px;
|
||||||
|
grid-template-rows: 100%;
|
||||||
|
grid-template-areas: 'identity metrics alerts macros';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portrait: 480x1920 — stacked top to bottom. */
|
||||||
|
.cf-display-layout--portrait {
|
||||||
|
grid-template-columns: 100%;
|
||||||
|
grid-template-rows: 80px minmax(0, 1fr) auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
'identity'
|
||||||
|
'metrics'
|
||||||
|
'alerts'
|
||||||
|
'macros';
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout__identity {
|
||||||
|
grid-area: identity;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--cf-display-surface-border, #30363d);
|
||||||
|
background: var(--cf-display-surface-raised, #161b22);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout--portrait .cf-display-layout__identity {
|
||||||
|
flex-direction: row;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--cf-display-surface-border, #30363d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout__product {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: clamp(0.8rem, 2vw, 1.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout__profile {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--cf-display-text-muted, #8b949e);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout__metrics {
|
||||||
|
grid-area: metrics;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout--portrait .cf-display-layout__metrics {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout__alerts {
|
||||||
|
grid-area: alerts;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-left: 1px solid var(--cf-display-surface-border, #30363d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout--portrait .cf-display-layout__alerts {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--cf-display-surface-border, #30363d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout__macros {
|
||||||
|
grid-area: macros;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-left: 1px solid var(--cf-display-surface-border, #30363d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-layout--portrait .cf-display-layout__macros {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--cf-display-surface-border, #30363d);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
83
packages/display/src/components/DisplayMacroButton.vue
Normal file
83
packages/display/src/components/DisplayMacroButton.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
export type MacroAction =
|
||||||
|
| { type: 'shell'; command: string }
|
||||||
|
| { type: 'url'; url: string }
|
||||||
|
| { type: 'api'; endpoint: string; method?: string; body?: unknown }
|
||||||
|
| { type: 'display_switch'; target: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
action: MacroAction
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
trigger: [action: MacroAction]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function onActivate() {
|
||||||
|
if (props.disabled) return
|
||||||
|
emit('trigger', props.action)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cf-display-macro"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="label"
|
||||||
|
@click="onActivate"
|
||||||
|
>
|
||||||
|
<span class="cf-display-macro__icon" aria-hidden="true">{{ icon }}</span>
|
||||||
|
<span class="cf-display-macro__label">{{ label }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cf-display-macro {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.15em;
|
||||||
|
/* 44px is the accessibility-minimum touch target; strip displays prefer 64px+ */
|
||||||
|
min-width: 4rem;
|
||||||
|
min-height: 4rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--cf-display-surface-border, #30363d);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--cf-display-surface-raised, #161b22);
|
||||||
|
color: var(--cf-display-text-primary, #e6edf3);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-macro:hover:not(:disabled),
|
||||||
|
.cf-display-macro:focus-visible {
|
||||||
|
border-color: var(--cf-display-accent, #39d353);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-macro:active:not(:disabled) {
|
||||||
|
background: var(--cf-display-surface, #0d1117);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-macro:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-macro__icon {
|
||||||
|
font-size: clamp(1.2rem, 3vw, 1.8rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-macro__label {
|
||||||
|
font-size: clamp(0.6rem, 1.4vw, 0.8rem);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--cf-display-text-muted, #8b949e);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
110
packages/display/src/components/DisplayMetric.vue
Normal file
110
packages/display/src/components/DisplayMetric.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { DisplaySeverity } from '../theme'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
value: string | number
|
||||||
|
label: string
|
||||||
|
unit?: string
|
||||||
|
severity?: DisplaySeverity
|
||||||
|
sparkline?: number[]
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
unit: '',
|
||||||
|
severity: 'ok',
|
||||||
|
sparkline: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const displayValue = computed(() => `${props.value}${props.unit ? props.unit : ''}`)
|
||||||
|
|
||||||
|
const sparklinePoints = computed(() => {
|
||||||
|
const data = props.sparkline
|
||||||
|
if (!data || data.length < 2) return null
|
||||||
|
const min = Math.min(...data)
|
||||||
|
const max = Math.max(...data)
|
||||||
|
const range = max - min || 1
|
||||||
|
const width = 100
|
||||||
|
const height = 24
|
||||||
|
const step = width / (data.length - 1)
|
||||||
|
return data
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = i * step
|
||||||
|
const y = height - ((v - min) / range) * height
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cf-display-metric" :class="`cf-display-metric--${severity}`" role="group" :aria-label="label">
|
||||||
|
<div class="cf-display-metric__value">{{ displayValue }}</div>
|
||||||
|
<div class="cf-display-metric__label">{{ label }}</div>
|
||||||
|
<svg
|
||||||
|
v-if="sparklinePoints"
|
||||||
|
class="cf-display-metric__sparkline"
|
||||||
|
viewBox="0 0 100 24"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polyline :points="sparklinePoints" fill="none" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cf-display-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.15em;
|
||||||
|
min-width: 6rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--cf-display-surface-raised, #161b22);
|
||||||
|
color: var(--cf-display-text-primary, #e6edf3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-metric__value {
|
||||||
|
font-size: clamp(1.1rem, 4vw, 2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-metric__label {
|
||||||
|
font-size: clamp(0.6rem, 1.5vw, 0.85rem);
|
||||||
|
color: var(--cf-display-text-muted, #8b949e);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-metric__sparkline {
|
||||||
|
width: 100%;
|
||||||
|
height: 1.2rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-metric__sparkline polyline {
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-metric--ok {
|
||||||
|
color: var(--cf-display-sev-ok, #3fb950);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-metric--warn {
|
||||||
|
color: var(--cf-display-sev-warn, #d29922);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-metric--crit {
|
||||||
|
color: var(--cf-display-sev-crit, #f85149);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf-display-metric--ok .cf-display-metric__value,
|
||||||
|
.cf-display-metric--warn .cf-display-metric__value,
|
||||||
|
.cf-display-metric--crit .cf-display-metric__value {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
packages/display/src/index.ts
Normal file
21
packages/display/src/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import DisplayLayout from './components/DisplayLayout.vue'
|
||||||
|
import DisplayMetric from './components/DisplayMetric.vue'
|
||||||
|
import DisplayAlert from './components/DisplayAlert.vue'
|
||||||
|
import DisplayMacroButton from './components/DisplayMacroButton.vue'
|
||||||
|
|
||||||
|
export { DisplayLayout, DisplayMetric, DisplayAlert, DisplayMacroButton }
|
||||||
|
|
||||||
|
export type { MacroAction } from './components/DisplayMacroButton.vue'
|
||||||
|
export type {
|
||||||
|
DisplayOrientation,
|
||||||
|
DisplayProfile,
|
||||||
|
DisplaySeverity,
|
||||||
|
} from './theme'
|
||||||
|
export {
|
||||||
|
DISPLAY_THEME_VARS,
|
||||||
|
DISPLAY_THEME_DEFAULTS_DARK,
|
||||||
|
DISPLAY_THEME_DEFAULTS_LIGHT,
|
||||||
|
DISPLAY_PROFILE_DENSITY,
|
||||||
|
displayUnoTheme,
|
||||||
|
displayUnoShortcuts,
|
||||||
|
} from './theme'
|
||||||
61
packages/display/src/tests/DisplayAlert.spec.ts
Normal file
61
packages/display/src/tests/DisplayAlert.spec.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import DisplayAlert from '../components/DisplayAlert.vue'
|
||||||
|
|
||||||
|
describe('DisplayAlert', () => {
|
||||||
|
it('renders the message', () => {
|
||||||
|
const wrapper = mount(DisplayAlert, {
|
||||||
|
props: { message: 'pacman lock detected', timestamp: '2026-01-01T14:22:00' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('pacman lock detected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats an ISO timestamp as a time string', () => {
|
||||||
|
const wrapper = mount(DisplayAlert, {
|
||||||
|
props: { message: 'x', timestamp: '2026-01-01T14:22:00' },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.cf-display-alert__time').text()).toMatch(/\d{1,2}:\d{2}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a Date object for timestamp', () => {
|
||||||
|
const wrapper = mount(DisplayAlert, {
|
||||||
|
props: { message: 'x', timestamp: new Date('2026-01-01T14:22:00') },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.cf-display-alert__time').text()).toMatch(/\d{1,2}:\d{2}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the raw string when timestamp is unparseable', () => {
|
||||||
|
const wrapper = mount(DisplayAlert, {
|
||||||
|
props: { message: 'x', timestamp: 'not-a-date' },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.cf-display-alert__time').text()).toBe('not-a-date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults severity to ok', () => {
|
||||||
|
const wrapper = mount(DisplayAlert, {
|
||||||
|
props: { message: 'x', timestamp: '2026-01-01T00:00:00' },
|
||||||
|
})
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-alert--ok')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the warn severity class', () => {
|
||||||
|
const wrapper = mount(DisplayAlert, {
|
||||||
|
props: { message: 'x', timestamp: '2026-01-01T00:00:00', severity: 'warn' },
|
||||||
|
})
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-alert--warn')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the crit severity class', () => {
|
||||||
|
const wrapper = mount(DisplayAlert, {
|
||||||
|
props: { message: 'x', timestamp: '2026-01-01T00:00:00', severity: 'crit' },
|
||||||
|
})
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-alert--crit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has an accessible status role', () => {
|
||||||
|
const wrapper = mount(DisplayAlert, {
|
||||||
|
props: { message: 'x', timestamp: '2026-01-01T00:00:00' },
|
||||||
|
})
|
||||||
|
expect(wrapper.attributes('role')).toBe('status')
|
||||||
|
})
|
||||||
|
})
|
||||||
87
packages/display/src/tests/DisplayLayout.spec.ts
Normal file
87
packages/display/src/tests/DisplayLayout.spec.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import DisplayLayout from '../components/DisplayLayout.vue'
|
||||||
|
|
||||||
|
describe('DisplayLayout', () => {
|
||||||
|
it('renders the product name in the identity zone by default', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||||
|
expect(wrapper.find('.cf-display-layout__product').text()).toBe('turnstone')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults profile to sysadmin', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||||
|
expect(wrapper.find('.cf-display-layout__profile').text()).toBe('sysadmin')
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-layout--profile-sysadmin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the given profile', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, {
|
||||||
|
props: { product: 'robin', profile: 'casual' },
|
||||||
|
})
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-layout--profile-casual')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults orientation to landscape', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-layout--landscape')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies portrait orientation when set', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, {
|
||||||
|
props: { product: 'turnstone', orientation: 'portrait' },
|
||||||
|
})
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-layout--portrait')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders content passed to the metrics slot', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, {
|
||||||
|
props: { product: 'turnstone' },
|
||||||
|
slots: { metrics: '<div class="probe-metrics">m</div>' },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.cf-display-layout__metrics .probe-metrics').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders content passed to the alerts slot', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, {
|
||||||
|
props: { product: 'turnstone' },
|
||||||
|
slots: { alerts: '<div class="probe-alerts">a</div>' },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.cf-display-layout__alerts .probe-alerts').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders content passed to the macros slot', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, {
|
||||||
|
props: { product: 'turnstone' },
|
||||||
|
slots: { macros: '<div class="probe-macros">x</div>' },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.cf-display-layout__macros .probe-macros').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overrides the identity zone with the identity slot', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, {
|
||||||
|
props: { product: 'turnstone' },
|
||||||
|
slots: { identity: '<span class="probe-identity">Custom</span>' },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.probe-identity').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.cf-display-layout__product').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits open-launcher when the identity button is tapped', async () => {
|
||||||
|
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||||
|
await wrapper.find('.cf-display-layout__identity').trigger('click')
|
||||||
|
expect(wrapper.emitted('open-launcher')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to dark theme and sets the data attribute', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||||
|
expect(wrapper.attributes('data-cf-display-theme')).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies light theme override values', () => {
|
||||||
|
const wrapper = mount(DisplayLayout, {
|
||||||
|
props: { product: 'turnstone', theme: 'light' },
|
||||||
|
})
|
||||||
|
expect(wrapper.attributes('data-cf-display-theme')).toBe('light')
|
||||||
|
expect(wrapper.attributes('style')).toContain('#ffffff')
|
||||||
|
})
|
||||||
|
})
|
||||||
73
packages/display/src/tests/DisplayMacroButton.spec.ts
Normal file
73
packages/display/src/tests/DisplayMacroButton.spec.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import DisplayMacroButton from '../components/DisplayMacroButton.vue'
|
||||||
|
|
||||||
|
describe('DisplayMacroButton', () => {
|
||||||
|
it('renders icon and label', () => {
|
||||||
|
const wrapper = mount(DisplayMacroButton, {
|
||||||
|
props: { icon: '🔄', label: 'Restart nginx', action: { type: 'shell', command: 'systemctl restart nginx' } },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('🔄')
|
||||||
|
expect(wrapper.text()).toContain('Restart nginx')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits trigger with the action payload on click', async () => {
|
||||||
|
const action = { type: 'shell' as const, command: 'systemctl restart nginx' }
|
||||||
|
const wrapper = mount(DisplayMacroButton, {
|
||||||
|
props: { icon: '🔄', label: 'Restart nginx', action },
|
||||||
|
})
|
||||||
|
await wrapper.trigger('click')
|
||||||
|
expect(wrapper.emitted('trigger')).toHaveLength(1)
|
||||||
|
expect(wrapper.emitted('trigger')![0]).toEqual([action])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not emit trigger when disabled', async () => {
|
||||||
|
const wrapper = mount(DisplayMacroButton, {
|
||||||
|
props: {
|
||||||
|
icon: '🔄',
|
||||||
|
label: 'Restart nginx',
|
||||||
|
action: { type: 'shell', command: 'x' },
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wrapper.trigger('click')
|
||||||
|
expect(wrapper.emitted('trigger')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets the disabled attribute on the button element', () => {
|
||||||
|
const wrapper = mount(DisplayMacroButton, {
|
||||||
|
props: {
|
||||||
|
icon: '🔄',
|
||||||
|
label: 'x',
|
||||||
|
action: { type: 'shell', command: 'x' },
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(wrapper.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports url actions', async () => {
|
||||||
|
const action = { type: 'url' as const, url: 'https://example.com' }
|
||||||
|
const wrapper = mount(DisplayMacroButton, {
|
||||||
|
props: { icon: '🌐', label: 'Docs', action },
|
||||||
|
})
|
||||||
|
await wrapper.trigger('click')
|
||||||
|
expect(wrapper.emitted('trigger')![0]).toEqual([action])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports display_switch actions', async () => {
|
||||||
|
const action = { type: 'display_switch' as const, target: 'robin' }
|
||||||
|
const wrapper = mount(DisplayMacroButton, {
|
||||||
|
props: { icon: '↔️', label: 'Switch', action },
|
||||||
|
})
|
||||||
|
await wrapper.trigger('click')
|
||||||
|
expect(wrapper.emitted('trigger')![0]).toEqual([action])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets an accessible label matching the button label', () => {
|
||||||
|
const wrapper = mount(DisplayMacroButton, {
|
||||||
|
props: { icon: '🔄', label: 'Restart nginx', action: { type: 'shell', command: 'x' } },
|
||||||
|
})
|
||||||
|
expect(wrapper.attributes('aria-label')).toBe('Restart nginx')
|
||||||
|
})
|
||||||
|
})
|
||||||
71
packages/display/src/tests/DisplayMetric.spec.ts
Normal file
71
packages/display/src/tests/DisplayMetric.spec.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import DisplayMetric from '../components/DisplayMetric.vue'
|
||||||
|
|
||||||
|
describe('DisplayMetric', () => {
|
||||||
|
it('renders value and label', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, {
|
||||||
|
props: { value: 87, label: 'CPU temp' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('87')
|
||||||
|
expect(wrapper.text()).toContain('CPU temp')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends unit to value when provided', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, {
|
||||||
|
props: { value: 87, label: 'CPU temp', unit: '°C' },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.cf-display-metric__value').text()).toBe('87°C')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not append anything when unit is omitted', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, { props: { value: 42, label: 'Fans' } })
|
||||||
|
expect(wrapper.find('.cf-display-metric__value').text()).toBe('42')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults severity to ok', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, { props: { value: 1, label: 'x' } })
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-metric--ok')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the warn severity class', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, {
|
||||||
|
props: { value: 1, label: 'x', severity: 'warn' },
|
||||||
|
})
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-metric--warn')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the crit severity class', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, {
|
||||||
|
props: { value: 1, label: 'x', severity: 'crit' },
|
||||||
|
})
|
||||||
|
expect(wrapper.classes()).toContain('cf-display-metric--crit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render a sparkline when none is given', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, { props: { value: 1, label: 'x' } })
|
||||||
|
expect(wrapper.find('.cf-display-metric__sparkline').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render a sparkline with fewer than 2 points', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, {
|
||||||
|
props: { value: 1, label: 'x', sparkline: [5] },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.cf-display-metric__sparkline').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a sparkline polyline with one point per reading', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, {
|
||||||
|
props: { value: 1, label: 'x', sparkline: [1, 2, 3, 2, 1] },
|
||||||
|
})
|
||||||
|
const polyline = wrapper.find('polyline')
|
||||||
|
expect(polyline.exists()).toBe(true)
|
||||||
|
const points = polyline.attributes('points')!.trim().split(' ')
|
||||||
|
expect(points).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets an accessible group label matching the metric label', () => {
|
||||||
|
const wrapper = mount(DisplayMetric, { props: { value: 1, label: 'RAM usage' } })
|
||||||
|
expect(wrapper.attributes('aria-label')).toBe('RAM usage')
|
||||||
|
})
|
||||||
|
})
|
||||||
109
packages/display/src/theme.ts
Normal file
109
packages/display/src/theme.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
/**
|
||||||
|
* @circuitforge/display — central theme file.
|
||||||
|
*
|
||||||
|
* Single source of truth for the strip-display design tokens. Components in
|
||||||
|
* this package read these as CSS custom properties (with fallbacks, so they
|
||||||
|
* render correctly even in a host that hasn't wired the theme up yet).
|
||||||
|
*
|
||||||
|
* Consuming products that already have a UnoCSS config (Turnstone, Robin)
|
||||||
|
* should spread `displayUnoTheme`/`displayUnoShortcuts` into their own
|
||||||
|
* `uno.config.ts` so the strip-display route matches their product's theme
|
||||||
|
* rather than diverging with a second, unrelated palette. See
|
||||||
|
* packages/display/README.md for the merge snippet.
|
||||||
|
*
|
||||||
|
* Dark theme is the default — strip displays typically sit adjacent to a
|
||||||
|
* bright monitor, so a light theme fights for attention. Set
|
||||||
|
* `data-cf-display-theme="light"` on the root element to override.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** CSS custom property names, gathered so components and consumers share one contract. */
|
||||||
|
export const DISPLAY_THEME_VARS = {
|
||||||
|
surface: '--cf-display-surface',
|
||||||
|
surfaceRaised: '--cf-display-surface-raised',
|
||||||
|
surfaceBorder: '--cf-display-surface-border',
|
||||||
|
accent: '--cf-display-accent',
|
||||||
|
accentMuted: '--cf-display-accent-muted',
|
||||||
|
textPrimary: '--cf-display-text-primary',
|
||||||
|
textMuted: '--cf-display-text-muted',
|
||||||
|
textDim: '--cf-display-text-dim',
|
||||||
|
sevOk: '--cf-display-sev-ok',
|
||||||
|
sevWarn: '--cf-display-sev-warn',
|
||||||
|
sevCrit: '--cf-display-sev-crit',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Default values, dark theme. Applied as :root fallbacks by DisplayLayout. */
|
||||||
|
export const DISPLAY_THEME_DEFAULTS_DARK: Record<string, string> = {
|
||||||
|
[DISPLAY_THEME_VARS.surface]: '#0d1117',
|
||||||
|
[DISPLAY_THEME_VARS.surfaceRaised]: '#161b22',
|
||||||
|
[DISPLAY_THEME_VARS.surfaceBorder]: '#30363d',
|
||||||
|
[DISPLAY_THEME_VARS.accent]: '#39d353',
|
||||||
|
[DISPLAY_THEME_VARS.accentMuted]: '#1f6feb',
|
||||||
|
[DISPLAY_THEME_VARS.textPrimary]: '#e6edf3',
|
||||||
|
[DISPLAY_THEME_VARS.textMuted]: '#8b949e',
|
||||||
|
[DISPLAY_THEME_VARS.textDim]: '#484f58',
|
||||||
|
[DISPLAY_THEME_VARS.sevOk]: '#3fb950',
|
||||||
|
[DISPLAY_THEME_VARS.sevWarn]: '#d29922',
|
||||||
|
[DISPLAY_THEME_VARS.sevCrit]: '#f85149',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Light theme override values, applied when data-cf-display-theme="light". */
|
||||||
|
export const DISPLAY_THEME_DEFAULTS_LIGHT: Record<string, string> = {
|
||||||
|
[DISPLAY_THEME_VARS.surface]: '#ffffff',
|
||||||
|
[DISPLAY_THEME_VARS.surfaceRaised]: '#f6f8fa',
|
||||||
|
[DISPLAY_THEME_VARS.surfaceBorder]: '#d0d7de',
|
||||||
|
[DISPLAY_THEME_VARS.accent]: '#1f883d',
|
||||||
|
[DISPLAY_THEME_VARS.accentMuted]: '#0969da',
|
||||||
|
[DISPLAY_THEME_VARS.textPrimary]: '#1f2328',
|
||||||
|
[DISPLAY_THEME_VARS.textMuted]: '#59636e',
|
||||||
|
[DISPLAY_THEME_VARS.textDim]: '#8c959f',
|
||||||
|
[DISPLAY_THEME_VARS.sevOk]: '#1a7f37',
|
||||||
|
[DISPLAY_THEME_VARS.sevWarn]: '#9a6700',
|
||||||
|
[DISPLAY_THEME_VARS.sevCrit]: '#cf222e',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DisplayProfile = 'sysadmin' | 'gamer' | 'pro' | 'casual'
|
||||||
|
export type DisplayOrientation = 'landscape' | 'portrait'
|
||||||
|
export type DisplaySeverity = 'ok' | 'warn' | 'crit'
|
||||||
|
|
||||||
|
/** Cosmetic-only hints per profile — products may ignore or extend these. */
|
||||||
|
export const DISPLAY_PROFILE_DENSITY: Record<DisplayProfile, 'dense' | 'bold' | 'clean' | 'spacious'> = {
|
||||||
|
sysadmin: 'dense',
|
||||||
|
gamer: 'bold',
|
||||||
|
pro: 'clean',
|
||||||
|
casual: 'spacious',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnoCSS theme extension fragment. Spread into a consuming product's own
|
||||||
|
* `uno.config.ts` theme block (`theme: { colors: { ...displayUnoTheme.colors } }`)
|
||||||
|
* so `uno-*` utility classes referencing these tokens are available.
|
||||||
|
*/
|
||||||
|
export const displayUnoTheme = {
|
||||||
|
colors: {
|
||||||
|
cfDisplay: {
|
||||||
|
surface: `var(${DISPLAY_THEME_VARS.surface})`,
|
||||||
|
surfaceRaised: `var(${DISPLAY_THEME_VARS.surfaceRaised})`,
|
||||||
|
surfaceBorder: `var(${DISPLAY_THEME_VARS.surfaceBorder})`,
|
||||||
|
accent: `var(${DISPLAY_THEME_VARS.accent})`,
|
||||||
|
accentMuted: `var(${DISPLAY_THEME_VARS.accentMuted})`,
|
||||||
|
textPrimary: `var(${DISPLAY_THEME_VARS.textPrimary})`,
|
||||||
|
textMuted: `var(${DISPLAY_THEME_VARS.textMuted})`,
|
||||||
|
textDim: `var(${DISPLAY_THEME_VARS.textDim})`,
|
||||||
|
sevOk: `var(${DISPLAY_THEME_VARS.sevOk})`,
|
||||||
|
sevWarn: `var(${DISPLAY_THEME_VARS.sevWarn})`,
|
||||||
|
sevCrit: `var(${DISPLAY_THEME_VARS.sevCrit})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnoCSS shortcuts fragment, per the strip display spec's "two additions
|
||||||
|
* needed": display-metric, display-macro, display-alert. Spread into
|
||||||
|
* `shortcuts: { ...displayUnoShortcuts }`.
|
||||||
|
*/
|
||||||
|
export const displayUnoShortcuts: Record<string, string> = {
|
||||||
|
'display-metric': 'flex flex-col items-center justify-center rounded-md px-3 py-2 min-w-24',
|
||||||
|
// 44px is the accessibility-minimum touch target; strip displays prefer 64px+.
|
||||||
|
'display-macro': 'flex flex-col items-center justify-center rounded-lg min-h-16 min-w-16 select-none',
|
||||||
|
'display-alert': 'flex items-center gap-2 border-l-4 px-2 py-1 text-sm truncate',
|
||||||
|
}
|
||||||
12
packages/display/tsconfig.json
Normal file
12
packages/display/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/tests"]
|
||||||
|
}
|
||||||
25
packages/display/vite.config.ts
Normal file
25
packages/display/vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
// vue-tsc --emitDeclarationOnly runs before this and writes .d.ts files
|
||||||
|
// into dist/ — don't let Vite's default emptyOutDir wipe them out.
|
||||||
|
emptyOutDir: false,
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
|
name: 'CircuitForgeDisplay',
|
||||||
|
fileName: 'circuitforge-display',
|
||||||
|
formats: ['es', 'cjs'],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['vue'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/tests/**/*.spec.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue