# State Panel (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-state-panel` · version 1.0.0 · component · free
- category: Feedback
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-state-panel.json`
- npm dependencies: none
- registry dependencies: utils, @beautiful-ui/lumen-empty, @beautiful-ui/lumen-alert, @beautiful-ui/lumen-button, @beautiful-ui/lumen-skeleton, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-state-panel
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

Every state a data surface can be in, decided one way: loading rows shaped like the content, first use, no results, access, disconnected, error and partial data, each with its next step. Content stays up while it refreshes, with a filament scanning the top edge.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/state-panel.tsx`; shared code: `lib/beautiful-ui/core/states.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-empty`, `lumen-alert`, `lumen-button`, `lumen-skeleton`, `lumen-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `StatePanel`, `StatePanelSkeleton`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { StatePanel } from "@/components/ui/lumen/controls/state-panel";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `StatePanel` | — | Shows the state, or the content. |
| `StatePanelSkeleton` | — | The default loader rows. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/state-panel.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- loading state, empty state, error state, no results, permission denied, disconnected, partial data, data states, production states, Lumen
- Any table, chart, list or workspace region that loads data
- One source of truth for which state shows, so neighbouring panels never disagree

### Not when
- A form's field errors: use Field
- A page-level outage: use Alert above the page

## Mistakes
- Don't replace loaded rows with a spinner on refresh: pass refreshing
- Tell 'no results' (filters) from 'empty' (no data): pass totalCount to resolveState

## Usage

```tsx
import { StatePanel } from "@/components/ui/lumen/controls/state-panel";
import { resolveState } from "@/lib/beautiful-ui/core/states";

const state = resolveState({ loading, error, count: rows.length, totalCount });

<StatePanel state={state} refreshing={loading && rows.length > 0} onAction={(s) => (s === "zero-results" ? clearFilters() : refetch())}>
  <AccountsTable rows={rows} />
</StatePanel>
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `state` | `"loading" \| "ready" \| "partial" \| "empty" \| "zero-results" \| "first-use" \| "error" \| "disconnected" \| "permission-denied"` |  | Pick it with resolveState(facts) from core/states. |
| `refreshing` | `boolean` | `false` | Content is on screen and a refresh is loading: keep it, mark it busy. |
| `onAction` | `(state) => void` |  | The next step (retry, reconnect, clear filters). No handler, no button. |
| `copy` | `Partial<Record<state, { title, body, action }>>` |  | Replace any state's words. |
| `loading` | `ReactNode` |  | A skeleton shaped like your content. |

Full docs: https://beautiful-ui.dev/components/lumen-state-panel

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Tab | Reaches the state's action button |

## Motion inventory

| Interaction | What moves |
|---|---|
| Refresh | A filament scans the top edge; a steady line with reduced motion |

## Accessibility contract (preserve when editing)
- Loading is a polite live region
- Errors are announced as alerts
- Busy content sets aria-busy while it refreshes

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-state-panel.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-state-panel.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-state-panel`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/state-panel.tsx

```tsx
// Generated from src/registry/core/controls/state-panel.tsx by scripts/gen-systems.ts. Edit the core file, not this one.
import * as React from "react";
import { cn } from "@/lib/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/lumen/alert";
import { Button } from "@/components/ui/lumen/button";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/lumen/empty";
import { Skeleton } from "@/components/ui/lumen/skeleton";
import { DEFAULT_STATE_COPY, type StateCopy, type SurfaceState } from "@/lib/beautiful-ui/core/states";

/*
 * State Panel: the one place a data surface shows its state (spec §8.11, §18). Pass the state
 * resolveState() picked and the content; the panel shows a loader, a designed empty/error/access
 * state with its next step, a partial-data banner above the content, or the content itself (kept
 * visible, marked busy, while a refresh loads). Words default to plain, specific copy and can be
 * replaced per screen. Server-safe: no hooks; actions are plain buttons.
 */

type Copy = Partial<Record<Exclude<SurfaceState, "ready" | "loading">, Partial<StateCopy>>>;

