# 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). ## Components - [Loading State](https://viewership.ai/ui/components/loading-state.md): Pixel-grid loader with a shimmering label and elapsed timer. - [Thinking](https://viewership.ai/ui/components/thinking-state.md): Expandable agent trace for steps, reasoning, search or tool calls. - [Streaming Text](https://viewership.ai/ui/components/streaming-text.md): Streamed answer with inline citations, sources, actions and follow-ups. - [Approval Card](https://viewership.ai/ui/components/approval-card.md): Questions an agent asks, one step at a time, before acting. - [Tool Chips](https://viewership.ai/ui/components/tool-chips.md): Agent run as expandable tool-call rows, then file-diff chips. - [Task Rows](https://viewership.ai/ui/components/task-rows.md): Live agent task status as expandable capsules or a list. - [Chat](https://viewership.ai/ui/components/chat-composer.md): Tabbed chat panel with reasoning replies and a growing composer. - [Prompt Bar](https://viewership.ai/ui/components/prompt-bar.md): Message composer with @ sources, / commands and a model picker. - [Recommendation Card](https://viewership.ai/ui/components/recommendation-card.md): Agent suggestion with a confidence meter and alternatives. - [Context Cards](https://viewership.ai/ui/components/context-cards.md): Retrieved knowledge chunks with sources, entering in sequence. - [Diff Table](https://viewership.ai/ui/components/diff-table.md): AI-proposed table edits you include or skip, then apply. - [Records Table](https://viewership.ai/ui/components/records-table.md): CRM-style AI spreadsheet with tags, sorting and configurable AI columns. - [Filter Table](https://viewership.ai/ui/components/filter-table.md): Status chips that filter a table in place. - [Sidebar Nav](https://viewership.ai/ui/components/sidebar-nav.md): Collapsible workspace and chat navigation with searchable history. - [Search](https://viewership.ai/ui/components/search.md): Command search with live filtering and an empty state. - [Flowchart](https://viewership.ai/ui/components/flowchart.md): Workflow trigger and condition cards on a dotted canvas with draggable steps. - [Insight Cards](https://viewership.ai/ui/components/insight-cards.md): Paged agent insights with scrubbable SVG charts and follow-up prompts. - [Code Block](https://viewership.ai/ui/components/code-block.md): Line-numbered code listing with copy, or a unified diff. - [Fine-tune Card](https://viewership.ai/ui/components/fine-tune-card.md): Compact inspector the agent adjusts, then hands back to you. - [Selection Actions](https://viewership.ai/ui/components/selection-actions.md): Select text and a floating toolbar rewrites it in place. - [Agent Screen](https://viewership.ai/ui/components/agent-screen.md): An agent's live screen with an expandable viewer and recording. ## Atoms - [Button](https://viewership.ai/ui/components/button.md): Pill-shaped action button in six variants and three sizes. - [Glide Menu](https://viewership.ai/ui/components/glide-menu.md): One highlight that glides to the hovered or focused row. - [Entity Chip](https://viewership.ai/ui/components/entity-chip.md): Inline reference to a client, person or record, as a monogram and name. - [Value Pill](https://viewership.ai/ui/components/value-pill.md): Inline badge that sets a value off from running text. - [Shimmer](https://viewership.ai/ui/components/shimmer.md): Label with a moving light sweep that shows the agent is working. - [Stream Text](https://viewership.ai/ui/components/stream-text.md): Text that streams in like model output, with a blurred edge and caret. - [Chip](https://viewership.ai/ui/components/chip.md): Monospace chip for code values and field names in text. - [Status Pill](https://viewership.ai/ui/components/status-pill.md): Tinted status label with a leading dot, in five tones. - [Segmented Control](https://viewership.ai/ui/components/segmented-control.md): Equal-width segments with a sliding thumb, as a radio group. - [Switch](https://viewership.ai/ui/components/switch.md): On/off toggle on a native checkbox, in two sizes and two tones. - [Progress Ring](https://viewership.ai/ui/components/progress-ring.md): Small progress ring with a count or glyph in the center. - [Text Row](https://viewership.ai/ui/components/text-row.md): Label on the left, value and optional meta on the right. - [Overlay core](https://viewership.ai/ui/components/overlay.md): Positioning, dismissal, focus-trap and roving-focus helpers for popovers, menus and dialogs. --- # 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"; --- ``` --- # 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/ui/components/glide-menu - **All files in one bundle:** https://viewership.ai/ui/components/glide-menu.bundle.txt - **Structured JSON:** https://viewership.ai/ui/components/glide-menu.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/ui/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/ui/raw/glide-menu/GlideMenu.astro.txt) - `glide-menu.ts` (raw: https://viewership.ai/ui/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/ui/components/approval-card.md), [Prompt Bar](https://viewership.ai/ui/components/prompt-bar.md), [Records Table](https://viewership.ai/ui/components/records-table.md), [Sidebar Nav](https://viewership.ai/ui/components/sidebar-nav.md), [Search](https://viewership.ai/ui/components/search.md), [Fine-tune Card](https://viewership.ai/ui/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(); }; } ``` --- # 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/ui/components/entity-chip - **All files in one bundle:** https://viewership.ai/ui/components/entity-chip.bundle.txt - **Structured JSON:** https://viewership.ai/ui/components/entity-chip.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/ui/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/ui/raw/entity-chip/EntityChip.astro.txt) - `Monogram.astro` (raw: https://viewership.ai/ui/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/ui/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; --- ``` --- # 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/ui/components/value-pill - **All files in one bundle:** https://viewership.ai/ui/components/value-pill.bundle.txt - **Structured JSON:** https://viewership.ai/ui/components/value-pill.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/ui/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/ui/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/ui/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/ui/components/shimmer - **All files in one bundle:** https://viewership.ai/ui/components/shimmer.bundle.txt - **Structured JSON:** https://viewership.ai/ui/components/shimmer.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/ui/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/ui/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/ui/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/ui/components/stream-text - **All files in one bundle:** https://viewership.ai/ui/components/stream-text.bundle.txt - **Structured JSON:** https://viewership.ai/ui/components/stream-text.json ## Install 1. Import the foundation tokens once (download: https://viewership.ai/ui/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/ui/raw/stream-text/StreamText.astro.txt) - `stream-text.ts` (raw: https://viewership.ai/ui/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/ui/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 &&