Skip to main content

Feedback

Porthole

The status spot: a small round window with one line about how current the app's data is, which opens into the facts behind it. It shows one item at a time, an action over an error over working over rest; at rest it says "Up to date" only while every area was verified within its freshWithinMs, otherwise the real age, and "Status unknown" when an area was never verified or the service's heartbeat stops. It owns its clock: it looks again only at the one moment the view could change (nextChangeAt), so a stuck service can never hold "Up to date", a beat never renders it, and it waits while the page is hidden. It opens into the detail lines while a mouse is over it, and a press keeps it open until a second press, Escape or a press anywhere else. It never takes focus on its own, and speaks only when it changes into an action or an error, at most once every ten seconds. Its state is on data-porthole-state for a fault-injection check.

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/porthole#v1.3.10

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/porthole

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/porthole#v1.3.10 ourostack/ouro-ui/porthole-tests#v1.3.10
import { Porthole } from "@/components/ouro/porthole/porthole";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<Porthole source={statusSource} label="Status" onAction={(item, id) => service.act(item.id, id)} onDismiss={(item) => service.dismiss(item.id)} />

Use instead when

  • Use SyncStatus instead for where one document's own changes are, and toast() to confirm a single action.

Props

Type: PortholeProps.

className
Type: string
Layout only: margins, placement (ms-auto). Appearance classes warn in development.
defaultOpen
Type: boolean
Default: false
Starts open, showing the detail lines, as on a page about the app's status.
freshWithinMs
Type: number
Default: 60_000
How recently, in ms, the areas must have been verified for "Up to date", unless an area sets its own freshWithinMs.
heartbeatTimeoutMs
Type: number
Default: 5_000
"Status unknown" once the last beat is older than this many ms.
label (required)
Type: string
The status spot's name for screen readers, such as "Status".
labels
Type: PortholeWords
Words for other languages.
onAction
Type: ((item: PortholeItem, actionId: string) => void)
Called when the person presses the shown item's action, with the item and the action's id. Pass it to the service, which changes or removes the item: Porthole never changes an item itself.
onDismiss
Type: ((item: PortholeItem) => void)
Called when the person dismisses a dismissable item. Pass it to the service, which removes the item.
quiet
Type: boolean
Default: true
At rest and fresh, shows the light alone and the words on hover or focus, in the same room, so nothing moves. Pass false to keep the words showing. The spot's name holds them while they are unseen.
ref
Type: Ref<HTMLDivElement>
Reaches the rendered element.
side
Type: "top" | "bottom"
Default: "bottom"
Where the detail lines open: under the spot, or over it for a spot at the foot of a column.
source (required)
Type: PortholeSource | PortholeFixed
Where the status comes from: the app's mirror of its service (getSnapshot, subscribe and lastBeatAt), or a fixed { snapshot, lastBeatAt }.
troubleAfterMs
Type: number
Default: 5_000
An area in trouble for this many ms stops counting as fresh.

Native attributes

Also accepts the attributes in React's HTMLAttributes (id, aria-*, event handlers), passed to the rendered element, except those named on this page.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • items: Not a prop. Porthole reads items from source: getSnapshot().items.
  • status: Not a prop. Porthole decides the status from source.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

PortholeFixed

A snapshot that does not change, with the time of its last beat: for a page rendered on the server, a story or a test. It is plain data, so a Server Component can pass it. The server renders it as of its last beat, so a page read later from a cache hydrates without a mismatch; once hydrated, the spot judges it against the clock, so it says "Status unknown" once the beat is older than heartbeatTimeoutMs.

interface PortholeFixed {
  snapshot: PortholeSnapshot;
  lastBeatAt: number;
}

PortholeWords

Porthole's words: those of portholeView, and the dismiss button's name.

interface PortholeWords extends PortholeLabels {
  /** The name of the button that dismisses an item. @default "Dismiss" */
  dismiss?: string | undefined;
}