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
+42 -30
View File
@@ -12,7 +12,7 @@ import {
* every consumer's meaning changing with it: `background.raised` stays "the surface that sits above
* an ordinary surface" whatever a church's brand makes it.
*
* The default values are a neutral, high-contrast light theme. They are defaults, not a brand —
* The default values are Ekklesia's calm, teal-accented light theme. They remain semantic defaults —
* a deployment overrides them by installing different values at its host root, and this pass
* deliberately ships no theme editor, brand builder, or dark-mode variant to do that with.
*
@@ -31,14 +31,14 @@ const declarations = [
description: "The furthest-back background of a whole page or a full-viewport display.",
label: "Page background",
name: "background.page",
value: "#f6f6f5",
value: "#f3f7f6",
},
{
description:
"A large working area inside a page — a scrolling list region, a preview area, an editor body.",
label: "Canvas background",
name: "background.canvas",
value: "#fbfbfa",
value: "#f8faf9",
},
{
description: "An ordinary panel, card, table, or dialog body sitting on the page.",
@@ -62,7 +62,7 @@ const declarations = [
description: "A recessed area such as a text input, a code block, or a well.",
label: "Inset background",
name: "background.inset",
value: "#f2f2f0",
value: "#edf3f1",
},
// ---------------------------------------------------------------------------------------------
@@ -72,19 +72,19 @@ const declarations = [
description: "Body copy and headings — the default foreground for readable content.",
label: "Primary text",
name: "text.primary",
value: "#1c1917",
value: "#182321",
},
{
description: "Supporting text that is still content: captions, secondary columns, help text.",
label: "Secondary text",
name: "text.secondary",
value: "#57534e",
value: "#4f5f5b",
},
{
description: "De-emphasised text such as placeholders, timestamps, and disabled labels.",
label: "Muted text",
name: "text.muted",
value: "#79716b",
value: "#6c7b77",
},
{
description: "Text drawn on a strongly coloured background, such as a primary button.",
@@ -118,31 +118,43 @@ const declarations = [
description: "The primary action colour: primary buttons, selected tabs, active links.",
label: "Accent",
name: "accent.base",
value: "#1d4ed8",
value: "#4fb3a8",
},
{
description: "The accent background under a hovered primary action.",
label: "Accent hover",
name: "accent.hover",
value: "#5ab9ae",
},
{
description: "The accent background while a primary action is pressed.",
label: "Accent active",
name: "accent.active",
value: "#45a59b",
},
{
description: "A deeper accent for pressed primary actions and high-emphasis emphasis.",
label: "Strong accent",
name: "accent.strong",
value: "#1e3a8a",
value: "#246b64",
},
{
description: "A faint accent wash for selected rows and subtle accent backgrounds.",
label: "Subtle accent",
name: "accent.subtle",
value: "#e8effc",
value: "#def2ef",
},
{
description: "Foreground drawn on top of the accent background.",
label: "Accent foreground",
name: "accent.foreground",
value: "#ffffff",
value: "#102a27",
},
{
description: "The focus ring colour. Never the only signal that something has focus.",
label: "Focus ring",
name: "accent.focus",
value: "#1d4ed8",
value: "#287a72",
},
// ---------------------------------------------------------------------------------------------
@@ -152,19 +164,19 @@ const declarations = [
description: "The default border around panels, inputs, and table cells.",
label: "Border",
name: "border.base",
value: "#d7d3d0",
value: "#d3dedb",
},
{
description: "A heavier border for emphasis, active inputs, and selected outlines.",
label: "Strong border",
name: "border.strong",
value: "#a8a29e",
value: "#91a6a1",
},
{
description: "A hairline rule separating rows or sections inside one surface.",
label: "Divider",
name: "border.divider",
value: "#e7e5e4",
value: "#e2eae8",
},
{
description: "The border width used by controls and panels.",
@@ -234,13 +246,13 @@ const declarations = [
description: "Background of a neutral informational badge or banner.",
label: "Info background",
name: "status.info-background",
value: "#e8effc",
value: "#def2ef",
},
{
description: "Border of a neutral informational badge or banner.",
label: "Info border",
name: "status.info-border",
value: "#bfd3f7",
value: "#9bd5ce",
},
{
description: "The non-colour signal for information, rendered beside the label.",
@@ -256,25 +268,25 @@ const declarations = [
description: "Background wash under a hovered control or row.",
label: "Hover background",
name: "state.hover-background",
value: "#f2f2f0",
value: "#edf3f1",
},
{
description: "Background wash under a control being pressed.",
label: "Pressed background",
name: "state.pressed-background",
value: "#e7e5e4",
value: "#e1ebe8",
},
{
description: "Background of a selected row, tab, or list item.",
label: "Selected background",
name: "state.selected-background",
value: "#e8effc",
value: "#def2ef",
},
{
description: "Foreground of a selected row, tab, or list item.",
label: "Selected foreground",
name: "state.selected-foreground",
value: "#1e3a8a",
value: "#205f59",
},
{
description: "Opacity applied to a disabled control. Disabled state is never colour alone.",
@@ -408,7 +420,7 @@ const declarations = [
description: "Spacing inside a compact control.",
label: "Extra small spacing",
name: "spacing.xs",
value: "0.25rem",
value: "0.375rem",
},
{
description: "Spacing between a label and its field.",
@@ -460,13 +472,13 @@ const declarations = [
description: "Corner radius for controls, inputs, and buttons.",
label: "Medium radius",
name: "radius.md",
value: "0.375rem",
value: "0.625rem",
},
{
description: "Corner radius for panels and dialogs.",
label: "Large radius",
name: "radius.lg",
value: "0.75rem",
value: "0.875rem",
},
{
description: "Fully rounded ends, for pills and status dots.",
@@ -484,19 +496,19 @@ const declarations = [
description: "Subtle lift for a resting panel.",
label: "Small shadow",
name: "shadow.sm",
value: "0 1px 2px rgba(23, 23, 21, 0.08)",
value: "0 1px 3px rgba(24, 35, 33, 0.08)",
},
{
description: "Shadow for a popover or a raised surface.",
label: "Medium shadow",
name: "shadow.md",
value: "0 4px 12px rgba(23, 23, 21, 0.12)",
value: "0 8px 24px rgba(24, 35, 33, 0.12)",
},
{
description: "Shadow for a modal dialog.",
label: "Large shadow",
name: "shadow.lg",
value: "0 16px 40px rgba(23, 23, 21, 0.22)",
value: "0 18px 48px rgba(24, 35, 33, 0.2)",
},
// ---------------------------------------------------------------------------------------------
@@ -506,19 +518,19 @@ const declarations = [
description: "Height of a compact control.",
label: "Small control height",
name: "control.height-sm",
value: "1.75rem",
value: "2rem",
},
{
description: "Height of an ordinary button, input, or select.",
label: "Medium control height",
name: "control.height-md",
value: "2.25rem",
value: "2.5rem",
},
{
description: "Height of a prominent primary control.",
label: "Large control height",
name: "control.height-lg",
value: "2.75rem",
value: "3rem",
},
{
description: "Horizontal padding inside a control.",
@@ -60,6 +60,8 @@ describe("the Ekklesia design token registry", () => {
"text.warning",
"text.danger",
"accent.base",
"accent.hover",
"accent.active",
"accent.focus",
"border.base",
"border.divider",
+28
View File
@@ -50,6 +50,34 @@ export interface ToastProps {
readonly children?: ReactNode;
}
export interface InlineNoticeProps {
readonly tone?: EkklesiaStatusTone;
readonly title?: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
}
/** A calm in-flow status or explanation that should not interrupt the current task. */
export function InlineNotice(props: InlineNoticeProps): ReactElement {
const tone = props.tone ?? "info";
return (
<div
className={classNames("ek-notice", `ek-notice--${tone}`, props.className)}
role={tone === "danger" ? "alert" : "status"}
>
<span aria-hidden="true" className="ek-notice__symbol">
{statusSymbols[tone]}
</span>
<div className="ek-notice__body">
{props.title === undefined ? undefined : (
<strong className="ek-notice__title">{props.title}</strong>
)}
{props.children}
</div>
</div>
);
}
/**
* One notification's presentation — and only its presentation.
*
+6
View File
@@ -52,6 +52,8 @@ export {
type EmptyStateProps,
ErrorState,
type ErrorStateProps,
InlineNotice,
type InlineNoticeProps,
LoadingState,
type LoadingStateProps,
Toast,
@@ -66,10 +68,14 @@ export {
installEkklesiaUiStyles,
} from "./styles.js";
export {
ActionBar,
type ActionBarProps,
Dialog,
type DialogProps,
Panel,
type PanelProps,
PageHeader,
type PageHeaderProps,
type TabDefinition,
Tabs,
type TabsProps,
+111 -7
View File
@@ -44,6 +44,7 @@ export const EKKLESIA_UI_STYLESHEET = `
align-items: center;
border: ${designToken("border.width")} solid transparent;
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
cursor: pointer;
display: inline-flex;
font-family: inherit;
@@ -72,11 +73,11 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-button--primary:hover:not(:disabled) {
background: ${designToken("accent.strong")};
background: ${designToken("accent.hover")};
}
.ek-button--primary:active:not(:disabled) {
background: ${designToken("accent.strong")};
background: ${designToken("accent.active")};
}
.ek-button--secondary {
@@ -160,9 +161,10 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-control {
background: ${designToken("background.inset")};
background: ${designToken("background.surface")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
color: ${designToken("text.primary")};
font-family: inherit;
font-size: ${designToken("typography.size-sm")};
@@ -212,9 +214,10 @@ export const EKKLESIA_UI_STYLESHEET = `
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.lg")};
box-shadow: ${designToken("shadow.sm")};
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.md")};
padding: ${designToken("spacing.lg")};
gap: ${designToken("spacing.lg")};
padding: ${designToken("spacing.xl")};
}
.ek-panel--raised {
@@ -223,12 +226,17 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-panel__header {
align-items: baseline;
align-items: flex-start;
display: flex;
gap: ${designToken("spacing.md")};
justify-content: space-between;
}
.ek-panel__heading {
display: grid;
gap: ${designToken("spacing.xs")};
}
.ek-panel__title {
font-size: ${designToken("typography.size-heading-3")};
font-weight: ${designToken("typography.weight-bold")};
@@ -281,7 +289,7 @@ export const EKKLESIA_UI_STYLESHEET = `
.ek-tabs__list {
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
display: flex;
gap: ${designToken("spacing.xs")};
gap: ${designToken("spacing.sm")};
}
.ek-tab {
@@ -293,6 +301,7 @@ export const EKKLESIA_UI_STYLESHEET = `
font-family: inherit;
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
border-radius: ${designToken("radius.md")} ${designToken("radius.md")} 0 0;
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
}
@@ -302,6 +311,7 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-tab[aria-selected="true"] {
background: ${designToken("accent.subtle")};
border-bottom-color: ${designToken("accent.base")};
color: ${designToken("state.selected-foreground")};
}
@@ -440,6 +450,7 @@ export const EKKLESIA_UI_STYLESHEET = `
}
.ek-message {
background: ${designToken("background.canvas")};
align-items: center;
border: ${designToken("border.width")} dashed ${designToken("border.base")};
border-radius: ${designToken("radius.lg")};
@@ -450,6 +461,99 @@ export const EKKLESIA_UI_STYLESHEET = `
text-align: center;
}
.ek-page-header {
align-items: flex-end;
display: flex;
gap: ${designToken("spacing.xl")};
justify-content: space-between;
}
.ek-page-header__copy {
display: grid;
gap: ${designToken("spacing.xs")};
}
.ek-page-header__eyebrow {
color: ${designToken("accent.strong")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-bold")};
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ek-page-header__title {
font-size: ${designToken("typography.size-heading-1")};
font-weight: ${designToken("typography.weight-bold")};
line-height: ${designToken("typography.line-height-tight")};
margin: 0;
}
.ek-page-header__description {
color: ${designToken("text.secondary")};
margin: 0;
max-width: 64ch;
}
.ek-page-header__actions,
.ek-action-bar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
}
.ek-notice {
align-items: flex-start;
background: ${designToken("background.inset")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
color: ${designToken("text.secondary")};
display: flex;
font-size: ${designToken("typography.size-sm")};
gap: ${designToken("spacing.sm")};
padding: ${designToken("spacing.md")};
}
.ek-notice--info {
background: ${designToken("status.info-background")};
border-color: ${designToken("status.info-border")};
}
.ek-notice--success {
background: ${designToken("status.success-background")};
border-color: ${designToken("status.success-border")};
}
.ek-notice--warning {
background: ${designToken("status.warning-background")};
border-color: ${designToken("status.warning-border")};
}
.ek-notice--danger {
background: ${designToken("status.danger-background")};
border-color: ${designToken("status.danger-border")};
color: ${designToken("text.danger")};
}
.ek-notice__symbol,
.ek-notice__title {
color: ${designToken("text.primary")};
font-weight: ${designToken("typography.weight-bold")};
}
.ek-notice__body {
display: grid;
gap: ${designToken("spacing.2xs")};
}
@media (max-width: 48rem) {
.ek-page-header,
.ek-panel__header {
align-items: stretch;
flex-direction: column;
}
}
.ek-message__title {
font-size: ${designToken("typography.size-lg")};
font-weight: ${designToken("typography.weight-bold")};
+56 -2
View File
@@ -1,9 +1,63 @@
import { type ReactElement, type ReactNode, useEffect, useId, useRef } from "react";
import {
type HTMLAttributes,
type ReactElement,
type ReactNode,
useEffect,
useId,
useRef,
} from "react";
import { classNames } from "./class-names.js";
/** Containers: the panel a screen is built from, the dialog that interrupts it, and the tab strip
* that divides it. */
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
readonly actions?: ReactNode;
readonly eyebrow?: ReactNode;
readonly className?: string;
}
/** The consistent top-level identity and primary-action region for an Ekklesia screen. */
export function PageHeader(props: PageHeaderProps): ReactElement {
return (
<header className={classNames("ek-page-header", props.className)}>
<div className="ek-page-header__copy">
{props.eyebrow === undefined ? undefined : (
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
)}
<h1 className="ek-page-header__title">{props.title}</h1>
{props.description === undefined ? undefined : (
<p className="ek-page-header__description">{props.description}</p>
)}
</div>
{props.actions === undefined ? undefined : (
<div className="ek-page-header__actions">{props.actions}</div>
)}
</header>
);
}
export interface ActionBarProps extends HTMLAttributes<HTMLDivElement> {
readonly children?: ReactNode;
readonly label?: string;
}
/** A wrapping row of related controls with predictable spacing and alignment. */
export function ActionBar(props: ActionBarProps): ReactElement {
const { children, className, label, ...rest } = props;
return (
<div
{...rest}
{...(label === undefined ? {} : { "aria-label": label })}
className={classNames("ek-action-bar", className)}
>
{children}
</div>
);
}
export interface PanelProps {
readonly title?: ReactNode;
readonly description?: ReactNode;
@@ -26,7 +80,7 @@ export function Panel(props: PanelProps): ReactElement {
>
{hasHeader ? (
<header className="ek-panel__header">
<div>
<div className="ek-panel__heading">
{props.title === undefined ? undefined : (
<h2 className="ek-panel__title">{props.title}</h2>
)}
+46 -45
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
<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"
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">
)}
</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
<Field label="Name this display">
{(control) => (
<TextInput
{...control}
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>
<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>
))}
+28 -9
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}>
<div style={shellStyles.brand}>
<span aria-hidden="true" style={shellStyles.brandMark} />
<h1 style={shellStyles.heading}>Ekklesia</h1>
<Button data-testid="host-logout" onClick={() => void signOut()}>
</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");