describe-me 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Grzegorz Łotysz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # describe-me
2
+
3
+ The viewer and CLI of [describe-me](https://github.com/grzehub/describe-me):
4
+ living component documentation generated from the Vitest tests you already have.
5
+ `describe` blocks become the sidebar, each `it` is a story, each step is a frame
6
+ you can scrub through.
7
+
8
+ It reads the `.describe-me/` directory that `@describe-me/vitest`'s reporter
9
+ writes, and replays the snapshots in a sandboxed iframe.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ pnpm add -D describe-me @describe-me/vitest @describe-me/react
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```sh
20
+ describe-me dev # viewer on http://localhost:6006, live while vitest --watch runs
21
+ describe-me build --out docs # self-contained static site: viewer + __data/
22
+ ```
23
+
24
+ | Flag | Default | Meaning |
25
+ | -------- | ------------------ | --------------------------------- |
26
+ | `--data` | `.describe-me` | The directory the reporter wrote. |
27
+ | `--out` | `describe-me-dist` | Output directory for `build`. |
28
+ | `--port` | `6006` | Port for `dev`. |
29
+
30
+ The static site uses relative URLs, so it works from a sub-path such as
31
+ GitHub Pages.
32
+
33
+ See the [root README](https://github.com/grzehub/describe-me#readme) for the
34
+ full picture.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // Committed entry point, so pnpm can link the binary at install time, before
3
+ // the CLI is compiled. The real code lives in dist-cli/ (built by `pnpm build`).
4
+ import '../dist-cli/main.js'
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { parseArgs } from './parse-args.js';
2
+ import { runBuild } from './run-build.js';
3
+ import { runDev } from './run-dev.js';
4
+ const USAGE = `describe-me — living component docs from your tests
5
+
6
+ describe-me dev [--data .describe-me] [--port 6006] viewer with live updates
7
+ describe-me build [--data .describe-me] [--out describe-me-dist] static site
8
+ `;
9
+ /** Entry point of the `describe-me` binary. */
10
+ async function main() {
11
+ const options = parseArgs(process.argv.slice(2));
12
+ if (options.command === 'help') {
13
+ process.stdout.write(USAGE);
14
+ return;
15
+ }
16
+ if (options.command === 'build') {
17
+ await runBuild(options.data, options.out);
18
+ return;
19
+ }
20
+ await runDev(options.data, options.port);
21
+ }
22
+ main().catch((error) => {
23
+ process.stderr.write(`describe-me: ${error instanceof Error ? error.message : String(error)}\n`);
24
+ process.exit(1);
25
+ });
@@ -0,0 +1,12 @@
1
+ export type Command = 'dev' | 'build' | 'help';
2
+ export interface CliOptions {
3
+ command: Command;
4
+ /** Directory written by the reporter. */
5
+ data: string;
6
+ /** Output directory for `build`. */
7
+ out: string;
8
+ /** Port for `dev`. */
9
+ port: number;
10
+ }
11
+ /** Turn `describe-me <command> [--data dir] [--out dir] [--port n]` into options with defaults. */
12
+ export declare function parseArgs(argv: string[]): CliOptions;
@@ -0,0 +1,29 @@
1
+ import { parseArgs as parseNodeArgs } from 'node:util';
2
+ function commandFrom(positional, help) {
3
+ if (help || positional === undefined) {
4
+ return 'help';
5
+ }
6
+ if (positional === 'build') {
7
+ return 'build';
8
+ }
9
+ return 'dev';
10
+ }
11
+ /** Turn `describe-me <command> [--data dir] [--out dir] [--port n]` into options with defaults. */
12
+ export function parseArgs(argv) {
13
+ const { values, positionals } = parseNodeArgs({
14
+ args: argv,
15
+ allowPositionals: true,
16
+ options: {
17
+ data: { type: 'string', default: '.describe-me' },
18
+ out: { type: 'string', default: 'describe-me-dist' },
19
+ port: { type: 'string', default: '6006' },
20
+ help: { type: 'boolean', short: 'h', default: false },
21
+ },
22
+ });
23
+ return {
24
+ command: commandFrom(positionals[0], values.help),
25
+ data: values.data,
26
+ out: values.out,
27
+ port: Number(values.port),
28
+ };
29
+ }
@@ -0,0 +1,2 @@
1
+ /** Produce a self-contained static site: the viewer plus the data directory under `__data/`. */
2
+ export declare function runBuild(data: string, out: string): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import { resolve } from 'node:path';
2
+ import { build } from 'vite';
3
+ import { viewerRoot } from './viewer-root.js';
4
+ /** Produce a self-contained static site: the viewer plus the data directory under `__data/`. */
5
+ export async function runBuild(data, out) {
6
+ process.env.DESCRIBE_ME_DIR = resolve(data);
7
+ const root = viewerRoot();
8
+ await build({
9
+ root,
10
+ configFile: resolve(root, 'vite.config.ts'),
11
+ build: { outDir: resolve(out), emptyOutDir: true },
12
+ });
13
+ }
@@ -0,0 +1,2 @@
1
+ /** Start the viewer against a data directory, with live updates as the reporter rewrites it. */
2
+ export declare function runDev(data: string, port: number): Promise<void>;
@@ -0,0 +1,15 @@
1
+ import { resolve } from 'node:path';
2
+ import { createServer } from 'vite';
3
+ import { viewerRoot } from './viewer-root.js';
4
+ /** Start the viewer against a data directory, with live updates as the reporter rewrites it. */
5
+ export async function runDev(data, port) {
6
+ process.env.DESCRIBE_ME_DIR = resolve(data);
7
+ const root = viewerRoot();
8
+ const server = await createServer({
9
+ root,
10
+ configFile: resolve(root, 'vite.config.ts'),
11
+ server: { port },
12
+ });
13
+ await server.listen();
14
+ server.printUrls();
15
+ }
@@ -0,0 +1,2 @@
1
+ /** The package directory that holds `index.html`, `src/` and `vite.config.ts`. */
2
+ export declare function viewerRoot(): string;
@@ -0,0 +1,5 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ /** The package directory that holds `index.html`, `src/` and `vite.config.ts`. */
3
+ export function viewerRoot() {
4
+ return fileURLToPath(new URL('..', import.meta.url));
5
+ }
package/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>describe-me</title>
7
+ <link rel="stylesheet" href="/src/style.css" />
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.ts"></script>
12
+ </body>
13
+ </html>
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "describe-me",
3
+ "version": "0.1.0",
4
+ "description": "Living component documentation generated from your Vitest tests: viewer and CLI",
5
+ "keywords": [
6
+ "vitest",
7
+ "storybook",
8
+ "component documentation",
9
+ "visual testing",
10
+ "testing",
11
+ "cli"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Grzegorz Łotysz",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/grzehub/describe-me.git",
18
+ "directory": "packages/viewer"
19
+ },
20
+ "homepage": "https://grzehub.github.io/describe-me/",
21
+ "bugs": "https://github.com/grzehub/describe-me/issues",
22
+ "type": "module",
23
+ "engines": {
24
+ "node": "^20.19.0 || >=22.12.0"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "bin": {
30
+ "describe-me": "./bin/describe-me.js"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "dist-cli",
35
+ "index.html",
36
+ "src",
37
+ "vite.config.ts",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "sideEffects": false,
42
+ "dependencies": {
43
+ "rrweb-snapshot": "^2.1.6",
44
+ "vite": "^8.3.0",
45
+ "@describe-me/core": "^0.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22"
49
+ },
50
+ "scripts": {
51
+ "build": "rimraf dist-cli && tsc -p tsconfig.cli.json",
52
+ "dev": "node dist-cli/main.js dev --data ../../examples/react-basic/.describe-me"
53
+ }
54
+ }
@@ -0,0 +1,75 @@
1
+ import type { ComponentDoc, ManifestTest, PropDoc } from '@describe-me/core/types'
2
+
3
+ /** One value of an enumerable prop, and whether the tests in scope produced it. */
4
+ export interface CoverageEntry {
5
+ label: string
6
+ covered: boolean
7
+ isDefault: boolean
8
+ }
9
+
10
+ /** What the props table shows for one prop of one component. */
11
+ export interface PropCoverage {
12
+ prop: PropDoc
13
+ entries: CoverageEntry[]
14
+ /** Tests that passed the prop at least once. Only meaningful without entries. */
15
+ passedIn: number
16
+ testsTotal: number
17
+ }
18
+
19
+ /** The manifest keeps literal types as source text, so `'primary'` arrives quoted. */
20
+ function unquote(text: string): string {
21
+ for (const quote of ["'", '"']) {
22
+ if (text.length > 1 && text.startsWith(quote) && text.endsWith(quote)) {
23
+ return text.slice(1, -1)
24
+ }
25
+ }
26
+
27
+ return text
28
+ }
29
+
30
+ /** The props of every `render` frame of one test; frames without props are ignored. */
31
+ function renderProps(test: ManifestTest): Record<string, unknown>[] {
32
+ const passed: Record<string, unknown>[] = []
33
+ for (const frame of test.frames) {
34
+ const props = frame.kind === 'render' ? frame.meta?.props : undefined
35
+ if (props && typeof props === 'object' && !Array.isArray(props)) {
36
+ passed.push(props as Record<string, unknown>)
37
+ }
38
+ }
39
+
40
+ return passed
41
+ }
42
+
43
+ function wasPassed(passed: Record<string, unknown>[], name: string, label: string): boolean {
44
+ return passed.some((props) => name in props && String(props[name]) === label)
45
+ }
46
+
47
+ function entriesFor(prop: PropDoc, passed: Record<string, unknown>[]): CoverageEntry[] {
48
+ if (prop.kind !== 'literals' && prop.kind !== 'boolean') {
49
+ return []
50
+ }
51
+
52
+ // Omitting the prop exercises its default, so it covers the matching value.
53
+ const omitted = passed.some((props) => !(prop.name in props))
54
+
55
+ return (prop.values ?? []).map((value) => {
56
+ const label = unquote(value)
57
+ const isDefault = prop.defaultValue !== undefined && unquote(prop.defaultValue) === label
58
+ const covered = wasPassed(passed, prop.name, label) || (isDefault && omitted)
59
+
60
+ return { label, covered, isDefault }
61
+ })
62
+ }
63
+
64
+ /** How well a set of tests exercises a component's props, prop by prop. */
65
+ export function computeCoverage(doc: ComponentDoc, tests: ManifestTest[]): PropCoverage[] {
66
+ const perTest = tests.map((test) => renderProps(test))
67
+ const passed = perTest.flat()
68
+
69
+ return doc.props.map((prop) => ({
70
+ prop,
71
+ entries: entriesFor(prop, passed),
72
+ passedIn: perTest.filter((frames) => frames.some((props) => prop.name in props)).length,
73
+ testsTotal: tests.length,
74
+ }))
75
+ }
package/src/data.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { rebuildIntoSandboxedIframe } from 'rrweb-snapshot'
2
+ import type { Manifest } from '@describe-me/core/types'
3
+ import { allTests, currentTest, state } from './state.js'
4
+ import { rerender } from './rerender.js'
5
+
6
+ /** rrweb's serialized document node; the package does not re-export the type. */
7
+ type SerializedNode = Parameters<typeof rebuildIntoSandboxedIframe>[0]
8
+
9
+ const snapshotCache = new Map<string, Promise<SerializedNode>>()
10
+
11
+ /**
12
+ * Fetch the manifest and repaint, but only if it differs from the one on
13
+ * screen. Polling an unchanged static site must not re-render or drop caches.
14
+ */
15
+ export async function loadManifest(): Promise<void> {
16
+ const response = await fetch('__data/manifest.json', { cache: 'no-store' })
17
+ if (!response.ok) {
18
+ throw new Error(`manifest: ${response.status}`)
19
+ }
20
+
21
+ const next = (await response.json()) as Manifest
22
+ if (state.manifest?.generatedAt === next.generatedAt) {
23
+ return
24
+ }
25
+
26
+ state.manifest = next
27
+ snapshotCache.clear()
28
+ if (!currentTest()) {
29
+ const first = allTests(state.manifest)[0]
30
+ state.testId = first?.id ?? null
31
+ state.frame = 0
32
+ }
33
+
34
+ const test = currentTest()
35
+ if (test) {
36
+ state.frame = Math.min(state.frame, Math.max(0, test.frames.length - 1))
37
+ }
38
+
39
+ rerender()
40
+ }
41
+
42
+ /** Fetch one serialized DOM, memoized until the next manifest load. */
43
+ export function loadSnapshot(path: string): Promise<SerializedNode> {
44
+ let pending = snapshotCache.get(path)
45
+ if (!pending) {
46
+ pending = fetch(`__data/${path}`, { cache: 'no-store' }).then(
47
+ (response) => response.json() as Promise<SerializedNode>,
48
+ )
49
+
50
+ snapshotCache.set(path, pending)
51
+ }
52
+
53
+ return pending
54
+ }
package/src/el.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Terse `createElement`: attributes, event listeners (by event name), children. */
2
+ export function el<K extends keyof HTMLElementTagNameMap>(
3
+ tag: K,
4
+ attrs: Record<string, string | boolean | ((e: Event) => void)> = {},
5
+ ...children: (Node | string | null | undefined | false)[]
6
+ ): HTMLElementTagNameMap[K] {
7
+ const node = document.createElement(tag)
8
+ for (const [key, value] of Object.entries(attrs)) {
9
+ if (typeof value === 'function') {
10
+ node.addEventListener(key, value)
11
+ } else if (value === true) {
12
+ node.setAttribute(key, '')
13
+ } else if (value !== false) {
14
+ node.setAttribute(key, value)
15
+ }
16
+ }
17
+
18
+ for (const child of children) {
19
+ if (child) {
20
+ node.append(child)
21
+ }
22
+ }
23
+
24
+ return node
25
+ }
package/src/header.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { el } from './el.js'
2
+ import { allTests, state } from './state.js'
3
+
4
+ /** Wordmark, pass/fail counts and the time of the last run. */
5
+ export function renderHeader(): HTMLElement {
6
+ const manifest = state.manifest
7
+ const tests = manifest ? allTests(manifest) : []
8
+ const passed = tests.filter((test) => test.state === 'passed').length
9
+ const failed = tests.filter((test) => test.state === 'failed').length
10
+ const summary = el('span', { class: 'summary' })
11
+ summary.append(
12
+ el('b', {}, String(tests.length)),
13
+ ' tests · ',
14
+ el('b', {}, String(passed)),
15
+ ' passed',
16
+ )
17
+
18
+ if (failed) {
19
+ summary.append(' · ', el('b', { class: 'fail' }, String(failed)), ' failed')
20
+ }
21
+
22
+ const wm = el('span', { class: 'wordmark' }, 'describe', el('span', { class: 'me' }, '-me'))
23
+ const when = manifest ? new Date(manifest.generatedAt).toLocaleTimeString() : '…'
24
+ return el(
25
+ 'header',
26
+ { class: 'header' },
27
+ wm,
28
+ summary,
29
+ el('span', { class: 'right live' }, `updated ${when}`),
30
+ )
31
+ }
@@ -0,0 +1,86 @@
1
+ import { el } from './el.js'
2
+ import { currentTest, state } from './state.js'
3
+ import { valueCell } from './value-cell.js'
4
+
5
+ /** Right-hand pane: test status, component props, current frame, errors. */
6
+ export function renderInspector(): HTMLElement {
7
+ const test = currentTest()
8
+ const aside = el('aside', { class: 'inspector' })
9
+ if (!test) {
10
+ return aside
11
+ }
12
+
13
+ const frame = test.frames[state.frame]
14
+ const props =
15
+ (frame?.meta?.props as Record<string, unknown> | undefined) ?? test.component?.props ?? {}
16
+
17
+ const status = el('section', {}, el('h3', {}, 'test'))
18
+ status.append(
19
+ el(
20
+ 'div',
21
+ { class: 'status' },
22
+ el('span', { class: `dot ${test.state}` }),
23
+ test.state,
24
+ test.duration != null
25
+ ? el('span', { class: 'hint' }, `${Math.round(test.duration)}ms`)
26
+ : null,
27
+ ),
28
+ )
29
+
30
+ aside.append(status)
31
+
32
+ if (test.component) {
33
+ const table = el('table', { class: 'kv' })
34
+ for (const [key, value] of Object.entries(props)) {
35
+ table.append(el('tr', {}, el('td', {}, key), el('td', {}, valueCell(value))))
36
+ }
37
+
38
+ if (!Object.keys(props).length) {
39
+ table.append(el('tr', {}, el('td', {}, el('span', { class: 'hint' }, 'no props'))))
40
+ }
41
+
42
+ aside.append(el('section', {}, el('h3', {}, `component · ${test.component.name}`), table))
43
+ }
44
+
45
+ if (frame) {
46
+ aside.append(
47
+ el(
48
+ 'section',
49
+ {},
50
+ el('h3', {}, `frame ${state.frame + 1} / ${test.frames.length}`),
51
+ el('div', {}, frame.label),
52
+ el('div', { class: 'hint' }, `${frame.kind} · ${frame.at}ms into the test`),
53
+ ),
54
+ )
55
+ }
56
+
57
+ if (test.errors?.length) {
58
+ const sec = el('section', {}, el('h3', {}, 'errors'))
59
+ for (const error of test.errors) {
60
+ sec.append(el('pre', { class: 'err' }, error.message))
61
+ }
62
+
63
+ aside.append(sec)
64
+ }
65
+
66
+ aside.append(
67
+ el(
68
+ 'section',
69
+ {},
70
+ el(
71
+ 'div',
72
+ { class: 'hint' },
73
+ el('kbd', {}, '←'),
74
+ ' ',
75
+ el('kbd', {}, '→'),
76
+ ' frames · ',
77
+ el('kbd', {}, '↑'),
78
+ ' ',
79
+ el('kbd', {}, '↓'),
80
+ ' tests',
81
+ ),
82
+ ),
83
+ )
84
+
85
+ return aside
86
+ }
@@ -0,0 +1,58 @@
1
+ import { rerender } from './rerender.js'
2
+ import { testsInScope } from './scope.js'
3
+ import { allTests, currentTest, select, state, writeHash } from './state.js'
4
+
5
+ /** In the overview, ↑ ↓ jump into the scope: the first or the last test it holds. */
6
+ function onSuiteKey(event: KeyboardEvent, key: string): void {
7
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
8
+ return
9
+ }
10
+
11
+ const tests = testsInScope(key)
12
+ const next = event.key === 'ArrowDown' ? tests[0] : tests[tests.length - 1]
13
+ if (next) {
14
+ select(next.id)
15
+ }
16
+
17
+ event.preventDefault()
18
+ }
19
+
20
+ /** ← → step through frames, ↑ ↓ through tests. */
21
+ export function onKey(event: KeyboardEvent): void {
22
+ if (!state.manifest) {
23
+ return
24
+ }
25
+
26
+ if (state.suiteKey) {
27
+ onSuiteKey(event, state.suiteKey)
28
+ return
29
+ }
30
+
31
+ const test = currentTest()
32
+ if (!test) {
33
+ return
34
+ }
35
+
36
+ if (event.key === 'ArrowRight' && state.frame < test.frames.length - 1) {
37
+ state.frame++
38
+ writeHash()
39
+ rerender()
40
+ }
41
+
42
+ if (event.key === 'ArrowLeft' && state.frame > 0) {
43
+ state.frame--
44
+ writeHash()
45
+ rerender()
46
+ }
47
+
48
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
49
+ const tests = allTests(state.manifest)
50
+ const index = tests.findIndex((candidate) => candidate.id === test.id)
51
+ const next = tests[index + (event.key === 'ArrowDown' ? 1 : -1)]
52
+ if (next) {
53
+ select(next.id)
54
+ }
55
+
56
+ event.preventDefault()
57
+ }
58
+ }
@@ -0,0 +1,69 @@
1
+ import { el } from './el.js'
2
+ import { rerender } from './rerender.js'
3
+ import { paintFrame } from './stage.js'
4
+ import { currentTest, state, writeHash } from './state.js'
5
+
6
+ /** Breadcrumbs, viewport buttons, the replay stage and the frame timeline. */
7
+ export function renderMain(): HTMLElement {
8
+ const test = currentTest()
9
+ const frame = test?.frames[state.frame]
10
+
11
+ const crumbs = el('div', { class: 'crumbs' })
12
+ if (test) {
13
+ for (const part of test.path) {
14
+ crumbs.append(el('span', {}, part), el('span', { class: 'sep' }, '›'))
15
+ }
16
+
17
+ crumbs.append(el('span', { class: 'cur' }, test.name))
18
+ }
19
+
20
+ const tools = el('div', { class: 'tools' })
21
+ for (const width of ['auto', '768', '375'] as const) {
22
+ tools.append(
23
+ el(
24
+ 'button',
25
+ {
26
+ class: state.width === width ? 'on' : '',
27
+ click: () => {
28
+ state.width = width
29
+ rerender()
30
+ },
31
+ },
32
+ width === 'auto' ? '100%' : `${width}px`,
33
+ ),
34
+ )
35
+ }
36
+
37
+ crumbs.append(tools)
38
+
39
+ const stage = el('div', { class: 'stage' })
40
+ if (frame) {
41
+ void paintFrame(stage, frame)
42
+ } else {
43
+ stage.append(
44
+ el('div', { class: 'empty' }, test ? 'no frames recorded for this test' : 'select a test'),
45
+ )
46
+ }
47
+
48
+ const timeline = el('div', { class: 'timeline' })
49
+ test?.frames.forEach((timelineFrame, i) => {
50
+ timeline.append(
51
+ el(
52
+ 'button',
53
+ {
54
+ class: `frame ${timelineFrame.kind}${i === state.frame ? ' active' : ''}`,
55
+ click: () => {
56
+ state.frame = i
57
+ writeHash()
58
+ rerender()
59
+ },
60
+ },
61
+ el('span', { class: 'k' }, timelineFrame.kind),
62
+ el('span', {}, timelineFrame.label),
63
+ el('span', { class: 't' }, `${timelineFrame.at}ms`),
64
+ ),
65
+ )
66
+ })
67
+
68
+ return el('main', { class: 'main' }, crumbs, stage, timeline)
69
+ }