Honua Web Components

@honua/sdk-js/web-components registers framework-neutral custom elements for plain TypeScript applications:

Import the subpath once in a browser module. The module auto-registers the elements when customElements is available and also exports defineHonuaWebComponents() for scoped registries.

import {
  createHonuaWebComponentController,
  defineHonuaWebComponents,
} from "@honua/sdk-js/web-components";

defineHonuaWebComponents();

const controller = createHonuaWebComponentController({
  mapPackage,
  featuresBySource: {
    incidents: incidentFeatures,
  },
});

document.querySelector("honua-map")!.controller = controller;

Supported application shell

The canonical @honua/app-platform/web-components package exposes one supported, versioned application context for the whole component suite. The deprecated @honua/sdk-js/web-components forwarding entrypoint exposes the same contract during the 0.1.x transition.

import { createHonuaApplicationContext, mountHonuaApplication } from "@honua/app-platform/web-components";

const context = createHonuaApplicationContext({
  binding: { source: incidents, sourceIdentity: "incidents", plan: acceptedPlan },
  authorization: { status: "authorized", principalId: "operator-17", scopes: ["incidents:read"] },
  locale: { locale: "ar", direction: "rtl" },
  theme: { accent: "#005ea8", colorScheme: "light dark" },
});

const application = await mountHonuaApplication({
  host: document.querySelector("main")!,
  context,
});

// Source or authorization replacement synchronously clears principal-bound
// selection/edit state and aborts work owned by the previous identity.
context.replaceBinding({ source: parcels, sourceIdentity: "parcels" });

// Release the observer, component bindings, listeners, and owned resources.
application.dispose();

mountHonuaApplication() registers the canonical suite once, binds existing and later honua-* descendants through their applicationContext property, and disconnects removed descendants. Components can implement the optional honuaApplicationContextConnected, honuaApplicationContextChanged, and honuaApplicationContextDisconnected lifecycle callbacks. The shell owns no result cache. runSharedRequest() only deduplicates in-flight work, and source, plan, or authorization invalidation aborts that work before the new identity is published.

The supported status vocabulary is idle, loading, ready, empty, stale, degraded, unauthorized, unsupported, offline, and failed. statusPresentation() gives every component the same localized label, role, aria-live, busy, and recovery semantics. Changes use the supported honua-application-context-change, honua-application-status-change, and honua-application-diagnostic events. Component-specific properties and events remain at the production or survival tier recorded in the catalog; experimental adapters remain explicitly opt-in.

Locale packs are scoped to a context and cover status messages plus Intl.NumberFormat / Intl.DateTimeFormat / unit defaults. The mount projects lang and dir (including RTL) on its host. Theme tokens map to the existing --honua-ui-* properties, while mount-local tokens override context tokens. Neither locale nor theme uses global mutable state, and disposal restores the host's original attributes and token values.

The installed-package end-to-end journey, time-bounded evidence, budgets, and component-to-test mapping are published in the Application Components Reference Workbench.

honua-map owns a MapLibre GL JS map for package and controller bindings. Import MapLibre's CSS in browser demos, then pass either an inline MapPackage, a hosted package URL, or a controller whose state contains a package. MapLibre 6 bundler hosts must configure the ESM worker before the element creates its map; for Vite:

import { setWorkerUrl } from "maplibre-gl";
import maplibreWorkerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";

setWorkerUrl(maplibreWorkerUrl);
<honua-map id="map" package-url="/fixtures/response-map-package.json"></honua-map>
const map = document.querySelector("honua-map")!;
map.mapPackage = mapPackage;
map.controller = createHonuaWebComponentController({ mapPackage });

If an application already created a HonuaMapRuntime, adapt it through the existing controller bridge and bind that controller to the element:

import { createHonuaWebComponentControllerFromRuntime } from "@honua/sdk-js/web-components";

document.querySelector("honua-map")!.controller = createHonuaWebComponentControllerFromRuntime(runtime);

Sibling controls bind to the same controller with for="<map-id>".

