import { designToken } from "@ekklesia/design-tokens"; import { SHELL_ICON_SIZE } from "./shell-icons.js"; /** The phone header's own height: one large control plus a little room above and below it. */ const MOBILE_BAR_HEIGHT = `calc(${designToken("control.height-lg")} + 2 * ${designToken("spacing.xs")})`; /** * The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover and * `:focus-visible` — for the shell's navigation, account menu, tooltip, and drawer. Durations are * motion tokens, so reduced motion needs no rule here (ADR 0060). * * Every value is a design token (ADR 0039), never a literal colour. Every class is `ek-shell-` * prefixed, so nothing here can collide with `@ekklesia/ui`'s `ek-` elements, and it is installed * into the shell's own document only: each module artifact still mounts in its own isolated root * (ADR 0040), which this stylesheet cannot reach. */ export const SHELL_STYLESHEET = ` .ek-shell-visually-hidden { border: 0; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; white-space: nowrap; width: 1px; } /* The authenticated shell's frame. dvh where the browser has it, so a phone's collapsing browser chrome never leaves the page a toolbar's height too tall. */ .ek-shell-root { min-height: 100vh; min-height: 100dvh; } /* The page's own region. A phone gets a slimmer margin than a desktop, and on a device with a notch or a home indicator the page's edges keep clear of them. */ .ek-shell-main { padding: ${designToken("spacing.xl")}; padding-bottom: calc(${designToken("spacing.xl")} + env(safe-area-inset-bottom, 0px)); padding-right: calc(${designToken("spacing.xl")} + env(safe-area-inset-right, 0px)); } .ek-shell-root[data-layout="compact"] .ek-shell-main { padding: ${designToken("spacing.lg")}; padding-bottom: calc(${designToken("spacing.lg")} + env(safe-area-inset-bottom, 0px)); padding-left: calc(${designToken("spacing.lg")} + env(safe-area-inset-left, 0px)); padding-right: calc(${designToken("spacing.lg")} + env(safe-area-inset-right, 0px)); } .ek-shell-sidebar { background: ${designToken("background.canvas")}; border-right: ${designToken("border.width")} solid ${designToken("border.divider")}; box-sizing: border-box; display: flex; flex-direction: column; gap: ${designToken("spacing.md")}; height: 100vh; height: 100dvh; padding: ${designToken("spacing.md")}; /* A landscape phone wide enough for the sidebar puts its left edge beside the notch, and its account area above the home indicator. */ padding-bottom: calc(${designToken("spacing.md")} + env(safe-area-inset-bottom, 0px)); padding-left: calc(${designToken("spacing.md")} + env(safe-area-inset-left, 0px)); position: sticky; top: 0; transition: width ${designToken("motion.duration-short")} ease; } .ek-shell-sidebar__top { align-items: center; display: flex; gap: ${designToken("spacing.sm")}; justify-content: space-between; min-height: ${designToken("control.height-md")}; } .ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__top { flex-direction: column; } .ek-shell-sidebar__scroll { display: grid; flex: 1 1 auto; align-content: start; gap: ${designToken("spacing.xl")}; min-height: 0; overflow-y: auto; } /* The rail is exactly as wide as its items. A scrollbar would take its width from one side and push every icon off centre, so the rail scrolls without one (wheel, touch, and keyboard focus still scroll it). */ .ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__scroll { scrollbar-width: none; } .ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__scroll::-webkit-scrollbar { display: none; } .ek-shell-sidebar__bottom { border-top: ${designToken("border.width")} solid ${designToken("border.divider")}; padding-top: ${designToken("spacing.sm")}; position: relative; } .ek-shell-brand { align-items: center; color: ${designToken("text.primary")}; display: flex; font-size: ${designToken("typography.size-md")}; font-weight: ${designToken("typography.weight-bold")}; gap: ${designToken("spacing.sm")}; min-width: 0; } .ek-shell-brand__mark { background: ${designToken("accent.base")}; border-radius: ${designToken("radius.md")}; box-shadow: inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}; display: block; flex: 0 0 auto; height: ${designToken("spacing.xl")}; width: ${designToken("spacing.xl")}; } .ek-shell-nav, .ek-shell-nav__group { display: grid; gap: ${designToken("spacing.2xs")}; } .ek-shell-nav { gap: ${designToken("spacing.lg")}; } .ek-shell-nav__group-label { color: ${designToken("text.muted")}; font-size: ${designToken("typography.size-xs")}; font-weight: ${designToken("typography.weight-medium")}; letter-spacing: 0.05em; margin: 0 0 ${designToken("spacing.2xs")}; padding-inline: ${designToken("control.padding-inline")}; text-transform: uppercase; } .ek-shell-nav__nested { border-inline-start: ${designToken("border.width")} solid ${designToken("border.divider")}; display: grid; gap: ${designToken("spacing.xs")}; margin-block-start: ${designToken("spacing.xs")}; margin-inline-start: ${designToken("spacing.lg")}; padding-inline-start: ${designToken("spacing.xs")}; } /* A parent's children — a module's pages, Discover's Installed and Updates (ADR 0064). The guide line runs down from beneath the parent's icon, and the children's text starts past the parent's label, so the relationship reads at a glance. */ .ek-shell-nav__children { border-inline-start: ${designToken("border.width")} solid ${designToken("border.divider")}; display: grid; gap: ${designToken("spacing.2xs")}; margin: ${designToken("spacing.2xs")} 0 ${designToken("spacing.xs")} calc(${designToken("control.padding-inline")} + ${SHELL_ICON_SIZE / 2}px); padding: 0 0 0 ${designToken("spacing.lg")}; } .ek-shell-nav__divider { border-top: ${designToken("border.width")} solid ${designToken("border.divider")}; margin: 0 ${designToken("spacing.xs")}; } /* A navigation link (DashboardLink). Its layout and open-page look live here, never inline, so the collapsed rail below can override them (ADR 0064). */ .ek-shell-link { justify-content: flex-start; text-decoration: none; width: 100%; } .ek-shell-link[aria-current="page"], .ek-shell-link[aria-current="page"]:hover:not(:disabled) { background: ${designToken("state.selected-background")}; color: ${designToken("state.selected-foreground")}; font-weight: ${designToken("typography.weight-bold")}; } /* The parent of the open page: named in the primary colour, its icon in the accent, and no fill — the fill belongs to the open page alone. */ .ek-shell-link[data-nav-ancestor] { color: ${designToken("text.primary")}; font-weight: ${designToken("typography.weight-bold")}; } .ek-shell-link[data-nav-ancestor] > svg { color: ${designToken("accent.strong")}; } .ek-shell-nav-item { box-sizing: border-box; gap: ${designToken("spacing.sm")}; height: auto; justify-content: flex-start; text-align: start; } .ek-shell-nav-item svg { flex: 0 0 auto; } .ek-shell-nav-item[data-nav-level="child"]:not([aria-current="page"]) { font-weight: ${designToken("typography.weight-regular")}; } .ek-shell-nav-item__label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ek-shell-nav-item__disclosure { color: ${designToken("text.muted")}; display: inline-flex; flex: 0 0 auto; transition: transform ${designToken("motion.duration-short")} ease; } .ek-shell-nav-item__disclosure[data-open="true"] { transform: rotate(90deg); } /* Collapsed, every item is the same square, centred on the rail, with its glyph centred in it — whatever its level, and whatever the expanded layout's padding, gap, or alignment would say. */ .ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-nav-item { /* A block-level flex box, so the auto margins centre it on the rail. */ display: flex; gap: 0; height: ${designToken("control.height-md")}; justify-content: center; margin-inline: auto; padding: 0; width: ${designToken("control.height-md")}; } .ek-shell-settings-link { align-items: center; border-radius: ${designToken("radius.md")}; box-sizing: border-box; color: ${designToken("text.primary")}; display: flex; gap: ${designToken("spacing.md")}; padding: ${designToken("spacing.sm")} ${designToken("spacing.md")}; text-decoration: none; } .ek-shell-settings-link:hover { background: ${designToken("state.hover-background")}; } .ek-shell-settings-link:focus-visible { outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")}; outline-offset: ${designToken("state.focus-visible-offset")}; } .ek-shell-settings-link svg { color: ${designToken("text.secondary")}; flex: 0 0 auto; } .ek-shell-settings-link__copy { display: grid; gap: ${designToken("spacing.2xs")}; min-width: 0; } .ek-shell-settings-link__label { font-weight: ${designToken("typography.weight-medium")}; } .ek-shell-settings-link__description { color: ${designToken("text.secondary")}; font-size: ${designToken("typography.size-sm")}; } /* Settings → Displays: one card per paired screen, wrapping onto two lines on a phone. */ .ek-shell-display-list { display: grid; gap: ${designToken("spacing.sm")}; list-style: none; margin: 0; padding: 0; } .ek-shell-display-card { align-items: center; border: ${designToken("border.width")} solid ${designToken("border.base")}; border-radius: ${designToken("radius.md")}; box-sizing: border-box; display: flex; flex-wrap: wrap; gap: ${designToken("spacing.sm")} ${designToken("spacing.md")}; padding: ${designToken("spacing.md")}; } .ek-shell-display-card__icon { align-items: center; background: ${designToken("background.inset")}; border-radius: ${designToken("radius.md")}; color: ${designToken("text.secondary")}; display: inline-flex; flex: 0 0 auto; height: ${designToken("control.height-md")}; justify-content: center; width: ${designToken("control.height-md")}; } .ek-shell-display-card__copy { display: grid; flex: 1 1 12rem; gap: ${designToken("spacing.2xs")}; min-width: 0; } .ek-shell-display-card__title { align-items: center; display: flex; flex-wrap: wrap; gap: ${designToken("spacing.sm")}; } .ek-shell-display-card__name { color: ${designToken("text.primary")}; font-weight: ${designToken("typography.weight-bold")}; overflow-wrap: anywhere; } .ek-shell-display-card__facts { color: ${designToken("text.secondary")}; font-size: ${designToken("typography.size-sm")}; margin: 0; } /* The Settings overview (ADR 0064): what state the installation is in, a card per area. */ .ek-shell-overview { display: grid; gap: ${designToken("spacing.md")}; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); } .ek-shell-overview--compact { grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); } .ek-shell-summary { background: ${designToken("background.surface")}; 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.sm")}; grid-template-rows: auto 1fr auto; min-width: 0; padding: ${designToken("spacing.lg")}; } .ek-shell-summary__heading { align-items: center; color: ${designToken("text.secondary")}; display: flex; font-size: ${designToken("typography.size-sm")}; font-weight: ${designToken("typography.weight-medium")}; gap: ${designToken("spacing.sm")}; margin: 0; } .ek-shell-summary__status { color: ${designToken("text.primary")}; font-size: ${designToken("typography.size-lg")}; font-weight: ${designToken("typography.weight-bold")}; margin: 0; } .ek-shell-summary--compact .ek-shell-summary__status { font-size: ${designToken("typography.size-md")}; font-weight: ${designToken("typography.weight-medium")}; } .ek-shell-summary__detail { color: ${designToken("text.secondary")}; font-size: ${designToken("typography.size-sm")}; margin: 0; } .ek-shell-summary__links { display: flex; flex-wrap: wrap; gap: ${designToken("spacing.xs")} ${designToken("spacing.md")}; list-style: none; margin: 0; padding: 0; } .ek-shell-summary__link { border-radius: ${designToken("radius.sm")}; color: ${designToken("accent.strong")}; font-size: ${designToken("typography.size-sm")}; font-weight: ${designToken("typography.weight-medium")}; text-decoration: none; } .ek-shell-summary__link:hover { text-decoration: underline; } .ek-shell-summary__link:focus-visible { outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")}; outline-offset: ${designToken("state.focus-visible-offset")}; } .ek-shell-tooltip { background: ${designToken("background.raised")}; border: ${designToken("border.width")} solid ${designToken("border.base")}; border-radius: ${designToken("radius.sm")}; box-shadow: ${designToken("shadow.md")}; color: ${designToken("text.primary")}; font-size: ${designToken("typography.size-xs")}; font-weight: ${designToken("typography.weight-medium")}; padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")}; position: fixed; transform: translateY(-50%); white-space: nowrap; z-index: 30; } .ek-shell-menu { background: ${designToken("background.raised")}; border: ${designToken("border.width")} solid ${designToken("border.base")}; border-radius: ${designToken("radius.md")}; box-shadow: ${designToken("shadow.md")}; bottom: calc(100% + ${designToken("spacing.xs")}); box-sizing: border-box; display: grid; gap: ${designToken("spacing.2xs")}; left: 0; margin: 0; max-width: min(20rem, calc(100vw - 2 * ${designToken("spacing.md")})); min-width: 12rem; padding: ${designToken("spacing.xs")}; position: absolute; /* As wide as a name and email need, within the cap, rather than the width of the sidebar. */ width: max-content; z-index: 20; } .ek-shell-menu__caption { color: ${designToken("text.muted")}; font-size: ${designToken("typography.size-xs")}; margin: 0; padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")}; } .ek-shell-menu__item { justify-content: flex-start; width: 100%; } .ek-shell-menu__items { display: grid; gap: ${designToken("spacing.2xs")}; } .ek-shell-menu__identity { border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")}; display: grid; gap: ${designToken("spacing.2xs")}; min-width: 0; padding: ${designToken("spacing.xs")} ${designToken("spacing.sm")} ${designToken("spacing.sm")}; } .ek-shell-menu__identity-name { color: ${designToken("text.primary")}; font-weight: ${designToken("typography.weight-medium")}; overflow-wrap: anywhere; } .ek-shell-menu__identity-email { color: ${designToken("text.secondary")}; font-size: ${designToken("typography.size-sm")}; overflow-wrap: anywhere; } .ek-shell-account-trigger__copy { display: grid; min-width: 0; text-align: start; } .ek-shell-choice-group { border: 0; margin: 0; min-width: 0; padding: 0; } .ek-shell-choice-group__legend { color: ${designToken("text.primary")}; font-size: ${designToken("typography.size-heading-3")}; font-weight: ${designToken("typography.weight-bold")}; padding: 0; } .ek-shell-choice-group__description { color: ${designToken("text.secondary")}; font-size: ${designToken("typography.size-sm")}; margin: ${designToken("spacing.2xs")} 0 ${designToken("spacing.md")}; } .ek-shell-choice-group__options { display: grid; gap: ${designToken("spacing.xs")}; } /* One option of a radio group: the whole row is the label, so the whole row is the touch target. The chosen option is marked by the radio itself and by weight as well as by its background. */ .ek-shell-choice { align-items: flex-start; border: ${designToken("border.width")} solid ${designToken("border.control")}; border-radius: ${designToken("radius.md")}; box-sizing: border-box; color: ${designToken("text.primary")}; cursor: pointer; display: flex; gap: ${designToken("spacing.md")}; padding: ${designToken("spacing.sm")} ${designToken("spacing.md")}; } .ek-shell-choice:hover { background: ${designToken("state.hover-background")}; } .ek-shell-choice:has(.ek-shell-choice__input:focus-visible) { outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")}; outline-offset: ${designToken("state.focus-visible-offset")}; } .ek-shell-choice[data-checked="true"] { background: ${designToken("state.selected-background")}; color: ${designToken("state.selected-foreground")}; } /* The radio itself is @ekklesia/ui's shared indicator (ek-radio__input), larger here because the whole row is the target and the radio is its one visual anchor. */ .ek-shell-choice .ek-shell-choice__input { --ek-choice-size: 1.25rem; margin: 0.125rem 0 0; } /* The whole row carries the focus ring (above), so the radio inside it draws none of its own. */ .ek-shell-choice .ek-shell-choice__input:focus-visible { outline: none; } .ek-shell-choice__copy { display: grid; gap: ${designToken("spacing.2xs")}; min-width: 0; } .ek-shell-choice__label { font-weight: ${designToken("typography.weight-medium")}; } .ek-shell-choice[data-checked="true"] .ek-shell-choice__label { font-weight: ${designToken("typography.weight-bold")}; } .ek-shell-choice__description { color: ${designToken("text.secondary")}; font-size: ${designToken("typography.size-sm")}; } .ek-shell-choice[data-checked="true"] .ek-shell-choice__description { color: ${designToken("state.selected-foreground")}; } .ek-shell-account-trigger__email { color: ${designToken("text.secondary")}; font-size: ${designToken("typography.size-xs")}; } /* The phone header (ADR 0064): the menu trigger and the open page's name, kept at the top while the page scrolls, below the status bar or notch (viewport-fit=cover). Not a desktop top bar: it holds nothing else. */ .ek-shell-mobile-bar { align-items: center; background: ${designToken("background.canvas")}; border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")}; box-sizing: border-box; display: flex; gap: ${designToken("spacing.sm")}; min-height: calc(${MOBILE_BAR_HEIGHT} + env(safe-area-inset-top, 0px)); padding: calc(${designToken("spacing.xs")} + env(safe-area-inset-top, 0px)) calc(${designToken("spacing.sm")} + env(safe-area-inset-right, 0px)) ${designToken("spacing.xs")} calc(${designToken("spacing.sm")} + env(safe-area-inset-left, 0px)); position: sticky; top: 0; z-index: 10; } .ek-shell-mobile-bar__title { color: ${designToken("text.primary")}; font-size: ${designToken("typography.size-md")}; font-weight: ${designToken("typography.weight-medium")}; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Something scrolled into view — a focused field, a fragment — lands below the header, not under it. */ html:has(.ek-shell-mobile-bar) { scroll-padding-top: calc(${MOBILE_BAR_HEIGHT} + env(safe-area-inset-top, 0px) + ${designToken("spacing.sm")}); } .ek-shell-drawer-backdrop { background: ${designToken("background.overlay")}; inset: 0; position: fixed; z-index: 40; } .ek-shell-drawer { background: ${designToken("background.canvas")}; box-shadow: ${designToken("shadow.lg")}; box-sizing: border-box; display: flex; flex-direction: column; gap: ${designToken("spacing.md")}; inset: 0 auto 0 0; max-width: 85vw; outline: none; padding: ${designToken("spacing.md")}; padding-bottom: calc(${designToken("spacing.md")} + env(safe-area-inset-bottom, 0px)); padding-left: calc(${designToken("spacing.md")} + env(safe-area-inset-left, 0px)); padding-top: calc(${designToken("spacing.md")} + env(safe-area-inset-top, 0px)); position: fixed; width: 20rem; z-index: 41; } /* The sidebar's transition lasts a motion token, which is zero when motion is reduced (ADR 0060). */ `;