TanStack
Catalog

Focus and context time window

interaction

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 }
}