# Viewership UI > Copy-in UI components for Astro. Server-rendered markup, vanilla TypeScript, themed with `--vs-*` tokens in one `foundation.css`. No React, no Tailwind, no runtime npm dependencies. ## How to use a component 1. Add `foundation.css` once (e.g. `src/styles/foundation.css`) and import it in your base layout. 2. Copy a component's folder into `src/components/viewership//`, and each dependency into its own sibling folder. 3. Import the `.astro` file and use it. Scripts boot themselves. Shortcut: `/components/.bundle.txt` has every file a component needs, each with its destination path. Credits: ported from [Beautiful UI](https://www.beautifului.dev) (MIT, © 2026 Shane Levine). ## Sections - [Hero Statement](https://viewership.ai/design/components/hero-statement.md): Centred headline and actions above a wide media panel. - [Hero Split](https://viewership.ai/design/components/hero-split.md): Headline and actions beside a media panel; stacks when narrow, flips with one prop. - [Hero Index](https://viewership.ai/design/components/hero-index.md): Oversized left-aligned headline over a ruled row of facts. Type-led, no media. - [Hero Poster](https://viewership.ai/design/components/hero-poster.md): Full-bleed image or video with the headline set over it, bottom left, behind a soft scrim. - [Feature Cards](https://viewership.ai/design/components/feature-cards.md): Features as a joined index grid: numbered cells sharing hairlines, display-face titles, optional tags and links. - [Feature Rules](https://viewership.ai/design/components/feature-rules.md): Headline pinned left, numbered features ruled like an index on the right. - [Feature Bento](https://viewership.ai/design/components/feature-bento.md): One large accent tile, three light tiles and a dark closer. Built for five features, with optional figures. - [Feature Alternating](https://viewership.ai/design/components/feature-alternating.md): Image and copy rows that swap sides as you scroll, for features that each deserve a picture. - [Testimonial Spotlight](https://viewership.ai/design/components/testimonial-spotlight.md): One customer quote set large in the display face, with up to three supporting quotes beneath. - [Testimonial Cards](https://viewership.ai/design/components/testimonial-cards.md): Quotes as an even row of cards with optional star ratings and initials avatars. - [Testimonial Wall](https://viewership.ai/design/components/testimonial-wall.md): Staggered columns of quotes in mixed tones. Takes quotes of any length. - [Logo Row](https://viewership.ai/design/components/logo-row.md): A lead-in line and one wrapping row of client or press logos. Names become wordmarks when there is no image. - [Logo Grid](https://viewership.ai/design/components/logo-grid.md): Logos in bordered cells beside a short statement, like a spec sheet. - [Logo Marquee](https://viewership.ai/design/components/logo-marquee.md): Logos drifting in a slow loop with faded edges. Pauses on hover, static under reduced motion. - [Stats Band](https://viewership.ai/design/components/stats-band.md): Headline figures on a dark band, each with a label, a footnote and a source line. - [Pricing Columns](https://viewership.ai/design/components/pricing-columns.md): Plans side by side as cards, with the featured plan inverted onto ink. - [Pricing Rows](https://viewership.ai/design/components/pricing-rows.md): One plan per ruled row with name, inclusions, price and action. Handles many plans and long lists. - [Pricing Commission](https://viewership.ai/design/components/pricing-commission.md): One lead offer shown large with the other packages as a quiet list. For services that quote by appointment. - [Pricing Toggle](https://viewership.ai/design/components/pricing-toggle.md): Plans with a monthly or yearly switch built from native radios and CSS. No JavaScript. - [FAQ Accordion](https://viewership.ai/design/components/faq-accordion.md): A centred column of expandable question cards on native details, with FAQPage structured data. - [FAQ Split](https://viewership.ai/design/components/faq-split.md): Headline and a contact link pinned left, numbered ruled questions on the right. FAQPage data included. - [FAQ Open](https://viewership.ai/design/components/faq-open.md): Every answer visible in a two-column grid on a tinted ground. For short answers people skim. - [CTA Band](https://viewership.ai/design/components/cta-band.md): The closing call to action on a full-width accent band, centred. - [CTA Card](https://viewership.ai/design/components/cta-card.md): An inset card with copy beside the actions and an optional square image. - [CTA Ink](https://viewership.ai/design/components/cta-ink.md): A dark band with an oversized headline and the actions tucked bottom right. - [Newsletter](https://viewership.ai/design/components/newsletter.md): Email sign-up with a short pitch and privacy line. A plain HTML form you point at any provider. - [Header Bar](https://viewership.ai/design/components/header-bar.md): Brand left, links and a call to action right. Folds into a native details menu when narrow. No JavaScript. - [Header Centered](https://viewership.ai/design/components/header-centered.md): Brand in the middle, links split either side, optional announcement strip. Folds into a details menu. - [Footer Columns](https://viewership.ai/design/components/footer-columns.md): Brand and tagline beside link columns, with a ruled legal line beneath. - [Footer Minimal](https://viewership.ai/design/components/footer-minimal.md): One line with the brand, a few links and the legal note. For landing pages and small sites. - [Footer Wordmark](https://viewership.ai/design/components/footer-wordmark.md): Link columns on a dark ground with the brand set across the full width beneath. ## Components - [Loading State](https://viewership.ai/design/components/loading-state.md): Pixel-grid loader with a shimmering label and elapsed timer. - [Thinking](https://viewership.ai/design/components/thinking-state.md): Expandable agent trace for steps, reasoning, search or tool calls. - [Streaming Text](https://viewership.ai/design/components/streaming-text.md): Streamed answer with inline citations, sources, actions and follow-ups. - [Approval Card](https://viewership.ai/design/components/approval-card.md): Questions an agent asks, one step at a time, before acting. - [Tool Chips](https://viewership.ai/design/components/tool-chips.md): Agent run as expandable tool-call rows, then file-diff chips. - [Task Rows](https://viewership.ai/design/components/task-rows.md): Live agent task status as expandable capsules or a list. - [Chat](https://viewership.ai/design/components/chat-composer.md): Tabbed chat panel with reasoning replies and a growing composer. - [Prompt Bar](https://viewership.ai/design/components/prompt-bar.md): Message composer with @ sources, / commands and a model picker. - [Recommendation Card](https://viewership.ai/design/components/recommendation-card.md): Agent suggestion with a confidence meter and alternatives. - [Context Cards](https://viewership.ai/design/components/context-cards.md): Retrieved knowledge chunks with sources, entering in sequence. - [Diff Table](https://viewership.ai/design/components/diff-table.md): AI-proposed table edits you include or skip, then apply. - [Records Table](https://viewership.ai/design/components/records-table.md): CRM-style AI spreadsheet with tags, sorting and configurable AI columns. - [Filter Table](https://viewership.ai/design/components/filter-table.md): Status chips that filter a table in place. - [Sidebar Nav](https://viewership.ai/design/components/sidebar-nav.md): Collapsible workspace and chat navigation with searchable history. - [Search](https://viewership.ai/design/components/search.md): Command search with live filtering and an empty state. - [Flowchart](https://viewership.ai/design/components/flowchart.md): Workflow trigger and condition cards on a dotted canvas with draggable steps. - [Insight Cards](https://viewership.ai/design/components/insight-cards.md): Paged agent insights with scrubbable SVG charts and follow-up prompts. - [Code Block](https://viewership.ai/design/components/code-block.md): Line-numbered code listing with copy, or a unified diff. - [Fine-tune Card](https://viewership.ai/design/components/fine-tune-card.md): Compact inspector the agent adjusts, then hands back to you. - [Selection Actions](https://viewership.ai/design/components/selection-actions.md): Select text and a floating toolbar rewrites it in place. - [Agent Screen](https://viewership.ai/design/components/agent-screen.md): An agent's live screen with an expandable viewer and recording. ## Atoms - [Button](https://viewership.ai/design/components/button.md): Pill-shaped action button in six variants and three sizes. - [Glide Menu](https://viewership.ai/design/components/glide-menu.md): One highlight that glides to the hovered or focused row. - [Entity Chip](https://viewership.ai/design/components/entity-chip.md): Inline reference to a client, person or record, as a monogram and name. - [Value Pill](https://viewership.ai/design/components/value-pill.md): Inline badge that sets a value off from running text. - [Shimmer](https://viewership.ai/design/components/shimmer.md): Label with a moving light sweep that shows the agent is working. - [Stream Text](https://viewership.ai/design/components/stream-text.md): Text that streams in like model output, with a blurred edge and caret. - [Chip](https://viewership.ai/design/components/chip.md): Monospace chip for code values and field names in text. - [Status Pill](https://viewership.ai/design/components/status-pill.md): Tinted status label with a leading dot, in five tones. - [Segmented Control](https://viewership.ai/design/components/segmented-control.md): Equal-width segments with a sliding thumb, as a radio group. - [Switch](https://viewership.ai/design/components/switch.md): On/off toggle on a native checkbox, in two sizes and two tones. - [Progress Ring](https://viewership.ai/design/components/progress-ring.md): Small progress ring with a count or glyph in the center. - [Text Row](https://viewership.ai/design/components/text-row.md): Label on the left, value and optional meta on the right. - [Overlay core](https://viewership.ai/design/components/overlay.md): Positioning, dismissal, focus-trap and roving-focus helpers for popovers, menus and dialogs. - [Icon](https://viewership.ai/design/components/icon.md): Twenty Lucide glyphs as inline SVG, sized and labelled through props. --- # Viewership UI: component conventions The contract every component (and every agent porting one) follows. If it conflicts with an upstream source, this file wins. ## 1. What a component is One folder per component under `src/ui/registry//`: | File | Purpose | | --- | --- | | `.astro` | The component. Consumers copy this. | | `*.ts` (optional) | Helpers the component imports. List them in `meta.files`. | | `demo.astro` | Gallery demo. Receives `variant` when `meta.variants` is set. | | `meta.ts` | `export default { ... } satisfies ComponentMeta` (see `src/lib/types.ts`). | The site finds components by globbing `meta.ts`. **Never edit a shared file to register a component.** `src/ui/registry/button/` is the reference; read it first. ## 2. Stack rules - **Astro + TypeScript + scoped CSS only.** No React, Tailwind or CSS-in-JS. - **No runtime npm dependencies** unless reimplementing is unreasonable. If you add one, check its license (MIT/ISC/Apache-2.0 only), list it in `meta.npm`, and say so in your report. - **Icons are inline SVG.** Use Lucide (ISC) paths. Iconoir (MIT) is fine when porting a component that used it. **Never copy Central Icons (proprietary).** - Import other registry components relatively (`import Button from "../button/Button.astro";`) and list their slugs in `meta.dependencies`. ## 3. Styling - Read every color, radius, shadow, font and easing from the `--vs-*` tokens in `src/ui/styles/foundation.css`. Never hard-code a theme color (pure `white` text on a filled accent is fine). - The root element gets class `vs-`. Scope everything under the component's ` ``` ## Variants Demo variants: `Variants`, `Sizes`. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `variant` | `"primary" \| "secondary" \| "ghost" \| "accent" \| "success" \| "quiet"` | `"secondary"` | Visual style. | | `size` | `"xs" \| "sm" \| "md"` | `"md"` | Height and padding. | | `href` | `string` | | Renders an instead of a * */ import type { HTMLAttributes } from "astro/types"; type Variant = "primary" | "secondary" | "ghost" | "accent" | "success" | "quiet"; type Size = "xs" | "sm" | "md"; type Props = (HTMLAttributes<"button"> & HTMLAttributes<"a">) & { variant?: Variant; size?: Size; /** Render as a link. */ href?: string; type?: "button" | "submit" | "reset"; }; const { variant = "secondary", size = "md", href, type = "button", class: className, ...rest } = Astro.props; const Tag = href ? "a" : "button"; --- ``` --- # Hero Statement > Centred headline and actions above a wide media panel. - **Slug:** `hero-statement` - **Kind:** section · **Category:** Hero - **Source:** original to Viewership UI - **Docs page:** https://viewership.ai/design/components/hero-statement - **All files in one bundle:** https://viewership.ai/design/components/hero-statement.bundle.txt - **Structured JSON:** https://viewership.ai/design/components/hero-statement.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt): ```astro --- // src/layouts/Layout.astro import "../styles/foundation.css"; --- ``` 2. Copy this file into `src/components/viewership/hero-statement/`: - `HeroStatement.astro` (raw: https://viewership.ai/design/raw/hero-statement/HeroStatement.astro.txt) 3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`: - `button`: `Button.astro` (docs: https://viewership.ai/design/components/button.md) npm packages: none. ## Usage ```astro --- import HeroStatement from "../components/viewership/hero-statement/HeroStatement.astro"; --- ``` ## Example The gallery demo, verbatim. Use the import path from Usage instead of `./`. ```astro --- import HeroStatement from "./HeroStatement.astro"; --- Layered mountain ridges at dusk ``` ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `title` | `string` | | Headline. | | `accent` | `string` | | Closing words of the headline, drawn in the theme's accent style (). | | `body` | `string` | | Supporting paragraph. | | `primary` | `{ label: string; href: string }` | | Main call to action. | | `secondary` | `{ label: string; href: string }` | | Quieter second action. | | `as` | `"h1" \| "h2"` | `"h1"` | Heading element. | | `slot: media` | `slot` | | Image, video or rendered visual, cropped to 16:7. | ## Source ### `src/components/viewership/hero-statement/HeroStatement.astro` ```astro --- /** * HeroStatement: centred headline and actions over a wide media panel. * * Media goes in the `media` slot (image, video, rendered visual). The section * responds to its own width, and its look follows the --vs-* tokens or a * theme preset. * * @example * * * */ import Button from "../button/Button.astro"; interface Cta { label: string; href: string; } interface Props { title: string; /** Closing words of the headline, drawn in the theme's accent style. */ accent?: string; body?: string; primary?: Cta; secondary?: Cta; /** Heading element. Use "h2" when the page already has an h1. */ as?: "h1" | "h2"; class?: string; } const { title, accent, body, primary, secondary, as: Heading = "h1", class: className } = Astro.props; ---
{title}{accent && <> {accent}} {body &&

{body}

} { (primary || secondary) && (
{primary && } {secondary && }
) } { Astro.slots.has("media") && (
) }
``` --- # Glide Menu > One highlight that glides to the hovered or focused row. - **Slug:** `glide-menu` - **Kind:** atom · **Category:** Navigation - **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/GlideMenu.tsx) (MIT) - **Docs page:** https://viewership.ai/design/components/glide-menu - **All files in one bundle:** https://viewership.ai/design/components/glide-menu.bundle.txt - **Structured JSON:** https://viewership.ai/design/components/glide-menu.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt): ```astro --- // src/layouts/Layout.astro import "../styles/foundation.css"; --- ``` 2. Copy these files into `src/components/viewership/glide-menu/`: - `GlideMenu.astro` (raw: https://viewership.ai/design/raw/glide-menu/GlideMenu.astro.txt) - `glide-menu.ts` (raw: https://viewership.ai/design/raw/glide-menu/glide-menu.ts.txt) npm packages: none. ## Usage ```astro --- import GlideMenu from "../components/viewership/glide-menu/GlideMenu.astro"; --- ``` ## Example The gallery demo, verbatim. Use the import path from Usage instead of `./`. ```astro --- import GlideMenu from "./GlideMenu.astro"; interface Props { variant?: string; } const { variant = "Menu" } = Astro.props; const actions = [ { label: "Run report", hint: "R", icon: '' }, { label: "Add edit notes", hint: "C", icon: '' }, { label: "Duplicate draft", hint: "D", icon: '' }, { label: "Export report", hint: "E", icon: '' }, ]; const nav = [ { label: "Content drafts", count: "14", current: true, icon: '' }, { label: "Reports", count: "6", icon: '' }, { label: "Citations", count: "23", icon: '' }, { label: "Threads", icon: '' }, { label: "Approvals", icon: '' }, ]; --- {variant === "Menu" && (

