diff --git a/.prettierignore b/.prettierignore index 86c3c55..0d30210 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,6 @@ coverage # Markdown *.md *.mdx + +# Styles (hand-authored, compact formatting preserved) +styles diff --git a/README.md b/README.md index 91b9327..a1902ba 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,21 @@ Then [import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modu ```javascript import { Util, ILog } from '@ceeblue/web-utils'; ``` + +The package root (`@ceeblue/web-utils`) is pure logic — no DOM, no CSS. DOM/canvas components live in the `ui` subpath: +```javascript +import { UIMetrics, UITimeline } from '@ceeblue/web-utils/ui'; +``` + +It also ships the Ceeblue design-system stylesheets. Import the layers you need, in order — each consumes the ones before it: +```javascript +import '@ceeblue/web-utils/tokens.css'; // design tokens (colors, radii, fonts, themes) +import '@ceeblue/web-utils/foundation.css'; // reset + base typography + scrollbars +import '@ceeblue/web-utils/components.css'; // app shell + generic UI components +``` +The stylesheets use [cascade layers](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) (`ceeblue.tokens` < `ceeblue.foundation` < `ceeblue.components`), so downstream styles override them without specificity hacks. + +All design-system custom properties and classes are namespaced with a `cb-` prefix (`--cb-accent`, `.cb-btn`, …) to avoid collisions with the host app or other libraries. The DOM/canvas components read these tokens at runtime — `UITimeline`, for instance, resolves `--cb-accent`, `--cb-ok`/`--cb-warn`/`--cb-err`, `--cb-txt`, `--cb-track-N`, the fonts and the tooltip surface tokens — so they follow your theme automatically when the stylesheets are loaded, and fall back to sensible built-in defaults when they aren't. > [!IMPORTANT] > > If your project uses TypeScript, it is recommended that you set target: "ES6" in your configuration to match our use of ES6 features and ensure that your build will succeed (for those requiring a backward-compatible UMD version, a local build is recommended). @@ -36,12 +51,14 @@ import { Util, ILog } from '@ceeblue/web-utils'; 1. [Clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) this repository 2. Got to the `web-utils` folder and run `npm install` to install the packages dependencies. -3. Run `npm run build`. The output will be five files placed in the **/dist/** folder: +3. Run `npm run build`. The output is placed in the **/dist/** folder, one set of files per entry point — `web-utils` (the pure-logic root) and `ui/web-utils-ui` (the DOM/canvas components): - **web-utils.d.ts** Typescript definitions file - **web-utils.js**: Bundled JavaScript library - **web-utils.js.map**: Source map that associates the bundled library with the original source files - **web-utils.min.js** Minified version of the library, optimized for size - **web-utils.min.js.map** Source map that associates the minified library with the original source files + - the same five **ui/web-utils-ui.\*** files for the `@ceeblue/web-utils/ui` entry + - **css/tokens.css**, **css/foundation.css** and **css/components.css** design-system stylesheets ``` git clone https://github.com/CeeblueTV/web-utils.git diff --git a/index.ts b/index.ts index 951af7f..3422c8d 100644 --- a/index.ts +++ b/index.ts @@ -20,11 +20,13 @@ export { WebSocketReliable, WebSocketReliableError } from './src/WebSocketReliab export * as EpochTime from './src/EpochTime'; export { LogLevel, ILog, Log, Loggable, log } from './src/Log'; export { PlayerStats } from './src/stats/PlayerStats'; +export * as Media from './src/Media'; // Export the Common Media Library as the CML namespace. // Example usage: CML.Cmcd, CML.CmcdStreamingFormat, etc. export * as CML from '@svta/common-media-library'; -export { UIMetrics } from './src/ui/UIMetrics'; +// UI components (UIMetrics, UITimeline) live in the `@ceeblue/web-utils/ui` subpath entry +// (see src/ui/index.ts) to keep this root entry free of DOM/CSS code. const __lib__version__ = '?'; // will be replaced on building by project version diff --git a/package.json b/package.json index 8ed24f8..aee7302 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,34 @@ "bugs": { "url": "https://github.com/CeeblueTV/web-utils/issues" }, + "files": [ + "dist" + ], "main": "dist/web-utils.js", + "module": "dist/web-utils.js", "types": "dist/web-utils.d.ts", "type": "module", + "exports": { + ".": { + "types": "./dist/web-utils.d.ts", + "default": "./dist/web-utils.js" + }, + "./ui": { + "types": "./dist/ui/web-utils-ui.d.ts", + "default": "./dist/ui/web-utils-ui.js" + }, + "./tokens.css": "./dist/css/tokens.css", + "./foundation.css": "./dist/css/foundation.css", + "./components.css": "./dist/css/components.css", + "./package.json": "./package.json" + }, + "sideEffects": [ + "**/*.css" + ], "scripts": { "build": "rollup -c", "build:es5": "rollup -c --format umd", - "build:docs": "typedoc --tsconfig tsconfig.json index.ts", + "build:docs": "typedoc --tsconfig tsconfig.json index.ts src/ui/index.ts", "test": "vitest --run", "test:coverage": "vitest --run --coverage", "lint": "eslint . && prettier --check .", diff --git a/rollup.config.js b/rollup.config.js index 86e755a..63e97a1 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -13,9 +13,27 @@ import typescript from '@rollup/plugin-typescript'; import terser from '@rollup/plugin-terser'; import { dts } from 'rollup-plugin-dts'; import { nodeResolve } from '@rollup/plugin-node-resolve'; +import { copyFileSync, mkdirSync } from 'node:fs'; -const input = 'index.ts'; -const output = 'dist/web-utils'; +// Copy the hand-authored stylesheets into dist/ so they publish to npm and are reachable on CDNs +// (jsDelivr, unpkg) at dist/.css, next to the bundles. +const copyStyles = () => ({ + name: 'copy-styles', + writeBundle() { + mkdirSync('dist/css', { recursive: true }); + for (const file of ['tokens.css', 'foundation.css', 'components.css']) { + copyFileSync('styles/' + file, 'dist/css/' + file); + } + } +}); + +// Public entry points, each emitted as a self-contained bundle in dist/: +// - index: pure logic (no DOM, no CSS) → `@ceeblue/web-utils` +// - ui/index: DOM/canvas components → `@ceeblue/web-utils/ui` +const entries = [ + { input: 'index.ts', out: 'dist/web-utils' }, + { input: 'src/ui/index.ts', out: 'dist/ui/web-utils-ui' } // distinct basename: safe if files get flattened +]; export default args => { let target; @@ -51,16 +69,17 @@ export default args => { throw new Error('Version is undefined or not a string.'); } - return [ + // Each entry yields three sequential builds: bundle → minify the bundle → type definitions. + return entries.flatMap((entry, i) => [ { // Transpile and bundle the code - input, + input: entry.input, output: { name: process.env.npm_package_name, format, // iife, es, cjs, umd, amd, system compact: true, sourcemap: true, - file: output + '.js' + file: entry.out + '.js' }, plugins: [ replace({ @@ -69,28 +88,30 @@ export default args => { }), eslint(), typescript({ target, downlevelIteration }), - nodeResolve() + nodeResolve(), + // Emit the stylesheets once, alongside the first bundle. + ...(i === 0 ? [copyStyles()] : []) ] }, { // Minify the bundled code - input: output + '.js', + input: entry.out + '.js', output: { compact: true, sourcemap: true, - file: output + '.min.js' + file: entry.out + '.min.js' }, plugins: [terser()], context: 'window' // Useful for ES5 builds, ensures 'this' refers to 'window' in a browser context }, { // Generate type definitions - input, + input: entry.input, output: { compact: true, - file: output + '.d.ts' + file: entry.out + '.d.ts' }, plugins: [dts()] } - ]; + ]); }; diff --git a/src/Media.spec.ts b/src/Media.spec.ts new file mode 100644 index 0000000..b69e7ca --- /dev/null +++ b/src/Media.spec.ts @@ -0,0 +1,82 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { Type, Codec, MAX_GOP_DURATION, typeToString, screenResolution, overScreenSize } from './Media'; + +describe('Media', () => { + it('exposes the media vocabulary constants', () => { + expect(MAX_GOP_DURATION).toBe(10000); + expect(Type.DATA).toBe(0); + expect(Type.AUDIO).toBe(1); + expect(Type.VIDEO).toBe(2); + expect(Codec.UNKNOWN).toBe(''); + expect(Codec.H264).toBe('H264'); + expect(Codec.OPUS).toBe('OPUS'); + }); + + describe('typeToString', () => { + it('maps each known type to its name', () => { + expect(typeToString(Type.AUDIO)).toBe('audio'); + expect(typeToString(Type.VIDEO)).toBe('video'); + expect(typeToString(Type.DATA)).toBe('data'); + }); + it('falls back to "unknown" for an unmapped value', () => { + expect(typeToString(99 as Type)).toBe('unknown'); + }); + }); + + describe('overScreenSize', () => { + it('is true only when the resolution exceeds the screen on both axes', () => { + expect(overScreenSize({ width: 1920, height: 1080 }, { width: 1280, height: 720 })).toBe(true); + expect(overScreenSize({ width: 1280, height: 720 }, { width: 1920, height: 1080 })).toBe(false); + // wider but not taller → not over on both axes + expect(overScreenSize({ width: 3000, height: 500 }, { width: 1920, height: 1080 })).toBe(false); + }); + it('is falsy when no screen is provided', () => { + expect(overScreenSize({ width: 1920, height: 1080 })).toBeFalsy(); + }); + }); + + describe('screenResolution', () => { + const realScreen = Object.getOwnPropertyDescriptor(window, 'screen'); + const realRatio = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio'); + const setScreen = (value: unknown) => Object.defineProperty(window, 'screen', { configurable: true, value }); + const setRatio = (value: unknown) => + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value }); + + afterEach(() => { + if (realScreen) { + Object.defineProperty(window, 'screen', realScreen); + } + if (realRatio) { + Object.defineProperty(window, 'devicePixelRatio', realRatio); + } + }); + + it('scales landscape dimensions by the device pixel ratio', () => { + setScreen({ width: 1280, height: 720 }); + setRatio(2); + expect(screenResolution()).toEqual({ width: 2560, height: 1440 }); + }); + + it('swaps axes for a portrait screen so it reports the max fullscreen ability', () => { + setScreen({ width: 1080, height: 1920 }); + setRatio(1); + expect(screenResolution()).toEqual({ width: 1920, height: 1080 }); + }); + + it('defaults the ratio to 1 when devicePixelRatio is absent', () => { + setScreen({ width: 800, height: 600 }); + setRatio(0); + expect(screenResolution()).toEqual({ width: 800, height: 600 }); + }); + + it('returns undefined when there is no screen', () => { + setScreen(undefined); + expect(screenResolution()).toBeUndefined(); + }); + }); +}); diff --git a/src/Media.ts b/src/Media.ts new file mode 100644 index 0000000..1c87e9e --- /dev/null +++ b/src/Media.ts @@ -0,0 +1,124 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +/** + * Maximum GOP (group-of-pictures) duration in milliseconds — a convenient averaging window. + */ +export const MAX_GOP_DURATION = 10000; + +/** + * Media type of a track or sample. Numeric so tracks can be ordered (video first). + */ +export enum Type { + DATA = 0, + AUDIO = 1, + VIDEO = 2 +} + +/** + * Media codec, empty string when unknown. + */ +export enum Codec { + UNKNOWN = '', + // Video + H264 = 'H264', + HEVC = 'HEVC', + VP8 = 'VP8', + // Audio + MP3 = 'MP3', + AAC = 'AAC', + OPUS = 'OPUS', + // Data + ID3 = 'ID3', + JSON = 'JSON', + SUBTITLE = 'SUBTITLE' +} + +/** + * A single media sample (frame). This is the protocol-agnostic input vocabulary consumed by UI + * widgets such as `UITimeline`: any producer able to emit this shape can feed them. + */ +export type Sample = { + time: number; + duration: number; + data: Uint8Array; + compositionOffset?: number; + isKeyFrame?: boolean; + subSamples?: Array<{ clearBytes: number; encryptedBytes: number }>; // DRM field for SENC box + iv?: Uint8Array; // DRM per-sample IV (when ContentProtection.ivMode === 'sample') +}; + +/** + * Track selection. + */ +export type Tracks = { + /** + * Audio track, undefined = MBR, -1 = Remove the track + */ + audio?: number; + /** + * Video track, undefined = MBR, -1 = Remove the track + */ + video?: number; + /** + * Datas tracks to receive, undefined = ALL + */ + data?: Set; +}; + +/** + * A pixel resolution. + */ +export type Resolution = { + width: number; + height: number; +}; + +/** + * Human-readable name of a media {@link Type}. + * @param type media type + */ +export function typeToString(type: Type) { + switch (type) { + case Type.AUDIO: + return 'audio'; + case Type.VIDEO: + return 'video'; + case Type.DATA: + return 'data'; + default: + } + return 'unknown'; +} + +/** + * The display resolution in device pixels, or undefined outside a browser. In portrait the axes are + * swapped so the result always represents the maximum fullscreen ability (landscape orientation). + * @returns the screen {@link Resolution}, or undefined when there is no DOM + */ +export function screenResolution(): Resolution | undefined { + if (typeof window === 'undefined' || !window.screen) { + return; + } + const ratio = window.devicePixelRatio || 1; + let height = ratio * window.screen.height; + let width = ratio * window.screen.width; + if (height > width) { + // smartphone, switch to compute max fullscreen ability (height becomes width) + [width, height] = [height, width]; + } + return { width, height }; +} + +/** + * Whether a resolution exceeds the displayable screen. + * @param resolution the resolution to test + * @param screen the screen resolution to compare against + * @returns true when resolution is larger than screen on both axes + */ +export function overScreenSize(resolution: Resolution, screen?: Resolution) { + return screen && resolution.height > screen.height && resolution.width > screen.width; +} diff --git a/src/ui/UITimeline.spec.ts b/src/ui/UITimeline.spec.ts new file mode 100644 index 0000000..158bffe --- /dev/null +++ b/src/ui/UITimeline.spec.ts @@ -0,0 +1,272 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import type { Sample } from '../Media'; + +// Drive Util.time() from a controllable clock so reception-gap grouping is deterministic. +const { clock } = vi.hoisted(() => ({ clock: { now: 0 } })); +vi.mock('../Util', async importOriginal => { + const actual = (await importOriginal()) as Record; + return { ...actual, time: () => clock.now }; +}); + +import { UITimeline } from './UITimeline'; + +interface TLInternals { + _canvas: HTMLCanvasElement; + _hits: Array<{ x0: number; x1: number; y0: number; y1: number }>; + _ovRect?: { x0: number; x1: number; y0: number; y1: number }; + _down(e: MouseEvent): void; + _move(e: MouseEvent): void; + _up(): void; +} +const internals = (tl: UITimeline) => tl as unknown as TLInternals; + +const sample = (time: number, opts: { duration?: number; key?: boolean; bytes?: number } = {}): Sample => ({ + time, + duration: opts.duration ?? 40, + data: new Uint8Array(opts.bytes ?? 100), + isKeyFrame: opts.key ?? false +}); + +/** Parse toCSV() into an array of column-keyed rows (skipping the header). */ +const rows = (tl: UITimeline) => { + const lines = tl.toCSV().split('\n'); + const header = lines[0].split(';'); + return lines + .slice(1) + .filter(Boolean) + .map(line => { + const cols = line.split(';'); + const o: Record = {}; + header.forEach((h, i) => (o[h] = cols[i])); + return o; + }); +}; + +/** Bypass the layout-visibility guard so render() actually draws under jsdom. */ +const forceVisible = (tl: UITimeline) => { + const canvas = internals(tl)._canvas; + Object.defineProperty(canvas, 'offsetParent', { configurable: true, get: () => canvas.parentElement }); +}; + +const created: UITimeline[] = []; +const make = () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const tl = new UITimeline(container); + created.push(tl); + return tl; +}; + +afterEach(() => { + created.forEach(tl => tl.destroy()); + created.length = 0; + document.body.innerHTML = ''; + clock.now = 0; +}); + +describe('UITimeline', () => { + it('has no data before any sample is pushed', () => { + const tl = make(); + expect(tl.hasData).toBe(false); + expect(rows(tl)).toHaveLength(0); + }); + + it('opens a new video sequence on each keyframe', () => { + clock.now = 1000; + const tl = make(); + tl.pushVideo(1, sample(0, { key: true, bytes: 100 })); + tl.pushVideo(1, sample(40, { bytes: 50 })); + tl.pushVideo(1, sample(80, { bytes: 50 })); + tl.pushVideo(1, sample(120, { key: true, bytes: 100 })); + tl.pushVideo(1, sample(160, { bytes: 40 })); + + const vids = rows(tl).filter(r => r.type === '2'); + expect(vids).toHaveLength(2); + expect(vids[0]).toMatchObject({ + seq: '0', + frames: '3', + bytes: '200', + dtsStart_ms: '0', + dtsEnd_ms: '120', + keyframe: '1' + }); + expect(vids[1]).toMatchObject({ + seq: '1', + frames: '2', + bytes: '140', + dtsStart_ms: '120', + dtsEnd_ms: '200', + keyframe: '1' + }); + expect(tl.hasData).toBe(true); + }); + + it('groups audio/data samples under the current video sequence number', () => { + clock.now = 1000; + const tl = make(); + tl.pushVideo(1, sample(0, { key: true })); + tl.pushAudio(2, sample(0, { bytes: 10 })); + tl.pushAudio(2, sample(20, { bytes: 10 })); + tl.pushVideo(1, sample(40)); // still sequence 0 (no new keyframe) + tl.pushAudio(2, sample(40, { bytes: 10 })); + + const audio = rows(tl).filter(r => r.type === '1'); + expect(audio).toHaveLength(1); + expect(audio[0]).toMatchObject({ track: '2', seq: '0', frames: '3', bytes: '30' }); + }); + + it('groups a no-video track into fixed 2s media-time buckets (fallback GOP)', () => { + clock.now = 1000; + const tl = make(); + tl.pushData(3, sample(0, { bytes: 10 })); + tl.pushData(3, sample(500, { bytes: 10 })); + tl.pushData(3, sample(1900, { bytes: 10 })); // all within the first 2s bucket + tl.pushData(3, sample(2100, { bytes: 10 })); // crosses into the next bucket → new sequence + tl.pushData(3, sample(3000, { bytes: 10 })); + + const data = rows(tl).filter(r => r.type === '0'); + expect(data).toHaveLength(2); + expect(data.map(r => r.frames)).toEqual(['3', '2']); + }); + + it('keeps a steady audio-only cadence in one sequence (no one-sliver-per-sample)', () => { + clock.now = 1000; + const tl = make(); + // ~45ms Opus/AAC-like frames: reception gaps exceed the old ~30ms heuristic threshold, but + // they all fall inside one 2s media-time bucket, so they group into a single sequence. + for (let i = 0; i < 10; ++i) { + clock.now += 45; + tl.pushAudio(2, sample(i * 45, { bytes: 10 })); + } + const audio = rows(tl).filter(r => r.type === '1'); + expect(audio).toHaveLength(1); + expect(audio[0].frames).toBe('10'); + }); + + it('trims old sequences past MAX_SEQUENCES', () => { + const prev = UITimeline.MAX_SEQUENCES; + UITimeline.MAX_SEQUENCES = 2; + try { + clock.now = 1000; + const tl = make(); + for (let i = 0; i < 5; ++i) { + tl.pushData(3, sample(i * 2000, { bytes: 10 })); // each in its own 2s bucket → 5 sequences + } + expect(rows(tl).filter(r => r.type === '0')).toHaveLength(2); + } finally { + UITimeline.MAX_SEQUENCES = prev; + } + }); + + it('reset() clears all data and returns to live', () => { + clock.now = 1000; + const tl = make(); + tl.pushVideo(1, sample(0, { key: true })); + tl.following = false; + tl.reset(); + expect(tl.hasData).toBe(false); + expect(tl.following).toBe(true); + expect(rows(tl)).toHaveLength(0); + }); + + it('clamps windowDuration to at least 1 second', () => { + const tl = make(); + expect(tl.windowDuration).toBe(10); + tl.windowDuration = 30; + expect(tl.windowDuration).toBe(30); + tl.windowDuration = 0; + expect(tl.windowDuration).toBe(1); + tl.windowDuration = -5; + expect(tl.windowDuration).toBe(1); + }); + + it('fires onFollowingChange only on real transitions', () => { + const tl = make(); + const fired: boolean[] = []; + tl.onFollowingChange = f => fired.push(f); + tl.following = false; + tl.following = false; // no-op, no event + tl.following = true; + expect(fired).toEqual([false, true]); + expect(tl.following).toBe(true); + }); + + it('switches axis and keeps the frozen view mapped across axes', () => { + clock.now = 1000; + const tl = make(); + tl.pushVideo(1, sample(0, { key: true })); + tl.pushVideo(1, sample(40)); + expect(tl.axis).toBe('reception'); + tl.following = false; // frozen → axis change runs the value mapping + tl.axis = 'media'; + expect(tl.axis).toBe('media'); + tl.axis = 'media'; // no-op branch + tl.axis = 'reception'; + expect(tl.axis).toBe('reception'); + }); + + it('renders the waiting state, populated state and media axis without throwing', () => { + const tl = make(); + forceVisible(tl); + expect(() => tl.render()).not.toThrow(); // "waiting for media…" + + clock.now = 1000; + for (let i = 0; i < 8; ++i) { + clock.now = 1000 + i * 50; + tl.pushVideo(1, sample(i * 40, { key: i % 3 === 0 })); + } + tl.pushAudio(2, sample(0, { bytes: 20 })); + tl.pushData(3, sample(0, { bytes: 20 })); + expect(() => tl.render()).not.toThrow(); // reception axis, full draw + + tl.axis = 'media'; + tl.getMediaTime = () => 0; // playhead within the visible window + expect(() => tl.render()).not.toThrow(); + }); + + it('pans on drag (pausing follow) and scrubs the overview minimap', () => { + clock.now = 1000; + const tl = make(); + forceVisible(tl); + for (let i = 0; i < 8; ++i) { + clock.now = 1000 + i * 50; + tl.pushVideo(1, sample(i * 40, { key: i % 3 === 0 })); + } + tl.render(); // builds hit-boxes + overview rect + const tl_ = internals(tl); + + // hover a sequence → tooltip path + const hit = tl_._hits[0]; + expect(hit).toBeDefined(); + tl_._move(new MouseEvent('mousemove', { clientX: hit.x0 + 1, clientY: hit.y0 + 1 })); + + // drag the plot to the right → pan into the past, pausing follow + tl_._down(new MouseEvent('mousedown', { clientX: 120, clientY: hit.y0 + 1 })); + tl_._move(new MouseEvent('mousemove', { clientX: 260, clientY: hit.y0 + 1 })); + tl_._up(); + expect(tl.following).toBe(false); + + // scrub the overview band → absolute jump + const ov = tl_._ovRect; + expect(ov).toBeDefined(); + if (ov) { + const midY = (ov.y0 + ov.y1) / 2; + tl_._down(new MouseEvent('mousedown', { clientX: (ov.x0 + ov.x1) / 2, clientY: midY })); + tl_._move(new MouseEvent('mousemove', { clientX: ov.x0 + 4, clientY: midY })); + tl_._up(); + } + }); + + it('destroy() removes its elements from the container', () => { + const tl = make(); + const canvas = internals(tl)._canvas; + expect(canvas.parentElement).not.toBeNull(); + tl.destroy(); + expect(canvas.parentElement).toBeNull(); + }); +}); diff --git a/src/ui/UITimeline.ts b/src/ui/UITimeline.ts new file mode 100644 index 0000000..2ca4599 --- /dev/null +++ b/src/ui/UITimeline.ts @@ -0,0 +1,848 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +import * as Util from '../Util'; +import * as Media from '../Media'; + +const root = typeof window !== 'undefined' ? window : (global as unknown as Window); + +/** Distinct row colors, assigned to tracks in order of first appearance. */ +const PALETTE = ['#78b5bf', '#e6a817', '#9b7ede', '#6fbf8b', '#df7e7e', '#7e9cdf', '#c98bd0', '#b5a06f']; +// Reception-health colors (reception duration vs media duration of a sequence). Picked to read on +// both light and dark backgrounds. +const HEALTH_OK = '#28a745'; +const HEALTH_WARN = '#e6a817'; +const HEALTH_ERR = '#c0392b'; +/** Accent used for the overview window highlight. */ +const ACCENT = '#78b5bf'; + +/** Width (px) of the left gutter holding the per-track labels (not part of the draggable plot area). */ +const LABEL_W = 92; +/** Drag-to-pan acceleration: each pixel covers `1 + min(ACCEL_MAX, |dx|/ACCEL_REF)` window-pixels. */ +const ACCEL_REF = 8; +const ACCEL_MAX = 14; +/** How long (ms) the overview minimap stays bright after the last navigation gesture. */ +const NAV_LINGER = 1400; +/** Height (px) of the overview minimap band at the bottom of the canvas. */ +const OV_H = 16; +const OV_GAP = 6; +/** No-video fallback GOP (ms): group audio/data-only tracks into fixed media-time buckets, like the server. */ +const FALLBACK_GOP = 2000; + +/** Time axis used to position sequences. */ +export type UITimelineAxis = 'media' | 'reception'; + +/** + * One sequence (a group of consecutive samples) drawn as a single rectangle. + * + * A video sequence is a GOP, delimited by keyframes. Audio/data sequences inherit the number of the + * video sequence current at reception time, so they line up vertically under the matching video + * sequence (GOP alignment is NOT assumed: an audio sequence can start/end past its video sequence). + * When there is no video track, audio/data fall back to fixed media-time buckets (a 2s fallback GOP, + * like the server) with their own counter. + */ +type Sequence = { + /** Sequence number (the current video sequence number, shared across tracks). */ + n: number; + /** Media timestamp (DTS, ms) of the first / last sample. */ + dtsStart: number; + dtsEnd: number; + /** Wall-clock reception time (ms) of the first / last sample. */ + recvStart: number; + recvEnd: number; + /** Number of samples accumulated. */ + frames: number; + /** Total payload bytes accumulated. */ + bytes: number; + /** Whether the sequence opened on a keyframe. */ + key: boolean; +}; + +type Row = { + id: number; + type: Media.Type; + color: string; + seqs: Sequence[]; + cur?: Sequence; + seqCounter: number; +}; + +type Hit = { x0: number; x1: number; y0: number; y1: number; s: Sequence; r: Row }; + +/** + * A self-contained, themable canvas widget that visualizes received media sequences over time, one row + * per track. It is the visual counterpart of the stats graph: where the graph plots scalar metrics, + * {@link UITimeline} shows the structure of reception — sequences (GOPs) as rectangles, their size, + * frame count, media-timestamp span and wall-clock reception span. + * + * Feed it the per-sample events from your media source (e.g. a player's `onVideo` / `onAudio` / + * `onData`). It owns its own canvas and hover tooltip inside the `container` you pass, and + * redraws on its own animation frame loop. Drag the plot to pan back through the buffer (the faster the + * drag, the faster it moves), or scrub the overview minimap at the bottom to jump anywhere. + * + * Two time axes are available (see {@link axis}): + * - `'media'` — position by sample DTS, so cross-track desync is directly visible; reception health + * is overlaid as a colored edge, and an optional playhead marks the playback time. + * - `'reception'` — position by wall-clock arrival, so late / slow tracks stand out. + * + * Styling follows the Ceeblue design system: when the design-system stylesheets are loaded it resolves + * the design tokens (`--cb-accent`, `--cb-ok`/`--cb-warn`/`--cb-err`, `--cb-txt`, `--cb-border`, `--cb-f-body`/`--cb-f-mono` + * and the tooltip surface tokens from `tokens.css`, plus the widget-specific `--cb-track-N` palette from + * `components.css`) at render time and tracks the light/dark theme; without the stylesheets it falls + * back to a built-in dark palette, so the widget stays self-contained. + * + * @example + * const timeline = new UITimeline(document.getElementById('timeline')); + * timeline.getMediaTime = () => player.currentTime * 1000; // optional playhead (ms) + * player.onVideo = (track, sample) => timeline.pushVideo(track, sample); + * player.onAudio = (track, sample) => timeline.pushAudio(track, sample); + * player.onData = (track, time, duration, data) => timeline.pushData(track, { time, duration, data }); + */ +export class UITimeline { + /** Maximum sequences retained per row (bounds memory; older sequences are dropped). */ + static MAX_SEQUENCES = 5000; + + /** + * Event fired when {@link following} changes on its own (e.g. the user grabs the scrollbar, which + * pauses live-follow). Lets a host UI keep a play/pause button in sync. + * @param following the new {@link following} value + * @event + */ + onFollowingChange(following: boolean) {} + + /** + * Optional provider for the playback position in milliseconds, used to draw the playhead on the + * `'media'` axis. Return `undefined` to hide it. Typically `() => player.currentTime * 1000`. + */ + getMediaTime?: () => number | undefined; + + /** The time axis used to position sequences. Defaults to `'reception'`. */ + get axis(): UITimelineAxis { + return this._axis; + } + set axis(value: UITimelineAxis) { + if (value === this._axis) { + return; + } + const from = this._axis; + this._axis = value; + // Keep the same point in time visible across axes: the frozen view edge lives in the previous + // axis' value space, so map it into the new one (DTS <-> reception). When following, render + // re-pins it to the live edge anyway. + if (!this._following) { + this._viewEnd = this._mapValue(this._viewEnd, from, value); + } + } + + /** Visible time window in seconds. Defaults to 10. */ + get windowDuration(): number { + return this._windowDuration; + } + set windowDuration(seconds: number) { + this._windowDuration = Math.max(1, seconds); + } + + /** + * Whether the view stays pinned to the live edge (following) or is frozen for inspection. + * Dragging the plot or scrubbing the overview sets this to `false` and fires {@link onFollowingChange}. + */ + get following(): boolean { + return this._following; + } + set following(value: boolean) { + if (value === this._following) { + return; + } + this._following = value; + if (value) { + this._snap = true; // jump back to live on resume + } + this.onFollowingChange(value); + } + + /** True once at least one sample has been received. */ + get hasData(): boolean { + return this._hasData; + } + + private _container: HTMLElement; + private _canvas: HTMLCanvasElement; + private _tip: HTMLDivElement; + /** Track palette resolved from the `--cb-track-N` tokens (cached; falls back to {@link PALETTE}). */ + private _palette?: string[]; + /** Signature of the last applied tooltip theme, to avoid rewriting its style every frame. */ + private _tipSig = ''; + + private _rows: Map = new Map(); + private _order?: Row[]; + private _hits: Hit[] = []; + + private _axis: UITimelineAxis = 'reception'; + private _windowDuration = 10; + private _following = true; + private _hasData = false; + + private _hasVideo = false; + private _videoSeq = -1; + private _t0 = 0; + + private _viewEnd = 0; // right edge of the window, in the current axis' value space (ms) + private _snap = true; // force the view edge back to the live edge on next render + private _dataMin = 0; // earliest value across all rows, in the current axis' value space + private _dataLo = 0; // earliest pannable edge (dataMin + window), in the current axis' value space + private _dataHi = 0; // live edge (dataMax), in the current axis' value space + private _plotW = 1; // width (px) of the plot area, for px↔value conversion while dragging + + private _dragging = false; + private _ovDragging = false; // dragging the overview minimap (absolute scrub) + private _lastX = 0; + private _navUntil = 0; // overview stays bright until this time (ms), refreshed while navigating + private _ovRect?: { x0: number; x1: number; y0: number; y1: number }; // overview band hit-box + + private _raf = 0; + private _onMove: (e: MouseEvent) => void; + private _onLeave: () => void; + private _onDown: (e: MouseEvent) => void; + private _onUp: () => void; + + /** + * @param container element that will host the canvas, scrollbar and tooltip (it is emptied and made + * `position: relative`) + */ + constructor(container: HTMLElement) { + this._container = container; + const cs = getComputedStyle(container); + if (cs.position === 'static') { + container.style.position = 'relative'; + } + + this._canvas = document.createElement('canvas'); + this._canvas.className = 'cb-uitl-canvas'; + this._canvas.style.cssText = 'display:block;width:100%;cursor:grab;'; + + this._tip = document.createElement('div'); + this._tip.className = 'cb-uitl-tip'; + // Functional layout only; the visual style (surface, text, border, shadow, radius, font) is + // pulled from the design tokens in _applyTipTheme so the tooltip follows the light/dark theme, + // and falls back to a dark card when the stylesheet is absent. + this._tip.style.cssText = + 'position:absolute;z-index:20;pointer-events:none;display:none;white-space:nowrap;padding:7px 9px;'; + this._applyTipTheme(cs); + + container.append(this._canvas, this._tip); + + this._onMove = e => this._move(e); + this._onLeave = () => { + this._tip.style.display = 'none'; + }; + this._onDown = e => this._down(e); + this._onUp = () => this._up(); + this._canvas.addEventListener('mousemove', this._onMove); + this._canvas.addEventListener('mouseleave', this._onLeave); + this._canvas.addEventListener('mousedown', this._onDown); + // Listen on the window so a drag that ends outside the canvas still releases. + root.addEventListener('mouseup', this._onUp); + + const loop = () => { + this.render(); + this._raf = root.requestAnimationFrame(loop); + }; + this._raf = root.requestAnimationFrame(loop); + } + + /** Ingest a received video sample (a new sequence starts at every keyframe). */ + pushVideo(track: number, sample: Media.Sample) { + this._push(Media.Type.VIDEO, track, sample); + } + + /** Ingest a received audio sample (grouped under the current video sequence). */ + pushAudio(track: number, sample: Media.Sample) { + this._push(Media.Type.AUDIO, track, sample); + } + + /** Ingest a received data sample (grouped under the current video sequence). */ + pushData(track: number, sample: Media.Sample) { + this._push(Media.Type.DATA, track, sample); + } + + /** Clear all accumulated sequences and reset the view to live. */ + reset() { + this._rows.clear(); + this._order = undefined; + this._hits = []; + this._hasData = false; + this._hasVideo = false; + this._videoSeq = -1; + this._t0 = 0; + this._viewEnd = 0; + this._snap = true; + this._following = true; + this._tip.style.display = 'none'; + } + + /** + * Export every retained sequence as CSV (`;`-separated). Reception times are relative to the first + * received sample. Mirrors the metrics CSV export. + */ + toCSV(): string { + // Flatten every sequence across tracks and order by reception time, so the file reads as the + // chronological arrival of sequences (more meaningful than grouping per track). + const all: Array<{ row: Row; s: Sequence }> = []; + for (const row of this._rows.values()) { + for (const s of row.seqs) { + all.push({ row, s }); + } + } + all.sort((a, b) => a.s.recvStart - b.s.recvStart); + + const lines = ['type;track;seq;frames;bytes;dtsStart_ms;dtsEnd_ms;recvStart_ms;recvEnd_ms;keyframe']; + for (const { row, s } of all) { + lines.push( + [ + row.type, + row.id, + s.n, + s.frames, + s.bytes, + s.dtsStart.toFixed(0), + s.dtsEnd.toFixed(0), + (s.recvStart - this._t0).toFixed(0), + (s.recvEnd - this._t0).toFixed(0), + s.key ? 1 : 0 + ].join(';') + ); + } + return lines.join('\n'); + } + + /** Stop the render loop and remove the elements/listeners created in the container. */ + destroy() { + root.cancelAnimationFrame(this._raf); + this._canvas.removeEventListener('mousemove', this._onMove); + this._canvas.removeEventListener('mouseleave', this._onLeave); + this._canvas.removeEventListener('mousedown', this._onDown); + root.removeEventListener('mouseup', this._onUp); + this._canvas.remove(); + this._tip.remove(); + } + + private _push(type: Media.Type, track: number, sample: Media.Sample) { + if (!sample || sample.time == null) { + return; + } + const now = Util.time(); + if (!this._t0) { + this._t0 = now; + } + let row = this._rows.get(track); + if (!row) { + row = { + id: track, + type, + color: this._colorFor(this._rows.size), + seqs: [], + seqCounter: 0 + }; + this._rows.set(track, row); + this._order = undefined; // re-sort rows on next render + } + + const dur = sample.duration || 0; + let n: number; + 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 { + // No video track to reference: fall back to fixed media-time buckets (like the server's + // fallback GOP), so a steady audio/data-only cadence still groups into regular sequences + // instead of one sliver per sample. + boundary = + !row.cur || Math.floor(sample.time / FALLBACK_GOP) !== Math.floor(row.cur.dtsStart / FALLBACK_GOP); + n = boundary ? row.seqCounter++ : (row.cur as Sequence).n; + } + + if (boundary) { + row.cur = { + n, + dtsStart: sample.time, + dtsEnd: sample.time + dur, + recvStart: now, + recvEnd: now, + frames: 0, + bytes: 0, + key: !!sample.isKeyFrame + }; + row.seqs.push(row.cur); + if (row.seqs.length > UITimeline.MAX_SEQUENCES) { + row.seqs.splice(0, row.seqs.length - UITimeline.MAX_SEQUENCES); + } + } + + const s = row.cur as Sequence; + ++s.frames; + s.bytes += sample.data ? sample.data.byteLength : 0; + s.dtsEnd = sample.time + dur; + s.recvEnd = now; + this._hasData = true; + } + + /** Draws the current state. Called every animation frame; cheap no-op while hidden. */ + render() { + const canvas = this._canvas; + if (canvas.offsetParent === null) { + return; // hidden (e.g. inactive tab) + } + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + // Canvas can't consume CSS, so resolve the design tokens to values here (each falling back to + // the built-in default when the stylesheet is absent). Read once per frame off a single computed + // style, and keep the DOM tooltip in sync with the same theme. + const style = getComputedStyle(this._container); + const colTxt = this._var(style, '--cb-txt', style.color || '#888'); + const colGrid = this._var(style, '--cb-border', 'rgba(128,128,128,.22)'); + const accent = this._var(style, '--cb-accent', ACCENT); + const okCol = this._var(style, '--cb-ok', HEALTH_OK); + const warnCol = this._var(style, '--cb-warn', HEALTH_WARN); + const errCol = this._var(style, '--cb-err', HEALTH_ERR); + const fBody = this._var(style, '--cb-f-body', 'sans-serif'); + const fMono = this._var(style, '--cb-f-mono', 'ui-monospace,monospace'); + this._applyTipTheme(style); + + const dpr = root.devicePixelRatio || 1; + const ROW_H = 34; + const ROW_GAP = 6; + const TOP = 8; + const AXIS_H = 20; + const cssW = this._container.clientWidth || 600; + + if (!this._order) { + this._order = [...this._rows.values()].sort((a, b) => b.type - a.type || a.id - b.id); + } + const rows = this._order; + + const rowsBottom = TOP + rows.length * (ROW_H + ROW_GAP); + const axisBottom = rowsBottom + AXIS_H; // bottom of the time-axis label band + const cssH = rows.length ? axisBottom + OV_GAP + OV_H : 60; + if (canvas.width !== Math.round(cssW * dpr) || canvas.height !== Math.round(cssH * dpr)) { + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + canvas.style.height = cssH + 'px'; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + + if (!rows.length || !this._hasData) { + ctx.fillStyle = colTxt; + ctx.globalAlpha = 0.5; + ctx.font = 'italic 12px ' + fBody; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText('Waiting for media…', 12, cssH / 2); + ctx.globalAlpha = 1; + return; + } + + const media = this._axis === 'media'; + const span = this._windowDuration * 1000; + + // Data bounds in the current axis' value space (seqs are time-ordered, so first/last suffice). + let dataMin = Infinity; + let dataMax = -Infinity; + for (const r of rows) { + if (!r.seqs.length) { + continue; + } + const first = r.seqs[0]; + const last = r.seqs[r.seqs.length - 1]; + const a = media ? first.dtsStart : first.recvStart; + const b = media ? last.dtsEnd : last.recvEnd; + if (a < dataMin) { + dataMin = a; + } + if (b > dataMax) { + dataMax = b; + } + } + if (!isFinite(dataMin)) { + return; + } + + const lo = dataMin + span; + const hi = dataMax; + // Expose the pan bounds + plot width to the drag/scrub handlers (px↔value conversion / clamping). + this._dataMin = dataMin; + this._dataLo = lo; + this._dataHi = hi; + this._plotW = cssW - 8 - LABEL_W; + if (this._following || this._snap) { + this._viewEnd = dataMax; + this._snap = false; + } + // Clamp the frozen edge inside the available data. + this._viewEnd = Math.max(Math.min(lo, hi), Math.min(this._viewEnd || dataMax, hi)); + const winB = this._viewEnd; + const winA = winB - span; + const xOf = (v: number) => LABEL_W + ((v - winA) / span) * this._plotW; + + // Grid + axis labels + ctx.textBaseline = 'middle'; + ctx.font = '10px ' + fMono; + const ticks = 6; + for (let i = 0; i <= ticks; ++i) { + const v = winA + (span * i) / ticks; + const gx = xOf(v); + ctx.strokeStyle = colGrid; + ctx.beginPath(); + ctx.moveTo(gx, TOP); + ctx.lineTo(gx, rowsBottom); + ctx.stroke(); + ctx.fillStyle = colTxt; + ctx.globalAlpha = 0.6; + ctx.textAlign = 'center'; + let lbl; + if (media) { + lbl = (v / 1000).toFixed(1) + 's'; + } else { + const ago = (dataMax - v) / 1000; + lbl = ago <= 0.05 ? 'now' : '-' + ago.toFixed(1) + 's'; + } + ctx.fillText(lbl, gx, rowsBottom + AXIS_H / 2); + ctx.globalAlpha = 1; + } + + const plotX1 = cssW - 8; + const hits: Hit[] = []; + rows.forEach((r, ri) => { + const y = TOP + ri * (ROW_H + ROW_GAP); + // Row label + ctx.fillStyle = r.color; + ctx.fillRect(2, y, 3, ROW_H); + ctx.fillStyle = colTxt; + ctx.textAlign = 'left'; + ctx.font = '600 11px ' + fBody; + ctx.fillText(Media.typeToString(r.type).toUpperCase(), 11, y + 11); + ctx.globalAlpha = 0.6; + ctx.font = '10px ' + fMono; + ctx.fillText('#' + r.id, 11, y + 24); + ctx.globalAlpha = 1; + + for (const s of r.seqs) { + const a = media ? s.dtsStart : s.recvStart; + const b = media ? s.dtsEnd : s.recvEnd; + if (b < winA || a > winB) { + continue; + } + const xa = Math.max(LABEL_W, xOf(a)); + const xb = Math.min(plotX1, xOf(b)); + const w = Math.max(2, xb - xa); + // Reception health: how long the sequence took to arrive vs its media duration. + const ratio = (s.recvEnd - s.recvStart) / Math.max(1, s.dtsEnd - s.dtsStart); + const health = ratio < 1.2 ? okCol : ratio < 2 ? warnCol : errCol; + // Fill by reception health in both axes (consistent with the legend); the track color + // stays as a thin left edge so each row keeps its identity. + ctx.globalAlpha = 0.85; + ctx.fillStyle = health; + ctx.fillRect(xa, y, w, ROW_H); + ctx.globalAlpha = 1; + ctx.fillStyle = r.color; + ctx.fillRect(xa, y, Math.min(3, w), ROW_H); + ctx.strokeStyle = colGrid; + ctx.strokeRect(xa + 0.5, y + 0.5, w - 1, ROW_H - 1); + if (w > 22) { + // Frame count is the most useful at-a-glance debug signal (spot irregular GOPs); + // the sequence number is in the hover tooltip. + ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; + ctx.font = '600 11px ' + fMono; + ctx.fillText(String(s.frames), xa + w / 2, y + ROW_H / 2); + } + hits.push({ x0: xa, x1: xa + w, y0: y, y1: y + ROW_H, s, r }); + } + }); + this._hits = hits; + + // Playhead (media axis only) + if (media && this.getMediaTime) { + const ct = this.getMediaTime(); + if (ct != null && ct >= winA && ct <= winB) { + const lx = xOf(ct); + ctx.strokeStyle = errCol; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(lx, TOP); + ctx.lineTo(lx, rowsBottom); + ctx.stroke(); + ctx.lineWidth = 1; + } + } + + // Overview minimap: full buffer extent + current window. Subtle by default, brightens while + // navigating (dragging the plot, or scrubbing this band directly for fast jumps). + const ovY0 = axisBottom + OV_GAP; + const ovX0 = LABEL_W; + const ovW = Math.max(1, plotX1 - ovX0); + this._ovRect = { x0: ovX0, x1: plotX1, y0: ovY0 - 3, y1: ovY0 + OV_H + 3 }; + const navActive = this._dragging || this._ovDragging || Util.time() < this._navUntil; + const totalSpan = Math.max(1, dataMax - dataMin); + const ovXOf = (v: number) => ovX0 + ((v - dataMin) / totalSpan) * ovW; + // Full-extent track + ctx.globalAlpha = navActive ? 0.55 : 0.3; + ctx.fillStyle = colGrid; + this._roundRect(ctx, ovX0, ovY0, ovW, OV_H, OV_H / 2); + ctx.fill(); + // Current window + const wx0 = ovXOf(Math.max(dataMin, winA)); + const wx1 = ovXOf(Math.min(dataMax, winB)); + ctx.globalAlpha = navActive ? 1 : 0.65; + ctx.fillStyle = accent; + this._roundRect(ctx, wx0, ovY0, Math.max(6, wx1 - wx0), OV_H, OV_H / 2); + ctx.fill(); + ctx.globalAlpha = 1; + // Start / end labels, only while navigating (keeps the resting state quiet) + if (navActive) { + ctx.font = '9px ' + fMono; + ctx.fillStyle = colTxt; + ctx.globalAlpha = 0.8; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + ctx.fillText( + media ? (dataMin / 1000).toFixed(1) + 's' : '-' + (totalSpan / 1000).toFixed(1) + 's', + ovX0 + 6, + ovY0 + OV_H / 2 + ); + ctx.textAlign = 'right'; + ctx.fillText(media ? (dataMax / 1000).toFixed(1) + 's' : 'now', plotX1 - 6, ovY0 + OV_H / 2); + ctx.globalAlpha = 1; + } + } + + /** Read a CSS custom property off a resolved style, falling back when it is unset. */ + private _var(style: CSSStyleDeclaration, name: string, fallback: string): string { + return style.getPropertyValue(name).trim() || fallback; + } + + /** Resolve the n-th track color from the `--cb-track-N` tokens (cached; falls back to {@link PALETTE}). */ + private _colorFor(index: number): string { + if (!this._palette) { + const style = getComputedStyle(this._container); + this._palette = PALETTE.map((def, i) => this._var(style, `--cb-track-${i + 1}`, def)); + } + return this._palette[index % this._palette.length]; + } + + /** Apply the design-token theme (surface, text, border, shadow, radius, font) to the DOM tooltip. */ + private _applyTipTheme(style: CSSStyleDeclaration) { + const bg = this._var(style, '--cb-bg-s', 'rgba(20,24,33,.96)'); + const txt = this._var(style, '--cb-txt', '#e7ecf3'); + const border = this._var(style, '--cb-border-s', 'rgba(255,255,255,.12)'); + const shadow = this._var(style, '--cb-shadow', '0 4px 24px rgba(0,0,0,.45)'); + const radius = this._var(style, '--cb-r-sm', '6px'); + const mono = this._var(style, '--cb-f-mono', 'ui-monospace,monospace'); + const sig = [bg, txt, border, shadow, radius, mono].join('|'); + if (sig === this._tipSig) { + return; // theme unchanged — skip the DOM write + } + this._tipSig = sig; + const s = this._tip.style; + s.background = bg; + s.color = txt; + s.border = '1px solid ' + border; + s.boxShadow = shadow; + s.borderRadius = radius; + s.font = '11px/1.5 ' + mono; + } + + private _roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + + private _refreshNav() { + this._navUntil = Util.time() + NAV_LINGER; + } + + /** Map an overview-band x to the view edge, centering the window on the pointer (absolute scrub). */ + private _ovScrub(x: number) { + const ov = this._ovRect; + if (!ov || ov.x1 <= ov.x0) { + return; + } + const frac = Math.min(1, Math.max(0, (x - ov.x0) / (ov.x1 - ov.x0))); + const v = this._dataMin + frac * (this._dataHi - this._dataMin) + (this._windowDuration * 1000) / 2; + this._snap = false; + if (v >= this._dataHi) { + this._viewEnd = this._dataHi; + this.following = true; + } else { + this._viewEnd = Math.max(Math.min(this._dataLo, this._dataHi), v); + this.following = false; + } + } + + /** + * Convert a value between the media (DTS) and reception axes using the recorded per-sequence + * (dts, recv) pairs, so the same instant stays visible when the axis is switched. + */ + private _mapValue(value: number, from: UITimelineAxis, to: UITimelineAxis): number { + if (from === to) { + return value; + } + const pairs: Array<[number, number]> = []; + for (const row of this._rows.values()) { + for (const s of row.seqs) { + pairs.push([from === 'media' ? s.dtsStart : s.recvStart, to === 'media' ? s.dtsStart : s.recvStart]); + pairs.push([from === 'media' ? s.dtsEnd : s.recvEnd, to === 'media' ? s.dtsEnd : s.recvEnd]); + } + } + if (!pairs.length) { + return value; + } + pairs.sort((a, b) => a[0] - b[0]); + if (value <= pairs[0][0]) { + return pairs[0][1]; + } + const last = pairs[pairs.length - 1]; + if (value >= last[0]) { + return last[1]; + } + let lo = 0; + let hi = pairs.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (pairs[mid][0] <= value) { + lo = mid; + } else { + hi = mid; + } + } + const [f0, t0] = pairs[lo]; + const [f1, t1] = pairs[hi]; + return f1 > f0 ? t0 + ((value - f0) / (f1 - f0)) * (t1 - t0) : t0; + } + + private _down(e: MouseEvent) { + const rect = this._canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + // Press on the overview minimap → absolute scrub (fast jump anywhere in the buffer). + if (this._ovRect && y >= this._ovRect.y0 && y <= this._ovRect.y1) { + this._ovDragging = true; + this._refreshNav(); + this._ovScrub(x); + this._tip.style.display = 'none'; + e.preventDefault(); + return; + } + if (x < LABEL_W) { + return; // label gutter + } + this._dragging = true; + this._lastX = x; + this._refreshNav(); + this.following = false; // grabbing the timeline pauses live-follow + this._tip.style.display = 'none'; + this._canvas.style.cursor = 'grabbing'; + e.preventDefault(); + } + + private _up() { + if (this._dragging || this._ovDragging) { + this._refreshNav(); + } + this._dragging = false; + this._ovDragging = false; + this._canvas.style.cursor = 'grab'; + } + + private _move(e: MouseEvent) { + const rect = this._canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (this._ovDragging) { + this._refreshNav(); + this._ovScrub(x); + return; + } + if (this._dragging) { + // Grab-and-pull: drag right → back into the past, drag left → forward (toward live). + // Acceleration: the faster the pointer moves, the more ground each pixel covers, so long + // buffers can be traversed quickly while slow drags stay precise. + const dx = x - this._lastX; + const perPx = (this._windowDuration * 1000) / Math.max(1, this._plotW); + const accel = 1 + Math.min(ACCEL_MAX, Math.abs(dx) / ACCEL_REF); + this._viewEnd -= dx * perPx * accel; + this._lastX = x; + this._snap = false; + this._refreshNav(); + if (this._viewEnd >= this._dataHi) { + // Reached the live edge → resume following. + this._viewEnd = this._dataHi; + this.following = true; + } else { + this._viewEnd = Math.max(Math.min(this._dataLo, this._dataHi), this._viewEnd); + this.following = false; + } + return; + } + + // Hovering the overview band keeps it visible and hints it is draggable. + if (this._ovRect && y >= this._ovRect.y0 && y <= this._ovRect.y1) { + this._refreshNav(); + this._canvas.style.cursor = 'ew-resize'; + this._tip.style.display = 'none'; + return; + } + this._canvas.style.cursor = 'grab'; + + const hit = this._hits.find(h => x >= h.x0 && x <= h.x1 && y >= h.y0 && y <= h.y1); + if (!hit) { + this._tip.style.display = 'none'; + return; + } + const s = hit.s; + const r = hit.r; + const medSpan = s.dtsEnd - s.dtsStart; + const recvSpan = s.recvEnd - s.recvStart; + this._tip.innerHTML = + `${r.type} #${r.id} · seq ${s.n}${s.key ? ' · key' : ''}
` + + `frames ${s.frames} · size ${(s.bytes / 1024).toFixed(1)} KiB
` + + `DTS ${(s.dtsStart / 1000).toFixed(3)}→${(s.dtsEnd / 1000).toFixed(3)}s (${medSpan}ms)
` + + `recv +${(s.recvStart - this._t0).toFixed(0)}→+${(s.recvEnd - this._t0).toFixed(0)}ms (${recvSpan.toFixed(0)}ms)`; + this._tip.style.display = 'block'; + // Flip the tooltip away from the edges so it is never clipped (notably on the bottom row). + const tw = this._tip.offsetWidth; + const th = this._tip.offsetHeight; + let tx = x + 12; + if (tx + tw > this._container.clientWidth) { + tx = Math.max(2, x - tw - 12); + } + let ty = y + 12; + if (ty + th > this._canvas.clientHeight) { + ty = Math.max(2, y - th - 12); + } + this._tip.style.left = tx + 'px'; + this._tip.style.top = ty + 'px'; + } +} diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 0000000..1996708 --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +// UI components (DOM/canvas). Import via the `@ceeblue/web-utils/ui` subpath so the pure-logic +// root entry stays free of DOM code. +export { UIMetrics } from './UIMetrics'; +export { UITimeline, UITimelineAxis } from './UITimeline'; diff --git a/styles/components.css b/styles/components.css new file mode 100644 index 0000000..5af60ac --- /dev/null +++ b/styles/components.css @@ -0,0 +1,400 @@ +/* + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +/* + * Components layer: the class-based Ceeblue UI kit — app shell, form controls, buttons, + * toggles, tabs, the UIMetrics stats panel, message log and modal. Consumes tokens.css + + * foundation.css (load those first). A downstream `ceeblue.features` layer (domain + * components) sits after these. + */ +@layer ceeblue.tokens, ceeblue.foundation, ceeblue.components; + +@layer ceeblue.components { + /* ── App shell ── */ + .cb-shell { + max-width: 960px; + margin: 0 auto; + padding: 14px 14px 48px; + display: flex; + flex-direction: column; + gap: 10px; + } + + .cb-hdr { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 12px; + border-bottom: 1px solid var(--cb-border); + } + .cb-hdr img { height: 28px; } + .cb-hdr h1 { + font-size: 15px; + font-weight: 600; + letter-spacing: .02em; + color: var(--cb-navy); + flex: 1; + } + .cb-hdr h1 em { color: var(--cb-accent); font-style: normal; } + + .cb-theme-btn { + width: 32px; height: 32px; + border-radius: 50%; + border: 1px solid var(--cb-border); + background: var(--cb-bg-s); + color: var(--cb-txt-2); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 13px; + transition: all var(--cb-t); + } + .cb-theme-btn:hover { color: var(--cb-accent); border-color: var(--cb-border-s); } + + /* ── Field label ── */ + .cb-lbl { + display: block; + font-size: 10px; + font-weight: 600; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--cb-txt-m); + margin-bottom: 3px; + } + + /* ── Inputs ── */ + .cb-inp, .cb-sel { + width: 100%; + background: var(--cb-bg); + border: 1px solid var(--cb-border); + border-radius: var(--cb-r-sm); + color: var(--cb-txt); + font-family: var(--cb-f-body); + font-size: 13px; + padding: 7px 11px; + outline: none; + transition: border-color var(--cb-t), box-shadow var(--cb-t); + appearance: none; + } + .cb-inp:focus, .cb-sel:focus { + border-color: var(--cb-accent); + box-shadow: 0 0 0 3px var(--cb-accent-d); + } + .cb-inp::placeholder { color: var(--cb-txt-m); } + .cb-inp:disabled, .cb-sel:disabled { opacity: .5; cursor: not-allowed; background: var(--cb-bg-e); } + .cb-inp[type="number"] { text-align: center; padding-left: 6px; padding-right: 6px; } + + .cb-sel { + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%239ba4bc' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 28px; + } + select option { background: var(--cb-bg); color: var(--cb-txt); } + + /* ── Buttons ── */ + .cb-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 7px 14px; + border: 1px solid var(--cb-border); + border-radius: var(--cb-r-sm); + background: var(--cb-bg-e); + color: var(--cb-txt-2); + font-family: var(--cb-f-body); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all var(--cb-t); + white-space: nowrap; + } + .cb-btn:hover:not(:disabled) { background: var(--cb-bg-h); border-color: var(--cb-border-s); color: var(--cb-txt); } + .cb-btn:disabled { opacity: .55; cursor: not-allowed; } + .cb-btn-icon { padding: 7px 9px; } + + /* Teal-tinted secondary (active DRM indicator) */ + .cb-btn-cyan { + background: var(--cb-accent-d); border-color: color-mix(in srgb, var(--cb-accent) 40%, transparent); + color: var(--cb-accent-b); + } + .cb-btn-cyan:hover:not(:disabled) { background: color-mix(in srgb, var(--cb-accent) 25%, transparent); border-color: var(--cb-accent); } + .cb-btn-cyan:disabled { + opacity: 1; + color: var(--cb-accent-b); + background: color-mix(in srgb, var(--cb-accent) 8%, transparent); + border-color: color-mix(in srgb, var(--cb-accent) 25%, transparent); + cursor: not-allowed; + } + + /* Accent pill (modal confirm / primary action) */ + .cb-btn-primary { + background: var(--cb-accent); border-color: var(--cb-accent); + color: #fff; font-weight: 600; + border-radius: var(--cb-r-pill); + padding: 7px 18px; + } + .cb-btn-primary:hover:not(:disabled) { background: var(--cb-accent-b); border-color: var(--cb-accent-b); } + + /* ── Toggle (switch + label) ── */ + .cb-tog { + display: inline-flex; align-items: center; gap: 8px; + padding: 7px 12px; + border: 1px solid var(--cb-border); + border-radius: var(--cb-r-sm); + background: var(--cb-bg-e); + cursor: pointer; + transition: all var(--cb-t); + white-space: nowrap; + user-select: none; + } + .cb-tog:hover { border-color: var(--cb-border-s); } + .cb-tog.cb-off { opacity: .4; pointer-events: none; } + .cb-tog input { display: none; } + .cb-tog-track { + width: 26px; height: 14px; + background: var(--cb-txt-m); + border-radius: 7px; + position: relative; + transition: background var(--cb-t); + flex-shrink: 0; + } + .cb-tog-track::after { + content: ''; + position: absolute; top: 2px; left: 2px; + width: 10px; height: 10px; + background: #fff; border-radius: 50%; + transition: transform var(--cb-t); + } + .cb-tog.cb-on .cb-tog-track { background: var(--cb-accent); } + .cb-tog.cb-on .cb-tog-track::after { transform: translateX(12px); } + .cb-tog span { font-size: 12.5px; color: var(--cb-txt-2); font-weight: 500; } + + /* ── Row layouts ── */ + .cb-row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .cb-gap-panel { margin-top: 14px; } + + /* Stream input takes full width; controls row holds the rest */ + .cb-stream-row .cb-inp { width: 100%; } + + .cb-ctrl-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + align-items: stretch; + } + .cb-ctrl-row .cb-btn, .cb-ctrl-row .cb-tog { width: 100%; justify-content: center; } + + /* Network profile + buffer min/max — single compact row. + Grid-template-areas keep description glued under profile on mobile. */ + .cb-net-row { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: + 'profile bufmin bufmax' + 'desc desc desc'; + gap: 8px; + align-items: end; + } + .cb-net-row .cb-net-prof { grid-area: profile; } + .cb-net-row .cb-buf-min { grid-area: bufmin; } + .cb-net-row .cb-buf-max { grid-area: bufmax; } + .cb-net-row .cb-net-desc { grid-area: desc; } + .cb-net-row .cb-lbl { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + + .cb-net-desc { + font-size: 12px; + color: var(--cb-txt-m); + padding-top: 6px; + margin-top: 2px; + border-top: 1px solid var(--cb-border); + font-style: italic; + } + .cb-net-desc strong { color: var(--cb-txt-2); font-weight: 500; font-style: normal; } + + /* ── Tabs ── */ + .cb-tab-bar { + display: flex; + border-bottom: 1px solid var(--cb-border); + gap: 0; + } + .cb-tab-btn { + background: none; border: none; + border-bottom: 2px solid transparent; + color: var(--cb-txt-m); + font-family: var(--cb-f-body); + font-size: 12px; font-weight: 600; + letter-spacing: .04em; text-transform: uppercase; + padding: 7px 14px 6px; + cursor: pointer; + transition: all var(--cb-t); + position: relative; top: 1px; + } + .cb-tab-btn:hover { color: var(--cb-txt-2); } + .cb-tab-btn.cb-on { color: var(--cb-accent); border-bottom-color: var(--cb-accent); } + + .cb-tab-badge { + display: inline-flex; align-items: center; justify-content: center; + min-width: 17px; height: 17px; padding: 0 4px; + background: var(--cb-accent); color: #fff; + font-size: 10px; font-weight: 700; font-family: var(--cb-f-mono); + border-radius: 9px; margin-left: 5px; + } + + .cb-tab-body { padding-top: 10px; } + + /* ── Messages ── */ + .cb-msg-box { + background: var(--cb-bg-s); + border: 1px solid var(--cb-border); + border-radius: var(--cb-r-sm); + height: 240px; overflow: auto; + font-family: var(--cb-f-mono); font-size: 11.5px; + scrollbar-width: thin; + scrollbar-color: var(--cb-border-s) transparent; + } + .cb-msg-empty { padding: 14px 12px; color: var(--cb-txt-m); font-style: italic; font-size: 12px; } + .cb-msg-row { + padding: 4px 12px; + border-bottom: 1px solid var(--cb-border); + color: var(--cb-txt-2); white-space: pre; min-width: max-content; line-height: 1.45; + } + .cb-msg-row:last-child { border-bottom: none; } + .cb-msg-track { color: var(--cb-accent-b); font-weight: 500; } + .cb-msg-time { color: var(--cb-txt-m); } + + /* ── Modal ── */ + .cb-modal-back { + position: fixed; inset: 0; + background: var(--cb-modal-back); + backdrop-filter: blur(4px); + z-index: 200; + } + .cb-modal-wrap { + position: fixed; inset: 0; z-index: 201; + display: flex; align-items: center; justify-content: center; + padding: 16px; + } + .cb-modal-box { + background: var(--cb-bg); + border: 1px solid var(--cb-border-s); + border-radius: var(--cb-r-lg); + width: 100%; max-width: 580px; + max-height: calc(100vh - 40px); + display: flex; flex-direction: column; + box-shadow: var(--cb-shadow-modal); + } + .cb-modal-hd { + display: flex; align-items: center; gap: 10px; + padding: 14px 18px; + border-bottom: 1px solid var(--cb-border); + flex-shrink: 0; + } + .cb-modal-hd i { color: var(--cb-accent); font-size: 14px; } + .cb-modal-hd h5 { + font-size: 15px; font-weight: 600; + letter-spacing: .02em; color: var(--cb-navy); + } + .cb-modal-x { + margin-left: auto; background: none; border: none; + color: var(--cb-txt-m); cursor: pointer; font-size: 16px; line-height: 1; + transition: color var(--cb-t); + } + .cb-modal-x:hover { color: var(--cb-txt); } + + .cb-modal-bd { + overflow-y: auto; padding: 14px 18px; flex: 1; + scrollbar-width: thin; scrollbar-color: var(--cb-border-s) transparent; + } + .cb-modal-hint { + font-size: 11.5px; color: var(--cb-txt-2); + margin-bottom: 14px; padding: 8px 11px; + background: var(--cb-bg-e); border-radius: var(--cb-r-sm); + border-left: 3px solid var(--cb-accent); + line-height: 1.55; + } + + .cb-btn-link { + background: none; border: none; color: var(--cb-accent); + font-size: 11.5px; cursor: pointer; padding: 0; + font-family: var(--cb-f-body); transition: color var(--cb-t); + } + .cb-btn-link:hover:not(:disabled) { color: var(--cb-accent-b); } + .cb-btn-link:disabled { opacity: .38; cursor: not-allowed; } + + .cb-modal-ft { + padding: 12px 18px; + border-top: 1px solid var(--cb-border); + display: flex; justify-content: flex-end; + flex-shrink: 0; + } + + /* ── Responsive ── */ + @media (max-width: 680px) { + .cb-ctrl-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .cb-row2 { grid-template-columns: 1fr 1fr; } + } + @media (max-width: 480px) { + .cb-net-row { + grid-template-columns: 1fr 1fr; + grid-template-areas: + 'profile profile' + 'desc desc' + 'bufmin bufmax'; + } + .cb-row2 { grid-template-columns: 1fr; } + } + + /* ═══════════════════════════════════════════════════════════════════════════════ + Custom Ceeblue UI widgets (src/ui) — kept apart from the generic kit above. + These back the JS-driven DOM/canvas components (UIMetrics, UITimeline); their + TypeScript reads these rules/tokens at runtime, so edit them with the widgets. + ═══════════════════════════════════════════════════════════════════════════════ */ + + /* ── UIMetrics (real-time stats graph) ── */ + .cb-stats-list { + background: transparent; + list-style: none; + padding: 0; margin: 0; + display: block; + width: 100%; + } + /* UIMetrics injects elements directly as children — force them full-width */ + .cb-stats-list > svg { + display: block; + width: 100%; + border-bottom: 1px solid var(--cb-border); + font-family: var(--cb-f-mono); + font-size: 12px; + fill: var(--cb-txt); + } + .cb-stats-list > svg:last-child { border-bottom: none; } + + .cb-stats-actions { + display: flex; gap: 12px; align-items: center; justify-content: center; + margin-top: 12px; padding-top: 10px; + border-top: 1px solid var(--cb-border); + } + .cb-stats-actions img { + cursor: pointer; opacity: .4; height: 26px; + transition: opacity var(--cb-t); + } + .cb-stats-actions img:hover { opacity: .9; } + + /* ── UITimeline (reception timeline canvas) ── + The widget draws to and can't consume CSS, so it resolves these values via + getComputedStyle at runtime (see src/ui/UITimeline.ts) and styles its tooltip/canvas + inline from the shared tokens above. Only the categorical track palette is specific to + this widget — override --cb-track-N to recolor the per-track rows. */ + :root { + --cb-track-1: #78b5bf; + --cb-track-2: #e6a817; + --cb-track-3: #9b7ede; + --cb-track-4: #6fbf8b; + --cb-track-5: #df7e7e; + --cb-track-6: #7e9cdf; + --cb-track-7: #c98bd0; + --cb-track-8: #b5a06f; + } +} diff --git a/styles/foundation.css b/styles/foundation.css new file mode 100644 index 0000000..bb3eeca --- /dev/null +++ b/styles/foundation.css @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +/* + * Foundation layer: unclassed base styles — CSS reset, base document/body typography and + * scrollbars. Built on the design tokens from tokens.css; sits between tokens and components + * in the cascade (tokens < foundation < components). Load tokens.css first. + */ +@layer ceeblue.tokens, ceeblue.foundation, ceeblue.components; + +@layer ceeblue.foundation { + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + body { + background: var(--cb-bg); + color: var(--cb-txt); + font-family: var(--cb-f-body); + font-size: 13.5px; + line-height: 1.5; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + } + + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { background: var(--cb-border-s); border-radius: 3px; } + + [v-cloak] { display: none; } +} diff --git a/styles/tokens.css b/styles/tokens.css new file mode 100644 index 0000000..c94e34e --- /dev/null +++ b/styles/tokens.css @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Ceeblue B.V. + * This file is part of https://github.com/CeeblueTV/web-utils which is released under GNU Affero General Public License. + * See file LICENSE or go to https://spdx.org/licenses/AGPL-3.0-or-later.html for full license details. + */ + +/* + * Design tokens for the Ceeblue UI: color palette, radii, fonts, transitions and the light/dark + * theme maps. This is the base of the cascade — the `ceeblue.tokens` layer sits below foundation + * and components (components.css), so every downstream layer can read these custom properties via + * var() and override them without specificity hacks. Load this before components.css. + */ +@layer ceeblue.tokens, ceeblue.foundation, ceeblue.components; + +@layer ceeblue.tokens { + :root { + --cb-accent: #78b5bf; + --cb-accent-b: #5e97a1; + --cb-accent-d: color-mix(in srgb, var(--cb-accent) 18%, transparent); + + --cb-ok: #28a745; + --cb-warn: #e6a817; + --cb-err: #c0392b; + + /* "input changed / danger" red — brighter than --cb-err, used for auto-tuned fields */ + --cb-danger: #e53935; + --cb-danger-d: color-mix(in srgb, var(--cb-danger) 18%, transparent); + + --cb-r-sm: 6px; + --cb-r-md: 12px; + --cb-r-lg: 16px; + --cb-r-pill: 999px; + + --cb-f-body: 'Poppins', system-ui, sans-serif; + --cb-f-mono: 'JetBrains Mono', ui-monospace, monospace; + + --cb-t: 160ms ease; + } + + html[data-cb-theme="light"] { + --cb-bg: #ffffff; + --cb-bg-s: #f4f7fb; + --cb-bg-e: #ebeff5; + --cb-bg-h: #dde4ee; + --cb-border: rgba(103,114,148,.18); + --cb-border-s: rgba(103,114,148,.38); + --cb-txt: #3d4459; + --cb-txt-2: #677294; + --cb-txt-m: #9ba4bc; + --cb-navy: #051441; + --cb-shadow: 0 4px 24px rgba(61,68,89,.10); + --cb-shadow-modal: 0 24px 64px rgba(5,20,65,.18); + --cb-modal-back: rgba(5,20,65,.35); + } + + html[data-cb-theme="dark"] { + --cb-bg: #0f1622; + --cb-bg-s: #161e2d; + --cb-bg-e: #1d2638; + --cb-bg-h: #283248; + --cb-border: rgba(255,255,255,.08); + --cb-border-s: rgba(255,255,255,.18); + --cb-txt: #e7ecf3; + --cb-txt-2: #aab4c8; + --cb-txt-m: #6c7894; + --cb-navy: #ffffff; + --cb-shadow: 0 4px 24px rgba(0,0,0,.45); + --cb-shadow-modal: 0 24px 64px rgba(0,0,0,.6); + --cb-modal-back: rgba(0,0,0,.55); + } +} diff --git a/tsconfig.json b/tsconfig.json index 248b466..c4b0c02 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["./index.ts"], + "include": ["./index.ts", "./src/ui/index.ts"], "compilerOptions": { "strictPropertyInitialization": true, "noImplicitAny": true,