diff --git a/db/test/conversation-search.test.ts b/db/test/conversation-search.test.ts new file mode 100644 index 0000000..ecee960 --- /dev/null +++ b/db/test/conversation-search.test.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { searchConversations } from '../../src/lib/chat/conversationSearch.ts'; +import { applyMigrations } from '../apply-migrations.ts'; +import { conversation, user } from '../schema.ts'; +import { createTestDatabase } from './harness.ts'; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../migrations'); + +/** + * Conversation search on Postgres reads each saved message's text and the + * title of the card a user message names, and leaves out the approved plan + * stored beside a reply. + */ +test('conversation search matches message text and card titles, not stored plans', async (t) => { + const database = await createTestDatabase(); + t.after(() => database.teardown()); + + await applyMigrations(database.migratorPool(), { + migrationsFolder: MIGRATIONS_FOLDER, + runtimeDbUser: database.runtimeUser, + migratorDbUser: database.migratorUser, + }); + + const db = drizzle(database.runtimePool()); + + await db.insert(user).values([{ id: 'owner', name: 'Owner', email: 'owner@example.org' }]); + await db.insert(conversation).values({ + id: 'conv-1', + userId: 'owner', + title: 'Aid question', + data: { + messages: [ + { role: 'user', text: 'How much aid did Ghana get?' }, + { + role: 'bot', + text: 'About 1 billion dollars.', + plan: { title: 'Net disbursements to Zanzibar', steps: [], choices: [] }, + }, + { role: 'user', text: 'Make it a bar chart', card: { title: 'Health aid trend' } }, + ], + }, + }); + + const hits = async (q: string) => + (await searchConversations(db, 'owner', q)).map((hit) => [hit.id, hit.snippet]); + + assert.deepEqual(await hits('ghana'), [['conv-1', 'How much aid did Ghana get?']]); + assert.deepEqual(await hits('health aid'), [['conv-1', 'Health aid trend']]); + assert.deepEqual(await hits('zanzibar'), []); +}); diff --git a/db/test/harness.ts b/db/test/harness.ts index 7069d87..b7c80ba 100644 --- a/db/test/harness.ts +++ b/db/test/harness.ts @@ -78,6 +78,16 @@ export async function createTestDatabase(): Promise { }); const pools: Pool[] = []; + const closed: Promise[] = []; + + const open = (user: string, password: string): Pool => { + const pool = poolFor(databaseName, user, password); + pool.on('connect', (client) => { + closed.push(new Promise((resolve) => client.once('end', resolve))); + }); + pools.push(pool); + return pool; + }; return { databaseName, @@ -86,22 +96,21 @@ export async function createTestDatabase(): Promise { runtimeUser, runtimePassword, migratorPool(): Pool { - const pool = poolFor(databaseName, migratorUser, migratorPassword); - pools.push(pool); - return pool; + return open(migratorUser, migratorPassword); }, runtimePool(): Pool { - const pool = poolFor(databaseName, runtimeUser, runtimePassword); - pools.push(pool); - return pool; + return open(runtimeUser, runtimePassword); }, adminPool(): Pool { - const pool = poolFor(databaseName, SUPERUSER_USER, SUPERUSER_PASSWORD); - pools.push(pool); - return pool; + return open(SUPERUSER_USER, SUPERUSER_PASSWORD); }, async teardown(): Promise { + // pool.end() resolves once it has asked its clients to close, not + // once they have. A backend that has not yet read its client's + // Terminate when the terminate below reaches it sends the client + // a FATAL error, which the ended pool emits with no listener. await Promise.all(pools.map((pool) => pool.end())); + await Promise.all(closed); const admin = new Client({ host: SUPERUSER_HOST, diff --git a/docs/development.md b/docs/development.md index e1ac5ce..da437ac 100644 --- a/docs/development.md +++ b/docs/development.md @@ -211,17 +211,17 @@ The `apis`, `ai` and `dcp` repositories run their own dev services. `apis` and ` ## API client package -The Search, Datasets and Graph types come from `@developmentfinance/client`, vendored as `vendor/developmentfinance-client-0.1.0.tgz`. The tarball is the `typescript/` package of the sdk repository, built and packed. To refresh it, pack that package into `vendor/` and re-lock, so `pnpm-lock.yaml` carries the new tarball's integrity: +The Search, Datasets and Graph types come from `@developmentfinance/client`, vendored as `vendor/developmentfinance-client-0.1.0.tgz`. The tarball is the `typescript/` package of the sdk repository, built and packed. To refresh it, pack that package into `vendor/` and re-lock: ```sh cd /typescript pnpm install && pnpm run build pnpm pack --pack-destination /vendor cd -pnpm install +just relock-client ``` -A new package version packs under a new file name. Point the `@developmentfinance/client` entry in `package.json` at it before re-locking. A rebuilt tarball keeps its file name, so a local re-vendor needs the client's directory under `node_modules/.pnpm` deleted before `pnpm install --frozen-lockfile` picks it up. Commit the tarball and the lockfile together. +A new package version packs under a new file name. Point the `@developmentfinance/client` entry in `package.json` at it, delete the old tarball, then re-lock. `just relock-client` deletes the client's stale directory under `node_modules/.pnpm` (pnpm keeps an installed `file:` package of the same name and version, so a rebuilt tarball is otherwise ignored), has pnpm re-resolve the client's lockfile entries from the tarball with `pnpm install --lockfile-only --fix-lockfile`, runs `pnpm install --frozen-lockfile`, and imports the client to prove it loads. The re-lock needs the registry. It changes the client's entries and those of any dependency the new tarball adds. pnpm 10.5.2 also fills in deprecation notices and writes `eslint-plugin-import`'s peer suffix in one of two equivalent forms, so a re-lock can touch those few lines too. Commit the tarball and the lockfile together. The tarball carries the statuses of the codes the proxy raises itself and not their descriptions. `src/lib/proxy/proxy-codes.json` holds the full file, copied from the spec-kit repository's `python/client-contract/src/dfo_client_contract/proxy-codes.json` and formatted with Prettier. Copy it again when the client contract changes it. diff --git a/justfile b/justfile index d038393..f3132b5 100644 --- a/justfile +++ b/justfile @@ -48,6 +48,16 @@ env: echo "ok Created {{ENV_FILE}} from {{ENV_SAMPLE}}." fi +# pnpm reuses an installed `file:` package whose name and version are +# unchanged, so the stale install goes first. +# +# Re-lock the vendored API client tarball +relock-client: + rm -rf node_modules/.pnpm/@developmentfinance+client@* + pnpm install --lockfile-only --fix-lockfile + pnpm install --frozen-lockfile + node --input-type=module -e "await import('@developmentfinance/client')" + # ── Development ─────────────────────────────────────── # LAN access (phone/tablet testing) needs DEV_ORIGINS set in {{ENV_FILE}}. diff --git a/motion-kit/README.md b/motion-kit/README.md new file mode 100644 index 0000000..9d7e83f --- /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..3e0ecd7 --- /dev/null +++ b/motion-kit/runtime/dfo-motion.js @@ -0,0 +1,594 @@ +// 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 + // 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; + + 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, 'style']), + 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]) => ({ + el: studio.q(hook), + name, + value, + })), + ); + return { + touches: changes.map((c) => [c.el, c.name]), + attrs: true, + end: cue.at, + apply(t) { + if (t < cue.at) return; + for (const c of changes) c.el.setAttribute(c.name, c.value); + }, + }; + } + + const el = studio.q(cue.v, cue.sel); + const s = el.style; + const dur = cue.dur ?? 0.4; + 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; + 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, 'style'], + [points, 'style'], + ] + : [[el, 'style']], + 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: [ + [el, 'class'], + [from, 'class'], + ], + attrs: true, + end: cue.at, + apply(t) { + if (t < cue.at) return; + el.classList.add(active); + from.classList.remove(active); + }, + }; + } + 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"]'); + const pin = () => { + if (!transcript) return; + for (const child of transcript.children) child.style.translate = ''; + const 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 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, 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. + 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, as it shows inside the frame: + // the frame's own rendered size gives the scale above it (the fit, the + // camera and the player's scaling of the stage), so every transform + // inside the frame counts, the transcript pin and a capture's scroll + // offset among them. + const box = (el) => { + if (!frame.contains(el) || !el.getClientRects().length) + throw new Error('DFO: cannot place ' + (el.dataset.v ?? el.className)); + const f = frame.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + const scale = f.width / frame.offsetWidth; + return { + x: left + ((r.left - f.left) / scale) * fit, + y: top + ((r.top - f.top) / scale) * fit, + w: (r.width / scale) * fit, + h: (r.height / scale) * 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, 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); + } + 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..7c7151d --- /dev/null +++ b/motion-kit/snapshot.ts @@ -0,0 +1,572 @@ +/** + * 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, 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. + */ +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 }); + }; + const scrollOf = (element: Element) => + element === document.body + ? { x: scroller.scrollLeft, y: scroller.scrollTop } + : { x: element.scrollLeft, y: element.scrollTop }; + // 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; + } + + if (!fixed.some((other) => element.contains(other))) { + addShift(element, x, y); + + return; + } + + if ( + [...element.childNodes].some((node) => node.nodeType === 3 && node.textContent?.trim()) + ) { + drop( + `${copies[index]!.localName}: text beside a scrolled element's moved children stays unscrolled`, + ); + } + + for (const child of element.children) { + scrollWith(child, x, y); + } + }; + + live.forEach((element, index) => { + const { x, y } = scrollOf(element); + + if (!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`, + ); + } + + for (const child of element.children) { + scrollWith(child, -x, -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..5b11a63 --- /dev/null +++ b/motion-kit/test/capture.test.ts @@ -0,0 +1,414 @@ +/** + * 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

+
+

pinned

+ +`; + +const CSS = ` +body { margin: 0; padding-top: 3rem; font-size: 1rem; } +.tall { height: 1000px; } +.pin { position: fixed; top: 20px; right: 0; margin: 0; } +.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' + + '
', + ), + [ + '