Skip to content

feat(ui)!: add UITimeline widget, Media vocabulary and design-system styles - #74

Open
thomasjammet wants to merge 5 commits into
mainfrom
feat/uitimeline
Open

feat(ui)!: add UITimeline widget, Media vocabulary and design-system styles#74
thomasjammet wants to merge 5 commits into
mainfrom
feat/uitimeline

Conversation

@thomasjammet

Copy link
Copy Markdown
Collaborator

Summary

First piece of consolidating Ceeblue's shared UI components and styles into web-utils.

  • UITimeline — a new self-contained reception-timeline canvas widget (one row per track, media/reception axis, drag-to-pan, overview minimap, CSV export). Feed it per-sample events; it renders on its own animation-frame loop.
  • Media vocabulary (src/Media.ts) — moved from wrts-client: Type, Codec, Sample, Tracks, Resolution, typeToString (+ screenResolution/overScreenSize). It is the protocol-agnostic input contract UITimeline consumes, so the widget depends only on a plain data shape — any producer that emits Media.Sample can feed it.
  • Design-system styles — cascade-layered stylesheets built to dist/css/: tokens.css (variables + light/dark themes), foundation.css (reset/base), components.css (class-based UI kit).

Package entry points

Import Contents
@ceeblue/web-utils pure logic (no DOM/CSS) → dist/web-utils.*
@ceeblue/web-utils/ui UIMetrics, UITimelinedist/ui/web-utils-ui.*
@ceeblue/web-utils/{tokens,foundation,components}.css design-system CSS → dist/css/*

sideEffects: ["**/*.css"] keeps DOM/CSS out of pure consumers via tree-shaking; files: ["dist"] ships only the built artifacts.

⚠️ Breaking change

UIMetrics is no longer exported from the package root — import it (and the new UITimeline) from @ceeblue/web-utils/ui. Triggers a major release (8.0.0).

Follow-ups (separate PRs)

  • Repoint wrts-client / webrtc-client UIMetrics imports to @ceeblue/web-utils/ui, and consume Media / styles from web-utils.

Verification