function StatePanel({
  state,
  refreshing = false,
  copy,
  onAction,
  loading,
  className,
  children,
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  state: SurfaceState;
  /** Content is on screen and a refresh is loading: show it, marked busy. */
  refreshing?: boolean | undefined;
  /** Replace any state's title, body or action label. */
  copy?: Copy | undefined;
  /** The next step for a state (retry, reconnect, clear filters); no handler, no button. */
  onAction?: ((state: SurfaceState) => void) | undefined;
  /** A skeleton shaped like the content, instead of the default rows. */
  loading?: React.ReactNode;
  children?: React.ReactNode;
}) {
  if (state === "loading") {
    return (
      <div data-slot="state-panel" data-state="loading" role="status" aria-live="polite" className={cn(`lumen-state-panel`, className)} {...props}>
        <span className="sr-only">Loading</span>
        {loading ?? <StatePanelSkeleton />}
      </div>
    );
  }

  if (state === "ready" || state === "partial") {
    const words = { ...DEFAULT_STATE_COPY.partial, ...copy?.partial };
    return (
      <div data-slot="state-panel" data-state={state} aria-busy={refreshing || undefined} className={cn(`lumen-state-panel`, className)} {...props}>
        {refreshing && <span aria-hidden data-slot="state-panel-busy" className={`lumen-state-panel-busy`} />}
        {state === "partial" && (
          <Alert tone="warning" data-slot="state-panel-banner" className={`lumen-state-panel-banner`}>
            <AlertTitle>{words.title}</AlertTitle>
            <AlertDescription>
              {words.body}
              {onAction && words.action && (
                <>
                  {" "}
                  <Button variant="link" size="sm" onClick={() => onAction(state)}>
                    {words.action}
                  </Button>
                </>
              )}
            </AlertDescription>
          </Alert>
        )}
        {children}
      </div>
    );
  }

  const words = { ...DEFAULT_STATE_COPY[state], ...copy?.[state] };
  return (
    <Empty data-slot="state-panel" data-state={state} role={state === "error" ? "alert" : undefined} className={cn(`lumen-state-panel`, className)} {...props}>
      <EmptyHeader>
        <EmptyMedia />
        <EmptyTitle>{words.title}</EmptyTitle>
        <EmptyDescription>{words.body}</EmptyDescription>
      </EmptyHeader>
      {onAction && words.action && (
        <EmptyContent>
          <Button variant={state === "error" || state === "disconnected" ? "default" : "secondary"} size="sm" onClick={() => onAction(state)}>
            {words.action}
          </Button>
        </EmptyContent>
      )}
    </Empty>
  );
}

/** The default loader: rows shaped like a list, so the layout doesn't jump when data lands. */
function StatePanelSkeleton({ rows = 4, className, ...props }: React.ComponentProps<"div"> & { rows?: number | undefined }) {
  return (
    <div data-slot="state-panel-skeleton" className={cn(`lumen-state-panel-skeleton`, className)} {...props}>
      {Array.from({ length: rows }, (_, i) => (
        <Skeleton key={i} className={`lumen-state-panel-skeleton-row`} style={{ width: `${92 - ((i * 17) % 34)}%` }} />
      ))}
    </div>
  );
}

export { StatePanel, StatePanelSkeleton };
```

### lib/beautiful-ui/core/states.ts

```tsx
/*
 * The production-state contract (spec §18): which state a data surface shows, decided the same way
 * by the State Panel, the Data Explorer, charts and workspaces. A screen passes what it knows;
 * resolveState picks the one state to render, in a fixed order, so two pieces on one screen never
 * disagree (one "loading" while its neighbour says "no results").
 */

export type SurfaceState =
  | "permission-denied"
  | "disconnected"
  | "error"
  | "loading"
  | "first-use"
  | "zero-results"
  | "empty"
  | "partial"
  | "ready";

