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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10Then 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.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/portholeIts 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.10import { 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
SyncStatusinstead for where one document's own changes are, andtoast()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
dismissableitem. 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
falseto 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,subscribeandlastBeatAt), 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 fromsource:getSnapshot().items.status: Not a prop. Porthole decides the status fromsource.
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;
}