Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 27 additions & 21 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -5237,32 +5237,38 @@ Each entry records:
- Status: resolved
- Severity: high
- Owner: Documentation/Tooling
- Observed in: catalog review of the axis-pointer tooltip, interactive legend,
linked data table, focus/context window, pinned nested-chart tooltip,
streaming controls, synchronized and free cursors, range brush, and time zoom
- Observed in: catalog review of the catalog application, axis-pointer
tooltip, interactive legend, linked data table, focus/context window, pinned
nested-chart tooltip, resource timeline, streaming controls, synchronized and
free cursors, range brush, time zoom, playback, editable range, motion, and
calendar examples
- Friction: the public TanStack examples assembled application-owned legends,
tables, tooltip rows, buttons, and nested chart containers with long
`createElement`, mutation, and listener blocks. The chart grammar remained
visible, but the surrounding composition was unfamiliar copy-paste material
for the primary React audience and obscured the adapter's intended ownership
boundary.
- Decision: render application-owned composition with React and
`@tanstack/react-charts`, while keeping chart-only examples and the direct
`mountChart` lifecycle framework-neutral. A small conformance-only React
mount adapter translates the benchmark's mount, update, driver, and destroy
contract without entering authored-source totals. Catalog source discovery,
artifact validation, and raw-source publication now follow `.tsx` support
modules and classify the React bridge as harness code.
- Verification: the focused TypeScript build reports zero diagnostics; source
loader, artifact, source-file, and source-view suites pass; and the
schema-v4 catalog build publishes all 102 cases with valid recursive source
closures. The definition-shape check follows migrated `view.tsx` modules, and
the loading graph accepts their lazy `.tsx?raw` source entries. Chromium
quick-profile checks pass visual and semantic interaction scenarios for all
ten migrated cases at 320px and 640px across both data revisions. Their
authored-source ratios remain 0.80–1.17× of the selected references, and the
measured isolated bundles include React rather than silently treating it as
benchmark-external infrastructure.
boundary. The catalog application itself still used `innerHTML`, string
templates, selector rebinding, and manual listener cleanup, and later
interaction and motion examples repeated the same application-shell pattern.
- Decision: assume React for the catalog application and all application-owned
example composition, using `@tanstack/charts/react`. Keep direct DOM access
only at actual browser integration boundaries such as metadata, measurement,
renderer mounting, and conformance inspection. Chart-only lifecycle fixtures
and third-party reference adapters may stay imperative when they do not
author application UI. A small conformance-only React mount adapter translates
the benchmark's mount, update, driver, and destroy contract without entering
authored-source totals. Catalog source discovery, artifact validation, and
raw-source publication follow `.tsx` support modules and classify the React
bridge as harness code.
- Verification: root TypeScript reports zero diagnostics; the focused React
example, source-loader, source-file, source-view, and catalog-index suites
pass; and the production catalog build publishes all 111 cases with valid
recursive source closures. Chromium quick-profile checks pass visual and
semantic interaction scenarios for all 14 examples migrated in the follow-up
at 320px and 640px across both data revisions. The browser run also verifies
preserved horizontal scroll, semantic synchronized focus, free-cursor input
precision, brush and zoom controls, playback, editable dates, motion, and the
calendar shell.