Build clean (both entries + dist/css/*), exports resolve for . / ./ui / CSS, 217 tests pass, eslint + prettier clean, docs build 0 errors, tarball ships only dist/* + package/README/LICENSE.

…styles

Add UITimeline, a self-contained reception-timeline canvas widget that
visualizes received media sequences over time (one row per track, media or
reception axis, drag-to-pan and an overview minimap), alongside the existing
UIMetrics. Move Media vocabulary module (Type, Codec, Sample, Tracks,
Resolution, typeToString + screen helpers) from wrts-client, this is the
protocol-agnostic input contract UITimeline consumes, so it depends only on a
plain data shape.

Split the package into two entry points:
- "."    pure logic (no DOM/CSS)           -> dist/web-utils.*
- "./ui" DOM/canvas components (UIMetrics, -> dist/ui/web-utils-ui.*
         UITimeline)

Add the Ceeblue design system as cascade-layered stylesheets built into
dist/css/: tokens.css, foundation.css and components.css. Add an exports map,
sideEffects and files:["dist"] so the bundles and stylesheets publish cleanly
to npm and CDNs.

BREAKING CHANGE: UIMetrics is no longer exported from the package root. Import
it (and the new UITimeline) from "@ceeblue/web-utils/ui".
@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.98758% with 87 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/ui/UITimeline.ts 80.79% 51 Missing and 36 partials ⚠️

📢 Thoughts on this report? Let us know!

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the two inline nits, I also checked a few other candidate issues raised during review and ruled them out: the shared _videoSeq counter is fine because the widget models a single active video track (per Media.Tracks, video selection is one index at a time, not concurrent multi-track), so there's no cross-row corruption; the _viewEnd === 0 falsy check doesn't actually cause an unwanted snap-to-live in practice since _snap/_following gate the reset separately; and the audio/data-before-video n=0 merge at stream start is the same narrow, self-correcting startup window already covered by the inline video-sequence-collision finding, not a separate bug.

Extended reasoning...

Bugs found this run were both nits confined to a new diagnostic widget (UITimeline), and the inline comments already convey what's needed there. This note is just to record, for future reviewers, which additional candidate issues raised by the finder agents were examined and refuted this run, so they aren't re-investigated from scratch.

Comment thread src/ui/UITimeline.ts
Comment on lines +356 to +364
} else {
// No video track to reference yet: detect sequences from reception gaps (each WebRTS
// sequence is a distinct request/burst), adapting the threshold to the track's cadence.
const dt = row.lastRecv ? now - row.lastRecv : 0;
boundary = !row.cur || (row.lastRecv > 0 && dt > Math.max(12, (row.avgDelta || 10) * 3));
if (!boundary && row.lastRecv) {
row.avgDelta = row.avgDelta ? row.avgDelta * 0.8 + dt * 0.2 : dt;
}
n = boundary ? row.seqCounter++ : (row.cur as Sequence).n;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In UITimeline._push, the no-video reception-gap heuristic seeds avgDelta at 0 and only updates it inside the !boundary branch, so if a track's steady per-sample cadence is above the initial ~30ms threshold (e.g. HE-AAC/AAC frames ~42-46ms, Opus 40/60ms frames, sparse data/subtitle tracks), every sample is judged a boundary forever and avgDelta can never grow past its seed. This is a genuine self-reinforcing dead-end for any audio-only or data-only stream (no video track), producing a fresh one-sample Sequence per push instead of grouping bursts. Fix: seed/update avgDelta from the first observed gap regardless of the boundary decision (or seed it directly from the sample duration when known).

Extended reasoning...

The bug: In the no-video branch of _push (src/ui/UITimeline.ts:356-364), sequence boundaries for audio/data-only tracks (rows where _hasVideo is false) are detected via a reception-gap heuristic:

const dt = row.lastRecv ? now - row.lastRecv : 0;
boundary = !row.cur || (row.lastRecv > 0 && dt > Math.max(12, (row.avgDelta || 10) * 3));
if (!boundary && row.lastRecv) {
    row.avgDelta = row.avgDelta ? row.avgDelta * 0.8 + dt * 0.2 : dt;
}

row.avgDelta starts at 0 (see the Row literal built earlier in _push) and is only written inside the if (!boundary && row.lastRecv) branch. Because avgDelta is falsy at start, the very first threshold evaluates to Math.max(12, (0 || 10) * 3) = 30ms. If the track's real, steady per-sample gap is consistently above that ~30ms seed — which is a normal cadence for several audio/data configurations (HE-AAC/AAC frames at ~42-46ms, Opus's 40/60ms frame sizes, or a sparse subtitle/ID3/JSON data track) — every single sample satisfies dt > 30, so boundary is always true. Since the update to avgDelta only runs when !boundary, it can never leave 0, which pins the threshold at 30ms forever. There is no other code path anywhere in the class that mutates avgDelta, so once the loop starts, it cannot escape on its own — the only way out is a run of samples arriving faster than 30ms apart, which never happens for a track with a steady slower cadence.

Trigger path: any row with no video reference (either a genuinely audio/data-only track, since _hasVideo only flips true once an actual video sample is pushed, or any row before the very first video sample arrives) whose steady inter-sample arrival exceeds ~30ms. Every pushAudio/pushData call for that row then hits the boundary = true path, so n = row.seqCounter++ fires every time and a brand-new one-sample Sequence object is pushed onto row.seqs on every single call.

Why nothing else prevents it: _hasVideo is a one-way latch that only becomes true when a video sample is actually pushed — it does not protect audio-only or data-only deployments, which are an explicitly supported use of this generic, protocol-agnostic widget (it consumes any producer of Media.Sample, and the PR description doesn't restrict it to audio+video streams). MAX_SEQUENCES trimming bounds memory growth but does not fix the underlying grouping — it just silently drops the oldest of the many spurious one-sample sequences.

Impact: the widget's entire purpose is to group consecutive samples into burst rectangles so reception structure is visible at a glance. For any affected row, this degenerates into a wall of one-sample slivers (visually and in the CSV export via toCSV()), which defeats that purpose specifically for audio-only or data-only streams — a plausible, real deployment shape for this widget.

Concrete walkthrough:

  1. Row created with avgDelta: 0, lastRecv: 0.
  2. Sample 1 arrives: row.lastRecv is falsy so dt = 0; boundary = !row.cur = true (no cur yet). A new Sequence is created. row.lastRecv is set to now.
  3. Sample 2 arrives 45ms later (e.g. one HE-AAC frame later): dt = 45. Threshold is Math.max(12, (0 || 10) * 3) = 30. Since 45 > 30, boundary = true again — the if (!boundary && row.lastRecv) branch that would update avgDelta is skipped, so avgDelta stays 0. A second, brand-new one-sample Sequence is pushed.
  4. Sample 3, 4, 5... arrive at the same steady ~45ms cadence: the threshold never moves off 30ms because the only place that raises it (avgDelta) never executes. Every sample becomes its own Sequence indefinitely.

Fix suggestion: update avgDelta from the observed gap unconditionally (or at least seed it from the first real gap) rather than gating the update on !boundary, e.g. maintain the running average every time dt > 0 and only use boundary to decide whether to start a new Sequence, not whether to learn the cadence.

Severity note: all five independent verifiers confirmed the logic flaw as real and self-reinforcing with no escape path, but converged on "nit": this is a brand new diagnostic/debug visualization widget, the failure is confined to the no-video, steady-slower-than-30ms-cadence case (most tracked streams carry video and use the keyframe path instead), memory is bounded by MAX_SEQUENCES, and the only observable consequence is degraded rendering (one-sliver-per-sample) rather than a crash, data loss, or any effect on the actual media pipeline. I agree with that assessment and am not marking this normal-severity.

Comment thread src/ui/UITimeline.ts
Comment on lines +344 to +356
let boundary: boolean;
if (type === Media.Type.VIDEO) {
this._hasVideo = true;
if (sample.isKeyFrame) {
++this._videoSeq; // advance the shared sequence number on each GOP
}
n = this._videoSeq < 0 ? 0 : this._videoSeq;
boundary = !row.cur || !!sample.isKeyFrame;
} else if (this._hasVideo) {
// Reference the video track: this sample belongs to the current video sequence.
n = this._videoSeq < 0 ? 0 : this._videoSeq;
boundary = !row.cur || row.cur.n !== n;
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In UITimeline._push, the shared video sequence counter _videoSeq starts at -1, and n is computed as this._videoSeq < 0 ? 0 : this._videoSeq. If the first video sample received is not a keyframe (e.g. joining a live stream mid-GOP), a stub Sequence is created with n=0; when the real first keyframe then arrives, _videoSeq advances from -1 to 0, so a second, distinct Sequence is also labeled n=0 — two adjacent video boxes share seq 0 in the tooltip/CSV, and any audio/data samples received in between get merged into one box straddling the real GOP boundary. This is a pre-existing edge case confined to stream startup and self-corrects once the second keyframe advances _videoSeq to 1 — purely cosmetic, not a functional break.

Extended reasoning...

The bug: _videoSeq is initialized to -1 as a "not yet started" sentinel, and the displayed sequence number is computed with n = this._videoSeq < 0 ? 0 : this._videoSeq (src/ui/UITimeline.ts:348-350). This folds the sentinel state into the same displayed value (0) as the first real sequence, so the sentinel and the first real GOP become indistinguishable by number.

Trigger and trace: this only manifests when the very first video sample delivered to pushVideo is not a keyframe — realistic when a viewer joins a live stream mid-GOP and the player forwards buffered delta frames before the next IDR/keyframe arrives.

  1. First sample, isKeyFrame falsy: _videoSeq stays -1n = 0. boundary = !row.cur || !!sample.isKeyFrame is true (via !row.cur) → a Sequence{n:0} is pushed into row.seqs.
  2. Next sample is the real first keyframe: ++this._videoSeq makes it 0. n = (0 < 0 ? 0 : 0) = 0 — still 0. boundary is now true via !!sample.isKeyFrame → a second, distinct Sequence{n:0} is pushed.
  3. Result: row.seqs contains two adjacent rectangles on the video row, both labeled "seq 0" in the hover tooltip and in the CSV export (toCSV() at line ~245 just prints s.n verbatim), even though they represent different reception windows.
  4. Cross-track effect: audio/data rows key their own boundary purely on row.cur.n !== n (line ~356). Since both the pre-keyframe stub and the true first GOP share n=0, any audio/data samples arriving in that window are merged into a single audio Sequence that straddles the real GOP boundary in the video row — contradicting the doc comment on the Sequence type ("so they line up vertically under the matching video sequence").

Why nothing upstream catches this: there's no dedup or validation on n — it's just used as an opaque display/grouping label, and the boundary conditions for video (!row.cur || !!sample.isKeyFrame) and audio/data (row.cur.n !== n) are evaluated independently with no shared "did the video row just open a new sequence" signal, only the numeric label.

Impact: this is confined to the reception timeline's diagnostic/visualization canvas (a debugging widget), not application logic. The effect is (a) a duplicated "seq 0" label shown briefly at stream startup, cosmetic only, and (b) one audio/data box that is slightly too wide, spanning a boundary it shouldn't. Both self-correct as soon as the second keyframe arrives and _videoSeq advances to 1, so there's no lasting misalignment, no crash, and no data loss — every sample is still counted and byte totals are still correct, just grouped into an extra/wider box.

Suggested fix: avoid folding the sentinel into a value that will be reused, e.g. skip creating a video sequence at all until the first keyframe is seen (only start counting/pushing once isKeyFrame has been true at least once), or initialize _videoSeq at 0 and always increment-before-labeling on the very first sample regardless of keyframe status coupled with a distinct "unknown/pre-key" bucket that never numerically collides with 0.

Given the narrow, self-correcting, cosmetic nature of the issue in a new diagnostic widget, this is a nit rather than a blocking issue.

Add Media.spec.ts (100% coverage of the vocabulary helpers) and
UITimeline.spec.ts, which drives the widget through its public API: sequence
grouping (video keyframes, audio/data under the current video sequence,
reception-gap fallback), MAX_SEQUENCES trimming, toCSV export, reset,
following/onFollowingChange, windowDuration clamping and axis switching. It
also invokes render() and the mouse handlers under jsdom (using node-canvas)
so the drawing and drag/scrub paths execute, bringing UITimeline to ~88% lines.
@thomasjammet
thomasjammet requested review from MathieuPOUX and jobl July 16, 2026 16:31
@thomasjammet thomasjammet self-assigned this Jul 16, 2026
The reception-gap heuristic seeded avgDelta at 0 and only updated it on
non-boundary samples, so an audio- or data-only track with a steady cadence
above ~30ms (AAC ~42-46ms, Opus 40/60ms, sparse data) was flagged a new
boundary on every sample forever — one sliver per sample instead of grouped
sequences.

Replace it with fixed 2s media-time buckets (mirroring the server's fallback
GOP): a new sequence starts when a sample crosses a bucket boundary. Removes
the now-dead avgDelta/lastRecv row state. Addresses the UITimeline review nit.
Canvas can't consume CSS, so resolve the design tokens to values at render
time (--accent, --ok/--warn/--err, --txt, --border, --f-body/--f-mono) with
fallbacks equal to the previous hardcoded defaults, so the widget follows the
light/dark theme when the stylesheets are loaded and stays self-contained
otherwise. The DOM tooltip is themed the same way from the surface tokens.

Add the widget-specific --track-N palette and move the custom JS-driven
widget styles (UIMetrics, UITimeline) into a clearly marked section at the
end of components.css, apart from the generic UI kit.
Prefix every design-system custom property with --cb- (--cb-accent, --cb-bg,
--cb-txt, --cb-r-sm, --cb-f-body, --cb-track-N, …) and every component class
with cb- (.cb-btn, .cb-inp, .cb-shell, .cb-tab-btn, .cb-modal-box, state
modifiers .cb-on/.cb-off, …). Rename the theme attribute html[data-theme] to
html[data-cb-theme]. UITimeline's runtime getComputedStyle reads, its
cb-uitl-canvas/cb-uitl-tip class hooks and the docstring follow suit.

These live on the global :root / document scope, where generic names collide
silently with the host app or other libraries; the prefix isolates them, as
Bootstrap (--bs-), Shoelace (--sl-) and others do.

Consumers must update to the cb- prefixed tokens, classes and
data-cb-theme attribute.
(not a breaking change since no one use the css files for now)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant