From 628606d565a8bbfdec9a71d1135bd9ef165d08c2 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 29 Oct 2026 15:47:46 +0100 Subject: [PATCH 1/7] refactor(studio): move the chart card's dataset builders to chartDatasets.ts barDatasets, lineDatasets, barCap and legendShown move out of ChartCard.tsx unchanged, so code outside the card can build the same Chart.js datasets. motion-kit draws a chart card's plot from them. --- .../Studio/canvas/cards/ChartCard.tsx | 117 ++++-------------- .../Studio/canvas/chart/chartDatasets.ts | 116 +++++++++++++++++ 2 files changed, 140 insertions(+), 93 deletions(-) create mode 100644 src/components/organisms/Studio/canvas/chart/chartDatasets.ts diff --git a/src/components/organisms/Studio/canvas/cards/ChartCard.tsx b/src/components/organisms/Studio/canvas/cards/ChartCard.tsx index b2c4e06..d2c9a4b 100644 --- a/src/components/organisms/Studio/canvas/cards/ChartCard.tsx +++ b/src/components/organisms/Studio/canvas/cards/ChartCard.tsx @@ -1,15 +1,14 @@ 'use client'; -import type { ScriptableContext } from 'chart.js'; import classNames from 'classnames'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Bar, Line } from 'react-chartjs-2'; +import { barDatasets, legendShown, lineDatasets } from '../chart/chartDatasets'; import { buildChartOptions, checkerPattern, type RowsLayout, - seriesPair, seriesVars, } from '../chart/chartSetup'; import cardStyles from './Card.module.scss'; @@ -21,14 +20,7 @@ import { cardEdges, type Fit, useFittedRows, useFontsReady } from './useFittedRo import type { ChartWidget } from '@/lib/chat/widgets'; import { chartAlt } from '@/lib/studio/chartAlt'; -import { - BAR_THICKNESS, - CARD_BORDERS, - fittedRows, - plotHeight, - plotShape, -} from '@/lib/studio/chartFit'; -import { lonePoints } from '@/lib/studio/linePoints'; +import { CARD_BORDERS, fittedRows, plotHeight, plotShape } from '@/lib/studio/chartFit'; import { legendLabels } from '@/lib/studio/seriesLabels'; import type { ThemeDerived } from '@/lib/studio/theme'; @@ -164,90 +156,33 @@ const ChartCard = ({ }), [widget, scale, theme, rows, fontsReady, onLayout], ); - // A ranking's bars keep one thickness under their names. - const barCap = (rows ? BAR_THICKNESS : 28) * scale; const barData = useMemo( () => ({ labels: widget.categories, - datasets: widget.series.map((s, si) => ({ - label: s.label, - data: s.values, - hidden: hiddenSeries.includes(si), - // A transposed comparison runs as one series, so every bar - // takes the accent: colour would otherwise repeat the name - // the category axis already carries. - backgroundColor: () => checkerPattern(seriesPair(theme.series, si), scale), - // Hover resolves the weave into the solid base color. - hoverBackgroundColor: seriesPair(theme.series, si)[0], - // The cap is a fixed fraction of the bar's width — clearly - // rounded ("half-square") at every thickness, never a full - // bulb. Bar elements aren't measured when options resolve, - // so the width is derived from the scale: the category slot - // shared by the visible datasets, with Chart.js's default - // 0.8/0.9 category/bar percentages. - borderRadius: (ctx: ScriptableContext<'bar'>) => { - const { chart } = ctx; - // The category slot is measured across the index axis, - // which is y once the chart is transposed. - const indexScale = chart.scales[widget.horizontal ? 'y' : 'x']; - const visible = chart.data.datasets.filter((_, i) => - chart.isDatasetVisible(i), - ).length; - const categories = chart.data.labels?.length || 1; - - if (!indexScale || !visible) { - return 4 * scale; - } - - const span = widget.horizontal ? indexScale.height : indexScale.width; - const slot = (span / categories) * 0.8; - const width = Math.min((slot / visible) * 0.9, barCap); - - return Math.max(1, width / 3); - }, - borderSkipped: (widget.horizontal ? 'left' : 'bottom') as 'left' | 'bottom', - maxBarThickness: barCap, - })), + datasets: barDatasets({ + widget, + scale, + theme, + hidden: hiddenSeries, + fill: (pair) => checkerPattern(pair, scale), + rows, + }), }), - [widget, scale, hiddenSeries, theme, barCap], + [widget, scale, hiddenSeries, theme, rows], ); - const lineData = useMemo(() => { - const dotRadius = 3 * scale; - const hoverRadius = 3.5 * scale; - const borderWidth = 2.5 * scale; - // Chart.js resolves a hovered point's ring from pointHoverBorderWidth, - // then hoverBorderWidth, whose point default is 1. - const hoverBorderWidth = 1; - // Chart.js sizes the clip margin from the first and last points only, - // so an interior dot on an axis limit would be cut; clip to the outer - // edge of the lone dot or of the hover disc, whichever is larger. - const clip = Math.max(dotRadius + borderWidth / 2, hoverRadius + hoverBorderWidth / 2); - - return { + const lineData = useMemo( + () => ({ labels: widget.categories, - datasets: widget.series.map((s, si) => ({ - label: s.label, - data: s.values, - hidden: hiddenSeries.includes(si), - borderColor: seriesPair(theme.series, si)[0], - // A missing value breaks the line, so a value with a gap on - // both sides has no segment. It gets a dot, which Chart.js - // fills from the dataset colours below, like the hover disc. - pointRadius: lonePoints(s.values).map((lone) => (lone ? dotRadius : 0)), - // The point fill (hover disc and lone dots): the checkered - // weave of the bars. The tooltip swatch comes from seriesPalette. - backgroundColor: () => checkerPattern(seriesPair(theme.series, si), scale), - borderWidth, - // Round joins keep a sharp dip or peak at an axis limit within - // half the line width, inside the clip margin. - borderJoinStyle: 'round' as const, - pointHoverRadius: hoverRadius, - pointHoverBorderWidth: hoverBorderWidth, - pointHitRadius: 8 * scale, - clip, - })), - }; - }, [widget, scale, hiddenSeries, theme]); + datasets: lineDatasets({ + widget, + scale, + theme, + hidden: hiddenSeries, + fill: (pair) => checkerPattern(pair, scale), + }), + }), + [widget, scale, hiddenSeries, theme], + ); // What a screen reader is told the canvas shows: Chart.js marks it // role="img" and leaves it unnamed, and the card's title is a separate @@ -259,11 +194,7 @@ const ChartCard = ({ // away (QA-57). const names = useMemo(() => legendLabels(widget.series.map((s) => s.label)), [widget]); - // A legend tells series apart, so a chart of one series has none (the - // title names it), and neither has a transposed comparison, whose one - // series is the period already in the title. - const showLegend = - widget.series.length > 1 && widget.series.some((s) => s.label) && !widget.horizontal; + const showLegend = legendShown(widget); return (
diff --git a/src/components/organisms/Studio/canvas/chart/chartDatasets.ts b/src/components/organisms/Studio/canvas/chart/chartDatasets.ts new file mode 100644 index 0000000..12b0a85 --- /dev/null +++ b/src/components/organisms/Studio/canvas/chart/chartDatasets.ts @@ -0,0 +1,116 @@ +import type { ScriptableContext } from 'chart.js'; + +import { seriesPair } from './chartSetup'; + +import type { ChartWidget } from '@/lib/chat/widgets'; +import { BAR_THICKNESS } from '@/lib/studio/chartFit'; +import { lonePoints } from '@/lib/studio/linePoints'; +import type { SeriesPair, ThemeDerived } from '@/lib/studio/theme'; + +/** + * A chart card's Chart.js datasets, bar and line. The fill of bars and of + * line points is the checkered weave, which needs a DOM canvas in the + * browser, so the caller hands in how a series pair becomes a fill. + */ + +type DatasetInputs = { + widget: ChartWidget; + scale: number; + theme: ThemeDerived; + // Series the legend has parked, by index. + hidden: readonly number[]; + fill: (pair: SeriesPair) => CanvasPattern | string; +}; + +// A legend tells series apart, so a chart of one series has none (the +// title names it), and neither has a transposed comparison, whose one +// series is the period already in the title. +export const legendShown = (widget: ChartWidget): boolean => + widget.series.length > 1 && widget.series.some((s) => s.label) && !widget.horizontal; + +// A ranking's bars keep one thickness under their names. +export const barCap = (rows: boolean, scale: number): number => (rows ? BAR_THICKNESS : 28) * scale; + +export const barDatasets = ({ + widget, + scale, + theme, + hidden, + fill, + rows, +}: DatasetInputs & { rows: boolean }) => { + const cap = barCap(rows, scale); + + return widget.series.map((s, si) => ({ + label: s.label, + data: s.values, + hidden: hidden.includes(si), + // A transposed comparison runs as one series, so every bar + // takes the accent: colour would otherwise repeat the name + // the category axis already carries. + backgroundColor: () => fill(seriesPair(theme.series, si)), + // Hover resolves the weave into the solid base color. + hoverBackgroundColor: seriesPair(theme.series, si)[0], + // The cap is a fixed fraction of the bar's width — clearly + // rounded ("half-square") at every thickness, never a full + // bulb. Bar elements aren't measured when options resolve, + // so the width is derived from the scale: the category slot + // shared by the visible datasets, with Chart.js's default + // 0.8/0.9 category/bar percentages. + borderRadius: (ctx: ScriptableContext<'bar'>) => { + const { chart } = ctx; + // The category slot is measured across the index axis, + // which is y once the chart is transposed. + const indexScale = chart.scales[widget.horizontal ? 'y' : 'x']; + const visible = chart.data.datasets.filter((_, i) => chart.isDatasetVisible(i)).length; + const categories = chart.data.labels?.length || 1; + + if (!indexScale || !visible) { + return 4 * scale; + } + + const span = widget.horizontal ? indexScale.height : indexScale.width; + const slot = (span / categories) * 0.8; + const width = Math.min((slot / visible) * 0.9, cap); + + return Math.max(1, width / 3); + }, + borderSkipped: (widget.horizontal ? 'left' : 'bottom') as 'left' | 'bottom', + maxBarThickness: cap, + })); +}; + +export const lineDatasets = ({ widget, scale, theme, hidden, fill }: DatasetInputs) => { + const dotRadius = 3 * scale; + const hoverRadius = 3.5 * scale; + const borderWidth = 2.5 * scale; + // Chart.js resolves a hovered point's ring from pointHoverBorderWidth, + // then hoverBorderWidth, whose point default is 1. + const hoverBorderWidth = 1; + // Chart.js sizes the clip margin from the first and last points only, + // so an interior dot on an axis limit would be cut; clip to the outer + // edge of the lone dot or of the hover disc, whichever is larger. + const clip = Math.max(dotRadius + borderWidth / 2, hoverRadius + hoverBorderWidth / 2); + + return widget.series.map((s, si) => ({ + label: s.label, + data: s.values, + hidden: hidden.includes(si), + borderColor: seriesPair(theme.series, si)[0], + // A missing value breaks the line, so a value with a gap on + // both sides has no segment. It gets a dot, which Chart.js + // fills from the dataset colours below, like the hover disc. + pointRadius: lonePoints(s.values).map((lone) => (lone ? dotRadius : 0)), + // The point fill (hover disc and lone dots): the checkered + // weave of the bars. The tooltip swatch comes from seriesPalette. + backgroundColor: () => fill(seriesPair(theme.series, si)), + borderWidth, + // Round joins keep a sharp dip or peak at an axis limit within + // half the line width, inside the clip margin. + borderJoinStyle: 'round' as const, + pointHoverRadius: hoverRadius, + pointHoverBorderWidth: hoverBorderWidth, + pointHitRadius: 8 * scale, + clip, + })); +}; From 18bab3fac52791af097f84802efa3eeaac4a3123 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 29 Oct 2026 15:47:46 +0100 Subject: [PATCH 2/7] feat(motion-kit): render the Studio and capture site pages as Claude Motion films pnpm motion-kit renders a Studio workspace from the app's own components (Header, WorkspaceToolbar, ChatPanel with PlanCard and ApprovedPlanPill, the card components) as one static HTML fragment and its scoped CSS, with each chart drawn by Chart.js into SVG. pnpm motion-kit capture drives Chrome through the running site and snapshots each state of any route as inert HTML and CSS. The manifest records the app's own keyframes and transitions, read in Chrome. A cue sheet beside the spec times the film: typing that grows the prompt as PromptForm does, streamed replies, the plan approved into its pill, cards arriving with their own keyframes, series drawing, and a camera that centres on named hooks. runtime/dfo-motion.js plays it as a pure function of time, restoring each element's own style every frame. Every run writes the Motion store documents and a preview.html that plays the film from disk. The generator refuses a cue, spec or module a film cannot play: a missing hook, state or keyframe, a field of the wrong type, a store document over 200 KiB, an outside URL, a media query it cannot decide. motion-kit/demos/kenya-debt-health.json is the worked example, and motion-kit/README.md documents the specs, cues and outputs. --- motion-kit/README.md | 279 ++++++++ motion-kit/browser.ts | 22 + motion-kit/build.ts | 496 +++++++++++++ motion-kit/capture.ts | 308 ++++++++ motion-kit/chart.tsx | 388 ++++++++++ motion-kit/cli.ts | 59 ++ motion-kit/css.ts | 330 +++++++++ motion-kit/cues.ts | 245 +++++++ motion-kit/demos/kenya-debt-health.cues.json | 199 ++++++ motion-kit/demos/kenya-debt-health.json | 510 +++++++++++++ motion-kit/fontMetrics.ts | 214 ++++++ motion-kit/html.ts | 143 ++++ motion-kit/motion.ts | 386 ++++++++++ motion-kit/pageCss.ts | 207 ++++++ motion-kit/pages/ids-source.json | 20 + motion-kit/pages/kenya-debt-search.json | 23 + motion-kit/preview.ts | 153 ++++ motion-kit/recorder.ts | 449 ++++++++++++ motion-kit/render.tsx | 708 +++++++++++++++++++ motion-kit/runtime/dfo-motion.js | 584 +++++++++++++++ motion-kit/snapshot.ts | 563 +++++++++++++++ motion-kit/spec.ts | 372 ++++++++++ motion-kit/stateClasses.ts | 158 +++++ motion-kit/stubs/next-link.tsx | 20 + motion-kit/stubs/next-navigation.ts | 19 + motion-kit/stubs/server-only.ts | 2 + motion-kit/test/capture.test.ts | 391 ++++++++++ motion-kit/test/motion-kit.test.ts | 571 +++++++++++++++ package.json | 4 +- pnpm-lock.yaml | 10 + 30 files changed, 7832 insertions(+), 1 deletion(-) create mode 100644 motion-kit/README.md create mode 100644 motion-kit/browser.ts create mode 100644 motion-kit/build.ts create mode 100644 motion-kit/capture.ts create mode 100644 motion-kit/chart.tsx create mode 100644 motion-kit/cli.ts create mode 100644 motion-kit/css.ts create mode 100644 motion-kit/cues.ts create mode 100644 motion-kit/demos/kenya-debt-health.cues.json create mode 100644 motion-kit/demos/kenya-debt-health.json create mode 100644 motion-kit/fontMetrics.ts create mode 100644 motion-kit/html.ts create mode 100644 motion-kit/motion.ts create mode 100644 motion-kit/pageCss.ts create mode 100644 motion-kit/pages/ids-source.json create mode 100644 motion-kit/pages/kenya-debt-search.json create mode 100644 motion-kit/preview.ts create mode 100644 motion-kit/recorder.ts create mode 100644 motion-kit/render.tsx create mode 100644 motion-kit/runtime/dfo-motion.js create mode 100644 motion-kit/snapshot.ts create mode 100644 motion-kit/spec.ts create mode 100644 motion-kit/stateClasses.ts create mode 100644 motion-kit/stubs/next-link.tsx create mode 100644 motion-kit/stubs/next-navigation.ts create mode 100644 motion-kit/stubs/server-only.ts create mode 100644 motion-kit/test/capture.test.ts create mode 100644 motion-kit/test/motion-kit.test.ts diff --git a/motion-kit/README.md b/motion-kit/README.md new file mode 100644 index 0000000..ca1e992 --- /dev/null +++ b/motion-kit/README.md @@ -0,0 +1,279 @@ +# motion-kit + +motion-kit renders the DFO Studio workspace, and captures any other page of the site, as static HTML and CSS for Claude Motion. A Motion film is a 1920×1080 sandboxed stage that builds its DOM from text modules and animates it as a function of time, with no React, no ` + +`; +}; diff --git a/motion-kit/recorder.ts b/motion-kit/recorder.ts new file mode 100644 index 0000000..c163f74 --- /dev/null +++ b/motion-kit/recorder.ts @@ -0,0 +1,449 @@ +/** + * A 2D canvas context that draws nothing and writes down what it was asked + * to draw: every stroked or filled path as SVG path data in canvas px, and + * every piece of text with its place, angle, font and alignment. Chart.js + * draws into it, so the SVG built from the record is Chart.js's own layout. + * + * It covers the part of CanvasRenderingContext2D that Chart.js's bar and + * line charts use. Clipping is recorded as nothing: the shapes Chart.js clips + * already sit inside the plot. + */ + +type Matrix = [a: number, b: number, c: number, d: number, e: number, f: number]; + +const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0]; + +type Style = { + matrix: Matrix; + fillStyle: unknown; + strokeStyle: unknown; + lineWidth: number; + lineCap: CanvasLineCap; + lineJoin: CanvasLineJoin; + lineDash: number[]; + globalAlpha: number; + font: string; + textAlign: CanvasTextAlign; + textBaseline: CanvasTextBaseline; +}; + +export type Shape = { + kind: 'fill' | 'stroke'; + d: string; + // The fill or stroke as Chart.js set it: a colour, or whatever the + // dataset's fill callback returned. + paint: unknown; + lineWidth: number; + lineCap: CanvasLineCap; + lineJoin: CanvasLineJoin; + lineDash: number[]; + alpha: number; + // The drawn length of a stroked path, every subpath summed. + length: number; + // Which dataset was drawing, or null for the scales. + dataset: number | null; +}; + +export type Text = { + text: string; + x: number; + y: number; + // Radians, clockwise, as the context was rotated. + rotation: number; + font: string; + color: unknown; + align: CanvasTextAlign; + baseline: CanvasTextBaseline; +}; + +/** A pattern made from a recorded tile: what `createPattern` hands back. Its + * string tag is the one Chart.js checks for to leave a fill alone. */ +export class RecordedPattern { + readonly width: number; + readonly height: number; + readonly shapes: Shape[]; + + constructor(width: number, height: number, shapes: Shape[]) { + this.width = width; + this.height = height; + this.shapes = shapes; + } + + get [Symbol.toStringTag](): string { + return 'CanvasPattern'; + } +} + +const round = (value: number) => Math.round(value * 100) / 100; + +const multiply = (m: Matrix, n: Matrix): Matrix => [ + m[0] * n[0] + m[2] * n[1], + m[1] * n[0] + m[3] * n[1], + m[0] * n[2] + m[2] * n[3], + m[1] * n[2] + m[3] * n[3], + m[0] * n[4] + m[2] * n[5] + m[4], + m[1] * n[4] + m[3] * n[5] + m[5], +]; + +type Point = { x: number; y: number }; + +export class RecordingContext { + canvas: unknown; + shapes: Shape[] = []; + texts: Text[] = []; + // Set by the drawing plugin around each dataset's draw. + dataset: number | null = null; + + private state: Style = { + matrix: IDENTITY, + fillStyle: '#000', + strokeStyle: '#000', + lineWidth: 1, + lineCap: 'butt', + lineJoin: 'miter', + lineDash: [], + globalAlpha: 1, + font: '10px sans-serif', + textAlign: 'start', + textBaseline: 'alphabetic', + }; + private stack: Style[] = []; + private path: string[] = []; + private length = 0; + private current: Point | null = null; + private start: Point | null = null; + + // Properties Chart.js sets but the record has no use for. + lineDashOffset = 0; + miterLimit = 10; + globalCompositeOperation = 'source-over'; + imageSmoothingEnabled = true; + shadowBlur = 0; + shadowColor = 'transparent'; + shadowOffsetX = 0; + shadowOffsetY = 0; + direction: CanvasDirection = 'inherit'; + + private readonly measure: (font: string, text: string) => number; + + constructor(measure: (font: string, text: string) => number) { + this.measure = measure; + } + + clear(): void { + this.shapes = []; + this.texts = []; + } + + // --- state -------------------------------------------------------------- + + save(): void { + this.stack.push({ ...this.state, lineDash: [...this.state.lineDash] }); + } + + restore(): void { + this.state = this.stack.pop() ?? this.state; + } + + get fillStyle(): unknown { + return this.state.fillStyle; + } + + set fillStyle(value: unknown) { + this.state.fillStyle = value; + } + + get strokeStyle(): unknown { + return this.state.strokeStyle; + } + + set strokeStyle(value: unknown) { + this.state.strokeStyle = value; + } + + get lineWidth(): number { + return this.state.lineWidth; + } + + set lineWidth(value: number) { + this.state.lineWidth = value; + } + + get lineCap(): CanvasLineCap { + return this.state.lineCap; + } + + set lineCap(value: CanvasLineCap) { + this.state.lineCap = value; + } + + get lineJoin(): CanvasLineJoin { + return this.state.lineJoin; + } + + set lineJoin(value: CanvasLineJoin) { + this.state.lineJoin = value; + } + + get globalAlpha(): number { + return this.state.globalAlpha; + } + + set globalAlpha(value: number) { + this.state.globalAlpha = value; + } + + get font(): string { + return this.state.font; + } + + set font(value: string) { + this.state.font = value; + } + + get textAlign(): CanvasTextAlign { + return this.state.textAlign; + } + + set textAlign(value: CanvasTextAlign) { + this.state.textAlign = value; + } + + get textBaseline(): CanvasTextBaseline { + return this.state.textBaseline; + } + + set textBaseline(value: CanvasTextBaseline) { + this.state.textBaseline = value; + } + + setLineDash(segments: number[]): void { + this.state.lineDash = [...segments]; + } + + getLineDash(): number[] { + return [...this.state.lineDash]; + } + + // --- transform ---------------------------------------------------------- + + setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void { + this.state.matrix = [a, b, c, d, e, f]; + } + + resetTransform(): void { + this.state.matrix = IDENTITY; + } + + translate(x: number, y: number): void { + this.state.matrix = multiply(this.state.matrix, [1, 0, 0, 1, x, y]); + } + + rotate(angle: number): void { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + this.state.matrix = multiply(this.state.matrix, [cos, sin, -sin, cos, 0, 0]); + } + + scale(x: number, y: number): void { + this.state.matrix = multiply(this.state.matrix, [x, 0, 0, y, 0, 0]); + } + + private apply(x: number, y: number): Point { + const [a, b, c, d, e, f] = this.state.matrix; + + return { x: a * x + c * y + e, y: b * x + d * y + f }; + } + + // --- paths -------------------------------------------------------------- + + beginPath(): void { + this.path = []; + this.length = 0; + this.current = null; + this.start = null; + } + + moveTo(x: number, y: number): void { + const point = this.apply(x, y); + + this.path.push(`M${round(point.x)} ${round(point.y)}`); + this.current = point; + this.start = point; + } + + lineTo(x: number, y: number): void { + if (!this.current) { + this.moveTo(x, y); + + return; + } + + const point = this.apply(x, y); + + this.path.push(`L${round(point.x)} ${round(point.y)}`); + this.length += Math.hypot(point.x - this.current.x, point.y - this.current.y); + this.current = point; + } + + closePath(): void { + if (this.current && this.start) { + this.path.push('Z'); + this.length += Math.hypot(this.start.x - this.current.x, this.start.y - this.current.y); + this.current = this.start; + } + } + + rect(x: number, y: number, w: number, h: number): void { + this.moveTo(x, y); + this.lineTo(x + w, y); + this.lineTo(x + w, y + h); + this.lineTo(x, y + h); + this.closePath(); + } + + arc(x: number, y: number, r: number, from: number, to: number, ccw = false): void { + const [a, b, c, d] = this.state.matrix; + const radius = r * Math.sqrt(Math.abs(a * d - b * c)); + const at = (angle: number) => this.apply(x + r * Math.cos(angle), y + r * Math.sin(angle)); + let sweep = ccw ? from - to : to - from; + + if (sweep >= 2 * Math.PI) { + sweep = 2 * Math.PI; + } else { + sweep = ((sweep % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + } + + const first = at(from); + + if (this.current) { + this.lineTo(x + r * Math.cos(from), y + r * Math.sin(from)); + } else { + this.path.push(`M${round(first.x)} ${round(first.y)}`); + this.current = first; + this.start = first; + } + + if (sweep === 0 || radius === 0) { + return; + } + + // SVG cannot draw a whole circle as one arc, so every arc is drawn in + // halves or less. + const steps = Math.ceil(sweep / Math.PI); + const direction = ccw ? -1 : 1; + + for (let i = 1; i <= steps; i++) { + const angle = from + (direction * sweep * i) / steps; + const point = at(angle); + + this.path.push( + `A${round(radius)} ${round(radius)} 0 0 ${ccw ? 0 : 1} ${round(point.x)} ${round(point.y)}`, + ); + this.current = point; + } + + this.length += radius * sweep; + } + + // --- painting ----------------------------------------------------------- + + private record(kind: Shape['kind']): void { + if (!this.path.length) { + return; + } + + this.shapes.push({ + kind, + d: this.path.join(''), + paint: kind === 'fill' ? this.state.fillStyle : this.state.strokeStyle, + lineWidth: this.state.lineWidth, + lineCap: this.state.lineCap, + lineJoin: this.state.lineJoin, + lineDash: [...this.state.lineDash], + alpha: this.state.globalAlpha, + length: round(this.length), + dataset: this.dataset, + }); + } + + stroke(): void { + this.record('stroke'); + } + + fill(): void { + this.record('fill'); + } + + clip(): void {} + + fillRect(x: number, y: number, w: number, h: number): void { + const saved = { path: this.path, length: this.length, current: this.current }; + + this.beginPath(); + this.rect(x, y, w, h); + this.record('fill'); + Object.assign(this, saved); + } + + strokeRect(x: number, y: number, w: number, h: number): void { + const saved = { path: this.path, length: this.length, current: this.current }; + + this.beginPath(); + this.rect(x, y, w, h); + this.record('stroke'); + Object.assign(this, saved); + } + + clearRect(): void {} + + // --- text --------------------------------------------------------------- + + fillText(text: string, x: number, y: number): void { + const [a, b] = this.state.matrix; + const point = this.apply(x, y); + + this.texts.push({ + text, + x: round(point.x), + y: round(point.y), + rotation: Math.atan2(b, a), + font: this.state.font, + color: this.state.fillStyle, + align: this.state.textAlign, + baseline: this.state.textBaseline, + }); + } + + strokeText(): void {} + + createPattern(image: RecordingCanvas): RecordedPattern { + return new RecordedPattern(image.width, image.height, [...image.context.shapes]); + } + + measureText(text: string): TextMetrics { + const width = this.measure(this.state.font, text); + + return { width, actualBoundingBoxLeft: 0, actualBoundingBoxRight: width } as TextMetrics; + } +} + +/** A canvas element whose 2D context is a recording one. */ +export class RecordingCanvas { + width: number; + height: number; + style: Record = {}; + readonly context: RecordingContext; + + constructor(width: number, height: number, measure: (font: string, text: string) => number) { + this.width = width; + this.height = height; + this.context = new RecordingContext(measure); + this.context.canvas = this; + } + + getContext(): RecordingContext { + return this.context; + } +} diff --git a/motion-kit/render.tsx b/motion-kit/render.tsx new file mode 100644 index 0000000..b469ee2 --- /dev/null +++ b/motion-kit/render.tsx @@ -0,0 +1,708 @@ +import classNames from 'classnames'; +import type { CSSProperties } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { type ChartLayout, layoutChart, renderPlot, type SeriesPath } from './chart.tsx'; +import { outfitMeasure } from './fontMetrics.ts'; +import { addAttributes, type Element, hasClass, parseMarkup, walk } from './html.ts'; +import { CELL, DEFAULT_VIEWPORT, type DemoSpec, WRAPPER } from './spec.ts'; + +import Button from '@/components/atoms/Button/Button'; +import buttonStyles from '@/components/atoms/Button/Button.module.scss'; +import GridPattern from '@/components/atoms/GridPattern/GridPattern'; +import segmentedStyles from '@/components/molecules/SegmentedTabs/SegmentedTabs.module.scss'; +import Header from '@/components/organisms/Header/Header'; +import cardStyles from '@/components/organisms/Studio/canvas/cards/Card.module.scss'; +import CardBody from '@/components/organisms/Studio/canvas/cards/CardBody'; +import CardChrome from '@/components/organisms/Studio/canvas/cards/CardChrome'; +import CardHead from '@/components/organisms/Studio/canvas/cards/CardHead'; +import chartStyles from '@/components/organisms/Studio/canvas/cards/ChartCard.module.scss'; +import type { CardExtras } from '@/components/organisms/Studio/canvas/cards/types'; +import { legendShown } from '@/components/organisms/Studio/canvas/chart/chartDatasets'; +import { seriesVars } from '@/components/organisms/Studio/canvas/chart/chartSetup'; +import { + effectiveKind, + GAP, + heightIsDerived, + resolveCardView, + spawnSpan, + widgetTitle, +} from '@/components/organisms/Studio/canvas/layout'; +import boardStyles from '@/components/organisms/Studio/canvas/Whiteboard.module.scss'; +import type { useChat } from '@/components/organisms/Studio/hooks/useChat'; +import studioStyles from '@/components/organisms/Studio/Studio.module.scss'; +import activityStyles from '@/components/organisms/Studio/workspace/ActivityLog.module.scss'; +import pillStyles from '@/components/organisms/Studio/workspace/ApprovedPlanPill.module.scss'; +import ChatPanel from '@/components/organisms/Studio/workspace/ChatPanel'; +import chatStyles from '@/components/organisms/Studio/workspace/ChatPanel.module.scss'; +import barStyles from '@/components/organisms/Studio/workspace/PlanApproveBar.module.scss'; +import planStyles from '@/components/organisms/Studio/workspace/PlanCard.module.scss'; +import WorkspaceToolbar from '@/components/organisms/Studio/workspace/WorkspaceToolbar'; +import type { ChartWidget, Widget } from '@/lib/chat/widgets'; +import { cardRefOf } from '@/lib/studio/cardDownload'; +import { legendLabels } from '@/lib/studio/seriesLabels'; +import { DEFAULT_THEME, deriveTheme, type ThemeDerived, themeVars } from '@/lib/studio/theme'; +import { isPrompt } from '@/lib/studio/transcript'; + +/** + * The Studio workspace as static markup: the skeleton of Studio.tsx and the + * slots of Whiteboard.tsx, rebuilt here because both fill themselves from + * effects, around the app's own components (Header, WorkspaceToolbar, + * ChatPanel, CardBody, CardHead, CardChrome). A chart card keeps + * ChartCard's DOM with the canvas swapped for the plot Chart.js draws in + * Node (chart.tsx). + */ + +// A card's padding and border on each side (Card.module.scss `.card`): +// rem(16) and 1 px, which the plot's width leaves out. +const CARD_INSET = 2 * 16 + 2; + +const noop = () => {}; + +export type CardOut = { + id: string; + kind: Widget['kind']; + hook: string; + col: number; + row: number; + w: number; + h: number; + // px on the whiteboard sheet, from its top-left corner. + x: number; + y: number; + width: number; + height: number; +}; + +export type ChartOut = { + cardId: string; + line: boolean; + plot: { width: number; height: number }; + valueTicks: ChartLayout['valueTicks']; + valueTitle: string | null; + series: (SeriesPath & { label: string })[]; +}; + +export type Rendered = { + html: string; + hooks: string[]; + cards: CardOut[]; + charts: ChartOut[]; + // Hashed class names an animation toggles: the chat panel's active view. + classes: Record; + // Rules for the states the app sets from measurements in effects. + css: string; + states: PlanStates; +}; + +/** The plan's two states, both in the one fragment: the hooks that belong + * to each, for a film to show one and hide the other, and the attributes + * that change once the plan is approved (the chat input's placeholder and + * label), by hook. A state the spec does not hold is absent. */ +export type PlanStates = { + pending?: { hooks: string[] }; + approved?: { hooks: string[]; attrs: Record> }; +}; + +// --- cards ---------------------------------------------------------------------- + +const extrasFor = (widget: Widget): CardExtras => { + const { view, choices } = resolveCardView(widget, undefined); + const isText = widget.kind === 'text'; + + return { + pinned: false, + title: widgetTitle(widget), + view, + viewChoices: choices, + onPin: noop, + onRemove: noop, + onAsk: noop, + onInfo: isText ? null : noop, + onDownload: cardRefOf(widget) ? noop : null, + onDownloadImage: noop, + onEdit: noop, + editLabel: isText ? 'Edit Card' : 'Edit Title', + onNote: isText ? null : noop, + onRemoveNote: isText ? null : noop, + onView: noop, + onShare: noop, + readOnly: false, + }; +}; + +// ChartCard's DOM (head, legend, plot, chrome) around the drawn plot. +const StaticChartCard = ({ + widget, + theme, + extras, + plot, + plotHeight, +}: { + widget: ChartWidget; + theme: ThemeDerived; + extras: CardExtras; + plot: React.ReactNode; + plotHeight: number; +}) => { + const names = legendLabels(widget.series.map((s) => s.label)); + + // The app measures the card's chrome and gives the card the rows that + // chrome and the plot need. Here the plot keeps its height and the card + // grows past its slot when the chrome needs more than the slot gives. + return ( +
+ + {legendShown(widget) && ( +
    + {widget.series.map((s, index) => ( +
  • + +
  • + ))} +
+ )} +
+ {plot} +
+ +
+ ); +}; + +type Placed = { + widget: Widget; + col: number; + row: number; + w: number; + h: number; + body: React.ReactNode; +}; + +// A Whiteboard slot (Whiteboard.tsx): position and size in cells, the card +// box with its grab bar and resize grip, the card inside. +const Slot = ({ placed }: { placed: Placed }) => { + const { widget, col, row, w, h, body } = placed; + const { view } = resolveCardView(widget, undefined); + const isText = widget.kind === 'text'; + + return ( +
+
+ + {!isText && ( + + + + )} + {body} +
+
+ ); +}; + +// --- the workspace -------------------------------------------------------------- + +const cardTabsOf = (widgets: Widget[]) => { + // Studio.tsx's tabs: a kind gets a tab once it is on the sheet. + const counts = widgets.reduce>((acc, widget) => { + const kind = effectiveKind(widget, undefined); + + acc[kind] = (acc[kind] ?? 0) + 1; + + return acc; + }, {}); + const tabFor = (id: string, label: string, kind: string) => + counts[kind] ? [{ id, label, count: counts[kind] }] : []; + + return [ + { id: 'all', label: 'All', count: widgets.length }, + ...tabFor('stats', 'Stats', 'key_number'), + ...tabFor('charts', 'Charts', 'chart'), + ...tabFor('tables', 'Tables', 'table'), + ...tabFor('notes', 'Notes', 'text'), + ]; +}; + +const chatOf = (spec: DemoSpec): ReturnType => { + // Every card belongs to the turn of the last answer. + const lastBot = spec.messages.findLastIndex((message) => message.role === 'bot'); + const turn = spec.messages.slice(0, Math.max(lastBot, 0)).filter(isPrompt).length; + + return { + messages: spec.messages, + widgets: spec.cards.map(({ widget }) => ({ ...widget, turn })), + queued: [], + activity: spec.activity ?? [], + status: null, + statusDetail: null, + statusSince: null, + stalled: false, + thinking: false, + clarify: null, + plan: spec.plan ?? null, + agentRemovals: 0, + takeAgentRemoved: () => [], + syncCanvas: noop, + reportHidden: noop, + canvasVersion: 0, + send: noop, + unqueue: noop, + answerClarify: noop, + skipClarify: noop, + respondPlan: noop, + switchChoice: noop, + applyChoice: noop, + stop: noop, + reset: noop, + hydrate: noop, + snapshot: () => ({ sessionId: null, componentRefs: [], turnCount: turn, canvas: null }), + }; +}; + +const Workspace = ({ spec, placed }: { spec: DemoSpec; placed: Placed[] }) => { + const theme = deriveTheme(DEFAULT_THEME); + const widgets = spec.cards.map((card) => card.widget); + const rightCol = Math.max(0, ...placed.map((card) => card.col + card.w)); + const bottomRow = Math.max(0, ...placed.map((card) => card.row + card.h)); + + return ( +
+ + +
+
+
+ +
+ {placed.map((card) => ( + + ))} +
+
+ +
+ + + +
+ +
+ + true} + onGo={noop} + onAdd={noop} + onRemove={noop} + /> +
+
+ ); +}; + +// --- hooks ---------------------------------------------------------------------- + +const only = (elements: Element[], what: string): Element => { + if (elements.length !== 1) { + throw new Error(`render: expected one ${what}, found ${elements.length}`); + } + + return elements[0]!; +}; + +/** The hooks of each plan state: the card awaiting approval with its + * approve bar (pending), and the pill under the answer (approved). A + * state the spec does not hold has none. */ +const planHooks = (spec: DemoSpec): { pending: string[]; approved: string[] } => ({ + pending: spec.plan ? ['plan', ...(spec.plan.live ? ['plan-bar', 'plan-approve'] : [])] : [], + approved: spec.messages.some((message) => message.role === 'bot' && message.plan) + ? ['plan-pill'] + : [], +}); + +// The chat input's attributes in `markup`. +const promptAttrs = (markup: string): Record => { + const foot = only( + walk(parseMarkup(markup)).filter((el) => hasClass(el, chatStyles.chatFoot!)), + 'chat foot', + ); + + return only( + walk(foot.children).filter((el) => el.name === 'textarea'), + 'prompt field', + ).attrs; +}; + +const planStates = (spec: DemoSpec, placed: Placed[], markup: string): PlanStates => { + const { pending, approved } = planHooks(spec); + + if (!approved.length) { + return pending.length ? { pending: { hooks: pending } } : {}; + } + + // What the chat foot says once no plan waits: rendered without the plan. + const before = promptAttrs(markup); + const after = promptAttrs( + renderToStaticMarkup(), + ); + const changed = Object.entries(after).filter(([name, value]) => before[name] !== value); + + return { + ...(pending.length ? { pending: { hooks: pending } } : {}), + approved: { + hooks: approved, + attrs: changed.length ? { prompt: Object.fromEntries(changed) } : {}, + }, + }; +}; + +// The hooks inside the app's own components, found by their classes. +const chatHooks = (html: string, spec: DemoSpec): string => { + const all = walk(parseMarkup(html)); + const within = (root: Element) => walk(root.children); + const additions: { element: Element; name: string; value: string }[] = []; + const hook = (element: Element, value: string) => + additions.push({ element, name: 'data-v', value }); + + const panel = only( + all.filter((el) => hasClass(el, chatStyles.chatPanel!)), + 'chat panel', + ); + const views = all.filter((el) => hasClass(el, chatStyles.view!)); + const [chatView, activityView] = views; + + if (views.length !== 2 || !chatView || !activityView) { + throw new Error(`render: expected the chat and activity views, found ${views.length}`); + } + + hook(panel, 'chat'); + hook(chatView, 'view-chat'); + hook(activityView, 'view-activity'); + + const transcript = only( + within(chatView).filter((el) => hasClass(el, chatStyles.chatScroll!)), + 'transcript', + ); + + if (transcript.children.length !== spec.messages.length) { + throw new Error( + `render: ${spec.messages.length} messages drew ${transcript.children.length} elements`, + ); + } + + transcript.children.forEach((element, index) => hook(element, `msg-${index}`)); + + const foot = only( + within(chatView).filter((el) => hasClass(el, chatStyles.chatFoot!)), + 'chat foot', + ); + + hook( + only( + within(foot).filter((el) => el.name === 'textarea'), + 'prompt field', + ), + 'prompt', + ); + hook( + only( + within(foot).filter((el) => el.name === 'button' && el.attrs.type === 'submit'), + 'send button', + ), + 'send', + ); + + // The plan awaiting approval (PlanCard in the chat foot) and the approved + // plan under its answer (ApprovedPlanPill): planHooks names them. + const names = planHooks(spec); + + if (names.pending.length) { + const card = only( + within(foot).filter((el) => el.name === 'section' && hasClass(el, planStyles.plan!)), + 'plan card', + ); + + hook(card, 'plan'); + + if (spec.plan?.live) { + const bar = only( + within(foot).filter((el) => hasClass(el, barStyles.bar!)), + 'approve bar', + ); + + hook(bar, 'plan-bar'); + hook( + only( + within(bar).filter( + (el) => el.name === 'button' && hasClass(el, buttonStyles.dark!), + ), + 'Approve button', + ), + 'plan-approve', + ); + } + } + + if (names.approved.length) { + hook( + only( + within(transcript).filter((el) => hasClass(el, pillStyles.pill!)), + 'approved plan pill', + ), + 'plan-pill', + ); + } + + within(activityView) + .filter((el) => el.name === 'section' && hasClass(el, activityStyles.turn!)) + .forEach((turn, index) => { + hook(turn, `activity-${index}`); + within(turn) + .filter((el) => el.name === 'li' && hasClass(el, activityStyles.tool!)) + .forEach((line, step) => hook(line, `activity-${index}-${step}`)); + }); + + // The app's entrance reveals start hidden until an observer marks them; + // the film shows them revealed. + for (const element of all) { + if ('data-reveal' in element.attrs || 'data-reveal-group' in element.attrs) { + additions.push({ element, name: 'data-revealed', value: '' }); + } + } + + return addAttributes(html, additions); +}; + +const hooksIn = (html: string): string[] => + walk(parseMarkup(html)).flatMap((element) => element.attrs['data-v'] ?? []); + +// SegmentedTabs measures the active tab in an effect and slides its +// indicator (SegmentedTabs.module.scss `.indicator`: the pill's full height, +// charcoal, the tab's own radius) under it. The film paints the active tab. +const measuredStates = `.${segmentedStyles.choice}[data-active="true"] { background-color: var(--color-charcoal); }\n`; + +/** A chart laid out in the default theme at `width` px, as a card shows it. */ +export const layoutDefaultChart = ( + widget: ChartWidget, + { line, width, fontsDir }: { line: boolean; width: number; fontsDir: string }, +): ChartLayout => + layoutChart({ + widget, + line, + theme: deriveTheme(DEFAULT_THEME), + width, + measure: outfitMeasure(fontsDir), + }); + +// --- entry ---------------------------------------------------------------------- + +export const renderStudio = (spec: DemoSpec, { fontsDir }: { fontsDir: string }): Rendered => { + const theme = deriveTheme(DEFAULT_THEME); + const measure = outfitMeasure(fontsDir); + const charts: ChartOut[] = []; + + const placed: Placed[] = spec.cards.map(({ widget, slot }) => { + const resolved = resolveCardView(widget, undefined); + const span = spawnSpan(widget, resolved); + const w = slot.w ?? span.w; + const h = slot.h ?? span.h; + const extras = extrasFor(widget); + const { view, chartSource } = resolved; + let body: React.ReactNode; + + if ((view === 'chart-bar' || view === 'chart-line') && chartSource) { + const line = view === 'chart-line'; + const layout = layoutChart({ + widget: chartSource, + line, + theme, + width: w * CELL - CARD_INSET, + measure, + }); + const plot = renderPlot(layout, widget.id, line); + + charts.push({ + cardId: widget.id, + line, + plot: { width: layout.width, height: layout.height }, + valueTicks: layout.valueTicks, + valueTitle: layout.valueTitle, + series: plot.series.map((path) => ({ + ...path, + label: chartSource.series[path.index]?.label ?? '', + })), + }); + body = ( + + ); + } else { + body = ( + + ); + } + + return { widget, col: slot.col, row: slot.row, w, h, body }; + }); + + const viewport = spec.viewport ?? DEFAULT_VIEWPORT; + const markup = renderToStaticMarkup(); + const html = chatHooks(markup, spec); + const states = planStates(spec, placed, markup); + const hooks = hooksIn(html); + const expected = [ + 'chat', + 'canvas', + 'prompt', + 'send', + ...spec.messages.map((_, index) => `msg-${index}`), + ...(states.pending?.hooks ?? []), + ...(states.approved?.hooks ?? []), + ...(spec.activity ?? []).map((_, index) => `activity-${index}`), + ...spec.cards.map(({ widget }) => `card-${widget.id}`), + ...charts.flatMap((chart) => chart.series.map((series) => series.hook)), + ]; + + for (const name of expected) { + const count = hooks.filter((other) => other === name).length; + + if (count !== 1) { + throw new Error(`render: hook "${name}" appears ${count} times`); + } + } + + return { + html: `
${html}
`, + hooks, + cards: placed.map(({ widget, col, row, w, h }) => ({ + id: widget.id, + kind: widget.kind, + hook: `card-${widget.id}`, + col, + row, + w, + h, + x: col * CELL, + y: row * CELL, + width: w * CELL, + height: h * CELL, + })), + charts, + classes: { viewActive: chatStyles.viewActive! }, + css: measuredStates, + states, + }; +}; diff --git a/motion-kit/runtime/dfo-motion.js b/motion-kit/runtime/dfo-motion.js new file mode 100644 index 0000000..b71a1b6 --- /dev/null +++ b/motion-kit/runtime/dfo-motion.js @@ -0,0 +1,584 @@ +// The film's shared.js: places a generated Studio or a captured page on a +// Motion stage and animates it from a cue sheet. Runs once before any scene; +// each scene calls DFO.mount(kit, …) with its own kit. Plain ES, no imports: +// Motion runs it as-is in its sandbox. +// +// What the app does in JavaScript is mirrored here from its components: +// PromptForm grows its field up to MAX_LINES, and ChatPanel pins the +// transcript to its latest line. What the app does in CSS plays from the +// manifest's own keyframes, timings and easing (cue kind `animate`). +window.DFO = (() => { + const STAGE = { width: 1920, height: 1080 }; + + // PromptForm.tsx MAX_LINES. + const PROMPT_MAX_LINES = 5; + + const manifestOf = (kit) => JSON.parse(kit.module('manifest.json')); + + const cssOf = (kit, manifest) => + manifest.modules.css.map((name) => kit.module(name)).join('\n'); + + // CSS easing functions, as the browser evaluates them. + const bezier = (x1, y1, x2, y2) => { + const at = (a, b, s) => 3 * a * s * (1 - s) ** 2 + 3 * b * s * s * (1 - s) + s ** 3; + return (x) => { + if (x <= 0 || x >= 1) return x <= 0 ? 0 : 1; + let lo = 0; + let hi = 1; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (at(x1, x2, mid) < x) lo = mid; + else hi = mid; + } + return at(y1, y2, (lo + hi) / 2); + }; + }; + const NAMED = { + linear: (x) => x, + ease: bezier(0.25, 0.1, 0.25, 1), + 'ease-in': bezier(0.42, 0, 1, 1), + 'ease-out': bezier(0, 0, 0.58, 1), + 'ease-in-out': bezier(0.42, 0, 0.58, 1), + }; + const easing = (spec) => { + if (NAMED[spec]) return NAMED[spec]; + const cubic = /^cubic-bezier\(([^)]+)\)$/.exec(spec); + if (cubic) return bezier(...cubic[1].split(',').map(Number)); + const steps = /^steps\((\d+)(?:,\s*([\w-]+))?\)$/.exec(spec); + if (steps) { + const n = Number(steps[1]); + const start = steps[2] === 'jump-start' || steps[2] === 'start'; + return (x) => Math.min(1, Math.floor(x * n + (start ? 1 : 0)) / n); + } + throw new Error('DFO: unknown easing ' + spec); + }; + + // Where a keyframe list leaves a property unset at 0% or 100%, the element + // shows its own value: opacity 1, no offset, no scaling. + const NUMBER = /-?\d*\.?\d+(?:e-?\d+)?/g; + const resting = (prop, sample) => { + if (prop === 'opacity') return '1'; + if (prop === 'translate') return sample.replace(NUMBER, '0'); + if (prop === 'scale') return sample.replace(NUMBER, '1'); + if (prop === 'transform') { + return sample.replace( + /([a-zA-Z3]+)\(([^)]*)\)/g, + (_, fn, args) => + fn + '(' + args.replace(NUMBER, /^scale/.test(fn) ? '1' : '0') + ')', + ); + } + return null; + }; + // Interpolates two values of one shape number by number; a value of + // another shape switches at the midpoint, as discrete CSS properties do. + const mix = (a, b, p) => { + const an = a.match(NUMBER) ?? []; + const bn = b.match(NUMBER) ?? []; + if (an.length !== bn.length || a.replace(NUMBER, '#') !== b.replace(NUMBER, '#')) { + return p < 0.5 ? a : b; + } + let i = 0; + return a.replace(NUMBER, () => { + const v = Number(an[i]) + (Number(bn[i]) - Number(an[i])) * p; + i++; + return String(Math.round(v * 1000) / 1000); + }); + }; + + // A keyframe list as full 0%..100% steps, each holding every property. + const steps = (frames) => { + const props = [...new Set(frames.flatMap((f) => Object.keys(f.style)))]; + const sorted = [...frames].sort((a, b) => a.offset - b.offset); + const ends = []; + if (sorted[0].offset > 0) ends.push({ offset: 0, style: {} }); + ends.push(...sorted); + if (sorted[sorted.length - 1].offset < 1) ends.push({ offset: 1, style: {} }); + for (const prop of props) { + const sample = sorted.find((f) => f.style[prop] != null).style[prop]; + for (const f of ends) { + if (f.style[prop] == null) f.style = { ...f.style, [prop]: resting(prop, sample) }; + } + } + return { props, frames: ends }; + }; + const sample = ({ props, frames }, p) => { + const out = {}; + let i = 0; + while (i < frames.length - 2 && frames[i + 1].offset <= p) i++; + const a = frames[i]; + const b = frames[i + 1]; + const local = b.offset === a.offset ? 1 : (p - a.offset) / (b.offset - a.offset); + for (const prop of props) { + const from = a.style[prop]; + const to = b.style[prop]; + out[prop] = + from == null || to == null ? (local < 0.5 ? from : to) : mix(from, to, local); + } + return out; + }; + + // One CSS animation from the manifest, as a function of seconds since it + // was triggered: null where the element shows its own style. + const player = (entry, frames) => { + const ease = easing(entry.timing); + const fill = entry.fill; + const runs = entry.iterations === 'infinite' ? Infinity : entry.iterations; + const plan = steps(frames); + // Iteration `pass` at progress p, in the direction it plays. + const at = (pass, p) => { + const odd = pass % 2 === 1; + const back = + entry.direction === 'reverse' || + (entry.direction === 'alternate' && odd) || + (entry.direction === 'alternate-reverse' && !odd); + return sample(plan, ease(back ? 1 - p : p)); + }; + return (local) => { + const t = local - entry.delay; + if (t < 0) return fill === 'backwards' || fill === 'both' ? at(0, 0) : null; + const total = entry.duration * runs; + if (t >= total) { + if (fill !== 'forwards' && fill !== 'both') return null; + const part = runs % 1; + return part ? at(Math.floor(runs), part) : at(runs - 1, 1); + } + return at(Math.floor(t / entry.duration), (t % entry.duration) / entry.duration); + }; + }; + + // Wraps every word inside `el` in a span, once, and returns the spans in + // reading order, so a cue can reveal them by count. + const wordSpans = (kit, el) => { + const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const nodes = []; + + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (node.nodeValue.trim()) nodes.push(node); + } + + const spans = []; + + for (const node of nodes) { + const frag = el.ownerDocument.createDocumentFragment(); + + for (const part of node.nodeValue.split(/(\s+)/)) { + if (!part) continue; + if (/^\s+$/.test(part)) { + frag.append(part); + continue; + } + const span = kit.el('span', { text: part }); + spans.push(span); + frag.append(span); + } + node.replaceWith(frag); + } + + return spans; + }; + + // Prepares one cue against the mounted fragment; returns `apply(t)`, the + // elements whose style it writes (`touches`), whether it sets attributes + // or classes instead (`attrs`) and the time it ends. Every + // frame starts from those elements' own inline styles (`mount` restores + // them), so a cue writes only what it shows at t, and cues apply in list + // order: a later cue on the same element wins. + const prepare = (kit, studio, cue) => { + const { seg, eo, ei, eob, lerp } = kit; + + if (cue.kind === 'animate') { + // Plays the app's own animation: every manifest entry on the hook + // (or inside it, or with that keyframe name), triggered at `at`. + const entries = (studio.manifest.animations ?? []).filter((a) => + cue.name + ? a.name === cue.name + : a.v === cue.v && (cue.sel == null || a.sel === cue.sel), + ); + if (!entries.length) + throw new Error('DFO: no app animation for ' + (cue.name ?? cue.v)); + const runs = entries.map((entry) => { + const el = entry.v + ? studio.q(entry.v, entry.sel) + : studio.root.querySelector(entry.sel); + if (!el) throw new Error('DFO: nothing at ' + entry.sel); + return { el, play: player(entry, studio.manifest.keyframes[entry.name]) }; + }); + const end = Math.max( + ...entries.map((a) => + a.iterations === 'infinite' ? 0 : a.delay + a.duration * a.iterations, + ), + ); + return { + touches: runs.map((r) => r.el), + end: cue.at + end, + apply(t) { + for (const { el, play } of runs) { + const style = play(t - cue.at); + for (const prop of Object.keys(style ?? {})) el.style[prop] = style[prop]; + } + }, + }; + } + + if (cue.kind === 'state') { + // Switches the fragment's attributes to a manifest state's, as the + // app does when the plan is approved (the prompt's placeholder). + const attrs = studio.manifest.states?.[cue.to]?.attrs ?? {}; + const changes = Object.entries(attrs).flatMap(([hook, values]) => + Object.entries(values).map(([name, value]) => { + const el = studio.q(hook); + return { el, name, value, was: el.getAttribute(name) }; + }), + ); + return { + touches: [], + attrs: true, + end: cue.at, + apply(t) { + for (const c of changes) { + const v = t >= cue.at ? c.value : c.was; + if (v == null) c.el.removeAttribute(c.name); + else c.el.setAttribute(c.name, v); + } + }, + }; + } + + const el = studio.q(cue.v, cue.sel); + const s = el.style; + const dur = cue.dur ?? 0.4; + const only = (end, apply) => ({ touches: [el], end, apply }); + // An entering element takes no room before its time. + const before = (t) => { + if (t >= cue.at) return false; + s.display = 'none'; + return true; + }; + + switch (cue.kind) { + case 'show': + // dur 0: the element simply appears, as a new message does. + return only(cue.at + dur, (t) => { + if (before(t) || !dur) return; + const p = eo(seg(t, cue.at, cue.at + dur)); + s.opacity = p; + s.transform = 'translateY(' + lerp(cue.dy ?? 10, 0, p) + 'px)'; + }); + case 'pop': { + // For film overlays; product cards use `animate`. + const end = cue.at + (cue.dur ?? 0.45); + return only(end, (t) => { + if (before(t)) return; + const p = seg(t, cue.at, end); + s.opacity = eo(Math.min(1, p * 1.6)); + s.transform = 'scale(' + lerp(0.94, 1, eob(p, 1.5)) + ')'; + }); + } + case 'stream': { + // The words are spans of the cue's own, so they keep their + // state between frames and change only when the count does. + const spans = wordSpans(kit, el); + const wps = cue.wps ?? 9; + let shown = -1; + return only(cue.at + spans.length / wps, (t) => { + before(t); + const n = Math.max(0, Math.min(spans.length, Math.floor((t - cue.at) * wps))); + if (n === shown) return; + // Unstreamed words take no room, so the transcript grows + // line by line as the app's does. + spans.forEach((span, i) => (span.style.display = i < n ? '' : 'none')); + shown = n; + }); + } + case 'draw': { + if (typeof el.getTotalLength !== 'function') + throw new Error('DFO: a draw cue needs an SVG shape at ' + cue.v); + const length = el.getTotalLength(); + const points = cue.points ? studio.q(cue.points) : null; + const end = cue.at + (cue.dur ?? 1.6); + return { + touches: points ? [el, points] : [el], + end, + apply(t) { + if (before(t)) return; + const p = kit.eio(seg(t, cue.at, end)); + s.strokeDasharray = String(length); + s.strokeDashoffset = String(length * (1 - p)); + if (points) points.style.opacity = p >= 1 ? 1 : 0; + }, + }; + } + case 'type': { + const typer = kit.typist(cue.text, cue.at, cue.cps ?? 24); + return only(Math.max(typer.end, cue.clearAt ?? 0), (t) => { + const text = cue.clearAt != null && t >= cue.clearAt ? '' : typer.at(t); + el.value = text; + el.textContent = text; + // PromptForm's own sizing: the field's content height, + // capped at MAX_LINES lines. + s.height = 'auto'; + const line = parseFloat(getComputedStyle(el).lineHeight) || el.scrollHeight; + s.height = Math.min(el.scrollHeight, line * PROMPT_MAX_LINES) + 'px'; + }); + } + case 'press': + return only(cue.at + 0.16, (t) => { + if (t < cue.at) return; + s.transform = + 'scale(' + + (1 - 0.06 * Math.sin(Math.PI * seg(t, cue.at, cue.at + 0.16))) + + ')'; + }); + case 'remove': + // Gone at once, as the app unmounts it (the pending plan on approval). + return only(cue.at, (t) => { + if (t >= cue.at) s.display = 'none'; + }); + case 'hide': + // For film overlays: fades out, then folds the height it has + // as earlier cues leave it. + return only(cue.at + 0.2 + dur, (t) => { + if (t < cue.at) return; + const height = el.offsetHeight; + const fold = kit.eio(seg(t, cue.at + 0.2, cue.at + 0.2 + dur)); + s.opacity = 1 - ei(seg(t, cue.at, cue.at + 0.25)); + if (fold <= 0) return; + s.overflow = 'hidden'; + s.height = lerp(height, 0, fold) + 'px'; + if (fold < 1) return; + s.marginTop = s.marginBottom = '0'; + s.visibility = 'hidden'; + }); + case 'tab': { + // Switches the chat panel between Chat and Activity at `at`. + const active = studio.manifest.classes?.viewActive; + if (!active) throw new Error('DFO: a tab cue needs the Studio, not a page capture'); + const from = studio.q(cue.from); + return { + touches: [], + attrs: true, + end: cue.at, + apply(t) { + const on = t >= cue.at; + el.classList.toggle(active, on); + from.classList.toggle(active, !on); + }, + }; + } + default: + throw new Error('DFO: unknown cue kind ' + cue.kind + ' on ' + cue.v); + } + }; + + /** + * Mounts the generated Studio, or one captured page state, in a scene. + * Options: `state` (a captured page's state; default its first), `keys` + * (camera keys; a key may name a `hook` to centre on), `fit` (scale on + * top of fitting the stage), `desk` (the colour around the frame). A + * capture's key or cue that names a state in `in` plays in that state only. + * Returns: + * - q(hook, sel?): the element with that data-v hook, or `sel` inside it; + * - focus(hook, z): a camera key centred on that element; + * - cue(list): adds cues; render(t): applies cues, then the camera; + * - end: when the last cue or camera hold ends, in seconds. + * + * Camera keys are {t, cx, cy, z, hold?, from?, drift?} in stage px, where + * the whole frame fits the stage at z = 1: the view rests on a key for + * `hold` seconds, then eases to the next one, arriving at its `t`. A key + * that names a hook follows that element where the layout has put it at + * that moment. + */ + const mount = (kit, options = {}) => { + const manifest = manifestOf(kit); + const { width, height } = manifest.viewport; + const fit = Math.min(STAGE.width / width, STAGE.height / height) * (options.fit ?? 1); + const left = (STAGE.width - width * fit) / 2; + const top = (STAGE.height - height * fit) / 2; + + kit.css(cssOf(kit, manifest)); + kit.css( + '.dfo-desk { position:absolute; inset:0; overflow:hidden; background:' + + (options.desk ?? '#ecece0') + + ' }' + + '.dfo-cam { position:absolute; left:0; top:0; width:' + + STAGE.width + + 'px; height:' + + STAGE.height + + 'px; transform-origin:0 0 }' + + '.dfo-frame { position:absolute; transform-origin:0 0; border-radius:14px; overflow:hidden;' + + ' box-shadow:0 24px 70px rgba(0,0,0,.12) }', + ); + + const desk = kit.h( + '
', + ); + const camLayer = desk.firstChild; + const frame = camLayer.firstChild; + // A Studio render has one fragment; a page capture has one per state. + const html = manifest.modules.html; + const state = + typeof html === 'string' ? undefined : (options.state ?? Object.keys(html)[0]); + const file = state === undefined ? html : html[state]; + if (!file) throw new Error('DFO: no captured state ' + state); + // A capture's cue or key with `in` plays in that state only. + const here = (item) => item.in == null || item.in === state; + frame.innerHTML = kit.module(file); + Object.assign(frame.style, { + left: left + 'px', + top: top + 'px', + width: width + 'px', + height: height + 'px', + transform: 'scale(' + fit + ')', + }); + kit.stage.append(desk); + + // ChatPanel keeps its transcript at scrollTop = scrollHeight. A film + // cannot scroll an element, so the same offset moves the transcript's + // children with `translate`, which no cue uses. + const transcript = frame.querySelector('[data-v="view-chat"] [class*="chatScroll"]'); + let shift = 0; + const pin = () => { + if (!transcript) return; + for (const child of transcript.children) child.style.translate = ''; + shift = Math.max(0, transcript.scrollHeight - transcript.clientHeight); + for (const child of transcript.children) { + child.style.translate = shift ? '0 ' + -shift + 'px' : ''; + } + }; + + const cues = []; + let keys = []; + // Each element a cue styles, with its own inline style, which every + // frame starts from so no cue's value outlives its time. + const own = new Map(); + const apply = (t) => { + for (const [el, style] of own) el.setAttribute('style', style); + // Attributes and classes first (`state` and `tab`): a cue that + // measures, such as the prompt's height under its placeholder, + // must see this frame's. + for (const c of cues) if (c.attrs) c.apply(t); + for (const c of cues) if (!c.attrs) c.apply(t); + }; + + // An element's box in stage px at z = 1, from layout offsets inside + // the frame, so the player's own scaling of the stage cannot skew it. + const box = (el) => { + let x = 0; + let y = 0; + let node = el; + while (node && node !== frame) { + x += node.offsetLeft; + y += node.offsetTop; + node = node.offsetParent; + } + if (node !== frame) + throw new Error('DFO: cannot place ' + (el.dataset.v ?? el.className)); + if (transcript && transcript !== el && transcript.contains(el)) y -= shift; + return { + x: left + x * fit, + y: top + y * fit, + w: el.offsetWidth * fit, + h: el.offsetHeight * fit, + }; + }; + + // A key that names a hook centres on that element as the layout + // stands at the key's own time: the cues are applied at k.t to + // measure it. A fixed time gives a fixed layout, so it is measured + // once. + const placed = new Map(); + const point = (k) => { + if (!k.hook) return k; + if (!placed.has(k)) { + apply(k.t); + pin(); + const r = box(studio.q(k.hook)); + placed.set(k, { ...k, cx: r.x + r.w / 2, cy: r.y + r.h / 2 }); + } + return placed.get(k); + }; + + const view = (t) => { + const at = (k, s) => { + const d = k.drift ?? [0, 0, 0]; + const dt = Math.max(0, s - k.t); + return { + cx: k.cx + d[0] * dt, + cy: k.cy + d[1] * dt, + lz: Math.log(k.z) + d[2] * dt, + }; + }; + let i = 0; + while (i + 1 < keys.length && keys[i + 1].t <= t) i++; + const a = point(keys[i]); + const next = keys[i + 1]; + if (!next || t <= a.t) return at(a, Math.max(t, a.t)); + const from = next.from ?? a.t + (a.hold ?? 0); + if (t <= from) return at(a, t); + const b = point(next); + const p = kit.eio(kit.seg(t, from, b.t)); + const s = at(a, from); + return { + cx: kit.lerp(s.cx, b.cx, p), + cy: kit.lerp(s.cy, b.cy, p), + lz: kit.lerp(s.lz, Math.log(b.z), p), + }; + }; + + const studio = { + manifest, + root: frame, + // `sel` narrows to a descendant, for controls with no hook of their own. + q(hook, sel) { + const base = '[data-v="' + hook + '"]'; + const el = frame.querySelector(sel ? base + ' ' + sel : base); + if (!el) throw new Error('DFO: nothing at ' + hook + (sel ? ' ' + sel : '')); + return el; + }, + box, + focus(hook, z = 1.6) { + const r = box(studio.q(hook)); + return { cx: r.x + r.w / 2, cy: r.y + r.h / 2, z }; + }, + cue(list) { + for (const c of list.filter(here)) { + const prepared = prepare(kit, studio, c); + for (const el of prepared.touches) { + if (!own.has(el)) own.set(el, el.getAttribute('style') ?? ''); + } + cues.push(prepared); + } + return studio; + }, + // When the last cue ends or the camera's last hold does. + get end() { + const last = keys[keys.length - 1]; + return Math.max(last.t + (last.hold ?? 0), ...cues.map((c) => c.end)); + }, + render(t) { + // The camera first: placing a key may apply the cues at + // another time, so the cues for t come after. + const v = view(t); + apply(t); + pin(); + const z = Math.exp(v.lz); + camLayer.style.transform = + 'translate(' + + (STAGE.width / 2 - v.cx * z) + + 'px,' + + (STAGE.height / 2 - v.cy * z) + + 'px) scale(' + + z + + ')'; + }, + }; + + const wide = { cx: STAGE.width / 2, cy: STAGE.height / 2, z: 1 }; + // No keys holds the whole stage in view. + const mine = (options.keys ?? []).filter(here); + keys = (mine.length ? mine : [{ t: 0 }]).map((k) => ({ ...wide, ...k })); + return studio; + }; + + return { mount }; +})(); diff --git a/motion-kit/snapshot.ts b/motion-kit/snapshot.ts new file mode 100644 index 0000000..e22fe00 --- /dev/null +++ b/motion-kit/snapshot.ts @@ -0,0 +1,563 @@ +/** + * The functions a page capture runs inside the browser, through Playwright's + * `page.evaluate`. Playwright sends each one as source text, so each is + * self-contained: no imports, no helpers from this module, only its + * argument and the page's globals. + */ + +import type { PageHook } from './spec.ts'; + +/** A box in viewport px. */ +export type Box = { x: number; y: number; width: number; height: number }; + +/** A stylesheet's text and the URL its relative `url()`s resolve against. + * `media` is the owner's `media` attribute, when it has one. */ +export type SheetSource = { key: string; base: string; text: string; media: string }; + +export type Snapshot = { + // The state as one `
` fragment. + html: string; + // The computed font size of the page's root element, in px. + rootFontSize: number; + // What the sanitiser removed and could not inline, one line each. + dropped: string[]; + sheets: SheetSource[]; +}; + +/** + * Stamps each hook's `data-v` on the one element its selector finds, after + * clearing the stamps of an earlier state, and returns each hook's box, or + * null when the selector finds nothing in this state. Throws when a + * selector finds more than one element. + */ +export function stampHooks(hooks: PageHook[]): Record { + for (const element of document.querySelectorAll('[data-v]')) { + element.removeAttribute('data-v'); + } + + const boxes: Record = {}; + const round = (n: number) => Math.round(n * 100) / 100; + + for (const { v, selector } of hooks) { + const found = document.querySelectorAll(selector); + + if (found.length > 1) { + throw new Error(`hook "${v}": "${selector}" matches ${found.length} elements`); + } + + const element = found[0]; + + if (!element) { + boxes[v] = null; + continue; + } + + element.setAttribute('data-v', v); + + const rect = element.getBoundingClientRect(); + + boxes[v] = { + x: round(rect.x), + y: round(rect.y), + width: round(rect.width), + height: round(rect.height), + }; + } + + return boxes; +} + +/** For each selector, whether it finds an element in the page now. A + * selector the browser cannot parse finds nothing. */ +export function matchSelectors(selectors: string[]): boolean[] { + return selectors.map((selector) => { + try { + return document.querySelector(selector) !== null; + } catch { + return false; + } + }); +} + +/** + * The page as it stands, made static for a film: + * + * - `document.body` is copied into an inert document (no scripts run, no + * custom element upgrades, no loads) inside `
`, with + * `
` and `
` standing for the root and + * the body and carrying their attributes; + * - scripts, styles, links, frames, media, templates, comments, event + * attributes and elements with a shadow root are removed; + * - a canvas becomes an `` of its pixels, and images, ``s and + * inline `url()`s become `data:` URLs; what cannot be fetched is dropped + * and reported, as is any other URL that leaves the page; + * - form controls carry their current value, checked and selected states + * as attributes; + * - hovered, focused and active elements carry `data-mk-hover`, + * `data-mk-focus`, `data-mk-focus-visible`, `data-mk-focus-within` and + * `data-mk-active`, which the stylesheet's dynamic pseudo-classes become; + * - a scrolled element's children (the body's, for the window) are + * translated by the scroll offset, and a fixed or stuck element is moved + * back to where it shows. + * + * Also returns the text of every stylesheet in the page. + */ +export async function snapshotPage({ + viewport, + state, +}: { + viewport: { width: number; height: number }; + state: string; +}): Promise { + const dropped: string[] = []; + const drop = (what: string) => { + if (!dropped.includes(what)) { + dropped.push(what); + } + }; + const inside = (url: string) => url.startsWith('data:') || url.startsWith('#'); + const absolute = (url: string) => new URL(url, document.baseURI).href; + const cache = new Map>(); + const dataUrl = (url: string): Promise => { + const href = absolute(url); + + if (!cache.has(href)) { + cache.set( + href, + (async () => { + try { + const response = await fetch(href); + + if (!response.ok) { + return null; + } + + const blob = await response.blob(); + + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } + })(), + ); + } + + return cache.get(href)!; + }; + const inlineCssUrls = async (text: string, what: string): Promise => { + let out = text; + + for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/g)) { + const url = match[2]!; + + if (inside(url)) { + continue; + } + + const data = await dataUrl(url); + + if (!data) { + drop(`${what}: url(${url})`); + } + + out = out.replace(match[0], data ? `url("${data}")` : 'none'); + } + + return out; + }; + + // --- measurements on the live page, before anything changes ------------- + + const scroller = document.scrollingElement ?? document.documentElement; + const live = [document.body, ...document.body.querySelectorAll('*')]; + const computed = live.map((element) => getComputedStyle(element)); + const sticky = live.filter((_, index) => computed[index]!.position === 'sticky'); + const stuckAt = sticky.map((element) => element.getBoundingClientRect()); + + // Where each sticky element would sit unstuck: static takes the same + // room in the flow, so switching them all at once moves nothing else. + const inlinePosition = sticky.map((element) => (element as HTMLElement).style.position); + + for (const element of sticky) { + (element as HTMLElement).style.position = 'static'; + } + + const unstuckAt = sticky.map((element) => element.getBoundingClientRect()); + + sticky.forEach((element, index) => { + (element as HTMLElement).style.position = inlinePosition[index]!; + }); + + // --- the copy ------------------------------------------------------------- + + const inert = document.implementation.createHTMLDocument(''); + const copies = (() => { + const body = inert.importNode(document.body, true); + + return [body, ...body.querySelectorAll('*')]; + })(); + const shift = new Map(); + const addShift = (element: Element, x: number, y: number) => { + const current = shift.get(element) ?? { x: 0, y: 0 }; + + shift.set(element, { x: current.x + x, y: current.y + y }); + }; + // What the scroll translation moved each live element's descendants by. + const scrolled = new Map(); + const scrollOf = (element: Element) => + element === document.body + ? { x: scroller.scrollLeft, y: scroller.scrollTop } + : { x: element.scrollLeft, y: element.scrollTop }; + + live.forEach((element, index) => { + const { x, y } = scrollOf(element); + + if (!x && !y) { + return; + } + + scrolled.set(element, { x, y }); + + for (const child of element.children) { + if (getComputedStyle(child).position !== 'fixed') { + addShift(child, -x, -y); + } + } + + if ( + [...element.childNodes].some((node) => node.nodeType === 3 && node.textContent?.trim()) + ) { + drop( + `${copies[index]!.localName}: text beside the children of a scrolled element stays unscrolled`, + ); + } + }); + + // A fixed element sits where it shows whatever its ancestors were moved by. + live.forEach((element, index) => { + if (computed[index]!.position !== 'fixed') { + return; + } + + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + const moved = shift.get(parent); + + if (moved) { + addShift(element, -moved.x, -moved.y); + } + } + }); + + sticky.forEach((element, index) => { + const dx = stuckAt[index]!.x - unstuckAt[index]!.x; + const dy = stuckAt[index]!.y - unstuckAt[index]!.y; + + if (dx || dy) { + addShift(element, dx, dy); + } + }); + + const remove = new Set(); + const pending: Promise[] = []; + const MEDIA = new Set(['video', 'audio', 'object', 'embed', 'iframe', 'frame', 'portal']); + const GONE = new Set(['script', 'noscript', 'template', 'style', 'link', 'meta', 'base']); + const URL_ATTRIBUTES = [ + 'src', + 'srcset', + 'action', + 'formaction', + 'poster', + 'ping', + 'background', + ]; + + live.forEach((element, index) => { + const copy = copies[index]!; + const name = element.localName; + const style = computed[index]!; + + if (GONE.has(name) || element.shadowRoot) { + if (element.shadowRoot) { + drop(`<${name}> with a shadow root`); + } + + remove.add(copy); + + return; + } + + if (MEDIA.has(name)) { + // A box of the same size keeps the layout. + const box = inert.createElement('div'); + const rect = element.getBoundingClientRect(); + + for (const key of ['class', 'data-v']) { + const value = element.getAttribute(key); + + if (value !== null) { + box.setAttribute(key, value); + } + } + + box.setAttribute( + 'style', + `display:${style.display === 'inline' ? 'inline-block' : style.display};width:${rect.width}px;height:${rect.height}px`, + ); + copy.replaceWith(box); + drop( + `<${name}${element.getAttribute('src') ? ` src="${element.getAttribute('src')}"` : ''}> (left as an empty box)`, + ); + + return; + } + + // Attributes: event handlers go; URLs that leave the page go. + for (const attribute of [...copy.attributes]) { + const key = attribute.name.toLowerCase(); + const value = attribute.value; + + if (key.startsWith('on')) { + copy.removeAttribute(attribute.name); + } else if ((key === 'href' || key === 'xlink:href') && !inside(value)) { + if (name === 'a' || name === 'area') { + // `#` keeps the link a link for `:link` and `[href]`. + copy.setAttribute(attribute.name, '#'); + } else { + drop(`<${name}> ${key}="${value}"`); + copy.removeAttribute(attribute.name); + } + } else if (URL_ATTRIBUTES.includes(key) && !(name === 'img' && key === 'src')) { + if (!inside(value)) { + copy.removeAttribute(attribute.name); + } + } else if (key === 'style' && /url\(/.test(value)) { + pending.push( + inlineCssUrls(value, `<${name}> style`).then((text) => { + // The scroll shift below may have set a translate + // already; it outlives the rewrite. + const { translate } = (copy as HTMLElement).style; + + copy.setAttribute('style', text); + + if (translate) { + (copy as HTMLElement).style.translate = translate; + } + }), + ); + } + } + + copy.removeAttribute('autofocus'); + + if (name === 'img') { + const image = element as HTMLImageElement; + const source = image.currentSrc || image.src; + + copy.removeAttribute('srcset'); + copy.removeAttribute('sizes'); + + if (source && !source.startsWith('data:')) { + pending.push( + dataUrl(source).then((data) => { + if (data) { + copy.setAttribute('src', data); + } else { + copy.removeAttribute('src'); + drop(``); + } + }), + ); + } + } else if (name === 'source') { + remove.add(copy); + } else if (name === 'image' && element.namespaceURI === 'http://www.w3.org/2000/svg') { + const href = element.getAttribute('href') ?? element.getAttribute('xlink:href'); + + if (href && !inside(href)) { + pending.push( + dataUrl(href).then((data) => { + if (data) { + copy.setAttribute('href', data); + } else { + drop(``); + } + }), + ); + } + } else if (name === 'canvas') { + const canvas = element as HTMLCanvasElement; + const image = inert.createElement('img'); + + for (const attribute of copy.attributes) { + image.setAttribute(attribute.name, attribute.value); + } + + try { + image.setAttribute('src', canvas.toDataURL('image/png')); + } catch { + drop(' whose pixels cannot be read (left blank)'); + } + + image.setAttribute('width', String(canvas.width)); + image.setAttribute('height', String(canvas.height)); + + // The canvas's laid-out size, so the pixel buffer (device px) + // does not set it. + const rect = element.getBoundingClientRect(); + + image.style.width = `${rect.width}px`; + image.style.height = `${rect.height}px`; + copy.replaceWith(image); + copies[index] = image; + } else if (name === 'input') { + const input = element as HTMLInputElement; + + if (input.type === 'checkbox' || input.type === 'radio') { + copy.toggleAttribute('checked', input.checked); + } else if (input.type !== 'file') { + copy.setAttribute('value', input.value); + } + } else if (name === 'textarea') { + copy.textContent = (element as HTMLTextAreaElement).value; + } else if (name === 'option') { + copy.toggleAttribute('selected', (element as HTMLOptionElement).selected); + } + + // The interaction states the film cannot have, as attributes. + for (const [pseudo, mark] of [ + [':hover', 'data-mk-hover'], + [':active', 'data-mk-active'], + [':focus', 'data-mk-focus'], + [':focus-visible', 'data-mk-focus-visible'], + [':focus-within', 'data-mk-focus-within'], + ] as const) { + if (element.matches(pseudo)) { + copies[index]!.setAttribute(mark, ''); + } + } + + const moved = shift.get(element); + + if (moved) { + // The individual `translate` property composes with any + // `transform` the element has; an existing one is added to. + const [tx = '0px', ty = '0px', tz] = + style.translate === 'none' ? [] : style.translate.split(' '); + const add = (length: string, px: number) => + length.endsWith('px') && !length.includes('(') + ? `${parseFloat(length) + px}px` + : `calc(${length} + ${px}px)`; + + (copies[index] as HTMLElement).style.translate = [ + add(tx, moved.x), + add(ty, moved.y), + ...(tz ? [tz] : []), + ].join(' '); + } + }); + + await Promise.all(pending); + + for (const copy of remove) { + copy.remove(); + } + + const comments = inert.createTreeWalker(copies[0]!, NodeFilter.SHOW_COMMENT); + const commentNodes: Node[] = []; + + while (comments.nextNode()) { + commentNodes.push(comments.currentNode); + } + + for (const comment of commentNodes) { + comment.parentNode?.removeChild(comment); + } + + // --- the fragment --------------------------------------------------------- + + const stand = (tag: 'html' | 'body', source: Element): HTMLElement => { + const element = inert.createElement('div'); + + for (const attribute of source.attributes) { + if (!attribute.name.toLowerCase().startsWith('on')) { + element.setAttribute(attribute.name, attribute.value); + } + } + + element.setAttribute(`data-mk-${tag}`, ''); + + return element; + }; + const wrapper = inert.createElement('div'); + const html = stand('html', document.documentElement); + const body = stand('body', copies[0]!); + + body.append(...copies[0]!.childNodes); + html.append(body); + wrapper.append(html); + wrapper.setAttribute('class', 'dfo-ui'); + wrapper.setAttribute('data-viewport', `${viewport.width}x${viewport.height}`); + wrapper.setAttribute('data-state', state); + + // The root's background (or the body's, when the root has none) paints + // the whole viewport in a browser; the wrapper stands for the viewport. + const rootStyle = getComputedStyle(document.documentElement); + const canvasStyle = + rootStyle.backgroundColor === 'rgba(0, 0, 0, 0)' && rootStyle.backgroundImage === 'none' + ? computed[0]! + : rootStyle; + + if (canvasStyle.backgroundColor !== 'rgba(0, 0, 0, 0)') { + wrapper.style.backgroundColor = canvasStyle.backgroundColor; + } + + // --- the stylesheets ------------------------------------------------------ + + const sheets: SheetSource[] = []; + + for (const sheet of [...document.styleSheets, ...document.adoptedStyleSheets]) { + if (sheet.disabled) { + continue; + } + + const media = sheet.media.mediaText; + + if (sheet.href) { + const response = await fetch(sheet.href).catch(() => null); + + if (!response) { + drop(`stylesheet ${sheet.href}`); + continue; + } + + // A sheet the server no longer has (a rebuild since the page + // loaded) would leave its rules out of the film unnoticed. + if (!response.ok) { + throw new Error( + `motion-kit: stylesheet ${sheet.href} answered ${response.status} when re-read`, + ); + } + + sheets.push({ key: sheet.href, base: sheet.href, text: await response.text(), media }); + } else { + const text = + sheet.ownerNode?.textContent ?? + [...sheet.cssRules].map((rule) => rule.cssText).join('\n'); + + sheets.push({ key: `inline:${text}`, base: document.baseURI, text, media }); + } + } + + return { + html: wrapper.outerHTML, + rootFontSize: parseFloat(rootStyle.fontSize), + dropped, + sheets, + }; +} diff --git a/motion-kit/spec.ts b/motion-kit/spec.ts new file mode 100644 index 0000000..4455a28 --- /dev/null +++ b/motion-kit/spec.ts @@ -0,0 +1,372 @@ +/** + * The demo spec: one Studio conversation, frozen in its running state, that + * the generator renders to static markup. Every shape below is the app's + * own type, so a spec says exactly what the Studio would hold. + */ + +import type { ChatMessage, PlanState } from '../src/components/organisms/Studio/hooks/useChat.ts'; +import type { Widget } from '../src/lib/chat/widgets.ts'; +import type { ActivityEntry } from '../src/lib/studio/activity.ts'; + +/** Where a card sits on the whiteboard, in grid cells. `w` and `h` default + * to the span the Studio gives a fresh card of that kind (`spawnSpan`). */ +export type CardSlot = { col: number; row: number; w?: number; h?: number }; + +export type DemoCard = { widget: Widget; slot: CardSlot }; + +export type Viewport = { width: number; height: number }; + +export type DemoSpec = { + // The conversation title in the tools row. + title: string; + // The prompt the film types into the chat input. + question: string; + // The transcript. A bot message's `plan` is the plan the user approved + // for that answer, shown under it. + messages: ChatMessage[]; + // The plan awaiting approval, as useChat holds it. + plan?: PlanState; + activity?: ActivityEntry[]; + cards: DemoCard[]; + // CSS px at a 16 px root; 1280×800 when absent. + viewport?: Viewport; +}; + +export const DEFAULT_VIEWPORT: Viewport = { width: 1280, height: 800 }; + +/** The narrowest viewport a demo renders at: below it the Studio takes its + * phone layout (`breakpoint(phone)`, under 660 px), which the generator + * does not model. */ +export const MIN_DEMO_WIDTH = 660; + +// What PlanCard and PlanLedger read from a PlanState. +const PLAN_FIELDS = [ + 'title', + 'summary', + 'steps', + 'answerStepId', + 'decisions', + 'caveats', + 'movedElsewhere', + 'version', + 'actions', + 'interruptId', + 'live', + 'revision', + 'baseline', + 'changed', + 'switched', + 'stepsUpdated', + 'switching', + 'notice', +] as const satisfies readonly (keyof PlanState)[]; + +/** The design cell in px at a 16 px root (GRID_CELL in GridPattern). */ +export const CELL = 20; + +/** The class of the element that wraps the fragment and scopes its CSS. */ +export const WRAPPER = 'dfo-ui'; + +/** The root font size the fragment is laid out at. */ +export const ROOT_FONT_SIZE = 16; + +const fail = (message: string): never => { + throw new Error(`demo spec: ${message}`); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * The spec as parsed JSON, checked for what the generator relies on: the + * required fields, card ids that are unique and usable as hook names, and + * bot messages that render (an empty one renders nothing, so it would have + * no element to carry its hook). + */ +export const readSpec = (raw: unknown): DemoSpec => { + if (!isRecord(raw)) { + return fail('expected an object'); + } + + for (const key of ['title', 'question'] as const) { + if (typeof raw[key] !== 'string' || !raw[key]) { + fail(`"${key}" must be a non-empty string`); + } + } + + if (!Array.isArray(raw.messages) || !Array.isArray(raw.cards)) { + return fail('"messages" and "cards" must be arrays'); + } + + if (raw.viewport !== undefined) { + const viewport = raw.viewport as Partial; + + if ( + !isRecord(viewport) || + !Number.isInteger(viewport.width) || + !Number.isInteger(viewport.height) || + viewport.height! <= 0 + ) { + fail('"viewport" needs whole, positive "width" and "height"'); + } + + if (viewport.width! < MIN_DEMO_WIDTH) { + fail( + `"viewport" is ${viewport.width} px wide; the Studio takes its phone layout ` + + `under ${MIN_DEMO_WIDTH} px, which the generator does not render`, + ); + } + } + + if (raw.plan !== undefined) { + const missing = isRecord(raw.plan) + ? PLAN_FIELDS.filter((key) => !(key in (raw.plan as object))) + : PLAN_FIELDS; + + if (missing.length) { + fail(`"plan" is a PlanState as useChat holds it; it lacks ${missing.join(', ')}`); + } + } + + const spec = raw as DemoSpec; + + spec.messages.forEach((message, index) => { + if ( + message.role === 'bot' && + !message.text && + !message.followups?.length && + !message.chips?.length && + !message.plan + ) { + fail(`message ${index} is an empty bot turn, which renders nothing`); + } + }); + + if (spec.messages.filter((message) => message.role === 'bot' && message.plan).length > 1) { + fail('only one answer may carry an approved "plan"; its pill is the "plan-pill" hook'); + } + + const ids = new Set(); + + for (const { widget, slot } of spec.cards) { + if (!/^[\w-]+$/.test(widget.id)) { + fail(`card id "${widget.id}" must be letters, digits, "_" or "-"`); + } + + if (ids.has(widget.id)) { + fail(`card id "${widget.id}" is used twice`); + } + + ids.add(widget.id); + + if (!isRecord(slot)) { + return fail(`card "${widget.id}" needs a "slot"`); + } + + for (const key of ['col', 'row'] as const) { + if (!Number.isInteger(slot[key]) || slot[key] < 0) { + fail(`card "${widget.id}" needs a whole, non-negative "${key}"`); + } + } + + for (const key of ['w', 'h'] as const) { + const span = slot[key]; + + if (span !== undefined && (!Number.isInteger(span) || span <= 0)) { + fail(`card "${widget.id}" needs a whole, positive "${key}" when it sets one`); + } + } + } + + return spec; +}; + +// --- page capture ------------------------------------------------------------- + +/** A `data-v` hook stamped on the one element `selector` finds. */ +export type PageHook = { v: string; selector: string }; + +export type PageStep = + | { type: 'click' | 'hover' | 'focus'; selector: string } + | { type: 'type'; selector: string; text: string } + | { type: 'press'; key: string } + | { type: 'wait'; selector?: string; ms?: number } + | { type: 'scroll'; selector?: string; y: number } + | { type: 'capture'; state: string }; + +/** A route of the running site, driven through `steps` and snapshotted at + * each `capture` step. */ +export type PageSpec = { + name: string; + // The route with its query, e.g. "/data?q=kenya+debt". + path: string; + // CSS px; 1280×800 when absent. + viewport?: Viewport; + hooks?: PageHook[]; + // A spec with no capture step captures once at the end, as "page". + steps?: PageStep[]; +}; + +const failPage = (message: string): never => { + throw new Error(`page spec: ${message}`); +}; + +const NAME = /^[\w-]+$/; + +const isText = (value: unknown): value is string => typeof value === 'string' && value !== ''; + +const readStep = (raw: unknown, index: number): PageStep => { + const at = `step ${index}`; + + if (!isRecord(raw)) { + return failPage(`${at} must be an object`); + } + + const need = (key: string) => { + if (!isText(raw[key])) { + failPage(`${at} (${String(raw.type)}) needs a non-empty "${key}"`); + } + }; + const optionalNumber = (key: string) => { + if (raw[key] !== undefined && (typeof raw[key] !== 'number' || raw[key] < 0)) { + failPage(`${at} (${String(raw.type)}) "${key}" must be a number of at least 0`); + } + }; + + switch (raw.type) { + case 'click': + case 'hover': + case 'focus': + need('selector'); + break; + case 'type': + need('selector'); + + if (typeof raw.text !== 'string') { + failPage(`${at} (type) needs a "text" string`); + } + + break; + case 'press': + need('key'); + break; + case 'wait': + if (raw.selector !== undefined) { + need('selector'); + } + + optionalNumber('ms'); + + if (raw.selector === undefined && raw.ms === undefined) { + failPage(`${at} (wait) needs a "selector" or "ms"`); + } + + break; + case 'scroll': + if (raw.selector !== undefined) { + need('selector'); + } + + if (typeof raw.y !== 'number') { + failPage(`${at} (scroll) needs a "y" number`); + } + + break; + case 'capture': + need('state'); + + if (!NAME.test(raw.state as string)) { + failPage(`${at} state "${String(raw.state)}" must be letters, digits, "_" or "-"`); + } + + break; + default: + failPage(`${at} has the unknown type "${String(raw.type)}"`); + } + + return raw as PageStep; +}; + +/** + * The page spec as parsed JSON, checked for what the capture relies on: a + * name and a route, a whole-px viewport, hooks with unique names usable as + * `data-v` values, known steps with their fields, and capture states that + * are unique and usable as file names. + */ +export const readPageSpec = (raw: unknown): PageSpec => { + if (!isRecord(raw)) { + return failPage('expected an object'); + } + + if (!isText(raw.name) || !NAME.test(raw.name)) { + failPage('"name" must be letters, digits, "_" or "-"'); + } + + if (!isText(raw.path) || !raw.path.startsWith('/')) { + failPage('"path" must be a route starting with "/"'); + } + + if (raw.viewport !== undefined) { + const viewport = raw.viewport as Partial; + + if ( + !isRecord(viewport) || + !Number.isInteger(viewport.width) || + !Number.isInteger(viewport.height) || + viewport.width! <= 0 || + viewport.height! <= 0 + ) { + failPage('"viewport" needs whole, positive "width" and "height"'); + } + } + + const hooks = raw.hooks ?? []; + const steps = raw.steps ?? []; + + if (!Array.isArray(hooks) || !Array.isArray(steps)) { + return failPage('"hooks" and "steps" must be arrays'); + } + + const names = new Set(); + + for (const hook of hooks) { + if (!isRecord(hook) || !isText(hook.v) || !isText(hook.selector)) { + failPage('each hook needs a non-empty "v" and "selector"'); + } + + const { v } = hook as PageHook; + + if (!NAME.test(v)) { + failPage(`hook "${v}" must be letters, digits, "_" or "-"`); + } + + if (names.has(v)) { + failPage(`hook "${v}" is named twice`); + } + + names.add(v); + } + + const states = new Set(); + + for (const step of steps.map(readStep)) { + if (step.type === 'capture') { + if (states.has(step.state)) { + failPage(`state "${step.state}" is captured twice`); + } + + states.add(step.state); + } + } + + return raw as PageSpec; +}; + +/** The spec's steps, ending in a capture of "page" when none captures. */ +export const pageSteps = (spec: PageSpec): PageStep[] => { + const steps = spec.steps ?? []; + + return steps.some((step) => step.type === 'capture') + ? steps + : [...steps, { type: 'capture', state: 'page' }]; +}; diff --git a/motion-kit/stateClasses.ts b/motion-kit/stateClasses.ts new file mode 100644 index 0000000..8524151 --- /dev/null +++ b/motion-kit/stateClasses.ts @@ -0,0 +1,158 @@ +import { dirname, join, relative, resolve } from 'node:path'; + +import ts from 'typescript'; + +/** + * The state classes of the app's components: for each class a component + * always applies, the classes it adds to the same element on a condition, + * read from the `classNames(…)` calls in the component's source. A + * condition reads `cond && styles.x`, `cond ? styles.x : styles.y` or + * `{ [styles.x]: cond }`; the rest of the call's `styles.*` arguments are + * the element's own classes. Class names come out as the bundle hashed + * them, from the class map of each SCSS module (`styles`, by repo path). + */ + +export type Source = { path: string; text: string }; + +/** Class maps of the bundle's SCSS modules, by path from the repo root. */ +export type StyleModules = Record>; + +/** Each own class mapped to the state classes that toggle beside it. */ +export type StateClasses = Record; + +const modulePath = (specifier: string, from: string, repo: string): string => + relative( + repo, + specifier.startsWith('@/') + ? join(repo, 'src', specifier.slice(2)) + : resolve(dirname(from), specifier), + ); + +export const stateClasses = ( + sources: Source[], + styles: StyleModules, + repo: string, +): StateClasses => { + const found = new Map>(); + + for (const { path, text } of sources) { + const file = ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + // The local names of the `classnames` import and of each SCSS module. + const joiners = new Set(); + const modules = new Map>(); + + for (const statement of file.statements) { + const name = ts.isImportDeclaration(statement) && statement.importClause?.name?.text; + + if (!name || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + + const specifier = statement.moduleSpecifier.text; + + if (specifier === 'classnames') { + joiners.add(name); + } else if (specifier.endsWith('.module.scss')) { + const map = styles[modulePath(specifier, path, repo)]; + + if (map) { + modules.set(name, map); + } + } + } + + if (!joiners.size || !modules.size) { + continue; + } + + const className = (node: ts.Expression): string | undefined => { + if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) { + return modules.get(node.expression.text)?.[node.name.text]; + } + + if ( + ts.isElementAccessExpression(node) && + ts.isIdentifier(node.expression) && + ts.isStringLiteral(node.argumentExpression) + ) { + return modules.get(node.expression.text)?.[node.argumentExpression.text]; + } + + return undefined; + }; + + const read = ( + node: ts.Expression, + conditional: boolean, + own: string[], + states: string[], + ) => { + const inner = ts.isParenthesizedExpression(node) ? node.expression : node; + const name = className(inner); + + if (name) { + (conditional ? states : own).push(name); + } else if (ts.isBinaryExpression(inner)) { + const operator = inner.operatorToken.kind; + + if (operator === ts.SyntaxKind.AmpersandAmpersandToken) { + read(inner.right, true, own, states); + } else if ( + operator === ts.SyntaxKind.BarBarToken || + operator === ts.SyntaxKind.QuestionQuestionToken + ) { + read(inner.left, true, own, states); + read(inner.right, true, own, states); + } + } else if (ts.isConditionalExpression(inner)) { + read(inner.whenTrue, true, own, states); + read(inner.whenFalse, true, own, states); + } else if (ts.isObjectLiteralExpression(inner)) { + for (const property of inner.properties) { + if ( + ts.isPropertyAssignment(property) && + ts.isComputedPropertyName(property.name) + ) { + read(property.name.expression, true, own, states); + } + } + } + }; + + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + joiners.has(node.expression.text) + ) { + const own: string[] = []; + const states: string[] = []; + + for (const argument of node.arguments) { + read(argument, false, own, states); + } + + for (const base of own) { + const set = found.get(base) ?? new Set(); + + states.forEach((state) => set.add(state)); + found.set(base, set); + } + } + + ts.forEachChild(node, visit); + }; + + visit(file); + } + + return Object.fromEntries( + [...found].filter(([, set]) => set.size).map(([base, set]) => [base, [...set].toSorted()]), + ); +}; diff --git a/motion-kit/stubs/next-link.tsx b/motion-kit/stubs/next-link.tsx new file mode 100644 index 0000000..ee8c0cc --- /dev/null +++ b/motion-kit/stubs/next-link.tsx @@ -0,0 +1,20 @@ +import type { AnchorHTMLAttributes } from 'react'; + +// Link's own props, which an anchor does not take. +const LINK_PROPS = new Set(['prefetch', 'replace', 'scroll', 'shallow', 'locale']); + +/** `next/link` for a render outside Next: a plain anchor. */ +const Link = ({ + href, + ...rest +}: Omit, 'href'> & { + href: string | { pathname?: string }; + [prop: string]: unknown; +}) => ( + !LINK_PROPS.has(key)))} + /> +); + +export default Link; diff --git a/motion-kit/stubs/next-navigation.ts b/motion-kit/stubs/next-navigation.ts new file mode 100644 index 0000000..77fbf1a --- /dev/null +++ b/motion-kit/stubs/next-navigation.ts @@ -0,0 +1,19 @@ +/** + * `next/navigation` for a render outside Next: the Studio's own path, a + * router that goes nowhere and no query. + */ + +const noop = () => {}; + +export const usePathname = (): string => '/studio'; + +export const useRouter = () => ({ + push: noop, + replace: noop, + refresh: noop, + back: noop, + forward: noop, + prefetch: noop, +}); + +export const useSearchParams = (): URLSearchParams => new URLSearchParams(); diff --git a/motion-kit/stubs/server-only.ts b/motion-kit/stubs/server-only.ts new file mode 100644 index 0000000..59b90ef --- /dev/null +++ b/motion-kit/stubs/server-only.ts @@ -0,0 +1,2 @@ +// `server-only` for a render outside Next: the import is a guard, nothing more. +export {}; diff --git a/motion-kit/test/capture.test.ts b/motion-kit/test/capture.test.ts new file mode 100644 index 0000000..8e0286d --- /dev/null +++ b/motion-kit/test/capture.test.ts @@ -0,0 +1,391 @@ +/** + * Page capture: the page spec's validation, the sanitiser's leak check, and + * captures of a small fixture page served from this process, so the tests + * need Chrome (or CHROMIUM_PATH) but not the site. The browser tests skip + * when no browser starts. + */ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { after, before, test } from 'node:test'; + +import type { Browser } from 'playwright-core'; + +import { launchBrowser } from '../browser.ts'; +import { generatePage } from '../capture.ts'; +import { htmlLeaks } from '../html.ts'; +import { pageSteps, readPageSpec } from '../spec.ts'; + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==', + 'base64', +); + +const FIXTURE = ` + + + + + + +
Fixture
+ + + + + + +
tile
+ + + + +
elsewhere +

