Timeline & audit trail
The approval-workflow pair: a status timeline (done / current / pending /
rejected) and a chronological audit trail. Two-channel state throughout —
glyphs are aria-hidden, state reaches AT via visually-hidden text and
aria-current. Composed example:
the approval-workflow pattern.
Not for a flow the user is walking right now — a timeline
reports history and status; use
Stepper when the point is which
step they are on and what comes next.
Timeline states
- Completed: Submitted
- Cost-center approval
- Rejected: Finance release
- Not started: Archive
<ol class="bo-timeline" role="list">
<li class="bo-timeline__step" data-state="done">
<span class="bo-timeline__marker" aria-hidden="true">✓</span>
<div>
<div class="bo-timeline__title"><span class="bo-visually-hidden">Completed: </span>Submitted</div>
<div class="bo-timeline__meta">R. Vance · <time datetime="2026-08-10T09:14">2026-08-10 09:14</time></div>
</div>
</li>
<li class="bo-timeline__step" data-state="current" aria-current="step">
<span class="bo-timeline__marker" aria-hidden="true">●</span>
<div><div class="bo-timeline__title">Cost-center approval</div></div>
</li>
<li class="bo-timeline__step" data-state="rejected">
<span class="bo-timeline__marker" aria-hidden="true">✕</span>
<div><div class="bo-timeline__title"><span class="bo-visually-hidden">Rejected: </span>Finance release</div></div>
</li>
<li class="bo-timeline__step" data-state="pending">
<span class="bo-timeline__marker" aria-hidden="true">4</span>
<div><div class="bo-timeline__title"><span class="bo-visually-hidden">Not started: </span>Archive</div></div>
</li>
</ol>
<!-- Two-channel per state: the marker glyph + tint is one channel; the
visually-hidden "Completed:/Rejected:/Not started:" prefix (and
aria-current on the active step) is the announced one. --> Audit trail
- R. Vance submitted PO-88213
- J. Kim approved "Within Q3 budget."
Rejected → resubmitted — the real approval loop
- M. Osei rejected "Wrong cost center — should be CC-2205, not CC-4021."
- R. Vance updated cost center and resubmitted
- M. Osei approved
The loop every ERP actually runs: rejection with a
reason in __detail, a fix, a resubmit. In the paired timeline the
step flips data-state="rejected" → back to
current on resubmit — the audit trail keeps the full history while
the timeline shows only the present.
--discussion — a comment thread
A discussion is the same list carrying a different kind of entry, so it is
a setting, not a second component. An audit trail's
__detail is small print about a record; a comment is
the content itself. Composing a thread without this modifier rendered the
message at 12px underneath a 13px byline — the metadata outranking the
thing it introduces — so --discussion returns the detail to
body size and primary ink. Everything else is already shared: the time
column, Byline for the author,
.bo-composer below to write one.
-
Vendor confirmed a 3-week lead time on the coupling. Asked whether we split the line and take 40 now.
-
Split it — budget is committed either way and the line stoppage costs more than a second delivery.
View code
<!-- Same list, different kind of entry. --discussion says the
detail IS the content, so it keeps body size and primary ink. -->
<ol class="bo-audit bo-audit--discussion" role="list">
<li class="bo-audit__entry">
<time class="bo-audit__time" datetime="2026-08-20T09:12">08-20 09:12</time>
<div class="bo-audit__detail">
<span class="bo-byline"><strong>M. Osei</strong> · Requester</span>
<div class="bo-prose"><p>Vendor confirmed a 3-week lead time on the coupling.
Asked whether we split the line and take 40 now.</p></div>
</div>
</li>
<li class="bo-audit__entry">
<time class="bo-audit__time" datetime="2026-08-20T11:40">08-20 11:40</time>
<div class="bo-audit__detail">
<span class="bo-byline"><strong>J. Kim</strong> · Cost-centre manager</span>
<div class="bo-prose"><p>Split it — budget is committed either way and the
line stoppage costs more than a second delivery.</p></div>
</div>
</li>
</ol> Use it for approval notes, chasing history and handover threads. It is a flat thread on purpose — no nesting, no replies-to-replies, no left/right bubbles. Those are messaging-app shapes; an ERP discussion hangs off a record and reads in one order.
Collaborating — who can see this, and what is settled
A thread people act on needs two things a thread people only read
does not. data-visibility="external" marks a comment that
leaves the building, and data-state="resolved" marks a
question that is settled.
-
Resolved
Split raised as PO-88240 for the balance.
-
External · sent to vendor
Please confirm the revised delivery date for the 40-unit first drop.
View code
<!-- Internal is the default: no attribute, no edge. -->
<ol class="bo-audit bo-audit--discussion" role="list">
<li class="bo-audit__entry" data-state="resolved">
<time class="bo-audit__time" datetime="2026-08-21T08:05">08-21 08:05</time>
<div class="bo-audit__detail">
<span class="bo-byline"><strong>M. Osei</strong> · Requester</span>
<span class="bo-badge">Resolved</span>
<div class="bo-prose"><p>Split raised as PO-88240 for the balance.</p></div>
</div>
</li>
<li class="bo-audit__entry" data-visibility="external">
<time class="bo-audit__time" datetime="2026-08-21T09:30">08-21 09:30</time>
<div class="bo-audit__detail">
<span class="bo-byline"><strong>M. Osei</strong> · Requester</span>
<span class="bo-badge bo-badge--warning">External · sent to vendor</span>
<div class="bo-prose"><p>Please confirm the revised delivery date for the
40-unit first drop.</p></div>
</div>
</li>
</ol>
<!-- The COMPOSER carries it too, which is the point: you can see where the
words go while you are writing them, not after. -->
<form class="bo-composer" data-visibility="external">
<span class="bo-avatar bo-byline__avatar" aria-hidden="true">MO</span>
<div class="bo-composer__body">
<span class="bo-badge bo-badge--warning">External · goes to the vendor</span>
<label class="bo-visually-hidden" for="c-ext">Message to vendor</label>
<textarea class="bo-input" id="c-ext" rows="2"></textarea>
<div class="bo-composer__actions">
<button class="bo-btn bo-btn--sm" type="button">Send to vendor</button>
</div>
</div>
</form> Internal is the default — no attribute, no edge. Getting the hook wrong then means a message that should have gone out stayed in, which someone chases; the reverse is unrecoverable, which is why the dangerous direction is the one you have to type.
The edge is geometry, and the badge is the other channel. The stripe survives forced-colors and a monochrome print, but a stripe alone tells a screen-reader user nothing — so name the audience in a badge, as above. That is your text to write: "External \u00b7 sent to vendor" is useful, "External" on its own is not.
Why the composer carries it as well. Every ERP we measured against puts this on a small tab pair above an otherwise identical box — so the surface you are looking at while you type is the one thing that never says where the words are going, and a mis-click is a broadcast. Marking the composer means the answer is in front of you at the moment it matters. Not a permission check: this is a signal, and the server still decides who may post what.
Resolved returns the comment to the audit trail's own treatment, because
that is what it now is — history. Only a --discussion
resolves; an audit entry is immutable by definition.
A visible "Resolved" marker is required, not optional —
measured under forced-colors: active, the UA repaints both
inks to the same black, so the ink difference is the whole signal in
normal use and no signal there. The external edge is geometry
and survives; this state has no non-colour channel of its own, so the
marker is it.
.bo-composer — writing a new entry
How a new .bo-audit entry gets written, not just read. Layout
only: an avatar (.bo-byline__avatar, composed — not
duplicated) + a plain .bo-input textarea + an actions row.
<form class="bo-composer">
<span class="bo-avatar bo-byline__avatar" aria-hidden="true">RV</span>
<div class="bo-composer__body">
<textarea class="bo-input" rows="2"
placeholder="Add a comment…" aria-label="Comment"></textarea>
<div class="bo-composer__actions">
<button class="bo-btn bo-btn--sm bo-btn--secondary" type="reset">Cancel</button>
<button class="bo-btn bo-btn--sm" type="submit">Comment</button>
</div>
</div>
</form>
<!-- Submit posts a new .bo-audit__entry — your form handler's job, server or JS. --> Markup — audit entry
<ol class="bo-audit" role="list">
<li class="bo-audit__entry">
<time class="bo-audit__time" datetime="2026-08-10T15:02">08-10 15:02</time>
<span><span class="bo-audit__actor">J. Kim</span> approved</span>
<span class="bo-audit__detail">"Within Q3 budget."</span>
</li>
</ol>
<!-- Grid-aligned: the time column self-sizes; details land in the content
column automatically — no width tuning when formats change. --> Quick reference generated
| Class | Role |
|---|---|
| .bo-audit | block (root) |
| .bo-audit--discussion | variant: discussion |
| .bo-audit__actor | part: actor |
| .bo-audit__detail | part: detail |
| .bo-audit__entry | part: entry |
| .bo-audit__time | part: time |
| .bo-composer | block (root) |
| .bo-composer__actions | part: actions |
| .bo-composer__body | part: body |
| .bo-timeline | block (root) |
| .bo-timeline__marker | part: marker |
| .bo-timeline__meta | part: meta |
| .bo-timeline__step | part: step |
| .bo-timeline__title | part: title |
State attributes: data-statedata-visibility
API generated from CSS
| Blocks | bo-auditbo-composerbo-timeline |
|---|---|
| Parts | bo-audit__actorbo-audit__detailbo-audit__entrybo-audit__timebo-composer__actionsbo-composer__bodybo-timeline__markerbo-timeline__metabo-timeline__stepbo-timeline__title |
| Variants | bo-audit--discussion |
| data-* hooks | data-state="current|done|pending|rejected|resolved"data-visibility="external" |
| Component tokens | --bo-timeline-marker-bg--bo-timeline-marker-fg |
| JS required | None — CSS-only. |
- State text pattern is mandatory:
Completed:/Rejected:/Not started:visually-hidden in the title — done vs rejected must be distinguishable non-visually. - Use
<time datetime>for audit timestamps. - Markers are fixed-size status graphics (not controls) — they deliberately ignore density.
.bo-composeris layout only — the textarea is a plain.bo-input, the avatar is.bo-byline__avatar; submit handling (posting a new.bo-audit__entry) is your form handler.
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 |
| accent-text | accent-subtle | 7.27 | 8.38 |
| 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 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| accent-text | bg-selected | 7.27 | 8.38 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| text-secondary | bg-canvas | 9.86 | 12.82 |
| accent | accent-subtle | 5.25 | 6.66 |
| 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 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-21
- 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
@layer — 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% (15/15) — 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-21.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | title/meta from weight and size tokens; audit timestamps tabular |
| Colour | 3 / 3 | the one raw hex is inside @media print; state is two-channel by construction — the marker glyph (✓ ✕ ●) is REAL MARKUP, so it survives forced-colors even without a rule |
| Spacing | 3 / 3 | marker size and all three connector numbers now state that they are DERIVED from the marker, not chosen (94.6b); the audit grid uses max-content so there is no width token to drift |
| Interaction | n/a | an <ol> of workflow states; approving happens elsewhere |
| Content | 3 / 3 | the opener names the wrong-choice context and the alternative: a timeline reports history, so use Stepper when the point is which step the user is on (94.8) |
| Fit | 3 / 3 | status timeline plus audit trail, the two read-only halves of an approval screen |