anything with a clock that plays: its length and its chapters, without React
npx shadcn add @ag/playableAlready installed? Preview an update
npx shadcn add @ag/playable --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-11-05
- Changed (breaking):
clipisplayable(swift-utils' words: a playable is anything with a clock that plays, a clip a part of one file's time):lib/clip'sClipislib/playable'sPlayable, andSpanlives in its own item,span. Every driver takesplayable=where it tookclip=(Playback,Player,DisplayPlayback), and every timeline that made one names it.playable(sessionTimeline,sceneClock,codeTimeline,browserTimeline,walkthroughTimeline). Migration:shadcn add @ag/playable, deleteclip.ts, importPlayableand the outline fromlib/playableandSpanfromlib/span, renameclip=and.clip.
shelf motion
uses span
used by video, walkthrough, walkthrough-session, glass-display, terminal-session, code-session, playback, player, timeline, macos-session, browser, telegram-chat, telegram-summary
chapters and an instant
- 0–678 ms awake grant
- 678–2118 ms awake grant --remove
- progressAt(playable, 1000) = 0.472
the demo above, verbatim
import { Sample } from "@/app/samples"
import { progressAt, spans } from "@/registry/base-nova/lib/playable"
import {
parseSession,
sessionTimeline,
} from "@/registry/base-nova/lib/terminal-session"
// #region playable
const PLAYABLE = sessionTimeline(
parseSession(`$ awake grant
~ macOS asks once, natively
@600 $ awake grant --remove
~ takes it back`),
).playable
// #endregion
/** A terminal session's playable: its length, a chapter per command, and an instant
* as the progress its component draws. */
export default function PlayableDemo() {
return (
<Sample
name="playable"
title="a session's playable"
description="chapters and an instant"
with="playable"
>
<ul className="space-y-1 font-mono text-xs">
{spans(PLAYABLE).map((c) => (
<li key={c.start}>
<span className="text-muted-foreground tabular-nums">
{c.start}–{c.end} ms
</span>{" "}
{c.title}
</li>
))}
<li className="text-muted-foreground">
progressAt(playable, 1000) = {progressAt(PLAYABLE, 1000).toFixed(3)}
</li>
</ul>
</Sample>
)
}the source, every file an install writes
// A PLAYABLE: anything with a clock that plays (swift-utils' WORDS.md: a file, a clip,
// a sequence, a scene, or content an app draws itself), told as the facts a driver
// moves through it by: how long it runs, how it is cut, and (when it is told in
// frames) its rate. The content's own lib makes its playable (macos-session's scene
// clock, terminal-session's session timeline); a driver moves through it (the player
// by the clock, a scroll stage by scroll, a still by one fixed instant); the component
// draws the frame at a progress. One contract, so any content plays in any driver.
// Framework-free.
//
// The words, each with one meaning: an INSTANT is a point in time (ms); a SPAN is a
// stretch `{from, to}` (ms, lib/span); the OUTLINE is how the playable is cut, coarse
// to fine; a LEVEL is one depth of it (chapter, scene, shot); a DIVISION is one item
// of a level, written the concise way, a start and a title, running to the next
// division's start. Chapters are the outline's first level. Nesting is never stored:
// a scene's chapter is the chapter containing its start, so a level that cuts across
// another breaks nothing.
import type { Span } from "@/registry/base-nova/lib/span"
/** One item of a level: where it starts (ms) and its title. It runs to the next
* division's start, the last to the end. */
export interface Division {
start: number
title: string
/** The title is code, as typed (a terminal's command): drawn in mono. */
code?: boolean
}
/** A division of the outline's first level. */
export type Chapter = Division
/** One depth of the outline: its name (`chapter`, `scene`, `shot`) and its divisions,
* ordered by start. */
export interface Level {
name: string
divisions: Division[]
}
/** How a playable is cut, coarse to fine. */
export type Outline = Level[]
export interface Playable {
/** ms */
duration: number
/** The outline's first level, the one a bar divides into. */
chapters: Chapter[]
/** Finer levels below the chapters, coarse to fine (scenes, shots), when known. */
levels?: Level[]
/** Frames per second, for a playable told in frames (two copies of a film compared
* frame by frame): its timebase steps by them. */
fps?: number
}
/** The playable's whole outline: its chapters as the first level, then any finer
* ones. A playable with no chapters is one level of one division, so every outline
* has a first level to divide a bar into. */
export function outlineOf(playable: Playable): Outline {
const chapters: Level = {
name: "chapter",
divisions: playable.chapters.length
? playable.chapters
: [{ start: 0, title: "" }],
}
return [chapters, ...(playable.levels ?? [])]
}
/** An instant of the playable (ms) as the 0..1 progress its component draws. */
export function progressAt(playable: Playable, ms: number): number {
return Math.min(1, Math.max(0, ms / playable.duration))
}
/** Chapters `first` through `last` as one span: from the first's start to the last's
* end. An act that tells messages 4 and 5 of a chat is `chapterSpan(playable, 4, 5)`. */
export function chapterSpan(
playable: Playable,
first: number,
last = first,
): Span {
const all = spans(playable)
const a = all[first]
const b = all[last]
if (!a || !b || last < first)
throw new Error(
`chapterSpan: chapters ${first}..${last} outside 0..${all.length - 1}`,
)
return { from: a.start, to: b.end }
}
/** A local 0..1 across a span, as the progress of the whole playable: a scrubbed card
* that tells only part of its playable. */
export function progressWithin(
playable: Playable,
span: Span,
local: number,
): number {
const t = Math.min(1, Math.max(0, local))
return progressAt(playable, span.from + (span.to - span.from) * t)
}
/** A level's divisions with their ends, the last running to `duration`. */
export function divisionSpans(
level: Level,
duration: number,
): (Division & { end: number })[] {
const list = level.divisions.length
? level.divisions
: [{ start: 0, title: "" }]
return list.map((d, i) => ({ ...d, end: list[i + 1]?.start ?? duration }))
}
/** The division of a level containing `ms`. */
export function divisionAt(
level: Level,
duration: number,
ms: number,
): (Division & { end: number }) | undefined {
const all = divisionSpans(level, duration)
return all.findLast((d) => ms >= d.start) ?? all[0]
}
/** The chapters with their ends; a playable without chapters is one whole span. */
export function spans(playable: Playable): (Chapter & { end: number })[] {
return divisionSpans(
{ name: "chapter", divisions: playable.chapters },
playable.duration,
)
}