one

two

three

four

+

a

b

+

styled inline

+
+ +`; + +const CSS = ` +body { margin: 0; padding-top: 3rem; font-size: 1rem; } +.tall { height: 1000px; } +.bar { position: fixed; top: 0; left: 0; right: 0; height: 2rem; } +.tile { width: 50vw; background: url(/tile.png) repeat; } +.tile:hover { outline: 2px solid rgb(0, 0, 255); } +.go::before { content: '>'; } +.list { height: 40px; overflow: auto; } +.list p { height: 30px; margin: 0; } +.never-rendered { color: red; } +@media (max-width: 400px) { .tile { width: 10px; } } +@keyframes spin { to { transform: rotate(1turn); } } +`; + +const serve = async () => { + // Served once: the page loads it, and the capture's re-read finds it gone. + let flakyServed = false; + const server = createServer((request, response) => { + const files: Record = { + '/': ['text/html', FIXTURE], + '/flaky': ['text/html', '

flaky

'], + '/flaky.css': ['text/css', 'p { color: red; }'], + '/site.css': ['text/css', CSS], + '/logo.png': ['image/png', PNG], + '/tile.png': ['image/png', PNG], + '/frame.html': ['text/html', '

frame

'], + }; + const path = new URL(request.url ?? '/', 'http://x').pathname; + const gone = path === '/flaky.css' && flakyServed; + + flakyServed ||= path === '/flaky.css'; + + const file = gone ? undefined : files[path]; + + response.writeHead(file ? 200 : 404, { 'content-type': file?.[0] ?? 'text/plain' }); + response.end(file?.[1] ?? 'not found'); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + return { server, base: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; +}; + +test('a page spec is checked for its route, hooks, steps and states', () => { + const spec = { + name: 'kenya', + path: '/data', + hooks: [{ v: 'prompt', selector: 'textarea' }], + steps: [ + { type: 'capture', state: 'empty' }, + { type: 'type', selector: 'textarea', text: 'Kenya' }, + { type: 'wait', ms: 10 }, + { type: 'scroll', y: 200 }, + { type: 'capture', state: 'typed' }, + ], + }; + + assert.deepEqual(readPageSpec(structuredClone(spec)), spec); + + const refusals: [unknown, RegExp][] = [ + [{ ...spec, name: 'a b' }, /"name"/], + [{ ...spec, path: 'data' }, /"path"/], + [{ ...spec, viewport: { width: 0, height: 800 } }, /"viewport"/], + [ + { + ...spec, + hooks: [ + { v: 'x', selector: 'a' }, + { v: 'x', selector: 'b' }, + ], + }, + /named twice/, + ], + [{ ...spec, hooks: [{ v: 'x y', selector: 'a' }] }, /hook "x y"/], + [{ ...spec, steps: [{ type: 'drag', selector: 'a' }] }, /unknown type "drag"/], + [{ ...spec, steps: [{ type: 'type', selector: 'a' }] }, /"text"/], + [{ ...spec, steps: [{ type: 'click' }] }, /"selector"/], + [{ ...spec, steps: [{ type: 'wait' }] }, /"selector" or "ms"/], + [{ ...spec, steps: [{ type: 'scroll' }] }, /"y"/], + [{ ...spec, steps: [{ type: 'capture', state: 'a/b' }] }, /state "a\/b"/], + [ + { + ...spec, + steps: [ + { type: 'capture', state: 'a' }, + { type: 'capture', state: 'a' }, + ], + }, + /captured twice/, + ], + ]; + + for (const [raw, message] of refusals) { + assert.throws(() => readPageSpec(raw), message); + } + + assert.deepEqual(pageSteps({ name: 'p', path: '/', steps: [{ type: 'press', key: 'Tab' }] }), [ + { type: 'press', key: 'Tab' }, + { type: 'capture', state: 'page' }, + ]); +}); + +test('the leak check finds what a film cannot hold', () => { + assert.deepEqual( + htmlLeaks( + '
' + + 'x' + + '
', + ), + [ + ' `; diff --git a/motion-kit/runtime/dfo-motion.js b/motion-kit/runtime/dfo-motion.js index 43554d7..3e0ecd7 100644 --- a/motion-kit/runtime/dfo-motion.js +++ b/motion-kit/runtime/dfo-motion.js @@ -178,11 +178,12 @@ window.DFO = (() => { }; // Prepares one cue against the mounted fragment; returns `apply(t)`, the - // elements whose style it writes (`touches`), whether it sets attributes - // or classes instead (`attrs`) and the time it ends. Every - // frame starts from those elements' own inline styles (`mount` restores - // them), so a cue writes only what it shows at t, and cues apply in list - // order: a later cue on the same element wins. + // attributes it writes as [element, name] pairs (`touches`: `style` for + // most, `class` or the state's own for `tab` and `state`, which set + // `attrs`) and the time it ends. Every frame starts from those + // attributes' own values (`mount` restores them), so a cue writes only + // from its own time, and cues apply in list order: a later cue on the + // same element wins. const prepare = (kit, studio, cue) => { const { seg, eo, ei, eob, lerp } = kit; @@ -209,7 +210,7 @@ window.DFO = (() => { ), ); return { - touches: runs.map((r) => r.el), + touches: runs.map((r) => [r.el, 'style']), end: cue.at + end, apply(t) { for (const { el, play } of runs) { @@ -225,21 +226,19 @@ window.DFO = (() => { // app does when the plan is approved (the prompt's placeholder). const attrs = studio.manifest.states?.[cue.to]?.attrs ?? {}; const changes = Object.entries(attrs).flatMap(([hook, values]) => - Object.entries(values).map(([name, value]) => { - const el = studio.q(hook); - return { el, name, value, was: el.getAttribute(name) }; - }), + Object.entries(values).map(([name, value]) => ({ + el: studio.q(hook), + name, + value, + })), ); return { - touches: [], + touches: changes.map((c) => [c.el, c.name]), attrs: true, end: cue.at, apply(t) { - for (const c of changes) { - const v = t >= cue.at ? c.value : c.was; - if (v == null) c.el.removeAttribute(c.name); - else c.el.setAttribute(c.name, v); - } + if (t < cue.at) return; + for (const c of changes) c.el.setAttribute(c.name, c.value); }, }; } @@ -247,7 +246,7 @@ window.DFO = (() => { const el = studio.q(cue.v, cue.sel); const s = el.style; const dur = cue.dur ?? 0.4; - const only = (end, apply) => ({ touches: [el], end, apply }); + const only = (end, apply) => ({ touches: [[el, 'style']], end, apply }); // An entering element takes no room before its time. const before = (t) => { if (t >= cue.at) return false; @@ -297,7 +296,12 @@ window.DFO = (() => { const points = cue.points ? studio.q(cue.points) : null; const end = cue.at + (cue.dur ?? 1.6); return { - touches: points ? [el, points] : [el], + touches: points + ? [ + [el, 'style'], + [points, 'style'], + ] + : [[el, 'style']], end, apply(t) { if (before(t)) return; @@ -355,13 +359,16 @@ window.DFO = (() => { if (!active) throw new Error('DFO: a tab cue needs the Studio, not a page capture'); const from = studio.q(cue.from); return { - touches: [], + touches: [ + [el, 'class'], + [from, 'class'], + ], attrs: true, end: cue.at, apply(t) { - const on = t >= cue.at; - el.classList.toggle(active, on); - from.classList.toggle(active, !on); + if (t < cue.at) return; + el.classList.add(active); + from.classList.remove(active); }, }; } @@ -447,11 +454,16 @@ window.DFO = (() => { const cues = []; let keys = []; - // Each element a cue styles, with its own inline style, which every - // frame starts from so no cue's value outlives its time. + // Each attribute a cue writes, by element and name, with its own value, + // which every frame starts from so no cue's value outlives its time. const own = new Map(); const apply = (t) => { - for (const [el, style] of own) el.setAttribute('style', style); + for (const [el, values] of own) { + for (const [name, value] of values) { + if (value == null) el.removeAttribute(name); + else el.setAttribute(name, value); + } + } // Attributes and classes first (`state` and `tab`): a cue that // measures, such as the prompt's height under its placeholder, // must see this frame's. @@ -539,8 +551,10 @@ window.DFO = (() => { cue(list) { for (const c of list.filter(here)) { const prepared = prepare(kit, studio, c); - for (const el of prepared.touches) { - if (!own.has(el)) own.set(el, el.getAttribute('style') ?? ''); + for (const [el, name] of prepared.touches) { + if (!own.has(el)) own.set(el, new Map()); + const values = own.get(el); + if (!values.has(name)) values.set(name, el.getAttribute(name)); } cues.push(prepared); } diff --git a/motion-kit/snapshot.ts b/motion-kit/snapshot.ts index e22fe00..7c7151d 100644 --- a/motion-kit/snapshot.ts +++ b/motion-kit/snapshot.ts @@ -97,8 +97,9 @@ export function matchSelectors(selectors: string[]): boolean[] { * `data-mk-focus`, `data-mk-focus-visible`, `data-mk-focus-within` and * `data-mk-active`, which the stylesheet's dynamic pseudo-classes become; * - a scrolled element's children (the body's, for the window) are - * translated by the scroll offset, and a fixed or stuck element is moved - * back to where it shows. + * translated by the scroll offset, past any element that holds a fixed + * one, so fixed elements stay where they show, and a stuck element is + * moved back to where it shows. * * Also returns the text of every stylesheet in the page. */ @@ -208,49 +209,57 @@ export async function snapshotPage({ shift.set(element, { x: current.x + x, y: current.y + y }); }; - // What the scroll translation moved each live element's descendants by. - const scrolled = new Map(); const scrollOf = (element: Element) => element === document.body ? { x: scroller.scrollLeft, y: scroller.scrollTop } : { x: element.scrollLeft, y: element.scrollTop }; - - live.forEach((element, index) => { - const { x, y } = scrollOf(element); - - if (!x && !y) { + // A translated ancestor would become a fixed element's containing block + // and carry it away from where it shows, so the scroll moves an element + // that holds a fixed one by moving its other children instead. + const fixed = live.filter((_, index) => computed[index]!.position === 'fixed'); + const scrollWith = (element: Element, x: number, y: number) => { + const index = live.indexOf(element); + + if (computed[index]!.position === 'fixed') { return; } - scrolled.set(element, { x, y }); + if (!fixed.some((other) => element.contains(other))) { + addShift(element, x, y); - for (const child of element.children) { - if (getComputedStyle(child).position !== 'fixed') { - addShift(child, -x, -y); - } + return; } if ( [...element.childNodes].some((node) => node.nodeType === 3 && node.textContent?.trim()) ) { drop( - `${copies[index]!.localName}: text beside the children of a scrolled element stays unscrolled`, + `${copies[index]!.localName}: text beside a scrolled element's moved children stays unscrolled`, ); } - }); - // A fixed element sits where it shows whatever its ancestors were moved by. + for (const child of element.children) { + scrollWith(child, x, y); + } + }; + live.forEach((element, index) => { - if (computed[index]!.position !== 'fixed') { + const { x, y } = scrollOf(element); + + if (!x && !y) { return; } - for (let parent = element.parentElement; parent; parent = parent.parentElement) { - const moved = shift.get(parent); + if ( + [...element.childNodes].some((node) => node.nodeType === 3 && node.textContent?.trim()) + ) { + drop( + `${copies[index]!.localName}: text beside the children of a scrolled element stays unscrolled`, + ); + } - if (moved) { - addShift(element, -moved.x, -moved.y); - } + for (const child of element.children) { + scrollWith(child, -x, -y); } }); diff --git a/motion-kit/test/capture.test.ts b/motion-kit/test/capture.test.ts index e258dbd..5b11a63 100644 --- a/motion-kit/test/capture.test.ts +++ b/motion-kit/test/capture.test.ts @@ -49,6 +49,7 @@ const FIXTURE = `

a

b

styled inline

+

pinned