Dashboard UI Primitives
Overview
@lumio/ui provides presentation and interaction primitives used by the Stream History list and report. The app supplies translated text, data loading, links and permissions. Import the components and helpers from @lumio/ui rather than copying their layout or drawing code into a dashboard feature.
| Primitive | What it supplies | Current History use |
|---|---|---|
Sparkline and sparklineGeometry | A trend line in real SVG pixels, with an optional filled area and a peak or last marker | Viewer trends in the history table and the start page's recent streams, KPI trends in reports and on the start page |
StatTile and STAT_TILE_FRAME | A figure tile with icon, label, value, sub, aside and footer slots; the frame constant also fits its loading skeleton | Overview tiles and report KPIs |
CalendarHeatmap and its model helpers | A keyboard-accessible year grid with range highlighting, a legend and a day tooltip | Streaming activity calendar |
Button toolbar sizes and TOOLBAR_CONTROL_CLASS | Matching 36 px controls with rounded corners | History filters, export and sort controls |
Badge variants live and accent | Soft red and brand-tinted labels | Live and multistream indicators |
PlatformButton | A brand-coloured button that leads to a platform, with logo, pending state and corner badge | Sign-in, step-up, linking, channel and bot connect, reconnect |
LumioMark | The Lumio logo mark, brand (teal gradient) or admin (grey) | Every app header, footer and sign-in card |
Trend lines and tiles
Sparkline accepts numeric values, a required pixel height, an optional fixed width, area, marker (peak, last or none), and markerTone (line or destructive). Without a width it measures its container, so the line and dots keep their proportions. sparklineGeometry() returns the line and area paths, the peak and last coordinates, and points (the box coordinates of every value, null for a gap); fewer than two finite points return null.
An optional hover ({ label(index), value(value, index) }) turns on the hover mode: a pointer move, or tap-and-drag on touch, snaps to the nearest step with a value (sparklineNearestIndex()) and shows a vertical rule, a dot and a tooltip with the label and the formatted value. A gap shows "–", never 0. The tooltip uses the popover surface, appears without motion, stays inside the box horizontally and sits above it, so the parent must not clip it. On touch the step stays after the finger lifts and clears on the next tap outside. activeIndex and onActiveIndexChange make the step controlled, so several sparklines can show the same step. The component stays aria-hidden and adds no tab stop: hover mode is a pointer enhancement, and the accessible values belong in a full chart or table next to it. Without hover the sparkline is the static line.
The graphic is always aria-hidden and has no animation. Place the number and its meaning in nearby text. History uses a peak marker for finalized viewer trends and viewer/message KPIs, a red last marker for live viewer trends, and a last marker for follower growth. The per-session viewer trend is one component, ViewerTrend (apps/web/src/components/history/viewer-trend.tsx), used by both the history table and the start page's recent-stream rows, so the two cannot drift apart; it takes its width from className. The start page's KPI tiles plot one value per day, so they keep the area and mark the peak, like the per-step KPIs of a report.
StatTile renders its label, large value and secondary line with optional top-right aside and bottom footer. Its STAT_TILE_FRAME gives skeletons the same outer dimensions as the populated tile, so loading does not move the surrounding layout.
Activity calendar
CalendarHeatmap receives local-day buckets, today's local date, first weekday, locale, translated labels, and a describeCell callback for accessible day names. onSelectDay handles click, Enter and Space. Optional selectedFrom and selectedTo dim days outside a selection. The grid uses one tab stop and arrow-key navigation; on phones it scrolls horizontally toward today.
The grid fills its container's width and available height. Its seven rows share that height, with a width-derived square cell as the minimum: cells stay square in a width-bound card and grow taller when a neighboring card makes the row taller. The legend stays at the bottom. Give the component a height-bearing flex-column parent, as the History list's HeatmapFrame does, so the grid can use that extra space.
The component uses the shared Popover for a day tooltip on hover, focus or tap. The caller supplies its content through renderTooltip. History's ActivityHeatmap wrapper owns the translated stream details, any data fetch and the report link; the shared grid contains none of those app rules. See Frontend Overlays for the floating-panel behavior.
The model exports buildHeatmap, toggleDay, moveFocus, inRange, levelFor, HEATMAP_WEEKS, and the CalendarHeatmapDay / HeatmapCell types. Use them for calendar calculations rather than duplicating date-grid logic.
Toolbar controls and badges
Use Button sizes toolbar and toolbar-icon for filter rows. TOOLBAR_CONTROL_CLASS gives a SelectTrigger or popover trigger the same 36 px height, rounded-md corners and label weight. A Select menu is as wide as its widest option, including the full placeholder of a searchable menu's search field: never narrower than the trigger, and at most 24rem and the viewport width minus 16 px. Under a compact trigger every option therefore stays on one line. Near a window edge the menu keeps that width and moves instead of shrinking. It opens under the trigger's left edge when it fits there. Otherwise it lines up with the trigger's right edge and opens towards the left. Only when neither edge fits is it shifted to stay 8 px inside the window. Popover panels with align="start" or "end" flip between the two trigger edges the same way. A menu only gets narrower than its content when the viewport itself is narrower, as on a phone. Option rows then stay on one line, are truncated with an ellipsis, and show their full text as a tooltip on hover. The menu never scrolls sideways. The size is fixed when the menu opens, so typing in a searchable menu does not resize it. Pass matchTriggerWidth to SelectContent only when the menu must be exactly as wide as its trigger. Badge variant live is a soft red indicator; accent is a soft brand tint for a neutral tag such as Multi.
Text fields on phones
iOS Safari zooms the whole page in when a text field with a font size below 16 px gets focus. @lumio/ui/globals.css, which every app imports, therefore sets every text-entry field to at least 16 px below the sm breakpoint (640 px). That covers <input> (except checkbox, radio, range, colour, file and button types), <textarea>, <select> and contenteditable editors such as the chat input. From sm up nothing changes, so Input and the other primitives keep text-sm on desktop.
The rule is unlayered and !important, so it wins over a caller's text-xs or text-sm and over an inline style={{ fontSize }}. You do not need to write text-base sm:text-sm on a field. A field whose class is text-base or larger keeps its own size. A field that sets its size inline also sets --lm-field-font-size to the same value, for example style={{ fontSize: "0.9em", ["--lm-field-font-size" as string]: "0.9em" }}. The rule then uses whichever is larger, 16 px or that value, so a user-scaled popout field is never made smaller on a phone.
Never stop the zoom by capping the scale or turning off user scaling in a viewport export. That also blocks pinch-zoom, which people with low vision rely on. shared/ui/__tests__/mobile-field-font-size.test.tsx checks that the rule stays outside every @layer and that every field primitive matches it.
Platform buttons
Every button that leads to a platform uses PlatformButton from @lumio/ui: sign-in, linking, step-up, reconnect, and channel or bot connect. This applies in the ID app and in the dashboard. The button is filled with the platform's brand colour and shows the logo in the text colour. provider-button.ts (PLATFORM_BUTTON_SURFACES) holds the colours:
| Platform | Surface | Text and logo |
|---|---|---|
| Twitch | #9146FF | white (4.64:1) |
| Kick | #53FC18 | #0B0E0F |
| Discord | #5865F2 | white (4.61:1) |
| YouTube | #E6002E | white (4.77:1). Brand red #FF0033 reaches only 3.96:1. |
| Spotify | #1DB954 | #0B0E0F |
#FFFFFF, border #747775 | #1F1F1F, multicolour G |
The colours are literal values, not theme tokens, so they are the same in dark and light mode. Google's sign-in guidelines forbid recolouring its button, so Google uses its light variant in both themes and keeps the same shape and size as the others. An unknown platform falls back to the neutral outline surface.
- Sizes:
lgis 44 px (sign-in and step-up, full width),mdis 40 px (dialog footers, the default),smis 36 px (inside rows). - Logo:
pinLogo(the default atlg) pins the logo 14 px from the left edge and centres the label, so stacked buttons line up. Otherwise logo and label sit together in the centre (e.g. the smallsmbuttons on dashboard cards). The sign-in page stacks its provider buttons in one column with the logo pinned. - Pending: set
pendingandpendingLabel("Continuing to Twitch …"). The ring takes the logo's place, the width stays the same, the button is disabled witharia-busy, and the label is announced. - Extras:
badgeputs a label such as "Last used" on the top-right corner, outside the label area.trailingadds an icon after the label, e.g. the external-link arrow in the connect dialog.asChildrenders an<a href>child with the button's look.
The YouTube channel card shows the YouTube logo: connecting a channel grants API access and is not a sign-in. The connect dialog behind the card continues with Google, because that is where the browser goes.
The Lumio mark
Every Lumio logo in the apps is LumioMark from @lumio/ui. Never paste the SVG into a component.
- Size it with
className(h-8 w-8); the smallest size is 24 px. It is decorative (aria-hidden) unless it gets atitle, which makes itrole="img"with that label. Next to the visible word "Lumio", leave it decorative. - Variants:
brand(default) draws the teal gradient from the tokens--brand-gradient-startand--brand-gradient-endinshared/ui/src/globals.css. They are the same in light and dark: the mark is never recoloured.adminis the grey operator-app mark and is never used outside the admin app. - Without CSS (
next/ogshare images, e-mail):lumioMarkSvg()returns the same mark as an SVG string with literal colours. - Files for everything outside the apps (press, social profiles, video) are in
assets/brand/: the mark as SVG and as 512 and 1024 px PNG, the outlined wordmark and the lockup. Next'sicon.svgfiles, the docs-site logos and the e-mail logo are file copies;shared/ui/__tests__/lumio-mark.test.tsxfails when one of them drifts from the component's geometry or colours. - Seasonal looks: in the dashboard,
SeasonalLogoandSeasonalBrandshow the season's logo at Subtle and Full. The regular mark they receive aschildrenisLumioMark.
Typeface
The UI typeface is Roboto, served by each app itself. Nothing is fetched from Google at build time or in the browser.
- Source: the npm package
@fontsource-variable/roboto(pinned inpnpm-lock.yaml), a dependency ofapps/web,apps/admin,apps/idandapps/stats. One variable WOFF2 per style (normal, italic) covers weights 100 to 900; the latin subset covers English and German. Characters outside it fall back to the next font in the stack. - Loading: each app's
src/lib/fonts.tscallsnext/font/localwith those files. Next.js copies them to/_next/static/media/, preloads them, and generates a size-adjusted fallback face from the font's metrics, so the swap from the fallback to Roboto does not shift the layout. - Wiring: every root layout puts
roboto.variableon<html>, which defines--font-roboto. Inshared/ui/src/globals.css, the@themetokens--font-sans(read by thefont-sansutility on<body>),--font-bodyand--font-headingnamevar(--font-roboto)first.--font-monois unchanged. - New root layout: a new route group with its own
<html>importsrobotofrom@/lib/fontsand addsclassName={roboto.variable}to<html>.apps/web/__tests__/root-layout-font.test.tsfails when a root layout misses it. The overlay root layout (apps/web/src/app/(overlay)/layout.tsx) is exempt: stream output keeps its own fonts through the API font proxy.