<honua-map id="map"></honua-map>
<honua-layer-list for="map"></honua-layer-list>
<honua-legend for="map"></honua-legend>
<honua-feature-table for="map" source="incidents"></honua-feature-table>
<honua-search for="map" source="incidents"></honua-search>
<honua-basemap-control for="map"></honua-basemap-control>
<honua-bookmarks
  for="map"
  bookmarks='[{"id":"north","label":"North district","viewport":{"center":[-157.86,21.3],"zoom":12}}]'
></honua-bookmarks>
<honua-locate-control for="map"></honua-locate-control>

The controller contract is intentionally small so this package can integrate with the runtime today and a richer HonuaController later. Use createHonuaWebComponentControllerFromRuntime(runtime) when a HonuaMapRuntime already owns the MapPackage and layer operations.

Components dispatch typed custom events:

Styling is scoped to each component's shadow root and can be themed with CSS custom properties such as --honua-ui-bg, --honua-ui-fg, --honua-ui-border, and --honua-ui-accent.

Controller state drives the renderer after initialization. Layer visibility updates call the runtime layer API, viewport updates call setViewState, selection updates set the selected feature-state key, and controller.setFeatureState() / removeFeatureState() apply source-qualified feature-state patches to the MapLibre map.

Expanded Map Controls

The expanded controls are framework-neutral siblings of honua-map. They bind with the same for="<map-id>" convention and dispatch typed custom events so applications can connect richer adapters later without replacing markup.

<honua-map id="map"></honua-map>
<honua-basemap-control for="map"></honua-basemap-control>
<honua-bookmarks for="map"></honua-bookmarks>
<honua-locate-control for="map"></honua-locate-control>
<honua-measure-control for="map"></honua-measure-control>
<honua-sketch-control for="map"></honua-sketch-control>
<honua-print-export for="map" title="Operations map"></honua-print-export>
<honua-map-status for="map" attribution="Honua"></honua-map-status>
<honua-action-panel
  for="map"
  actions='[{"id":"refresh","label":"Refresh sources"}]'
></honua-action-panel>

honua-basemap-control lists background layers and layers with metadata.basemap === true. Selecting a basemap toggles those layer visibilities through the shared controller and emits honua-basemap-change.

honua-bookmarks always includes the package initialView as Home when it is available. Additional bookmarks can be supplied as a JSON array through the bookmarks attribute. Activating a bookmark calls controller.setViewport() and emits both honua-bookmark-change and honua-viewport-change.

honua-locate-control uses a configured latitude / longitude pair when present, otherwise it requests browser geolocation. If neither path is available it renders an explicit unsupported state and emits honua-locate-change with status: "unsupported".

honua-measure-control and honua-sketch-control expose accessible mode buttons and typed events (honua-measure-change, honua-sketch-change). Until a geometry editing or measurement adapter is attached to the shared controller, they render visible unsupported messaging instead of disappearing.

honua-print-export emits honua-export for print, snapshot, and JSON export actions. Browser print calls window.print() when available. Snapshot and state export fail closed until an application supplies an export adapter — see Secure export below.

honua-map-status displays an approximate scale, attribution text, and a fullscreen action. Fullscreen emits honua-fullscreen-change and renders an unsupported event when the browser does not expose requestFullscreen().

honua-action-panel renders application-defined action buttons from an actions JSON array and emits honua-action. Empty panels render a visible unsupported state so apps do not appear to lose configured controls.

Time slider