Draft 214 · Spring Trails

{actions.map((a) => ( ))}
)} {variant === "Navigation" && (
)} ``` ## Variants Demo variants: `Menu`, `Navigation`. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `as` | `"div" \| "nav"` | `"div"` | Root element. You set the role. | | `rowSelector` | `string` | `"[data-menu-row]"` | Rows the highlight snaps to. | | `highlightBg` | `string` | `"var(--vs-hover)"` | Highlight background. | | `highlightRadius` | `string` | `"8px"` | Highlight corner radius. | | `highlightInset` | `string` | `"0px"` | Horizontal inset; negative bleeds past rows (sets --vs-glide-inset). | | `highlightClass` | `string` | | Extra class on the highlight. Style with :global(). | | `highlightStyle` | `string` | | Extra highlight inline style. | | `arrowKeys` | `boolean` | `false` | Up, Down, Home, End move focus between rows (roving tabindex). | | `initGlideMenu(el, opts)` | `(el: HTMLElement, opts?: GlideMenuOptions) => () => void` | | From glide-menu.ts: wire a script-built menu. Returns a teardown. | ## Used by [Approval Card](https://viewership.ai/design/components/approval-card.md), [Prompt Bar](https://viewership.ai/design/components/prompt-bar.md), [Records Table](https://viewership.ai/design/components/records-table.md), [Sidebar Nav](https://viewership.ai/design/components/sidebar-nav.md), [Search](https://viewership.ai/design/components/search.md), [Fine-tune Card](https://viewership.ai/design/components/fine-tune-card.md) ## Source ### `src/components/viewership/glide-menu/GlideMenu.astro` ```astro --- /** * GlideMenu: a container whose single highlight glides to the hovered or * focused row. * * Ported from Beautiful UI `components/primitives/GlideMenu.tsx` * (MIT, © 2026 Shane Levine). * * Mark rows with `data-menu-row` (or pass `rowSelector`). The highlight * follows pointer and keyboard focus; set `arrowKeys` for Up/Down/Home/End * with a roving tabindex. Roles (menu, listbox, nav) are up to you. * * Style the highlight with `highlightBg` / `highlightRadius` / * `highlightInset` (or `--vs-glide-*`). A `highlightClass` needs * `:global(...)`. While showing, the root has `data-glide-active` and the * row has `data-glide-current`. * * For menus built by script, use `initGlideMenu` from `./glide-menu`. * * @example * * * * */ import type { HTMLAttributes } from "astro/types"; type Props = HTMLAttributes<"div"> & { /** Root element. */ as?: "div" | "nav"; /** Selector for rows the highlight snaps to. */ rowSelector?: string; /** Highlight background. */ highlightBg?: string; /** Highlight corner radius. */ highlightRadius?: string; /** Horizontal inset of the highlight; negative values bleed outward. */ highlightInset?: string; /** Extra class on the highlight (style it with :global). */ highlightClass?: string; /** Extra inline style on the highlight. */ highlightStyle?: string; /** Arrow keys / Home / End move focus between rows (roving tabindex). */ arrowKeys?: boolean; }; const { as: Tag = "div", rowSelector = "[data-menu-row]", highlightBg, highlightRadius, highlightInset, highlightClass, highlightStyle, arrowKeys = false, class: className, style, ...rest } = Astro.props; const vars = [ highlightBg && `--vs-glide-bg: ${highlightBg}`, highlightRadius && `--vs-glide-radius: ${highlightRadius}`, highlightInset && `--vs-glide-inset: ${highlightInset}`, ] .filter(Boolean) .join("; "); const rootStyle = [vars, typeof style === "string" ? style : ""].filter(Boolean).join("; ") || undefined; --- ``` ### `src/components/viewership/glide-menu/glide-menu.ts` ```ts /** * glide-menu: the behaviour behind . * * Ported from Beautiful UI `components/primitives/GlideMenu.tsx` * (MIT, © 2026 Shane Levine). * * One highlight glides between a container's rows, following pointer and * keyboard focus. GlideMenu.astro calls this; call it yourself for menus * built at runtime: * * const stop = initGlideMenu(menuEl, { arrowKeys: true }); * // later: stop(); * * A highlight element is created if missing. Style it with custom * properties on the container: * --vs-glide-bg (default var(--vs-hover)) * --vs-glide-radius (default 8px) * --vs-glide-inset horizontal inset; negative bleeds outward (default 0) * * While showing, the container has `data-glide-active` and the row has * `data-glide-current`. */ export interface GlideMenuOptions { /** Selector for rows the highlight snaps to. Default `[data-menu-row]`, or the container's `data-row-selector`. */ rowSelector?: string; /** * Arrow keys / Home / End move focus between rows, with a roving tabindex so * Tab enters and leaves the group in one stop. Default: the container's * `data-arrow-keys` attribute. */ arrowKeys?: boolean; } const EASE = "var(--vs-ease-out, cubic-bezier(0.23, 1, 0.32, 1))"; const TRANSITION = `top 220ms ${EASE}, height 220ms ${EASE}, opacity 150ms ease`; function ensureHighlight(root: HTMLElement): HTMLElement { const existing = root.querySelector(":scope > [data-glide-highlight]"); if (existing) return existing; const hl = document.createElement("span"); hl.setAttribute("aria-hidden", "true"); hl.setAttribute("data-glide-highlight", ""); Object.assign(hl.style, { position: "absolute", pointerEvents: "none", left: "var(--vs-glide-inset, 0px)", right: "var(--vs-glide-inset, 0px)", top: "0px", height: "0px", opacity: "0", borderRadius: "var(--vs-glide-radius, 8px)", background: "var(--vs-glide-bg, var(--vs-hover))", } satisfies Partial); root.prepend(hl); return hl; } /** Wire one container. Returns a teardown function. */ export function initGlideMenu(root: HTMLElement, options: GlideMenuOptions = {}): () => void { const rowSelector = options.rowSelector ?? root.dataset.rowSelector ?? "[data-menu-row]"; const arrowKeys = options.arrowKeys ?? root.hasAttribute("data-arrow-keys"); const hl = ensureHighlight(root); const reduce = window.matchMedia("(prefers-reduced-motion: reduce)"); if (getComputedStyle(root).position === "static") root.style.position = "relative"; let current: HTMLElement | null = null; let placedOnce = false; const rows = () => Array.from(root.querySelectorAll(rowSelector)).filter( (row) => row !== root && !isDisabled(row) && row.getClientRects().length > 0, ); const isDisabled = (row: HTMLElement) => row.matches(":disabled, [aria-disabled='true'], [inert] *"); const rowFrom = (target: EventTarget | null): HTMLElement | null => { if (!(target instanceof Element)) return null; const row = target.closest(rowSelector); return row && row !== root && root.contains(row) ? row : null; }; const place = (row: HTMLElement) => { const rowRect = row.getBoundingClientRect(); const rootRect = root.getBoundingClientRect(); hl.style.top = `${rowRect.top - rootRect.top - root.clientTop + root.scrollTop}px`; hl.style.height = `${rowRect.height}px`; }; const moveTo = (row: HTMLElement) => { if (row === current || isDisabled(row)) return; // Rows must paint above the absolutely positioned highlight. if (getComputedStyle(row).position === "static") row.style.position = "relative"; // The first placement jumps into position; later ones glide. hl.style.transition = reduce.matches ? "none" : placedOnce ? TRANSITION : "opacity 150ms ease"; place(row); placedOnce = true; hl.style.opacity = "1"; current?.removeAttribute("data-glide-current"); current = row; row.setAttribute("data-glide-current", ""); root.setAttribute("data-glide-active", ""); }; const hide = () => { hl.style.opacity = "0"; current?.removeAttribute("data-glide-current"); current = null; root.removeAttribute("data-glide-active"); }; const focusedRow = () => { const active = document.activeElement; return active instanceof HTMLElement && active.matches(":focus-visible") ? rowFrom(active) : null; }; const onMouseOver = (event: MouseEvent) => { const row = rowFrom(event.target); if (row) moveTo(row); }; const onMouseLeave = () => { // Hand the highlight back to a keyboard-focused row instead of hiding it. const row = focusedRow(); if (row) moveTo(row); else hide(); }; const onFocusIn = (event: FocusEvent) => { const row = rowFrom(event.target); if (!row) return; moveTo(row); if (arrowKeys) setRoving(row); }; const onFocusOut = (event: FocusEvent) => { const next = event.relatedTarget; if (next instanceof Node && root.contains(next)) return; if (!root.matches(":hover")) hide(); }; const setRoving = (active: HTMLElement) => { for (const row of root.querySelectorAll(rowSelector)) { row.tabIndex = row === active ? 0 : -1; } }; const onKeyDown = (event: KeyboardEvent) => { if (event.altKey || event.ctrlKey || event.metaKey) return; const from = rowFrom(event.target); if (!from) return; const list = rows(); const i = list.indexOf(from); let next: HTMLElement | undefined; if (event.key === "ArrowDown") next = list[(i + 1) % list.length]; else if (event.key === "ArrowUp") next = list[(i - 1 + list.length) % list.length]; else if (event.key === "Home") next = list[0]; else if (event.key === "End") next = list[list.length - 1]; if (!next) return; event.preventDefault(); next.focus(); }; if (arrowKeys) { const list = rows(); const initial = list.find((row) => row.matches("[aria-current]:not([aria-current='false']), [aria-checked='true'], [aria-selected='true']")) ?? list[0]; if (initial) setRoving(initial); } // Rows can reflow (fonts loading, a sidebar collapsing): keep the highlight on its row. const resize = new ResizeObserver(() => { if (current?.isConnected) place(current); }); resize.observe(root); root.addEventListener("mouseover", onMouseOver); root.addEventListener("mouseleave", onMouseLeave); root.addEventListener("focusin", onFocusIn); root.addEventListener("focusout", onFocusOut); if (arrowKeys) root.addEventListener("keydown", onKeyDown); return () => { resize.disconnect(); root.removeEventListener("mouseover", onMouseOver); root.removeEventListener("mouseleave", onMouseLeave); root.removeEventListener("focusin", onFocusIn); root.removeEventListener("focusout", onFocusOut); root.removeEventListener("keydown", onKeyDown); hide(); }; } ``` --- # Hero Split > Headline and actions beside a media panel; stacks when narrow, flips with one prop. - **Slug:** `hero-split` - **Kind:** section · **Category:** Hero - **Source:** original to Viewership UI - **Docs page:** https://viewership.ai/design/components/hero-split - **All files in one bundle:** https://viewership.ai/design/components/hero-split.bundle.txt - **Structured JSON:** https://viewership.ai/design/components/hero-split.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt): ```astro --- // src/layouts/Layout.astro import "../styles/foundation.css"; --- ``` 2. Copy this file into `src/components/viewership/hero-split/`: - `HeroSplit.astro` (raw: https://viewership.ai/design/raw/hero-split/HeroSplit.astro.txt) 3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`: - `button`: `Button.astro` (docs: https://viewership.ai/design/components/button.md) npm packages: none. ## Usage ```astro --- import HeroSplit from "../components/viewership/hero-split/HeroSplit.astro"; --- ``` ## Example The gallery demo, verbatim. Use the import path from Usage instead of `./`. ```astro --- import HeroSplit from "./HeroSplit.astro"; --- Topographic contour map ``` ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `title` | `string` | | Headline. | | `accent` | `string` | | Closing words of the headline, drawn in the theme's accent style. | | `body` | `string` | | Supporting paragraph. | | `primary` | `{ label: string; href: string }` | | Main call to action. | | `secondary` | `{ label: string; href: string }` | | Text-style second action. | | `note` | `string` | | Small line under the actions. | | `reverse` | `boolean` | `false` | Media on the left. | | `as` | `"h1" \| "h2"` | `"h1"` | Heading element. | | `slot: media` | `slot` | | Image or video, cropped to 4:3. | ## Source ### `src/components/viewership/hero-split/HeroSplit.astro` ```astro --- /** * HeroSplit: copy on one side, media on the other. Stacks on narrow widths. * * @example * * * */ import Button from "../button/Button.astro"; interface Cta { label: string; href: string; } interface Props { title: string; accent?: string; body?: string; primary?: Cta; secondary?: Cta; /** Small line under the actions: a guarantee, a rating, a note. */ note?: string; /** Put the media on the left instead. */ reverse?: boolean; as?: "h1" | "h2"; class?: string; } const { title, accent, body, primary, secondary, note, reverse = false, as: Heading = "h1", class: className } = Astro.props; ---
{title}{accent && <> {accent}} {body &&

{body}

} { (primary || secondary) && (
{primary && } {secondary && }
) } {note &&

{note}

}
{ Astro.slots.has("media") && (
) }
``` --- # Entity Chip > Inline reference to a client, person or record, as a monogram and name. - **Slug:** `entity-chip` - **Kind:** atom · **Category:** Content - **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/atoms/EntityChip.tsx) (MIT) - **Docs page:** https://viewership.ai/design/components/entity-chip - **All files in one bundle:** https://viewership.ai/design/components/entity-chip.bundle.txt - **Structured JSON:** https://viewership.ai/design/components/entity-chip.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt): ```astro --- // src/layouts/Layout.astro import "../styles/foundation.css"; --- ``` 2. Copy these files into `src/components/viewership/entity-chip/`: - `EntityChip.astro` (raw: https://viewership.ai/design/raw/entity-chip/EntityChip.astro.txt) - `Monogram.astro` (raw: https://viewership.ai/design/raw/entity-chip/Monogram.astro.txt) npm packages: none. ## Usage ```astro --- import EntityChip from "../components/viewership/entity-chip/EntityChip.astro"; --- ``` ## Example The gallery demo, verbatim. Use the import path from Usage instead of `./`. ```astro --- import EntityChip from "./EntityChip.astro"; import Monogram from "./Monogram.astro"; ---
N Weekly marketing note

Send the spring trail draft to before the review window closes, and hold two posts for .

signed off the Q3 citation report; the GA4 export from is still pending.

``` ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | | Name shown in the chip. | | `color` | `string` | `"var(--vs-orange)"` | Disc color (any CSS color). | | `monogram` | `string` | | Monogram glyph. Defaults to the first letter of name. | | `` | `slot + color` | | Monogram.astro alone: a 16px decorative disc. | ## Used by [Recommendation Card](https://viewership.ai/design/components/recommendation-card.md) ## Source ### `src/components/viewership/entity-chip/EntityChip.astro` ```astro --- /** * EntityChip: inline reference to a person, brand or record, as a monogram and name. * * Ported from Beautiful UI `components/atoms/EntityChip.tsx` * (MIT, © 2026 Shane Levine). Softer than a StatusPill (no dot, no state) and not a mono * token (see Chip). * * @example * Cited by */ import type { HTMLAttributes } from "astro/types"; import Monogram from "./Monogram.astro"; type Props = HTMLAttributes<"span"> & { /** Entity name (the chip's text). */ name: string; /** Monogram color. Any CSS color. */ color?: string; /** Monogram glyph. Defaults to the first character of `name`. */ monogram?: string; }; const { name, color, monogram, class: className, ...rest } = Astro.props; --- {monogram ?? name.charAt(0)} {name} ``` ### `src/components/viewership/entity-chip/Monogram.astro` ```astro --- /** * Monogram: a colored disc with an initial or short glyph. * * Ported from Beautiful UI `components/atoms/EntityChip.tsx` * (MIT, © 2026 Shane Levine). Hidden from assistive tech, so pair it with * the name. * * @example * K */ import type { HTMLAttributes } from "astro/types"; type Props = HTMLAttributes<"span"> & { /** Disc color. Any CSS color. */ color?: string; }; const { color = "var(--vs-orange)", class: className, ...rest } = Astro.props; --- ``` --- # Hero Index > Oversized left-aligned headline over a ruled row of facts. Type-led, no media. - **Slug:** `hero-index` - **Kind:** section · **Category:** Hero - **Source:** original to Viewership UI - **Docs page:** https://viewership.ai/design/components/hero-index - **All files in one bundle:** https://viewership.ai/design/components/hero-index.bundle.txt - **Structured JSON:** https://viewership.ai/design/components/hero-index.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt): ```astro --- // src/layouts/Layout.astro import "../styles/foundation.css"; --- ``` 2. Copy this file into `src/components/viewership/hero-index/`: - `HeroIndex.astro` (raw: https://viewership.ai/design/raw/hero-index/HeroIndex.astro.txt) 3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`: - `button`: `Button.astro` (docs: https://viewership.ai/design/components/button.md) npm packages: none. ## Usage ```astro --- import HeroIndex from "../components/viewership/hero-index/HeroIndex.astro"; --- ``` ## Example The gallery demo, verbatim. Use the import path from Usage instead of `./`. ```astro --- import HeroIndex from "./HeroIndex.astro"; --- ``` ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `title` | `string` | | Headline. | | `accent` | `string` | | Closing words of the headline, drawn in the theme's accent style. | | `body` | `string` | | Supporting paragraph, set in the right half on wide screens. | | `primary` | `{ label: string; href: string }` | | Main call to action. | | `secondary` | `{ label: string; href: string }` | | Quieter second action. | | `facts` | `{ value: string; label: string }[]` | `[]` | Figures under the headline. | | `as` | `"h1" \| "h2"` | `"h1"` | Heading element. | ## Source ### `src/components/viewership/hero-index/HeroIndex.astro` ```astro --- /** * HeroIndex: an oversized left-aligned headline over a ruled row of facts. * No media needed; the type carries it. * * @example * */ import Button from "../button/Button.astro"; interface Cta { label: string; href: string; } interface Fact { value: string; label: string; } interface Props { title: string; accent?: string; body?: string; primary?: Cta; secondary?: Cta; facts?: Fact[]; as?: "h1" | "h2"; class?: string; } const { title, accent, body, primary, secondary, facts = [], as: Heading = "h1", class: className } = Astro.props; ---
{title}{accent && <> {accent}} {body &&

{body}

} { (primary || secondary) && (
{primary && } {secondary && }
) }
{ facts.length > 0 && (
{facts.map((f) => (
{f.label}
{f.value}
))}
) }
``` --- # Hero Poster > Full-bleed image or video with the headline set over it, bottom left, behind a soft scrim. - **Slug:** `hero-poster` - **Kind:** section · **Category:** Hero - **Source:** original to Viewership UI - **Docs page:** https://viewership.ai/design/components/hero-poster - **All files in one bundle:** https://viewership.ai/design/components/hero-poster.bundle.txt - **Structured JSON:** https://viewership.ai/design/components/hero-poster.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt): ```astro --- // src/layouts/Layout.astro import "../styles/foundation.css"; --- ``` 2. Copy this file into `src/components/viewership/hero-poster/`: - `HeroPoster.astro` (raw: https://viewership.ai/design/raw/hero-poster/HeroPoster.astro.txt) 3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`: - `button`: `Button.astro` (docs: https://viewership.ai/design/components/button.md) npm packages: none. ## Usage ```astro --- import HeroPoster from "../components/viewership/hero-poster/HeroPoster.astro"; --- ``` ## Example The gallery demo, verbatim. Use the import path from Usage instead of `./`. ```astro --- import HeroPoster from "./HeroPoster.astro"; --- Mountain ridges under a full moon ``` ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `title` | `string` | | Headline. | | `accent` | `string` | | Closing words of the headline. | | `body` | `string` | | Supporting paragraph. | | `primary` | `{ label: string; href: string }` | | Main call to action (accent button). | | `secondary` | `{ label: string; href: string }` | | Second action. | | `caption` | `string` | | Short line in the top corner. | | `as` | `"h1" \| "h2"` | `"h1"` | Heading element. | | `slot: media` | `slot` | | Background image or video, covers the section. | ## Source ### `src/components/viewership/hero-poster/HeroPoster.astro` ```astro --- /** * HeroPoster: full-bleed media with the headline set over it, bottom left. * * A scrim keeps the type readable on any image. Text is always light here, * whatever the theme mode, because it sits on the picture. * * @example * * * */ import Button from "../button/Button.astro"; interface Cta { label: string; href: string; } interface Props { title: string; accent?: string; body?: string; primary?: Cta; secondary?: Cta; /** Short caption in the top corner: a place, a season, a credit. */ caption?: string; as?: "h1" | "h2"; class?: string; } const { title, accent, body, primary, secondary, caption, as: Heading = "h1", class: className } = Astro.props; ---
{caption &&

{caption}

}
{title}{accent && <> {accent}} {body &&

{body}

} { (primary || secondary) && (
{primary && } {secondary && }
) }
``` --- # Value Pill > Inline badge that sets a value off from running text. - **Slug:** `value-pill` - **Kind:** atom · **Category:** Content - **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/atoms/ValuePill.tsx) (MIT) - **Docs page:** https://viewership.ai/design/components/value-pill - **All files in one bundle:** https://viewership.ai/design/components/value-pill.bundle.txt - **Structured JSON:** https://viewership.ai/design/components/value-pill.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt): ```astro --- // src/layouts/Layout.astro import "../styles/foundation.css"; --- ``` 2. Copy this file into `src/components/viewership/value-pill/`: - `ValuePill.astro` (raw: https://viewership.ai/design/raw/value-pill/ValuePill.astro.txt) npm packages: none. ## Usage ```astro --- import ValuePill from "../components/viewership/value-pill/ValuePill.astro"; --- ``` ## Example The gallery demo, verbatim. Use the import path from Usage instead of `./`. ```astro --- import ValuePill from "./ValuePill.astro"; interface Props { variant?: string; } const { variant = "In prose" } = Astro.props; --- {variant === "In prose" && (

Approve the draft within 9 days so it ships before the Spring Trail launch on Nov 18.

Bounce rate on the trail pack page hit 62.4%, and two links are broken.

)} {variant === "Tones" && (
Organic 9 days 62.4% bounce 2 broken links Nov 18
)} ``` ## Variants Demo variants: `In prose`, `Tones`. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `tone` | `"neutral" \| "green" \| "orange" \| "red" \| "accent"` | `"neutral"` | Tint, ring and text color. | ## Used by [Recommendation Card](https://viewership.ai/design/components/recommendation-card.md) ## Source ### `src/components/viewership/value-pill/ValuePill.astro` ```astro --- /** * ValuePill: inline badge that sets a plain value off from running text. * * Ported from Beautiful UI `components/atoms/ValuePill.tsx` * (MIT, © 2026 Shane Levine). Softer than a StatusPill (no dot) and not a * mono token (see Chip). * * @example * with a CTR of 4.1% */ import type { HTMLAttributes } from "astro/types"; type Tone = "neutral" | "green" | "orange" | "red" | "accent"; type Props = HTMLAttributes<"span"> & { tone?: Tone; }; const { tone = "neutral", class: className, ...rest } = Astro.props; --- ``` --- # Shimmer > Label with a moving light sweep that shows the agent is working. - **Slug:** `shimmer` - **Kind:** atom · **Category:** Feedback - **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/atoms/Shimmer.tsx) (MIT) - **Docs page:** https://viewership.ai/design/components/shimmer - **All files in one bundle:** https://viewership.ai/design/components/shimmer.bundle.txt - **Structured JSON:** https://viewership.ai/design/components/shimmer.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt): ```astro --- // src/layouts/Layout.astro import "../styles/foundation.css"; --- ``` 2. Copy this file into `src/components/viewership/shimmer/`: - `Shimmer.astro` (raw: https://viewership.ai/design/raw/shimmer/Shimmer.astro.txt) npm packages: none. ## Usage ```astro --- import Shimmer from "../components/viewership/shimmer/Shimmer.astro"; --- ``` ## Example The gallery demo, verbatim. Use the import path from Usage instead of `./`. ```astro --- import Shimmer from "./Shimmer.astro"; const steps = [ { label: "Read GA4 data for week 41", state: "done" }, { label: "Compare against Google Ads spend", state: "done" }, { label: "Drafting report summary…", state: "active" }, { label: "Suggest budget moves for next week", state: "todo" }, ]; ---
Summarizing weekly report…
    {steps.map((s) => (
  1. {s.state === "active" ? {s.label} : {s.label}}
  2. ))}
``` ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `(default slot)` | `text` | | The label. Inherits font. Static under reduced motion. | ## Used by [Selection Actions](https://viewership.ai/design/components/selection-actions.md) ## Source ### `src/components/viewership/shimmer/Shimmer.astro` ```astro --- /** * Shimmer: a label with a light sweep, to show the agent is working. * * Ported from Beautiful UI `components/atoms/Shimmer.tsx` * (MIT, © 2026 Shane Levine). Inherits font size and weight. The text stays * real text; put it in an `aria-live` region when it appears dynamically. * Under reduced motion it renders as plain secondary ink. * * @example * Drafting the weekly report… */ import type { HTMLAttributes } from "astro/types"; type Props = HTMLAttributes<"span">; const { class: className, ...rest } = Astro.props; --- ``` --- # Stream Text > Text that streams in like model output, with a blurred edge and caret. - **Slug:** `stream-text` - **Kind:** atom · **Category:** Feedback - **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/atoms/StreamText.tsx) (MIT) - **Docs page:** https://viewership.ai/design/components/stream-text - **All files in one bundle:** https://viewership.ai/design/components/stream-text.bundle.txt - **Structured JSON:** https://viewership.ai/design/components/stream-text.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/design/raw/foundation.css.txt): ```astro --- // src/layouts/Layout.astro import "../styles/foundation.css"; --- ``` 2. Copy these files into `src/components/viewership/stream-text/`: - `StreamText.astro` (raw: https://viewership.ai/design/raw/stream-text/StreamText.astro.txt) - `stream-text.ts` (raw: https://viewership.ai/design/raw/stream-text/stream-text.ts.txt) npm packages: none. ## Usage ```astro --- import StreamText from "../components/viewership/stream-text/StreamText.astro"; --- ``` ## Example The gallery demo, verbatim. Use the import path from Usage instead of `./`. ```astro --- import StreamText from "./StreamText.astro"; import Button from "../button/Button.astro"; interface Props { variant?: string; } const { variant = "Report summary" } = Astro.props; const notes = "Organic sessions rose 12% week over week, led by the trail pack guide. Paid search held a 4.1x ROAS on flat spend. Perplexity now cites the guide on 3 of 5 tracked prompts. Move $800 from display to search next week."; --- {variant === "Report summary" && (

