1,103 lines · 6 files · 29.5 kB
cases/83-focus-context-window/model.ts77 lines · dependency
cases/83-focus-context-window/model.ts
import type { AaplRow } from '@charts-poc/demo-data/aapl'
export interface FocusContextWindow {
selected: Date
start: Date
end: Date
}
export function monthlyAaplRows(rows: readonly AaplRow[]): readonly AaplRow[] {
const byMonth = new Map<number, AaplRow>()
for (const row of rows) {
if (row.Date.getUTCFullYear() === 2017) {
byMonth.set(row.Date.getUTCMonth(), row)
}
}
return [...byMonth.values()]
}
export function focusContextDomain(
rows: readonly AaplRow[],
): readonly [Date, Date] {
const first = rows[0]
const last = rows.at(-1)
if (!first || !last) throw new Error('Focus/context requires AAPL rows.')
return [first.Date, last.Date]
}
export function initialFocusContextWindow(
dates: readonly Date[],
): FocusContextWindow {
const selected = dates[5] ?? dates[0]
if (!selected) throw new Error('Focus/context requires observed dates.')
return windowForDate(dates, selected)
}
export function windowForDate(
dates: readonly Date[],
selected: Date,
): FocusContextWindow {
const selectedIndex = Math.max(
0,
dates.findIndex((date) => date.getTime() === selected.getTime()),
)
const startIndex = Math.min(dates.length - 4, Math.max(0, selectedIndex - 1))
const selectedDate = dates[selectedIndex]
const start = dates[startIndex]
const end = dates[startIndex + 3]
if (!selectedDate || !start || !end) {
throw new Error('Focus/context requires at least four observed dates.')
}
return {
selected: selectedDate,
start,
end,
}
}
export function rowsInWindow(
rows: readonly AaplRow[],
window: FocusContextWindow,
) {
const start = window.start.getTime()
const end = window.end.getTime()
return rows.filter((row) => {
const timestamp = row.Date.getTime()
return timestamp >= start && timestamp <= end
})
}
export function dateKey(date: Date) {
return date.toISOString().slice(0, 10)
}
export function dateFromAnchor(dates: readonly Date[], anchor: string) {
const key = anchor.startsWith('date:') ? anchor.slice(5) : ''
return dates.find((date) => dateKey(date) === key) ?? null
}cases/83-focus-context-window/tanstack.ts5 lines · entry
cases/83-focus-context-window/tanstack.ts
export {
focusContextDetailDefinition,
focusContextOverviewDefinition,
mount,
} from './view'cases/83-focus-context-window/view.tsx444 lines · dependency
cases/83-focus-context-window/view.tsx
import {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { defineChart, dot, lineY } from '@tanstack/charts'
import { brushX } from '@tanstack/charts/interaction/brush'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { keyedSelection, whenSelected } from '@tanstack/charts/selection'
import { Chart } from '@tanstack/charts/react'
import { aapl } from '@charts-poc/demo-data/aapl'
import { scaleLinear, scaleUtc } from 'd3-scale'
import { catalogPreviewDefinition } from '../../shared/preview'
import { reactMount } from '../../shared/react-mount'
import {
dateFromAnchor,
dateKey,
focusContextDomain,
initialFocusContextWindow,
monthlyAaplRows,
rowsInWindow,
windowForDate,
} from './model'
import type { ChartScene } from '@tanstack/charts'
import type {
BrushRange,
BrushXChange,
} from '@tanstack/charts/interaction/brush'
import type { KeyedSelectionChange } from '@tanstack/charts/selection'
import type { AaplRow } from '@charts-poc/demo-data/aapl'
import type { FocusContextWindow } from './model'
import type { ConformanceTarget, ConformanceTestDriver } from '../../types'
import type { ReactConformanceProps } from '../../shared/react-mount'
interface BrushStatus {
dragging: boolean
outcome: 'idle' | 'dragging' | 'commit' | 'cancel'
}
const detailMargin = { top: 16, right: 24, bottom: 38, left: 52 }
const overviewMargin = { top: 8, right: 24, bottom: 22, left: 52 }
const gap = 8
const focusContextRows = monthlyAaplRows(aapl)
const focusContextDates = focusContextRows.map((row) => row.Date)
const fullDomain = focusContextDomain(focusContextRows)
export function focusContextDetailDefinition(window: FocusContextWindow) {
const rows = rowsInWindow(focusContextRows, window)
const selection = keyedSelection<AaplRow, string, Date, number>({
selected: controlledSignal<
string | null,
KeyedSelectionChange<AaplRow, string, Date, number>
>(dateKey(window.selected), () => {}),
key: (row) => dateKey(row.Date),
})
return defineChart(
defineChart({
marks: [
lineY(rows, {
id: 'detail-line',
x: 'Date',
y: 'Close',
stroke: '#2563eb',
strokeWidth: 2.5,
}),
dot(rows, {
id: 'detail-points',
x: 'Date',
y: 'Close',
fill: '#2563eb',
r: 3,
}),
whenSelected(
dot(rows, {
id: 'selected-point',
x: 'Date',
y: 'Close',
fill: '#f97316',
stroke: '#ffffff',
strokeWidth: 2,
r: 6,
}),
selection,
),
],
x: {
scale: scaleUtc().domain([window.start, window.end]),
axis: { label: 'Selected time window' },
},
y: {
scale: scaleLinear,
grid: true,
axis: { label: 'Close ($)' },
},
margin: detailMargin,
}),
{ svgAnimation: false, keyboard: false },
)
}
export function focusContextOverviewDefinition(
window: FocusContextWindow,
onChange: (range: BrushRange<Date>, reason: BrushXChange<Date>) => void,
) {
return defineChart(
defineChart({
marks: [
lineY(focusContextRows, {
id: 'overview-line',
x: 'Date',
y: 'Close',
stroke: '#2563eb',
strokeWidth: 1.75,
}),
],
x: {
scale: scaleUtc().domain(fullDomain),
axis: {
ticks: {
count: 4,
format: (value) =>
value.toLocaleDateString(undefined, {
month: 'short',
timeZone: 'UTC',
}),
},
},
},
y: { scale: scaleLinear, axis: false },
margin: overviewMargin,
controls: [
brushX({
id: 'focus-window',
range: controlledSignal<BrushRange<Date>, BrushXChange<Date>>(
{ start: window.start, end: window.end },
(next, { reason }) => onChange(next, reason),
),
values: focusContextDates,
ariaLabel: 'Selected time window',
startAriaLabel: 'Selected time window start',
endAriaLabel: 'Selected time window end',
format: monthLabel,
handleSize: 16,
selectionStyle: {
fill: '#2563eb',
fillOpacity: 0.16,
stroke: '#2563eb',
strokeWidth: 1.5,
},
handleStyle: { fill: '#2563eb', fillOpacity: 0.9 },
}),
],
}),
{ svgAnimation: false, keyboard: false },
)
}
const FocusContextExample = forwardRef<
ConformanceTestDriver,
ReactConformanceProps
>(function FocusContextExample({ input, idPrefix }, ref) {
const detailSurfaceRef = useRef<HTMLDivElement>(null)
const overviewSurfaceRef = useRef<HTMLDivElement>(null)
const overviewSceneRef = useRef<ChartScene<AaplRow> | null>(null)
const windowRef = useRef(initialFocusContextWindow(focusContextDates))
const brushStatusRef = useRef<BrushStatus>({
dragging: false,
outcome: 'idle',
})
const [window, setWindowState] = useState(windowRef.current)
const heights = viewHeights(input.height)
const detailDefinition = useMemo(
() => focusContextDetailDefinition(window),
[window.start, window.end, window.selected],
)
const chooseDate = useCallback((date: Date) => {
const next = windowForDate(focusContextDates, date)
windowRef.current = next
setWindowState(next)
}, [])
const handleBrushChange = useCallback(
(range: BrushRange<Date>, reason: BrushXChange<Date>) => {
brushStatusRef.current = {
dragging: reason.type === 'preview',
outcome: reason.type === 'preview' ? 'dragging' : reason.type,
}
if (reason.type !== 'commit') return
chooseDate(nearestDateInRange(range))
},
[chooseDate],
)
const overviewDefinition = useMemo(
() => focusContextOverviewDefinition(window, handleBrushChange),
[handleBrushChange, window.end, window.start],
)
useImperativeHandle(
ref,
() => ({
resolveTarget(target) {
const surface = overviewSurfaceRef.current
const scene = overviewSceneRef.current
if (
target.view === 'overview' &&
target.anchor === 'control:selected-month'
) {
const control = surface?.parentElement?.parentElement?.querySelector(
'input[aria-label="Selected month"]',
)
return control instanceof HTMLInputElement ? center(control) : null
}
const date = targetDate(target)
return surface && scene && date
? scenePointToClient(
surface,
scene,
scene.scales.x.map(date),
scene.chart.y + scene.chart.height / 2,
)
: null
},
readState() {
const currentWindow = windowRef.current
const rows = rowsInWindow(focusContextRows, currentWindow)
const selectedRow = rows.find(
(row) => row.Date.getTime() === currentWindow.selected.getTime(),
)
return {
window: {
selected: dateKey(currentWindow.selected),
start: dateKey(currentWindow.start),
end: dateKey(currentWindow.end),
},
detail: {
pointCount: rows.length,
selectedValue: selectedRow?.Close ?? null,
},
control: {
value: selectedIndex(currentWindow),
label: rangeLabel(currentWindow),
},
brush: brushSelectionState(
overviewSurfaceRef.current,
brushStatusRef.current,
),
}
},
}),
[],
)
if (input.preview) {
const previewGap = 4
const previewOverviewHeight = 56
const previewDetailHeight =
input.height - previewOverviewHeight - previewGap
return (
<div
data-catalog-preview-composition="focus-context"
style={{
display: 'grid',
gridTemplateRows: `${previewDetailHeight}px ${previewOverviewHeight}px`,
gap: previewGap,
width: input.width,
height: input.height,
}}
>
<Chart
idPrefix={idPrefix ? `${idPrefix}-detail` : undefined}
definition={catalogPreviewDefinition(detailDefinition)}
width={input.width}
height={previewDetailHeight}
ariaLabel="Detail time window"
/>
<Chart
idPrefix={idPrefix ? `${idPrefix}-overview` : undefined}
definition={catalogPreviewDefinition(overviewDefinition)}
width={input.width}
height={previewOverviewHeight}
ariaLabel="Overview time series with selected detail window"
/>
</div>
)
}
return (
<div
style={{
display: 'grid',
gridTemplateRows: `${heights.detail}px ${heights.overview}px ${heights.controls}px`,
gap,
width: input.width,
height: input.height,
}}
>
<div ref={detailSurfaceRef} data-conformance-view="detail">
<Chart
idPrefix={idPrefix ? `${idPrefix}-detail` : undefined}
definition={detailDefinition}
width={input.width}
height={heights.detail}
ariaLabel="Detail time window"
/>
</div>
<div
ref={overviewSurfaceRef}
data-conformance-view="overview"
style={{ position: 'relative' }}
>
<Chart
idPrefix={idPrefix ? `${idPrefix}-overview` : undefined}
definition={overviewDefinition}
width={input.width}
height={heights.overview}
ariaLabel="Overview time series with draggable detail window"
ariaDescription="Drag the visible selection or use the range control below to reposition the four-month detail window."
onRender={({ scene }) => {
overviewSceneRef.current = scene
}}
/>
</div>
<div
data-focus-controls=""
style={{
display: 'grid',
gridTemplateColumns: 'minmax(9rem, 1fr) auto',
alignItems: 'center',
gap: 10,
padding: '4px 12px',
boxSizing: 'border-box',
font: '600 11px/1.25 system-ui, sans-serif',
}}
>
<input
type="range"
min={0}
max={focusContextDates.length - 1}
step={1}
value={selectedIndex(window)}
aria-label="Selected month"
aria-description="Use arrow keys, Home, or End to move the four-month detail window."
onChange={(event) => {
const date = focusContextDates[Number(event.currentTarget.value)]
if (date) chooseDate(date)
}}
style={{ width: '100%', minHeight: 44, cursor: 'pointer' }}
/>
<output data-focus-range="" aria-live="polite">
{rangeLabel(window)}
</output>
</div>
</div>
)
})
export const catalogComponent = FocusContextExample
export const mount = reactMount(FocusContextExample)
function viewHeights(height: number) {
const controls = 52
const overview = Math.max(56, Math.min(100, Math.round(height * 0.24)))
return {
detail: Math.max(1, height - overview - controls - gap * 2),
overview,
controls,
}
}
function targetDate(target: ConformanceTarget) {
return target.view === 'overview'
? dateFromAnchor(focusContextDates, target.anchor)
: null
}
function scenePointToClient(
surface: HTMLElement,
scene: ChartScene<AaplRow>,
x: number,
y: number,
) {
const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
if (!svg) return null
const bounds = svg.getBoundingClientRect()
return {
x: bounds.left + (x / scene.width) * bounds.width,
y: bounds.top + (y / scene.height) * bounds.height,
focusElement: svg,
}
}
function rangeLabel(window: FocusContextWindow) {
return `${monthLabel(window.start)} – ${monthLabel(window.end)}`
}
function monthLabel(date: Date) {
return date.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
})
}
function selectedIndex(window: FocusContextWindow) {
return Math.max(
0,
focusContextDates.findIndex(
(date) => date.getTime() === window.selected.getTime(),
),
)
}
function brushSelectionState(surface: HTMLElement | null, status: BrushStatus) {
const selection = surface?.querySelector<SVGRectElement>(
'[data-chart-brush="focus-window"] .selection',
)
return {
x: Number(selection?.getAttribute('x') ?? 0),
width: Number(selection?.getAttribute('width') ?? 0),
dragging: status.dragging,
outcome: status.outcome,
}
}
function nearestDateInRange(range: BrushRange<Date>) {
const midpoint = (range.start.getTime() + range.end.getTime()) / 2
return focusContextDates.reduce((candidate, date) =>
Math.abs(date.getTime() - midpoint) <
Math.abs(candidate.getTime() - midpoint)
? date
: candidate,
)
}
function center(element: HTMLElement | SVGElement) {
const bounds = element.getBoundingClientRect()
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
focusElement: element,
}
}shared/preview.ts144 lines · dependency
shared/preview.ts
import { isResponsiveChartDefinition } from '@tanstack/charts'
import type {
ChartPoint,
ChartScene,
ChartValue,
DomChartDefinition,
} from '@tanstack/charts'
import type { ConformanceInput } from '../types'
export interface CatalogPreviewOptions<
TDatum = unknown,
TXValue extends ChartValue = ChartValue,
TYValue extends ChartValue = ChartValue,
> {
/** Keep the source definition's Cartesian axes and grid. */
guides?: boolean
/** Keep the source definition's color legend. */
legend?: boolean
/** Keep the source definition's authored or automatic margins. */
margin?: boolean
/** Paint one deterministic source point through the chart's focus strategy. */
focus?: (
scene: ChartScene<TDatum, TXValue, TYValue>,
input: ConformanceInput,
) => ChartPoint<TDatum, TXValue, TYValue> | null
}
export function catalogPreviewDefinition<
TDatum,
TXValue extends ChartValue,
TYValue extends ChartValue,
>(
definition: DomChartDefinition<TDatum, TXValue, TYValue>,
options: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
if (isResponsiveChartDefinition(definition)) {
return {
...definition,
chart(context) {
const spec = definition.chart(context)
const color = previewColor(spec.color, options.legend === true)
return {
...spec,
...(options.guides === true ? {} : { guides: false }),
...(options.margin === true ? {} : { margin: 0 }),
...(color ? { color } : {}),
}
},
}
}
const color = previewColor(definition.color, options.legend === true)
return {
...definition,
...(options.guides === true ? {} : { guides: false }),
...(options.margin === true ? {} : { margin: 0 }),
...(color ? { color } : {}),
}
}
function previewColor<TColor extends { legend?: unknown }>(
color: TColor | undefined,
keepLegend: boolean,
): Omit<TColor, 'legend'> | TColor | undefined {
if (!color || keepLegend) return color
const { legend: _legend, ...withoutLegend } = color
return withoutLegend
}
export function samplePreviewData<TDatum>(
data: readonly TDatum[],
input: ConformanceInput,
limit: number,
accessors: readonly ((datum: TDatum) => number | null | undefined)[] = [],
): readonly TDatum[] {
if (input.preview !== true || data.length <= limit) return data
const selected = new Set<number>()
const slots = Math.max(2, limit - accessors.length * 2)
for (let slot = 0; slot < slots; slot += 1) {
selected.add(Math.round((slot / (slots - 1)) * (data.length - 1)))
}
for (const accessor of accessors) {
let minimumIndex = -1
let minimum = Number.POSITIVE_INFINITY
let maximumIndex = -1
let maximum = Number.NEGATIVE_INFINITY
data.forEach((datum, index) => {
const value = accessor(datum)
if (value === null || value === undefined || !Number.isFinite(value)) {
return
}
if (value < minimum) {
minimum = value
minimumIndex = index
}
if (value > maximum) {
maximum = value
maximumIndex = index
}
})
if (minimumIndex >= 0) selected.add(minimumIndex)
if (maximumIndex >= 0) selected.add(maximumIndex)
}
return data.filter((_datum, index) => selected.has(index))
}
export function samplePreviewSeries<TDatum, TSeries>(
data: readonly TDatum[],
input: ConformanceInput,
limitPerSeries: number,
series: (datum: TDatum) => TSeries,
): readonly TDatum[] {
if (input.preview !== true) return data
const indicesBySeries = new Map<TSeries, number[]>()
data.forEach((datum, index) => {
const key = series(datum)
const indices = indicesBySeries.get(key) ?? []
indices.push(index)
indicesBySeries.set(key, indices)
})
const selected = new Set<number>()
for (const indices of indicesBySeries.values()) {
if (indices.length <= limitPerSeries) {
indices.forEach((index) => selected.add(index))
continue
}
for (let slot = 0; slot < limitPerSeries; slot += 1) {
const index =
indices[
Math.round((slot / (limitPerSeries - 1)) * (indices.length - 1))
]
if (index !== undefined) selected.add(index)
}
}
return data.filter((_datum, index) => selected.has(index))
}shared/react-mount.ts57 lines · dependency
shared/react-mount.ts
import { createElement } from 'react'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
import type {
ConformanceInput,
ConformanceMount,
ConformanceTestDriver,
} from '../types'
export interface ReactConformanceProps {
input: ConformanceInput
idPrefix?: string
}
export type ReactConformanceComponent = ForwardRefExoticComponent<
ReactConformanceProps & RefAttributes<ConformanceTestDriver>
>
export function reactMount(
Component: ReactConformanceComponent,
): ConformanceMount {
return (container, input) => {
const root = createRoot(container)
let activeDriver: ConformanceTestDriver | null = null
const driver = new Proxy({} as ConformanceTestDriver, {
get(_target, property) {
const value = activeDriver?.[property as keyof ConformanceTestDriver]
return typeof value === 'function' ? value.bind(activeDriver) : value
},
})
const render = (nextInput: ConformanceInput) => {
flushSync(() => {
root.render(
createElement(Component, {
input: nextInput,
ref: (nextDriver: ConformanceTestDriver | null) => {
activeDriver = nextDriver
},
}),
)
})
}
render(input)
return {
update: render,
driver,
destroy() {
flushSync(() => {
root.unmount()
})
},
}
}
}types.ts376 lines · dependency
types.ts
export type ConformanceReferenceRenderer =
'observable-plot' | 'recharts' | 'echarts'
export type ConformanceRenderer = ConformanceReferenceRenderer | 'tanstack'
export type ConformanceSupport = 'native' | 'composed' | 'gap' | 'deferred'
export type ConformanceGeometryRole =
| 'arc'
| 'area'
| 'arrow'
| 'bar'
| 'cell'
| 'contour'
| 'delaunay'
| 'density'
| 'dot'
| 'frame'
| 'geo'
| 'hexagon'
| 'line'
| 'link'
| 'rect'
| 'radar'
| 'regression'
| 'rule'
| 'text'
| 'tick'
| 'vector'
| 'voronoi'
| 'waffle'
export interface ConformanceInput {
width: number
height: number
revision: number
interactive?: boolean
/** Use lower-detail geometry suited to compact catalog cards. */
preview?: boolean
/** True only for semantic browser scenarios, not catalog or visual mounts. */
behavior?: boolean
}
export interface ConformanceHandle {
update: (input: ConformanceInput) => void
driver?: ConformanceTestDriver
destroy: () => void
}
export type ConformanceMount = (
container: HTMLElement,
input: ConformanceInput,
) => ConformanceHandle
export interface ConformanceGeometryExpectation {
id?: string
view?: string
role: ConformanceGeometryRole
count: number
maxCount?: number
rendererRoles?: Partial<Record<ConformanceRenderer, ConformanceGeometryRole>>
}
export type ConformanceAxis = 'x' | 'y' | 'fx' | 'fy'
export interface ConformanceGuideExpectation {
id: string
axis:
| ConformanceAxis
| (Record<'tanstack', ConformanceAxis> &
Partial<Record<ConformanceReferenceRenderer, ConformanceAxis>>)
sequence?: readonly string[]
maxRepeat?: number
}
export type ConformanceJsonValue =
| null
| boolean
| number
| string
| readonly ConformanceJsonValue[]
| ConformanceJsonObject
export interface ConformanceJsonObject {
readonly [key: string]: ConformanceJsonValue
}
export interface ConformanceTarget {
view?: string
anchor: string
}
export type ConformanceRenderedTarget =
| {
selector: string
index?: number
role?: never
name?: never
exact?: never
root?: never
page?: never
}
| {
role: string
name?: string
exact?: boolean
index?: number
selector?: never
root?: never
page?: never
}
| {
root: true
selector?: never
role?: never
name?: never
exact?: never
index?: never
page?: never
}
| {
page: true
selector?: never
role?: never
name?: never
exact?: never
index?: never
root?: never
}
export interface ConformanceResolvedTarget {
/** Viewport-relative client coordinate used by Playwright mouse input. */
x: number
/** Viewport-relative client coordinate used by Playwright mouse input. */
y: number
/** Optional element to focus before a real Playwright keyboard action. */
focusElement?: HTMLElement | SVGElement
}
export interface ConformanceGeometryQuery {
view?: string
role: ConformanceGeometryRole
}
export interface ConformanceGeometrySample {
/** Viewport-relative client box, matching getBoundingClientRect coordinates. */
x: number
y: number
width: number
height: number
paint?: string
}
export interface ConformanceTestDriver {
/**
* Benchmark-only semantic bridge. Case metadata names anchors; each renderer
* resolves those anchors without exposing renderer-specific selectors.
*/
resolveTarget: (target: ConformanceTarget) => ConformanceResolvedTarget | null
readState: () => ConformanceJsonObject
geometry?: (
query: ConformanceGeometryQuery,
) => readonly ConformanceGeometrySample[]
/**
* Viewport-relative logical view bounds. Multi-grid renderers may expose
* independent views without separate DOM roots.
*/
viewBounds?: (view?: string) => ConformanceGeometrySample | null
settle?: () => void | Promise<void>
}
export type ConformanceStateAssertion =
| {
path: string
equals: ConformanceJsonValue
}
| {
path: string
includes: ConformanceJsonValue
}
| {
path: string
approx: number
tolerance: number
}
type ConformanceRenderedStringMatcher =
| {
equals: string | null
includes?: never
}
| {
includes: string
equals?: never
}
type ConformanceRenderedNumberMatcher =
| {
equals: number
approx?: never
tolerance?: never
atLeast?: never
atMost?: never
}
| {
approx: number
tolerance: number
equals?: never
atLeast?: never
atMost?: never
}
| {
atLeast: number
equals?: never
approx?: never
tolerance?: never
atMost?: never
}
| {
atMost: number
equals?: never
approx?: never
tolerance?: never
atLeast?: never
}
export type ConformanceRenderedAssertion =
| ({
target: ConformanceRenderedTarget
property: 'count'
} & ConformanceRenderedNumberMatcher)
| ({
target: ConformanceRenderedTarget
property: 'text'
} & ConformanceRenderedStringMatcher)
| ({
target: ConformanceRenderedTarget
property: 'attribute'
attribute: string
} & ConformanceRenderedStringMatcher)
| {
target: ConformanceRenderedTarget
property: 'visible' | 'focused'
equals: boolean
}
| ({
target: ConformanceRenderedTarget
property:
| 'scrollLeft'
| 'scrollTop'
| 'scrollWidth'
| 'scrollHeight'
| 'clientWidth'
| 'clientHeight'
| 'width'
| 'height'
} & ConformanceRenderedNumberMatcher)
| {
target: ConformanceRenderedTarget
property: 'contained'
within?: ConformanceRenderedTarget
tolerance?: number
equals: true
}
export type ConformanceInteractionStep =
| {
type: 'pointerMove'
target: ConformanceTarget
steps?: number
}
| {
type: 'pointerDown'
target: ConformanceTarget
}
| {
type: 'pointerUp'
target: ConformanceTarget
}
| {
type: 'pointerCancel'
}
| {
type: 'pointerLeave'
view?: string
}
| {
type: 'update'
revision: number
}
| {
type: 'click'
target: ConformanceTarget
}
| {
type: 'key'
key: string
target?: ConformanceTarget
}
| {
type: 'drag'
from: ConformanceTarget
to: ConformanceTarget
steps?: number
}
| {
type: 'wheel'
target: ConformanceTarget
deltaX?: number
deltaY?: number
steps?: number
deltaMode?: 'pixel' | 'line' | 'page'
}
| {
type: 'touchTap'
target: ConformanceTarget
}
| {
type: 'touchDrag'
from: ConformanceTarget
to: ConformanceTarget
steps?: number
cancel?: boolean
}
| {
type: 'wait'
durationMs: number
}
| {
type: 'assert'
assertions: readonly ConformanceStateAssertion[]
}
| {
type: 'assertRendered'
assertions: readonly ConformanceRenderedAssertion[]
}
| {
type: 'screenshot'
name: string
view?: string
}
export interface ConformanceInteractionScenario {
id: string
steps: readonly ConformanceInteractionStep[]
}
export interface ConformanceCaseMeta {
schemaVersion: 1
referenceRenderer?: ConformanceReferenceRenderer
order: number
id: string
title: string
family: string
intent: string
support: ConformanceSupport
features: readonly string[]
geometry: readonly ConformanceGeometryExpectation[]
minimumGeometrySimilarity?: number
guideAssertions?: readonly ConformanceGuideExpectation[]
interactionScenarios?: readonly ConformanceInteractionScenario[]
source: {
title: string
url: string
}
ai: {
create: string
maintain: string
}
}
export interface ConformanceImplementationModule {
mount: ConformanceMount
/** Definition-only mount used by compact generated catalog previews. */
catalogCase?: { mount: ConformanceMount }
}