honua-time-slider is a view over the headless temporal playback controller (createTemporalPlayback from @honua/sdk-js/map, issue #497). It owns no window state and no timer of its own: every gesture becomes one controller call, and every rendered value comes back from the controller's own tick / play / pause / end events. Layered over a realtime source the controller keeps its snapshot-plus-delta semantics unchanged, because the element adds nothing between the controller and its filter sinks.

It binds through a property rather than for="<map-id>", because the thing it drives is the controller, not the map:

<honua-map id="map"></honua-map>
<honua-time-slider id="time" label="Incident time"></honua-time-slider>
import { createTemporalPlayback } from "@honua/sdk-js/map";
import "@honua/sdk-js/web-components";

const playback = createTemporalPlayback({
  handle: mountedSource, // or `layer: { map, layerIds }`
  timeField: "event_time",
  extent: ["2026-06-01T00:00:00Z", "2026-07-01T00:00:00Z"],
  windowMs: 24 * 60 * 60 * 1000,
  stepMs: 24 * 60 * 60 * 1000,
});

document.querySelector("honua-time-slider")!.playback = playback;

Attributes: label, speeds (comma-separated rate multipliers, default 0.5,1,2,4), and unavailable-reason. Set unavailable-reason when the mounted source has no usable temporal metadata: the panel still renders, names the reason, and disables every transport control rather than offering a scrubber that cannot move anything. The speed control is hidden entirely when the bound controller exposes no setSpeed.

The scrubber follows the WAI-ARIA slider pattern — role="slider" with aria-valuemin / aria-valuemax / aria-valuenow in epoch milliseconds, a readable aria-valuetext naming the window, and a single tab stop. Keyboard: Arrow keys step one window (the inline axis mirrors under dir="rtl"), Page Up / Page Down move ten steps, Home / End jump to the first / last window.

TimeSliderCompat from @honua/sdk-esri-compat mounts this element into its ArcGIS-style container once the application has called registerHonuaWidgetKit(); without a registered kit the shim stays state-model-only, exactly as before.

Secure export

Print, snapshot, and sanitized-state export all run through one explicit adapter contract (issue #683). There is deliberately no ambient fallback for snapshot or state export: reading renderer pixels needs a canvas the application created with preserveDrawingBuffer: true and is authorized to read, and serializing "the current state" would otherwise carry the signed tile URLs, Authorization headers, SAS query strings, and OAuth scope detail that made the map work. With no adapter, both fail closed — no bytes at all, and a HonuaCapabilityNotSupportedError on the result — rather than degrading into a blank image or a partially credentialed JSON file. Browser print keeps working without an adapter because window.print() reads no pixels and serializes no state.

import {
  createHonuaExportAdapter,
  runHonuaExport,
} from "@honua/sdk-js/web-components";

const adapter = createHonuaExportAdapter({
  id: "operations-console",
  snapshot: { getCanvas: () => map.getCanvas() },
  state: true,
});

// Wire it to the element...
document.querySelector("honua-print-export")!.exportAdapter = adapter;

// ...or drive an export directly.
const result = await runHonuaExport({ kind: "snapshot", adapter, state });
if (result.status === "ready") {
  download(result.bytes!, result.filename!);
}
await result.release(); // always safe to call once; `bytes` stay valid

Every export is redacted by default in two independent layers: the state document is an allowlist projection (a field nobody allowlisted has no path into the output, so upstream state growth cannot silently widen an export), and everything that survives is then scrubbed and re-scanned. A credential reaching the final scan is a hard failure, not a silent scrub. The same treatment applies to download filenames, to the adapter-supplied media type (validated against a strict type/subtype grammar with an allowlisted parameter set, so text/plain; token=… cannot ride out on the result), and to every message placed on a honua-export event or a log line — a token disclosed through a filename is exactly as disclosed as one in the payload. Binary artifacts are scanned too: "binary" is not a security boundary, so printable runs are extracted from every byte payload and swept, catching a token inside a PNG tEXt chunk, a JPEG comment, or PDF/XMP metadata.

Attribution, licence notices, scale, export timestamp, data freshness, and fidelity warnings travel with every artifact. Whether the artifact's bytes carry the attribution is tracked separately as provenanceEmbedded, because conflating the two is how an unattributed image ships: reading a WebGL canvas captures the map's pixels and nothing else, since MapLibre renders attribution as DOM outside the canvas. An adapter that does not composite attribution must not claim it did, so the result reports provenanceEmbedded: false and carries an explicit fidelity warning naming what the caller has to present alongside the artifact. Pass requireEmbeddedProvenance: true to turn that into a hard failure when the artifact will travel on its own with no surrounding surface to carry the notice.

Production Feature Table (bounded query + linked state)

honua-feature-table has two lanes and renders the same accessible WAI-ARIA grid in both.

The controller lane is the original behavior: with no engine attached the element asks the shared controller for one bounded page and renders it. Nothing about that markup or its events changed.

The bounded lane is for operational applications. Assign the element's table property a bounded engine created with createHonuaFeatureTable. The engine owns remote paging, multi-column sort, typed filters, column semantics, budgets, virtualization, bounded export, and realtime reconciliation; the element only renders its snapshot and forwards input.

import {
  createHonuaFeatureTable,
  linkFeatureTableToExploration,
} from "@honua/sdk-js/web-components";
import { explainQuery } from "@honua/sdk-js/query-planner";

const source = dataset.source<Incident>("incidents")!;

const table = createHonuaFeatureTable({
  source,
  sourceId: "incidents",
  columns: [
    { field: "OBJECTID", label: "ID", type: "integer" },
    { field: "NAME", label: "Incident", type: "string" },
    { field: "SEVERITY", label: "Severity", type: "number", format: (v) => `P${v}` },
    { field: "INTERNAL_NOTE", visible: false },
  ],
  budgets: { pageSize: 200, maxCachedRows: 2_000, maxRequests: 64 },
  planner: (query) => explainQuery({ descriptor: source.descriptor, query }),
});

document.querySelector("honua-feature-table")!.table = table;
await table.refresh();

Bounded by construction

Every fetch is bounded by HonuaFeatureTableBudgetspageSize, maxCachedRows, maxCachedBytes, maxRequests, maxExportRows, and windowOverscan. The engine loads only the pages the visible window needs, evicts least-recently-used pages to hold the row and byte ceilings, and stops issuing requests once maxRequests is reached. snapshot.ledger reports exact consumption and names the ceiling that was hit in ledger.exhausted. A window row whose page is not resident is undefined in snapshot.rows, and the grid paints a placeholder rather than inventing values.

The row and byte ceilings are hard. If a single page cannot fit under maxCachedBytes, that page is evicted too rather than left resident above the ceiling; ledger.exhausted reports bytes and snapshot.message explains it. A pageSize larger than maxCachedRows can never fit, so it is rejected up front as unsupported instead of failing per page.

maxRequests bounds one filter/sort/projection identity, so ledger.requests resets when that identity changes and an exhausted table can still load a new question. ledger.lifetimeRequests, ledger.rows, ledger.bytes, and ledger.evictedRows are lifetime totals and never reset.

table.export({ format: "csv", maxRows }) reuses the same paged query path. maxRows can only lower the ceiling — a request above budgets.maxExportRows is clamped and the result reports truncated: true with the limit that applied.

CSV serialization treats leading =, +, -, @, tab, and carriage return as spreadsheet control syntax and prefixes those cells with an apostrophe. This prevents source-returned text from becoming an executable formula when a bounded CSV is opened in a spreadsheet application.

budgets.maxFields bounds the projected and rendered column window, while budgets.maxExportBytes bounds the final UTF-8 artifact independently of the row ceiling. The column disclosure above the grid provides native checkbox controls for every declared field; hidden fields stay in the declarative model but leave the projection and DOM.

Columns are horizontally virtualized independently of rows. The engine's setColumnScroll(...) computes a bounded window capped by budgets.maxRenderedColumns; the grid materializes only that slice plus inert leading/trailing spacers. aria-colcount continues to name every admitted visible field and each rendered header/cell carries its absolute aria-colindex, so assistive technology retains the full grid coordinates. <honua-feature-table column-width="160"> controls the fixed width used for the horizontal window calculation.

For downloadable artifacts, use table.secureExport(...). Its required host adapter re-authorizes the current query, receives no credentials, and records a provenance envelope after the engine re-executes the accepted bounded query. The envelope names the host decision, adapter, authorization scope, accepted plan, row count, truncation, and timestamp. Aborting before authorization, during paging, or before audit recording prevents a successful result.

Result truth, never a manufactured count

snapshot.state is one of idle, loading, ready, partial, stale, cancelled, unsupported, or error, and snapshot.count names the evidence behind any number:

count.kind count.evidence Meaning
known result-total-count / exhausted-pages The source reported it, or paging drained
estimated plan-estimate The accepted plan estimated it
partial loaded-rows Only count.loaded is known
unknown none No evidence exists

count.value is absent for partial and unknown, the grid renders aria-rowcount="-1", and the live region says "at least N rows loaded; total unknown".

Stable row identity is required, not best-effort. A table with no identityField (or descriptor.schema.primaryKey) reports unsupported, and so does a table whose declared identity attribute is absent, null, or not a string/number on any returned row. Substituting a row's position in its page would key it by an offset that changes with every sort, filter, and page boundary, corrupting selection — so the engine refuses instead.

Paging modes

pagingMode: "offset" (default) issues random-access Query.pagination requests. pagingMode: "cursor" drives the source's stream() generator — the protocol-neutral cursor seam — and is therefore forward-only: a jump past the drained frontier reports unsupported instead of serving the wrong page. A source without stream reports unsupported for cursor paging.

Query evidence

snapshot.evidence carries the accepted plan's planId, planFingerprint, pushdown, and fidelity, plus a work list that attributes each unit of work to the server, worker, or client tier. Remote plan steps are server work, non-remote residual steps are attributed to residualExecution ("client" by default, "worker" when a worker executes them), filter degradation is recorded as a residual, and virtualization plus column formatting are always recorded as client presentation work. featureTableWorkByTier(evidence, tier) selects one tier.

The grid projects that same evidence into a compact, accessible Query execution badge list. A Server, Worker, or Client badge is rendered only when the accepted evidence records work at that tier; its tooltip names every concern and detail, so the UI never infers pushdown from a protocol name.

Page-cache identity (featureTablePageCacheKey) includes the source id and version, schema identity, accepted-plan fingerprint, filter, sort, projection, authorization scope, and freshness — so a stale page can never answer a different question.

The table consumes only canonical Source.query(). Protocol translation of its filter, sort, projection, pagination, spatial, and aggregate requests is covered by the existing real adapter fixtures:

Protocol Adapter evidence
GeoServices test/contract/geoservices-conformance.test.ts
OGC API Features test/contract/ogc-features-backend-agnostic.test.ts, test/contract/ogc-conformance.test.ts
WFS 2.0 test/contract/wfs-backend-agnostic.test.ts, test/contract/wfs.test.ts
OData v4 test/contract/odata-backend-agnostic.test.ts, test/contract/odata-conformance.test.ts

The engine-level suite separately asserts that the accepted canonical request contains the expected page, sort, filter, extent, projection, and aggregation; the adapter suites above prove that request is translated without a table-specific protocol branch.

Linked exploration state

linkFeatureTableToExploration(table, view) binds the table to an ExplorationViewController in both directions. Table selection, sort, filters, and the virtualization window (as the shared page slice) publish outward; peer changes to selection, sort, visible fields, and filters apply inward. Row keys and exploration selection targets round-trip deterministically through table.keysForTargets() and table.selectionTargets(), so map-to-table and table-to-map selection agree on identity.

Extent linkage is explicit. A table starts in "independent" viewport mode; setViewportMode("linked") applies setViewportExtent(...) as a bounded spatial query. linkFeatureTableToExploration(table, view, { extent: true }) tracks the shared map extent, while omitting the option keeps map navigation from changing the table question.

table.aggregate({ groupBy, metrics }) executes a single bounded aggregate request through the same planner, filter, extent, cancellation, and evidence seams as row navigation. Aggregate rows never enter the page cache.

Selection is independent of cache residency: keysForTargets() resolves any target for the table's own source — computed from the target's id, not looked up in the page cache — so a map selecting a feature far outside the loaded window still selects the right row, and selecting it does not clear the shared selection. Targets for other sources resolve to nothing, and publishing the table's selection outward replaces only its own source's entries, leaving a multi-source workspace's peer selections intact.

const grid = context.connectView({ id: "grid", role: "grid" });
const unlink = linkFeatureTableToExploration(table, grid);
// ... later, on teardown:
unlink();

Realtime deltas preserve interaction state

table.applyRealtimeDiff(diff) accepts a reconciliation diff from @honua/sdk-js/realtime directly. Updates patch rows in place — a delta never reorders a materialized page behind the user — so the focused cell, selected rows, sort, and window survive. When state cannot be preserved the outcome announces a documented conflict instead of failing silently:

Conflict code Cause
sort-key-changed An update changed a sorted column's value
selection-invalidated Selected rows were deleted upstream
focused-row-deleted The focused row was deleted upstream
filter-membership-unknown An update may have moved a row into or out of the filtered result
snapshot-reset A replacement snapshot arrived
schema-changed The source schema changed

Conflicts appear in snapshot.conflicts, are read out by the grid's polite live region, and are dispatched as honua-table-conflict.

Keyboard and screen-reader contract

The grid follows the WAI-ARIA grid pattern for the WCAG 2.2 AA workflow: role="grid" with aria-rowcount / aria-colcount / aria-busy, role="row" and role="gridcell" carrying 1-based aria-rowindex / aria-colindex so a virtualized window still announces absolute positions, aria-sort per column header, aria-selected per row, and a role="status" aria-live="polite" region for result truth and conflicts. Exactly one cell is tabbable (roving tabindex).

Keys Action
Arrow keys Move the focused cell
Home / End First / last cell in the row
Ctrl/Cmd + Home/End First / last cell in the grid
PageUp / PageDown Move one window, loading the page focus lands on
Enter / Space Select the focused row

Clicking a column header toggles its sort (Shift-click adds a secondary sort key). The row-height attribute sizes the virtualization spacers; keep it in step with any custom row CSS.

Production-Tier Feature Editing (honua-feature-editor)

honua-feature-editor is the production-tier editing surface (issue #680). It composes the public contract edit primitives — createEditSketchWorkflow, createEditSession, snapping, attachment staging, undo/redo, optimistic hooks — rather than reimplementing any protocol behavior, and it imports no protocol-specific adapter. The survival-tier honua-editor remains available as the simpler controller-bound widget.

Wire it to a Source through createFeatureEditorWorkflow:

import {
  createFeatureEditorWorkflow,
  defineHonuaWebComponents,
} from "@honua/sdk-js/web-components";

defineHonuaWebComponents();

const workflow = createFeatureEditorWorkflow({
  source: dataset.source("permits")!,
  // Optional. Subtypes are a presentation contract: one field carries the
  // subtype code, and each subtype narrows the valid choices per field.
  subtypes: {
    field: "permit_kind",
    defaultCode: 1,
    subtypes: [
      { code: 1, name: "Residential" },
      {
        code: 2,
        name: "Commercial",
        fieldOverrides: {
          status: { domain: { type: "coded-value", codedValues: [{ name: "Under review", code: "review" }] } },
          inspector: { required: true },
        },
      },
    ],
  },
  // Deny-only. An override can never grant an operation the source lacks.
  operations: { delete: { available: false, reason: "Permits are archived, never deleted." } },
});

const editor = document.querySelector("honua-feature-editor")!;
editor.workflow = workflow;
editor.selectedFeature = selectedPermit; // drives update / delete availability

workflow.begin("update");
const review = workflow.preflight();
// No adapter call occurs. The review contains source/feature identity, changed
// field names, geometry/attachment counts, and validation — never values,
// coordinates, attachment payloads, locators, or credentials.
if (!review.ready) console.warn(review.blockers, review.validation.errors);

What the element derives, and what it refuses:

Geometry can additionally be drawn on the map. createTerraDrawEditorSketch is the app-platform default: it constructs terra-draw (an optional peer) for a MapLibre map, wires SDK snapping, reports terra-draw's real tool support so rectangle / circle become enabled, and re-binds itself to each new draft.

import { createTerraDrawEditorSketch } from "@honua/sdk-js/web-components";

const sketch = await createTerraDrawEditorSketch(map, { workflow, snapping: { index } });
sketch.setTool("polygon"); // arms terra-draw and the workflow together
await sketch.ready(); // resolves once bound to the current draft

Apps that construct terra-draw themselves use bindEditorSketch(draw, { workflow }) and, for snapping, editorSnappingOptions(workflow, { index }). Route snapping through that helper rather than passing a model directly: terra-draw resolves its snapping hook once per mode, and the editor builds a new contract sketch model for every draft, so a directly-passed model would pin snapping to the first draft and make setSnapping(...) on later drafts a silent no-op.

Two lifecycle details worth knowing:

Events: honua-feature-edit-change (redacted snapshot on every state change) and honua-feature-edit-commit (the submit outcome, including transported).

Production qualification matrix

Production support for the component kit is tracked as an enforceable matrix rather than a claim in prose: every shipped tag in the component catalog against every gate that matters for accessibility, localization, visual behavior, CSP, lifecycle cleanup, and bundle cost (issue #683, REQ-004/REQ-005/NFR-001).

The matrix is seeded from what the test suite actually asserts today. passing requires evidence files that are verified to exist, so deleting the test behind a gate fails CI instead of quietly downgrading the claim. failing records a requirement we know is unmet — including from code inspection, before an automated gate exists. pending — most of this matrix — means no automated gate and no verdict yet. not-applicable requires an argument.

These gates are a different axis from the catalog's supportTier. The tier records the functional maturity a feature issue delivered on a component (honua-feature-editor is production-tier because issue #680 delivered capability-aware editing, conflict handling, snapping, and attachment staging). Gate completion is the strictly harder bar, so the verifier enforces only that direction — anything clearing every gate must carry the production tier — and every production-tier component's still-open gates are listed as openGates in the manifest, so the tier can never be mistaken for gate completion.

414 cells across 23 components x 18 gates: 343 passing, 0 failing, 21 pending, 50 not applicable.

2 of 23 components have cleared every gate. That is a different axis from the catalog's supportTier, which records the functional maturity a feature issue delivered: 2 component(s) are production-tier and still carry open gates, listed per component as openGates in the manifest.

Component Tier keyboard-behavior screen-reader-semantics focus-restoration reduced-motion high-contrast responsive-layout localization pseudo-locale rtl visual-regression strict-csp zero-console-error deterministic-disposal duplicate-listener memory-leak ssr-import secure-export bundle-budget
honua-basemap-switcher (controls) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-swipe-control (controls) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-legend (controls) survival n/a pass n/a n/a pass pass pass pass pass pending pass pass pass n/a pass pass n/a pass
honua-layer-list (controls) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-map (web-components) survival pass pass pass pass pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-layer-list (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-legend (web-components) survival n/a pass n/a n/a pass pass pass pass pass pending pass pass pass n/a pass pass n/a pass
honua-feature-table (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-feature-inspection (web-components) production pass pass pass n/a pass pass pass pass pass pass pass pass pass pass pass pass n/a pass
honua-search (web-components) survival pass pass pass pass pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-editor (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-feature-editor (web-components) production pass pass pass n/a pass pass pass pass pass pass pass pass pass pass pass pass n/a pass
honua-chart (web-components) survival n/a pass n/a n/a pass pass pass pass pass pending pass pass pass n/a pass pass n/a pass
honua-basemap-control (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-bookmarks (web-components) survival pass pass pass pass pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-locate-control (web-components) survival pass pass pass pass pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-measure-control (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-measurement (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-time-slider (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-sketch-control (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-print-export (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass pass pass
honua-map-status (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass
honua-action-panel (web-components) survival pass pass pass n/a pass pass pass pass pass pending pass pass pass pass pass pass n/a pass

Gate definitions and per-cell evidence and notes live in config/component-qualification.v1.json.

Regenerate with npm run qualification:components; npm run qualification:components:check is the CI drift and invariant gate.