export type SurfaceFacts = {
  /** The viewer may not see this data. */
  forbidden?: boolean | undefined;
  /** The integration that feeds it is disconnected (a revoked token, a paused sync). */
  disconnected?: boolean | undefined;
  /** The request failed. Kept alongside data: stale rows stay visible under an error banner. */
  error?: unknown;
  /** A request is in flight. */
  loading?: boolean | undefined;
  /** Rows available now (after filters). */
  count?: number | undefined;
  /** Rows before filters, to tell "nothing yet" from "nothing matches". */
  totalCount?: number | undefined;
  /** Nothing has ever been set up here (no source connected, no records created). */
  neverUsed?: boolean | undefined;
  /** Some sources or series failed while others loaded. */
  partial?: boolean | undefined;
};

/**
 * The order is deliberate:
 * - access and connection problems first (retrying or filtering can't fix them);
 * - then errors;
 * - then loading, but only when there is nothing to show yet. Existing rows stay up while a refresh
 *   loads, and the surface marks itself busy instead;
 * - then the kinds of empty: first use, filtered to nothing, genuinely empty;
 * - then partial data, then ready.
 */
export function resolveState(f: SurfaceFacts): SurfaceState {
  if (f.forbidden) return "permission-denied";
  if (f.disconnected) return "disconnected";
  const hasRows = (f.count ?? 0) > 0;
  if (f.error && !hasRows) return "error";
  if (f.loading && !hasRows) return "loading";
  if (!hasRows) {
    if (f.neverUsed) return "first-use";
    if ((f.totalCount ?? 0) > 0) return "zero-results";
    return "empty";
  }
  if (f.partial || f.error) return "partial";
  return "ready";
}

/** True while existing content should show a quiet busy state rather than be replaced by a loader. */
export function isRefreshing(f: SurfaceFacts): boolean {
  return Boolean(f.loading) && (f.count ?? 0) > 0;
}

export type StateCopy = { title: string; body: string; action?: string | undefined };

/** Default words, overridable per screen. Plain, specific, and they say what to do next. */
export const DEFAULT_STATE_COPY: Record<Exclude<SurfaceState, "ready" | "loading">, StateCopy> = {
  "permission-denied": { title: "You don't have access to this", body: "Ask a workspace admin to give you access.", action: "Request access" },
  disconnected: { title: "The source is disconnected", body: "Reconnect it to bring in new data. What was synced before is still here.", action: "Reconnect" },
  error: { title: "This didn't load", body: "Something went wrong on our side. Try again in a moment.", action: "Try again" },
  "first-use": { title: "Nothing here yet", body: "Connect a source or add your first record to see it here.", action: "Get started" },
  "zero-results": { title: "No results match these filters", body: "Try removing a filter or widening the date range.", action: "Clear filters" },
  empty: { title: "No data for this period", body: "Pick a different date range to see activity." },
  partial: { title: "Some data is missing", body: "A few sources didn't load, so totals may be low.", action: "Retry missing" },
};
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  @keyframes lumen-state-scan {
    from {
      transform: translateX(-100%);
    }
    to {
      transform: translateX(250%);
    }
  }
  .lumen-state-panel {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 12px;
    min-width: 0;
  }
  .lumen-state-panel-busy {
    position: absolute;
    inset: 0 0 auto;
    height: 2px;
    overflow: hidden;
    border-radius: calc(2px * var(--lumen-radius-k, 1));
    pointer-events: none;
  }
  .lumen-state-panel-busy::after {
    content: "";
    position: absolute;
    inset: 0 auto 0 0;
    width: 40%;
    background: linear-gradient(90deg,transparent,var(--gc-acc),transparent);
    animation: lumen-state-scan calc(1.4s * var(--gcp-k,1)) cubic-bezier(.45,0,.55,1) infinite;
  }
  @media (prefers-reduced-motion:reduce) {
    .lumen-state-panel-busy::after {
      animation: none;
      inset: 0;
      width: auto;
      background: color-mix(in srgb,var(--gc-acc) 45%,transparent);
    }
  }
  .lumen-state-panel-skeleton {
    display: flex;
    flex-direction: column;
    gap: 12px;
    padding: 4px 0;
  }
  .lumen-state-panel-skeleton-row {
    height: 14px;
    border-radius: calc(6px * var(--lumen-radius-k, 1));
  }
  .lumen-state-panel-banner .lumen-button-link {
    height: auto;
    padding: 0;
    vertical-align: baseline;
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
