Behaviour of the shared UI components every page is built from (tables, paginator, badges, tiles, charts, range
picker, states, lock overlay, search), so that every page looks and behaves the same.
## Requirements
### Requirement: Data table
Edition: CE. The data table SHALL take column definitions (key, header, sortable, hideable, cell template), rows, the
current sort key and order, and loading, error and empty states. It renders `<th scope="col">` with `aria-sort`,
selectable cell text, and emits sort changes only, never navigating on row click. An error keeps the previous rows below
an error notice with Retry. A wide table scrolls horizontally and has no vertical scrollbar of its own.
#### Scenario: Sort emits, URL navigates
- **GIVEN** a sortable column header
- **WHEN** the user activates it
- **THEN** the table emits the key and the toggled order, and the page writes `sort` and `order` to the URL without `cursor`
#### Scenario: Error keeps rows
- **GIVEN** rows from the previous page load
- **WHEN** the refresh request fails
- **THEN** the rows stay visible below an inline error notice with Retry
#### Scenario: No inner vertical scroll
- **GIVEN** a rooms table of 25 rows wider than the viewport
- **WHEN** it renders
- **THEN** it scrolls horizontally inside its card and the rows grow the page, with no vertical scrollbar on the table
#### Scenario: First load
- **GIVEN** a list opened for the first time with a page size of 25
- **WHEN** its first request is in flight
- **THEN** the table shows 25 skeleton rows
#### Scenario: Refresh keeps rows
- **GIVEN** a rooms table showing 25 rows
- **WHEN** a refresh is in flight
- **THEN** the 25 rows stay visible and no skeleton row renders
#### Scenario: Refresh keeps the empty state
- **GIVEN** a list showing "No rooms match"
- **WHEN** a refresh is in flight
- **THEN** the empty state stays and no skeleton row renders
#### Scenario: Filter change keeps rows
- **GIVEN** a rooms table showing active rooms
- **WHEN** the status filter changes to closed
- **THEN** the active rows stay until the closed rooms arrive and replace them, with no skeleton rows in between
#### Scenario: Loading states
- **WHEN** the table waits for its first response, refreshes, or changes filters, sort or page
- **THEN** skeleton rows equal to the page size render only until the table's first response; after it, a refresh keeps
the rendered rows or the empty state, and a change of filters, sort or page keeps the previous rows until the new ones
arrive
### Requirement: Cursor paginator
Edition: CE. The paginator SHALL take `limit`, `nextCursor`, `prevCursor`, optional `total` and the page number from the
URL (`page`, 1 when absent). It renders Prev/Next (disabled without their cursor), a page-size select with 10/25/50/100
whose label is never truncated and the shown range. Prev and Next write the cursor and the page; range changes are
announced to assistive technology. It is hidden while the first page (no `cursor`) has no rows.
#### Scenario: Unknown total
- **GIVEN** a response without `total`, 25 rows, `limit=25` and `page=3`
- **WHEN** the paginator renders
- **THEN** it reads "Showing 51–75" and Next is enabled only if `nextCursor` is present
#### Scenario: Page size change
- **GIVEN** the user selects 100
- **WHEN** the change applies
- **THEN** the page writes `limit=100`, drops `cursor` and `page` and stores 100 as the preference
#### Scenario: Next and previous
- **GIVEN** `page=2`
- **WHEN** the user presses Next and then Prev
- **THEN** the URL reads `page=3` with the next cursor, then `page=2` with the previous cursor
#### Scenario: Empty first page
- **GIVEN** a list whose first page answers no rows
- **WHEN** the page renders
- **THEN** the empty state shows and no paginator is rendered
#### Scenario: Shown range
- **WHEN** the paginator renders a page
- **THEN** it reads "Showing x–y of total" when `total` is known, or "Showing x–y" otherwise, with x = (page − 1) ×
limit + 1
### Requirement: Column visibility
Edition: CE. Hideable columns SHALL be toggled from the column menu of the table's Columns button, the selection stored
per table as a viewer preference; status, the identifying link and the primary timestamp cannot be hidden. The table
shows exactly the selected columns (by default all but those hidden by default) at any width, never hiding one to fit,
and scrolls sideways inside a narrower container. As cards, the table hides its Columns button.
#### Scenario: Hidden column persists
- **GIVEN** the user hides Data up / down on the rooms table
- **WHEN** the page is reopened
- **THEN** Data up / down stays hidden and the menu offers to show it again
#### Scenario: Selection is what shows
- **GIVEN** the rooms table, 1100 px wide on a 1920 px viewport, with every column selected
- **WHEN** it renders
- **THEN** every selected column shows, Data up / down included, the table scrolls sideways inside its card when the
columns do not fit, and the Columns menu lists them all as shown
#### Scenario: Cards hide Columns
- **GIVEN** a table rendered as cards
- **WHEN** it renders
- **THEN** no Columns button is shown
### Requirement: Table to cards on narrow screens
Edition: CE. The table SHALL become cards only below 600 px (XSmall): title = identifying cell (link + copy button),
second line = status column cell, if any, + primary timestamp, third line = up to three key metrics, other columns in an
expandable "More" area, sorting in a select above the cards; card cells wrap inside the card, keeping each copy button
beside its value, never over the next field. Wider viewports keep the table at any container width.
#### Scenario: Phone layout
- **GIVEN** a 375 px viewport and the rooms list
- **WHEN** it renders
- **THEN** each row is a card titled by the identifying link and its copy button, then the status and the start time, then up to three metrics, and a sort select is shown above
#### Scenario: Half-width card on a wide viewport
- **GIVEN** a table of seven visible columns in a card 560 px wide on a 1440 px viewport
- **WHEN** it renders
- **THEN** it renders as a table that scrolls sideways inside the card, not as cards
#### Scenario: Status column in the card
- **GIVEN** a table with a Status column rendered as cards
- **WHEN** a card renders
- **THEN** its second line shows the cell of that Status column next to the primary timestamp
### Requirement: Status badge
Edition: CE. The status badge SHALL render icon + label for a status within a domain (room, participant, egress,
ingress) using container/text pairs that meet 4.5:1 in both themes: active/live green, closed/complete neutral, failed
red, aborted orange, starting/pending blue, idle neutral. The label keeps the lowercase words of the status. An
`estimated` flag adds a dotted outline and the tooltip "Closed by the reconciler: the end time is estimated". Colour is
never the only cue.
#### Scenario: Reconciler-closed room
- **GIVEN** a room with status closed and `closedBy=reconciler`
- **WHEN** the badge renders
- **THEN** it shows the check icon, "closed", a dotted outline and the tooltip "Closed by the reconciler: the end time is estimated"
#### Scenario: Idle ingress
- **GIVEN** an ingress with status `inactive`
- **WHEN** the badge renders
- **THEN** it reads "idle" in the neutral colours
### Requirement: Kind badge
Edition: CE. The kind badge SHALL render participant kinds, egress types and ingress input types as outlined chips with
neutral colours and the label in text: the value's friendly label, such as "Agent", "Room composite" or "RTMP", or the
raw value when the dashboard has no label for it.
#### Scenario: Agent participant
- **GIVEN** a participant of kind `AGENT`
- **WHEN** the badge renders
- **THEN** it reads "Agent" as an outlined chip
#### Scenario: Unknown kind
- **GIVEN** an egress type `new_type` the dashboard has no label for
- **THEN** the interval select or menu button stays visible but disabled, with the page's reason as its tooltip
### Requirement: Empty, skeleton and error states
Edition: CE. Empty state SHALL show icon, title, hint and up to two actions ("Clear filters", "Widen range"); skeletons
match the height of rows, tiles, charts or text they replace and render only until the first response of what they
replace; the error notice shows the message, a Retry action and the request id, inline in a card or at page level.
#### Scenario: No rooms match
- **GIVEN** a filtered list returns no rows
- **WHEN** the empty state renders
- **THEN** it reads "No rooms match" with the hint "Change the filters or widen the time range.", offers "Clear filters" and "Widen range", and the layout height does not jump
### Requirement: PRO lock overlay
Edition: CE. The lock overlay SHALL take a feature key and render its title, explanation and docs link in a full-page
or card variant, make no network call, and keep the page's header and breadcrumbs in the full-page variant. The
full-page variant dims a silhouette of three tiles and one block behind the message; the card variant sizes to its
message.
#### Scenario: Locked analytics card
- **GIVEN** the quality analytics feature is disabled
- **WHEN** the card renders
- **THEN** it is as tall as its message, not as the real chart, and reads "Quality analytics is available in OpenVidu PRO" with the link
#### Scenario: Locked page silhouette
- **GIVEN** a CE room detail page
- **WHEN** the full-page lock renders
- **THEN** behind the message it shows a dimmed row of three tiles and one block below them, with no bar above the tiles
### Requirement: Live chip
Edition: CE. The live chip SHALL show a pulsing dot and "LIVE", switch to a "stale" state with a `schedule` icon when
the last server status found the writer batch older than the writer freshness windows' fresh window, and announce
updates in a polite live region. One minimum width across states keeps a state change from moving what follows. It
shows only on the Overview.
#### Scenario: Rooms list
- **GIVEN** an active room in the Rooms list
- **WHEN** the row renders
- **THEN** it shows the active status badge and no LIVE chip
#### Scenario: Not in lists
- **WHEN** a list page or a list row shows an active entity
- **THEN** no live chip is shown, since the status badge already says active
ordered list, append the current range parameters to links, and render the last item as text followed by a copy
button.
#### Scenario: Range in crumb links
- **GIVEN** the current range `from=now-6h&to=now&tz=UTC`
- **WHEN** the "Rooms" crumb renders
- **THEN** its link is `/rooms?from=now-6h&to=now&tz=UTC`
### Requirement: Global search
Edition: CE. The global search SHALL debounce input and send no request below 2 characters, showing the hint "Type at
least 2 characters". An exact ID (`RM_`, `PA_`, `EG_`, `IN_` prefix) shows "Open <entity><ID>" as the first option;
Enter opens that detail route (locked in CE) keeping the current range. Other text shows grouped prefix results (rooms,
participants, egress, ingress; up to five each) with "See all in …" links to the list filtered by `q`.
#### Scenario: Exact SID
- **GIVEN** the user types `PA_x1y2` with the range `from=now-6h&to=now&tz=UTC`
- **WHEN** the results panel opens and the user presses Enter
- **THEN** its first option reads "Open participant PA_x1y2", the application navigates to `/participants/PA_x1y2`, and the Participants breadcrumb links to `/participants?from=now-6h&to=now&tz=UTC`
#### Scenario: Prefix search
- **GIVEN** the user types `dem`
- **WHEN** results arrive
- **THEN** rooms starting with "dem" are listed under Rooms with "See all in Rooms" linking to `/rooms?q=dem`
#### Scenario: One character
- **GIVEN** the user types `d`
- **WHEN** the debounce elapses
- **THEN** no search request is sent and the hint reads "Type at least 2 characters"
#### Scenario: Clear the search
- **GIVEN** the global search holding `dem` with its results open
- **WHEN** the user activates the "Clear search" button
- **THEN** the box is empty, the results close, the focus stays in the box and the button disappears
#### Scenario: Keyboard, placeholder and clear button
- **WHEN** the global search renders at any header width
- **THEN** it is keyboard navigable, its placeholder shows whole, without truncation, and while it holds text a clear
button empties it and closes the results
### Requirement: Section card
Edition: CE. The section card SHALL show a title, a count, an actions slot and collapse/expand, remembering the
collapsed state in memory per route for the session. The actions slot, in the section header, holds the section's
filters and the Columns button of its table.
#### Scenario: Collapsed card survives a reload
- **GIVEN** the Rooms card of the Analytics page collapsed
- **WHEN** the page reloads its data after a range change
- **THEN** the card stays collapsed
#### Scenario: Card actions in the header
- **GIVEN** the Rooms card of the Analytics page
- **WHEN** it renders
- **THEN** its Export CSV button is in the section header, after the title
- **THEN** no element between the link and the document root carries a `title` attribute
### Requirement: List empty state
Edition: CE. A list whose first page has no rows SHALL read "No <things> match" with the hint "Change the filters or
widen the time range." and two actions: Clear filters, which removes every filter and `q`, keeping the range, sort and
page size, and Widen range, which opens the range picker. The paginator is hidden meanwhile.
#### Scenario: No agents match
- **GIVEN** the Agents list with filters that match no agent
- **WHEN** it renders
- **THEN** it reads "No agents match" with the hint "Change the filters or widen the time range." and the Clear filters and Widen range actions, and no paginator
#### Scenario: Clear filters
- **GIVEN** `/rooms?from=now-7d&to=now&status=closed&q=demo&cursor=abc&page=2` with no rows
- **WHEN** the user presses Clear filters
- **THEN** the URL becomes `/rooms?from=now-7d&to=now` with the same `tz`, and the list loads its first page
### Requirement: Exact ID not found in a list
Edition: CE. When a list's search holds an exact ID that matches no entity, the list SHALL read "<Entity><ID> was not
found" with a Clear search action that removes `q`, in place of the filters empty state.
#### Scenario: Unknown egress ID
- **GIVEN** the Egress list searched for `EG_missing`
- **WHEN** the response has no rows
- **THEN** the list reads "Egress EG_missing was not found" with a Clear search action and no paginator
### Requirement: Notice
Edition: CE. Error, warning and info messages inside pages SHALL use one notice: an icon, a message and an optional
action, coloured by variant. The error variant shows the message for the error code, a Retry action and the request id
when it is known, inline in a card or at page level.
#### Scenario: Error with Retry
- **GIVEN** a request that failed with a request id
- **WHEN** its error notice renders
- **THEN** it shows the error icon, the message for the error code, the request id and a Retry action in the error colours
#### Scenario: Info without action
- **GIVEN** an info notice without action
- **WHEN** it renders
- **THEN** it shows the info icon and the message in the info colours, and no button
### Requirement: Section error keeps content
Edition: CE. When the refresh of a card fails, the card SHALL keep its last content and show an error notice with Retry
above it.
#### Scenario: Analytics card refresh fails
- **GIVEN** the Rooms card of the Analytics page showing data
- **WHEN** its refresh fails
- **THEN** it keeps its charts under the error notice with Retry
### Requirement: Charts update in place
Edition: CE. A refresh SHALL update the series of a rendered chart in place, without re-creating the chart or replaying
its entry animation; the entry animation runs only on the first render, and never when reduced motion is requested.
#### Scenario: Refresh of an Analytics chart
- **GIVEN** a rendered chart of the Analytics page
- **WHEN** the user presses refresh
- **THEN** its series update in place without replaying the entry animation
### Requirement: Enum labels
Edition: CE. Every enum value outside a status badge SHALL show its friendly sentence-case label, in kind badges,
chart categories and filters: egress type, SDK, OS, connection type and participant kind. A value without a label
shows the raw value. Status badges keep their lowercase words.
#### Scenario: Chart category and filter
- **GIVEN** egresses of type `room_composite`
- **WHEN** the egress type breakdown and the Type filter render
- **THEN** both read "Room composite"
### Requirement: Reconciler estimate text
Edition: CE. Every value estimated by the reconciler SHALL be explained with one text, "Closed by the reconciler: the
end time is estimated", as the tooltip of the estimated status badge.
#### Scenario: Estimated status badge
- **GIVEN** a room closed by the reconciler in the Rooms list
- **WHEN** the user hovers its status badge
- **THEN** the tooltip reads "Closed by the reconciler: the end time is estimated"
### Requirement: Time cells
Edition: CE. Every table column that shows times SHALL render each time as a time element with tabular figures, so the
digits align from row to row, and SHALL carry the time-zone header hint: a tooltip on the column header naming the
active zone.
#### Scenario: Started column
- **GIVEN** the Rooms list with `tz=Europe/Madrid`
- **WHEN** the Started column renders
- **THEN** its times align digit by digit across rows and hovering the header shows Europe/Madrid
### Requirement: Live durations
Edition: CE. Every duration of something still running SHALL count up live from its start between refreshes, ignoring
any stored duration it carries; a total that includes a running part adds that part's elapsed time. An ended item shows
its stored duration, or its end minus its start without one, and an item with neither a stored duration nor the times
to compute one shows "—".
#### Scenario: Running total
- **GIVEN** an ingress with a stored total duration of 1 h and a session publishing for 5 minutes
- **WHEN** its total duration renders
- **THEN** it reads "1 h 05 m" and keeps counting while the session runs
#### Scenario: Nothing to compute
- **GIVEN** a row with no stored duration and no start time
- **WHEN** it renders
- **THEN** its duration reads "—"
### Requirement: Abbreviation tooltips
Edition: CE. Wherever SDK labels a column header, a fact, a chart or a tile, its info tip SHALL spell the abbreviation
out; the label itself carries no dotted underline and no hover text.
#### Scenario: Charts named by an abbreviation
- **WHEN** the user opens the info tip of the Analytics SDK breakdown
- **THEN** it reads "Software development kit the client connected with."
### Requirement: List search box
Edition: CE. Every list SHALL search with one box: a search icon, the list's label (Rooms "Room name or SID",
Participants and Agents "Identity or SID", Egress "Room name or egress ID", Ingress "Ingress name or ID") and a "Clear
search" button. Text of at least 2 characters, or an exact ID, is written to `q` after 300 ms, dropping `cursor` and
`page`; one character shows the hint "Type at least 2 characters" and writes nothing.
#### Scenario: Agents search
- **GIVEN** the Agents list
- **WHEN** the user types `voice` in the "Identity or SID" box and waits 300 ms
- **THEN** the URL gets `q=voice` without `cursor` and `page`, and the list shows the agents whose identity starts with "voice"
#### Scenario: Too short
- **GIVEN** the Rooms list
- **WHEN** the user types `d`
- **THEN** the box shows the hint "Type at least 2 characters" and the URL keeps its `q`
#### Scenario: Clear search
- **GIVEN** the Ingress list with `q=cam`
- **WHEN** the user presses the "Clear search" button
- **THEN** the box empties and `q` leaves the URL
### Requirement: Exact ID search in lists
Edition: CE. An exact ID in a list's search box (the list's `RM_`, `PA_`, `EG_` or `IN_` prefix followed by letters and
digits) SHALL filter the list to that entity, ignoring the range and the other filters, with the hint "Exact ID: range
and filters ignored · Enter opens it"; Enter opens the entity's detail (the locked page in CE), keeping the range.
#### Scenario: Room SID outside the range
- **GIVEN** the Rooms list over the last hour with `status=active`, and a room `RM_ab12cd` closed two days ago
- **WHEN** the user types `RM_ab12cd`
- **THEN** the list shows that room alone and the hint reads "Exact ID: range and filters ignored · Enter opens it"
#### Scenario: Enter opens the detail
- **GIVEN** `PA_x1y2` typed in the Participants search box
- **WHEN** the user presses Enter
- **THEN** the application opens `/participants/PA_x1y2`, and the Participants breadcrumb links back with the same `from`, `to` and `tz`
### Requirement: Filter bar layout
Edition: CE. In the filter bar of a list or section, a hint under one field SHALL NOT move the other controls: every
control keeps its position and the hint only adds height below the bar.
#### Scenario: Hint appears
- **GIVEN** the Rooms filter bar with the status toggle and the search box side by side
- **WHEN** the hint "Type at least 2 characters" appears under the search box
- **THEN** the status toggle and the other filters keep their position
### Requirement: Room filter chip
Edition: CE. Participants, Agents and Egress SHALL show their `room` filter as one chip, "Room: <name>" (the room SID
until the name is known), that links to the room and has a remove button dropping `room`, `cursor` and `page`.
#### Scenario: Participants of a room
- **GIVEN** `/participants?room=RM_ab12cd` for room `demo-room`
- **WHEN** the list renders
- **THEN** it shows the chip "Room: demo-room" linking to the room, and its remove button lists every room's participants from the first page
### Requirement: Zone chip
Edition: CE. On a timed page without the range picker (the Overview) the header SHALL show a compact chip naming the
session zone ("UTC", "Europe/Madrid") whose menu offers UTC, the browser zone (named, such as "Browser
(Europe/Madrid)") and a search over every IANA zone, the active one checked; choosing one writes `tz` to the URL, which
sets the session zone without changing the stored default, and keeps the page's other parameters.
#### Scenario: Any other zone
- **GIVEN** the zone chip menu open on Overview
- **WHEN** the user types "tok" in its search and picks Asia/Tokyo
- **THEN** Overview reloads its today figures for Asia/Tokyo, the chip reads "Asia/Tokyo" and the menu closes
#### Scenario: Keyboard
- **GIVEN** the zone chip focused
- **WHEN** the user presses Enter, moves with the arrow keys and presses Enter on UTC
- **THEN** the session zone becomes UTC and focus returns to the chip
### Requirement: Info tips
Edition: CE. An info tip SHALL be an info icon button right after a label or heading, named "About <label>" for
assistive technology, that shows its text as a tooltip on hover, keyboard focus or tap and hides it on Escape, blur or a
second tap. KPI tiles, section and Analytics cards, charts, data table column headers (table and card layouts) and
entity facts take one from their definition; the label then carries no hover text of its own.
#### Scenario: Hover
- **GIVEN** a KPI tile "Unique identities" with an info tip
- **WHEN** the pointer rests on its info icon
- **THEN** the tooltip shows the explanation, and resting on the label or the value shows nothing
#### Scenario: Keyboard
- **GIVEN** a section card heading with an info tip
- **WHEN** the user tabs to its info icon and then presses Escape
- **THEN** the icon is announced as "About <heading>" with the explanation as its description, the tooltip shows on focus and hides on Escape
#### Scenario: Touch
- **GIVEN** a phone and a chart heading with an info tip
- **WHEN** the user taps the info icon and then taps it again
- **THEN** the first tap shows the tooltip and the second hides it, without a long press
#### Scenario: Sortable column header
- **GIVEN** a sortable column header with an info tip