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** 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
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`
- **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,