### F-179 — Animation clocks drift at fixed frame indices

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('declarative entrance motion', () => {
const source = readFileSync(
resolve(
process.cwd(),
'benchmarks/conformance/cases/112-motion-entrance/tanstack.ts',
'benchmarks/conformance/cases/112-motion-entrance/view.tsx',
),
'utf8',
)
Expand All @@ -54,8 +54,8 @@ describe('declarative entrance motion', () => {
const definitionSource = source.slice(start, end)

expect(source).toContain("from '../../shared/motion'")
expect(source).toContain('controls.replay.addEventListener')
expect(source).toContain('settleChartMotion(chart')
expect(source).toContain('<ControlButton ref={replayRef} onClick={replay}>')
expect(source).toContain('settleChartMotion(viewRef.current')
expect(definitionSource).toContain('defineChart({')
expect(definitionSource).toContain('motion: {')
expect(definitionSource).toContain('barY(rows, {')
Expand Down
304 changes: 2 additions & 302 deletions benchmarks/conformance/cases/112-motion-entrance/tanstack.ts
Original file line number Diff line number Diff line change
@@ -1,302 +1,2 @@
import { barY, defineChart, lineY } from '@tanstack/charts'
import { motion } from '@tanstack/charts/motion'
import { mountChartRenderer } from '@tanstack/charts/renderer'
import { scaleBand, scaleLinear } from 'd3-scale'
import { readChartMotionState, settleChartMotion } from '../../shared/motion'
import { entranceRows as rows } from './model'
import { tanstackCase } from '../../shared/mount'
import type {
ChartMotionTweenTransition,
ChartRenderer,
ChartRendererHost,
} from '@tanstack/charts'
import type { MotionRow } from './model'
import type {
ConformanceInput,
ConformanceMount,
ConformanceTestDriver,
} from '../../types'

export interface MotionSettings {
duration: number
staggerMs: number
easing: ChartMotionTweenTransition['easing']
customTiming: boolean
}

export const mount: ConformanceMount = (container, input) => {
let currentInput = input
let replayCount = 0
let host: ChartRendererHost<MotionRow, string, number> | undefined
let renderer: ChartRenderer<MotionRow, string, number> | undefined
const settings: MotionSettings = {
duration: 1_100,
staggerMs: 55,
easing: undefined,
customTiming: true,
}
const view = container.ownerDocument.createElement('div')
const controls = createControls(container.ownerDocument, settings)
const chart = container.ownerDocument.createElement('div')
view.dataset.conformanceView = 'main'
Object.assign(view.style, {
display: 'grid',
gridTemplateRows: 'auto minmax(0, 1fr)',
height: `${input.height}px`,
color: 'CanvasText',
})
chart.style.minHeight = '0'
view.append(controls.root, chart)
container.append(view)
const chartHeight = () =>
Math.max(
180,
currentInput.height - controls.root.getBoundingClientRect().height,
)

const replay = () => {
host?.destroy()
replayCount += 1
renderer = motion<MotionRow, string, number>()
host = mountChartRenderer(chart, {
definition: motionEntranceDefinition(settings),
renderer,
width: currentInput.width,
height: chartHeight(),
ariaLabel: 'Staggered monthly actuals and target',
})
}

controls.replay.addEventListener('click', replay)
controls.duration.addEventListener('input', () => {
controls.durationValue.value = `${controls.duration.value} ms`
})
controls.duration.addEventListener('change', () => {
settings.duration = Number(controls.duration.value)
replay()
})
controls.stagger.addEventListener('input', () => {
controls.staggerValue.value = `${controls.stagger.value} ms`
})
controls.stagger.addEventListener('change', () => {
settings.staggerMs = Number(controls.stagger.value)
replay()
})
controls.easing.addEventListener('change', () => {
settings.easing = readEasing(controls.easing.value)
replay()
})
controls.customTiming.addEventListener('change', () => {
settings.customTiming = controls.customTiming.checked
replay()
})
replay()

const driver: ConformanceTestDriver = {
resolveTarget(target) {
if (target.view && target.view !== 'main') return null
if (target.anchor !== 'control:replay') return null
const bounds = controls.replay.getBoundingClientRect()
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
focusElement: controls.replay,
}
},
readState() {
return {
duration: settings.duration,
staggerMs: settings.staggerMs,
customTiming: settings.customTiming,
replayCount,
motionState: readChartMotionState(chart),
}
},
settle: () => settleChartMotion(chart, settings.duration * 1.6),
}

return {
driver,
update(nextInput) {
currentInput = nextInput
view.style.height = `${nextInput.height}px`
if (!renderer) return
host?.update({
definition: motionEntranceDefinition(settings),
renderer,
width: nextInput.width,
height: chartHeight(),
ariaLabel: 'Staggered monthly actuals and target',
})
},
destroy() {
host?.destroy()
view.remove()
},
}
}

export function motionEntranceDefinition(settings: MotionSettings) {
const { duration, easing, staggerMs, customTiming } = settings
return defineChart({
motion: {
transition: { type: 'tween', duration, easing },
},
marks: [
barY(rows, {
x: 'period',
y: 'actual',
key: 'id',
fill: '#7c3aed',
radius: 7,
inset: 4,
motion(context) {
if (customTiming && context.datum?.featured) {
return {
delay: duration * 0.19,
transition: { type: 'tween', duration: duration * 0.64 },
}
}
return { delay: context.datumIndex * staggerMs }
},
}),
lineY(rows, {
x: 'period',
y: 'target',
key: 'id',
stroke: '#f97316',
strokeWidth: 3,
motion: customTiming
? {
delay: duration * 0.1,
transition: { type: 'tween', duration: duration * 0.78 },
}
: undefined,
}),
],
x: { scale: scaleBand().domain(rows.map((row) => row.period)) },
y: { scale: scaleLinear().domain([0, 100]) },
guides: false,
margin: { top: 20, right: 20, bottom: 20, left: 20 },
})
}

export const catalogCase = tanstackCase(
() =>
motionEntranceDefinition({
duration: 1_100,
staggerMs: 55,
easing: undefined,
customTiming: true,
}),
'Staggered monthly actuals and target',
)

function readEasing(value: string): MotionSettings['easing'] {
return value === 'linear' ||
value === 'ease' ||
value === 'ease-in' ||
value === 'ease-out' ||
value === 'ease-in-out'
? value
: undefined
}

function createControls(document: Document, settings: MotionSettings) {
const root = document.createElement('div')
root.setAttribute('role', 'group')
root.setAttribute('aria-label', 'Entrance motion controls')
Object.assign(root.style, {
display: 'flex',
alignItems: 'center',
alignContent: 'center',
flexWrap: 'wrap',
gap: '8px 12px',
padding: '8px 10px',
font: '500 12px/1.2 system-ui, sans-serif',
})

const duration = range(document, 300, 1_800, 100, settings.duration)
const durationValue = document.createElement('output')
durationValue.value = `${settings.duration} ms`
const stagger = range(document, 0, 120, 5, settings.staggerMs)
const staggerValue = document.createElement('output')
staggerValue.value = `${settings.staggerMs} ms`
const easing = document.createElement('select')
for (const [value, label] of [
['polished', 'Polished'],
['ease', 'Ease'],
['ease-out', 'Ease out'],
['ease-in-out', 'Ease in/out'],
['linear', 'Linear'],
]) {
const option = document.createElement('option')
option.value = value
option.textContent = label
easing.append(option)
}
const customTiming = document.createElement('input')
customTiming.type = 'checkbox'
customTiming.checked = settings.customTiming
const replay = button(document, 'Replay')
root.append(
field(document, 'Duration', duration, durationValue),
field(document, 'Stagger', stagger, staggerValue),
field(document, 'Easing', easing),
field(document, 'Apr + line timing', customTiming),
replay,
)
return {
root,
duration,
durationValue,
stagger,
staggerValue,
easing,
customTiming,
replay,
}
}

function field(
document: Document,
label: string,
control: HTMLElement,
value?: HTMLOutputElement,
) {
const root = document.createElement('label')
Object.assign(root.style, {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
whiteSpace: 'nowrap',
})
root.append(label, control)
if (value) root.append(value)
return root
}

function range(
document: Document,
min: number,
max: number,
step: number,
value: number,
) {
const input = document.createElement('input')
input.type = 'range'
input.min = String(min)
input.max = String(max)
input.step = String(step)
input.value = String(value)
input.style.width = '96px'
return input
}

function button(document: Document, label: string) {
const control = document.createElement('button')
control.type = 'button'
control.textContent = label
control.style.padding = '0 14px'
return control
}
export { catalogCase, motionEntranceDefinition, mount } from './view'
export type { MotionSettings } from './view'
Loading
Loading