feat: establish shared Ekklesia design system
CI / verify (pull_request) Successful in 48s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m47s

This commit is contained in:
ekklesia
2026-09-30 18:21:09 -05:00
parent 3ca6b2cb32
commit 3a26ef49ad
9 changed files with 336 additions and 110 deletions
+60 -59
View File
@@ -1,3 +1,5 @@
import { designToken } from "@ekklesia/design-tokens";
import { Button, Field, TextInput } from "@ekklesia/ui";
import { type FormEvent, type ReactElement, useCallback, useEffect, useState } from "react";
import {
approveDevicePairing,
@@ -19,33 +21,27 @@ import {
* a viewer without it is told nothing about which devices exist.
*/
const panelStyles = {
action: {
background: "transparent",
border: "1px solid #d6d3d1",
borderRadius: "0.25rem",
cursor: "pointer",
font: "inherit",
padding: "0.25rem 0.5rem",
meta: {
color: designToken("text.muted"),
fontSize: designToken("typography.size-xs"),
margin: 0,
},
input: {
border: "1px solid #d6d3d1",
borderRadius: "0.25rem",
font: "inherit",
minWidth: 0,
padding: "0.25rem 0.5rem",
width: "100%",
},
meta: { color: "#78716c", fontSize: "0.8125rem", margin: 0 },
row: {
alignItems: "center",
borderTop: "1px solid #e7e5e4",
borderTop: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
gap: "0.5rem",
gap: designToken("spacing.sm"),
justifyContent: "space-between",
padding: "0.375rem 0",
padding: `${designToken("spacing.sm")} 0`,
},
section: { display: "grid", gap: designToken("spacing.sm") },
title: {
color: designToken("text.secondary"),
fontSize: designToken("typography.size-xs"),
fontWeight: designToken("typography.weight-bold"),
letterSpacing: "0.05em",
margin: 0,
},
section: { display: "grid", gap: "0.5rem" },
title: { fontSize: "0.8125rem", fontWeight: 600, letterSpacing: "0.04em", margin: 0 },
} as const;
type PairingStep =
@@ -123,52 +119,58 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
<section data-testid="host-devices" style={panelStyles.section}>
<h2 style={panelStyles.title}>DISPLAY DEVICES</h2>
<form onSubmit={(event) => void lookUp(event)} style={{ display: "grid", gap: "0.375rem" }}>
<label htmlFor="device-pairing-code" style={panelStyles.meta}>
Pairing code shown on the screen
</label>
<input
autoComplete="off"
data-testid="device-pairing-code-input"
id="device-pairing-code"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value)}
style={panelStyles.input}
value={code}
/>
<button data-testid="device-pairing-lookup" style={panelStyles.action} type="submit">
<form
onSubmit={(event) => void lookUp(event)}
style={{ display: "grid", gap: designToken("spacing.sm") }}
>
<Field label="Pairing code shown on the screen">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-code-input"
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value)}
value={code}
/>
)}
</Field>
<Button data-testid="device-pairing-lookup" size="sm" type="submit">
Find display
</button>
</Button>
</form>
{pairing.step === "found" ? (
<div data-testid="device-pairing-found" style={{ display: "grid", gap: "0.375rem" }}>
<div
data-testid="device-pairing-found"
style={{ display: "grid", gap: designToken("spacing.sm") }}
>
<p style={panelStyles.meta}>
A display is asking to show <strong>{pairing.moduleId}</strong>.
</p>
<label htmlFor="device-pairing-name" style={panelStyles.meta}>
Name this display
</label>
<input
autoComplete="off"
data-testid="device-pairing-name"
id="device-pairing-name"
maxLength={128}
onChange={(event) => setName(event.target.value)}
placeholder="Foyer TV"
style={panelStyles.input}
value={name}
/>
<button
<Field label="Name this display">
{(control) => (
<TextInput
{...control}
autoComplete="off"
data-testid="device-pairing-name"
maxLength={128}
onChange={(event) => setName(event.target.value)}
placeholder="Foyer TV"
value={name}
/>
)}
</Field>
<Button
data-testid="device-pairing-approve"
disabled={name.trim().length === 0}
onClick={() => void approve()}
style={panelStyles.action}
type="button"
size="sm"
variant="primary"
>
Approve
</button>
</Button>
</div>
) : undefined}
@@ -191,14 +193,13 @@ export function DevicePanel(props: DevicePanelProps): ReactElement | undefined {
{device.revoked ? " (revoked)" : ""}
</span>
{device.revoked ? undefined : (
<button
<Button
data-testid={`device-revoke-${device.id}`}
onClick={() => void revoke(device.id)}
style={panelStyles.action}
type="button"
size="sm"
>
Revoke
</button>
</Button>
)}
</div>
))}
+29 -10
View File
@@ -114,11 +114,22 @@ export interface HostAppProps {
const shellStyles = {
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
aside: {
background: designToken("background.canvas"),
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
boxSizing: "border-box",
minWidth: "13rem",
padding: designToken("spacing.lg"),
width: "13rem",
minWidth: "15rem",
overflowY: "auto",
padding: designToken("spacing.xl"),
width: "15rem",
},
brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") },
brandMark: {
background: designToken("accent.base"),
borderRadius: designToken("radius.md"),
boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`,
display: "block",
height: designToken("spacing.xl"),
width: designToken("spacing.xl"),
},
groupLabel: {
color: designToken("text.muted"),
@@ -129,20 +140,25 @@ const shellStyles = {
},
header: {
alignItems: "center",
background: designToken("background.surface"),
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
fontWeight: designToken("typography.weight-bold"),
gridColumn: "1 / -1",
justifyContent: "space-between",
padding: `${designToken("spacing.md")} ${designToken("spacing.lg")}`,
padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`,
},
heading: {
fontSize: designToken("typography.size-lg"),
fontWeight: designToken("typography.weight-bold"),
margin: 0,
},
heading: { font: "inherit", margin: 0 },
layout: {
background: designToken("background.page"),
color: designToken("text.primary"),
display: "grid",
fontFamily: designToken("typography.family-sans"),
gridTemplateColumns: "13rem minmax(0, 1fr)",
gridTemplateColumns: "15rem minmax(0, 1fr)",
gridTemplateRows: "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
@@ -152,7 +168,7 @@ const shellStyles = {
boxSizing: "border-box",
minWidth: 0,
overflowX: "auto",
padding: designToken("spacing.lg"),
padding: designToken("spacing.xl"),
},
message: {
color: designToken("text.secondary"),
@@ -543,8 +559,11 @@ export function HostApp(props: HostAppProps): ReactElement {
return (
<div data-testid="host-shell" style={shellStyles.layout}>
<header style={shellStyles.header}>
<h1 style={shellStyles.heading}>Ekklesia</h1>
<Button data-testid="host-logout" onClick={() => void signOut()}>
<div style={shellStyles.brand}>
<span aria-hidden="true" style={shellStyles.brandMark} />
<h1 style={shellStyles.heading}>Ekklesia</h1>
</div>
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
Sign out
</Button>
</header>
@@ -555,7 +574,7 @@ export function HostApp(props: HostAppProps): ReactElement {
No installed module provides a user interface.
</p>
) : (
<nav aria-label="Modules" style={{ display: "grid", gap: "1rem" }}>
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
{groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
+2 -2
View File
@@ -979,9 +979,9 @@ describe("Ekklesia shell — mount lifecycle", () => {
const aside = shell.querySelector("aside") as HTMLElement;
const main = shell.querySelector("main") as HTMLElement;
const frame = query("module-container") as HTMLElement;
expect(shell.style.gridTemplateColumns).toBe("13rem minmax(0, 1fr)");
expect(shell.style.gridTemplateColumns).toBe("15rem minmax(0, 1fr)");
expect(shell.style.gridTemplateRows).toBe("auto minmax(0, 1fr)");
expect(aside.style.width).toBe("13rem");
expect(aside.style.width).toBe("15rem");
expect(main.style.minWidth).toBe("0");
expect(main.style.overflowX).toBe("auto");
expect(frame.style.minWidth).toBe("0");