Weekly report

Northwind Outdoor · Week 41 · GA4

)} {variant === "Inline rewrite" && (

Our trail packs are tested on real routes every season.{" "} {" "} Spring colors ship to retailers Friday.

)} ``` ## Variants Demo variants: `Report summary`, `Inline rewrite`. ## Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `text` | `string` | | The text to stream. | | `charsPerTick` | `number` | `2` | Characters per tick. Higher is faster. | | `tickMs` | `number` | `9` | Tick interval, ms. | | `blurTail` | `number` | `6` | Trailing characters that are blurred. | | `caret` | `boolean` | `true` | Show the caret. Blinks once idle. | | `autoplay` | `boolean` | `true` | Stream when in view. If false, wait for vs-stream-play. | | `loop` | `boolean` | `false` | Replay after loopDelay. | | `loopDelay` | `number` | `2400` | Pause before replay, ms. | | `reserveSpace` | `boolean` | `false` | Reserve final size to prevent layout shift. Block contexts only. | | `events` | `vs-stream-play · vs-stream-progress · vs-stream-done` | | Dispatch vs-stream-play ({ text? }) to restart. Listen for progress ({ count }) and done. | | `streamText(el, text, opts)` | `(el: HTMLElement, text: string, opts?: StreamTextOptions) => StreamTextHandle` | | Stream into any element from your own script. Handle: pause, resume, finish, stop. | ## Used by [Selection Actions](https://viewership.ai/design/components/selection-actions.md) ## Source ### `src/components/viewership/stream-text/StreamText.astro` ```astro --- /** * StreamText: text that streams in like model output, with a soft blurred * edge and a caret that blinks once it settles. * * Adapted from Beautiful UI StreamText (MIT, © 2026 Shane Levine). * * - Without JS, and under reduced motion, the full text renders at once. * - Streams in view; pauses offscreen. * - Screen readers get the full text once from a hidden copy. The animated * copy is aria-hidden. * - Script API on the root: * el.dispatchEvent(new CustomEvent("vs-stream-play", { detail: { text } })) * el.addEventListener("vs-stream-progress", (e) => e.detail.count) * el.addEventListener("vs-stream-done", …) * - For your own DOM, import `streamText` from `./stream-text`. * * @example *

*/ import type { HTMLAttributes } from "astro/types"; type Props = HTMLAttributes<"span"> & { /** The text to stream. */ text: string; /** Characters revealed per tick; higher is faster. */ charsPerTick?: number; /** Interval between reveals, in ms. */ tickMs?: number; /** How many trailing characters carry the soft blur edge. */ blurTail?: number; /** Render the caret (solid while streaming, blinks once idle). */ caret?: boolean; /** Start streaming when scrolled into view. When false, wait for `vs-stream-play`. */ autoplay?: boolean; /** Replay after a pause once the text settles. */ loop?: boolean; /** Pause before a loop restarts, in ms. */ loopDelay?: number; /** Reserve the final text's space up front so nothing below shifts (block contexts only). */ reserveSpace?: boolean; }; const { text, charsPerTick = 2, tickMs = 9, blurTail = 6, caret = true, autoplay = true, loop = false, loopDelay = 2400, reserveSpace = false, class: className, ...rest } = Astro.props; --- {text} {reserveSpace &&