Dashboard
Widgets are named containers: a table inside a half-width widget auto-compacts
while the same table full-width does not. Stat values use proportional figures
(tabular-nums is reserved for columns); compact big values
server-side (12.9K, $4.2M). One hero figure per view, maximum.
Looking for a "Card"? .bo-widget is it —
header/body/band composition, independent of dashboards; see
"Widget parts" below.
Not as a wrapper around every section of a page — a
widget is a box that says "this content is a separate thing from the
box beside it". Wrapping each section of one continuous form or record
in its own bordered card draws lines where the reader needs
continuity, and the page turns into a stack of containers with no
hierarchy. A long record's own sections want
form sections or
object page's anchored
headings; reach for widgets when the pieces are genuinely independent
and could be reordered.
Hero figure + span-2 widget
The hero tile spans two columns
(.bo-widget--span-2) and collapses to one when the grid
(a named container) drops below its two-column width. One hero per view, max.
Widget with an embedded table (container-driven compaction)
Widget parts — header, body, band
.bo-widget is the framework's card primitive —
filed under "Dashboard" because that's its most common use, but it composes
into any card-shaped UI: a widget is __header / __body /
__band. The band footer carries a status message (--msg)
or actions — one durable strip. This is what record and approval cards are
built from.
Collapsible opt-in
data-collapse-trigger + initCollapsibleCards(): the
trigger's aria-expanded and the panel's data-state are the
two-channel contract (chevron rotation is decoration on top). CSS does the
height transition — same grid-template-rows 0fr/1fr technique as
the Motion module's .bo-motion-collapse. No data-state
at all defaults to open, so this degrades to a plain always-open widget without
the behavior wired up.
<div class="bo-widget">
<header class="bo-widget__header">
<!-- __title is a CLASS, not an element. On a real screen give it the
heading that fits the page's outline — <h2> for a section widget,
<h1> when the widget IS the record's identity (see the object-page
pattern). The <span> below is only right when something else on the
page already provides the heading. -->
<h2 class="bo-widget__title">Shipping details</h2>
<button class="bo-btn bo-btn--ghost bo-btn--icon" data-collapse-trigger
aria-expanded="true" aria-controls="ship-body" aria-label="Toggle">
<span class="bo-widget__toggle-icon">▾</span>
</button>
</header>
<div class="bo-widget__collapse" id="ship-body" data-state="open">
<div class="bo-widget__body">123 Warehouse Rd, Dock 4</div>
</div>
</div>
<script type="module">
import { initCollapsibleCards } from "@busy-office/ui/js";
initCollapsibleCards(); // once, ever — event delegation
</script> Markup
<div class="bo-widget">
<div class="bo-stat">
<span class="bo-stat__label">Open invoices</span>
<span class="bo-stat__value">1,284</span>
<span class="bo-stat__delta bo-stat__delta--bad">
<span aria-hidden="true">▲</span> +12%
<span aria-hidden="true">⚠</span>
<span class="bo-visually-hidden">increase, worse,</span> vs July
</span>
</div>
</div>
<!-- Valence has two channels: the visible ⚠ on BAD deltas (arrow + sign only
carry direction — a colorblind reader can't tell good ▲ from bad ▲) and
the visually-hidden text for screen readers. Glyphs are aria-hidden so
AT hears "+12%, increase, worse, vs July" without triangle noise. --> Quick reference generated
| Class | Role |
|---|---|
| .bo-badge | block (root) |
| .bo-badge--type | variant: type |
| .bo-stat | block (root) |
| .bo-stat--hero | variant: hero |
| .bo-stat__delta | part: delta |
| .bo-stat__delta--bad | variant: bad |
| .bo-stat__delta--good | variant: good |
| .bo-stat__label | part: label |
| .bo-stat__value | part: value |
| .bo-widget | block (root) |
| .bo-widget--span-2 | variant: span-2 |
| .bo-widget-grid | block (root) |
| .bo-widget__band | part: band |
| .bo-widget__band--msg | variant: msg |
| .bo-widget__body | part: body |
| .bo-widget__body--flush | variant: flush |
| .bo-widget__collapse | part: collapse |
| .bo-widget__header | part: header |
| .bo-widget__title | part: title |
| .bo-widget__toggle-icon | part: toggle-icon |
State attributes: data-collapse-triggerdata-statearia-expanded
API generated from CSS
| Blocks | bo-badgebo-statbo-widgetbo-widget-grid |
|---|---|
| Parts | bo-stat__deltabo-stat__labelbo-stat__valuebo-widget__bandbo-widget__bodybo-widget__collapsebo-widget__headerbo-widget__titlebo-widget__toggle-icon |
| Variants | bo-badge--typebo-stat--herobo-stat__delta--badbo-stat__delta--goodbo-widget--span-2bo-widget__band--msgbo-widget__body--flush |
| data-* hooks | data-collapse-triggerdata-state="closed" |
| ARIA styled | aria-expanded |
| Named containers | bo-widgetbo-widget-grid |
| JS required | Optional: initCollapsibleCards() — needed only for collapsible widgets. It flips a [data-collapse-trigger] button's aria-expanded and the data-state on the panel its aria-controls names; the CSS does the transition. A widget that never collapses needs no JS. |
- Delta valence needs two channels: the visible
⚠on bad deltas AND visually-hidden text ("increase, worse,"). - Stat values use proportional figures; compact big numbers server-side (12.9K, $4.2M). One hero per view.
- Widgets are named containers (
bo-widget); the grid isbo-widget-grid— span-2 collapses at the grid's 2-column threshold. bo-widget__titleis a CLASS, not an element — put it on the heading level the page's outline needs (h2for a section widget,h1when the widget carries the record's identity). Every demo here nests inside this page's ownh1, so copying aspaninto a real screen is how a page ends up with none.- Collapsible is opt-in:
data-collapse-trigger+initCollapsibleCards(). Without the behavior, a.bo-widget__collapsewith nodata-statejust renders open.
Contrast — 29 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-secondary | bg-muted | 9.37 | 9.75 |
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
| bg-surface | text-primary | 17.74 | 16.15 |
| danger-text | danger-subtle | 5.91 | 8.06 |
| success-text | success-subtle | 4.79 | 10.17 |
| danger-text | bg-surface | 6.47 | 8.89 |
| danger-text | bg-canvas | 6.19 | 9.96 |
| success-text | bg-surface | 5.02 | 12.02 |
| success-text | bg-canvas | 4.8 | 13.46 |
| accent | bg-surface | 5.47 | 9.07 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| text-secondary | bg-canvas | 9.86 | 12.82 |
| border-control | bg-surface | 4.83 | 6.65 |
| --bo-state-error-color | bg-surface | 4.83 | 6.1 |
| border-control | bg-muted | 4.39 | 5.66 |
| accent | bg-muted | 4.97 | 7.72 |
| warning-strong | bg-muted | 4.56 | 6.69 |
| warning-strong | bg-surface | 5.02 | 7.86 |
| danger | bg-muted | 4.39 | 5.19 |
| text-primary | bg-muted | 16.12 | 13.75 |
| success-text | bg-muted | 4.56 | 10.23 |
| danger-text | bg-muted | 5.88 | 7.57 |
Maturity generated
- Introduced
- v0.1.0
- CSS floor
- Chrome/Edge 105 · Firefox 121 · Safari 16
- Alignment scored
- 2026-08-23
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.1.0.
The floor is derived from this component's stylesheet alone — set by
@container — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-23.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | 3rem is a display size deliberately above the scale top, left a literal because it has exactly one caller; a second is the trigger to promote it (94.13) |
| Colour | 3 / 3 | zero raw colour; delta valence is two-channel — a visible glyph plus visually-hidden text, per its own note |
| Spacing | 3 / 3 | every live dimension literal carries its reason in place: the 20rem is the fallback of the documented consumer-override hook --bo-widget-min, and the 41rem container threshold is derived in the comment directly above it (2 x 20rem min + 1rem gap); the only other literal is a font-size, scored under typography. 32px and 1rem occur in explanatory comments only. Corrected 2026-09-02 (Polish round 2): the previous wording counted 20rem among the comment-only numbers, where it is live in .bo-widget-grid's grid-template-columns, and never mentioned 41rem at all. Line number dropped 2026-09-04 (Polish round 1 on avatar): that reference named a line in dashboard.css which 249.8's 3-line @tagline header moved underneath it; the declaration is named instead, and does not move. |
| Interaction | 3 / 3 | the page heads its one JS-backed section "Collapsible (opt-in)" and draws the platform-versus-behavior line in both directions: data-collapse-trigger + initCollapsibleCards() flip aria-expanded and data-state, while CSS owns the 0fr/1fr height transition and a panel carrying no data-state renders open — so without the JS the widget degrades to a plain always-open card. The same sentence ships in the generated ApiTable notes, and check:claims drives both channels live. Blind re-scored 2026-09-02 (Polish round 2), correcting an `na`: the rubric admits `na` only where there is no interaction surface at all, and this component ships initCollapsibleCards (behaviors.json), whose hooks bo-widget__collapse / data-collapse-trigger / data-state are all on dashboard's own api.json surface. |
| Content | 3 / 3 | opener carries the wrong-choice clause: "Not as a wrapper around every section of a page" — a widget says the content inside is separate from the box beside it, so card-wrapping one continuous record draws lines where the reader needs continuity; points at form sections and object-page's anchored headings. Blind re-scored 2026-08-23 (Polish round 6), judged genuine and doing real work precisely because this page declares the widget to BE the Card two sentences earlier. |
| Fit | 3 / 3 | named containers whose width drives the density of what they hold; the overview surface the sidebar assigns it |