Skip to content

Changelog

The canonical source is /CHANGELOG.md at the repo root. The package's prepack hook copies it into packages/core/ so npm publish ships it inside the tarball.

and Semantic Versioning.

Unreleased — 0.2.0 (API surface alignment)

Fixed

  • mapBack erased unrelated columns on partial updatesgrist-plugin-api.js's mapColumnNamesBack applies transformations for all mapped columns, injecting undefined for fields absent from the patch. Those undefined values JSON-serialise to null over RPC, causing Grist to erase the corresponding cells. mapBack now strips all undefined entries from the result so only fields explicitly included in the patch are sent in the update.
  • Access-insufficient alerts for all SDK hooksuseGristSchema, useGristRowsFromTable, and useGristAttachmentsRest now surface access-insufficient errors through the provider's readError so the SDK alert system displays a smooth "Access level" alert instead of failing silently. useGristSchema and useGristRowsFromTable delegate to the provider's guarded read methods when inside a GristWidgetProvider; useGristAttachmentsRest uses its own guardedRpc wrapper whose readError is merged into UseGristResult.
  • Heartbeat false-positive on semantic errorsapplyActions failures (e.g. "No such column") no longer briefly flash the connection-degraded indicator. RPC failures are no longer coalesced into the heartbeat; the regular probe detects real transport issues on its own schedule.
  • fetchTable / fetchTableRows / fetchRow / listColumns / buildReplicaDocumentFromDocApi access guard — these methods now check the granted access level before making an RPC call. When the widget only has "read table" access, the SDK throws immediately with a descriptive message and sets readError, preventing a looping RPC failure cycle. The access-insufficient SDK alert is emitted automatically so the <GristSdkAlerts> / useGristSdkAlertDescriptors shell shows actionable instructions.
  • @access annotations — corrected fetchTable, fetchTableRows, fetchRow, listColumns, and buildReplicaDocumentFromDocApi from @access "read table" to @access "full" in UseGristResult JSDoc.

Added

  • gristAddVisibleColumnAction(tableId, colId, colInfo) — new action builder that emits ["AddVisibleColumn", ...]. Unlike gristAddColumnAction, it also adds the column to the current view section so it is immediately visible.
  • w.listColumns(tableId, options?) — new lightweight API to retrieve column metadata (id, label, type, formula, description) for a given table without fetching all row data. Noise columns (id, manualSort, gristHelper_*, logging formulas) are filtered out by default.
  • w.listTables(options?) — system-table filteringlistTables() now accepts { includeSystem?: boolean } and filters system/hidden tables (_grist*, GristHidden_*) by default.
  • useGristWidgetOptionsFromContext<T>() — typed widget options hook designed for use inside <GristWidgetProvider>. Provides options, loading, setOptions, patchOptions, and reset with debounced writes and a namespace option. Unlike useGristWidgetOptions() (advanced), this hook does not call grist.ready() and is fully compatible with the provider.
  • UseGristResult JSDoc — documents that all function-typed fields are referentially stable (useCallback-wrapped) and safe in useEffect deps, while the container object itself is not.
  • UseGristResult access-level annotations — every field now carries an @access JSDoc tag ("none", "read table", or "full") so editors and documentation show the minimum requiredAccess at a glance. Fields are grouped by access tier in the type definition and in the API reference.
  • suppressAlerts on UseGristOptions — widgets that intentionally operate without a link source can now declare suppressAlerts: ["section-not-linked"] in their GRIST_OPTIONS. The alert system (useGristSdkAlertDescriptors) reads it automatically from the widget slice — no extra wiring needed. A lower-level suppressKinds option on GetGristSdkAlertDescriptorsOptions is also available as an override.
  • source-not-wired SDK alert — when a widget declares allowSelectBy: true but no other section is linked to read from it, a distinct source-not-wired alert is emitted instead of section-not-linked. This clearly distinguishes "widget expects an incoming link" from "widget is a selector but nothing listens yet".
  • access-insufficient SDK alert — when a write or REST call fails because of insufficient access ("Access not granted", etc.), the alert system now emits a dedicated access-insufficient alert with actionable copy instead of the generic action-error. Hosts render it as an error-severity callout.
  • API reference grouped by access tier — the useGrist API reference page organizes fields under "none", "read table", and "full" headings so developers can quickly see which features require which access level.
  • create-email-draft widget: diffusion lists — users can configure a "diffusion list" table via the widget's Open configuration panel (select table, display-name column, and emails column). Typing / in the Bcc field opens a picker to insert all emails from a diffusion list at once. Config panel now uses listColumns() for lightweight column discovery.

Changed

  • Monorepo tooling — upgrade to pnpm 11 (packageManager pin), root engines (Node 22+, pnpm 11+), and pnpm-workspace.yaml settings (engineStrict, minimumReleaseAge 7 days, allowBuilds). README documents corepack enable.

Added

  • <GristBoundary> shell UX — blocking states (booting, unavailable, error, preparing) use centered layout via GristBoundaryScreen with neutral typography, visible card borders, and shell background (#f8f8f8 fallback). Access-denied copy is short and points to Custom widget settings. Widget HTML templates include inline background styles to reduce the initial white flash before the bundle loads. Helpers formatBoundaryUserMessage, GRIST_BOUNDARY_PREPARING_COPY.

  • Host access level enforcementinteraction.access_level from grist.onOptions is applied to the handshake authz axis (AUTHZ_REPORT). When Grist grants less than requiredAccess (e.g. widget requests read table but the document is set to no access), useGrist().status becomes error and <GristBoundary> shows the error fallback instead of widget content. After Try reconnecting / reload(), the cached onOptions level is re-checked when the handshake goes online so insufficient access stays blocked even when Grist does not send a fresh onOptions event.

  • section-not-linked SDK alertgetGristSdkAlertDescriptors emits a warning when Grist reports widgetInteraction.linking.asTarget === null (including when a stale row is still shown). onOptions settings are normalized (accessLevelaccess_level, linking parsed) before they reach w.widgetInteraction. (section not driven by a linked table/selector). Helpers isWidgetSectionNotLinked, formatSectionNotLinkedAlertMessage; type GristWidgetLinkingInfo. Older hosts without linking on onOptions are unchanged (no false positive).

  • grist-widget-sdk/advanced build exportadvanced entry in tsup and package.json exports so documented advanced hooks resolve from npm.

  • useGrist().capabilities — projects handshake GristCapabilities (canRender, canWriteRecords, missingMappings, …) on the primary hook; type GristCapabilities exported from the main entry.

  • Guide: Raw plugin API vs SDK — comparison table and migration snippets vs calling grist directly.

  • Docs home — eight VitePress feature cards (four « One … » product links + four guide links); original hero tagline.

  • Vite template DX — ESLint no-restricted-globals for grist, grist-types.example.ts, GristBoundary gate="canRender" when columns are set (no bundled tests — see /guide/testing).

  • Handshake-aware boundary + alert helperstask-070. deriveBoundaryView, deriveBoundaryBootLabel, extended getGristSdkAlertDescriptors (mapping-pending, mapping-unreported, link-stale, current-table-error; title / severity on descriptors), useGristSdkAlertDescriptors, and <GristBoundary gate="canRender"> with phase-aware boot labels when the manager is mounted.

  • useGristHandshake() / useGristCapabilities() hookstask-062. Exported from grist-widget-sdk/advanced. Returns the full GristWidgetSnapshot (lifecycle / link / authz / config / sync), derived status, error message, and pre-computed GristCapabilities (canRead, canRender, canWriteRecords, canWriteSchema, canFetchTable, hasFreshSelection, …). Includes reload() and restart() controls. Independent of the existing useGrist* hooks — no breaking change to the current API surface.

  • <GristHandshakeProvider> + useGristHandshakeContext() / useGristHandshakeContextOptional()task-064. Opt-in React provider that mounts a single GristHandshakeManager per app tree and broadcasts its snapshot to all descendants. Coexists with the legacy <GristWidgetProvider> without interference (ready calls are deduped at the singleton level).

  • Public snapshot typesGristWidgetSnapshot, GristCapabilities, GristLifecycle*, GristLink*, GristAuthz, GristConfig, GristSync, GristMapping*, GristStreamFreshness, GristCurrentTableState, GristGeneration, GristTerminationReason re-exported from /advanced.

Fixed

  • mapBack injected spurious id fieldgrist-plugin-api.js's mapColumnNamesBack unconditionally copies from.id → to.id (a side-effect of sharing code with forward mapping). When the input patch has no id key, the result contained id: undefined, causing Grist to reject writes with "Invalid column 'id'". The SDK now strips the injected id key.

  • Playground theme-demo stuck on "light"useGristTheme listens on grist.on("message") only (production grist-plugin-api.js). Emulator transports post the same msg.theme object shape (appearance, name, colors); removed emulator-only themeInitialChange / themeChange. Playground shell theme (d) is mirrored via emulator.theme.set.

  • useGrist() cursor updates in production Grist — merged widget state is rebuilt from live slice contexts (useGristFromProvider) instead of a memoized GristContext snapshot that could keep w.record.id on the first row after onRecord fired again. recordEvent also listens for host message events with a new numeric rowId and refetches via fetchSelectedRecord (same path as grist-plugin-api.js onRecord).

  • Playground iframe: row stuck on first selection — iframe transport now sends dataChange: true on cursor-change (same contract as inline pushRecord), so grist-plugin-api.js refetches the record when the inspector changes the cursor. Cursor-only messages left w.record.id frozen on the initial row.

  • Selected row missing after handshakeuseGristSelection now binds grist.onRecord / onRecords on mount instead of waiting for docApi (lifecycle.phase === "online"). The host can push the initial cursor record during grist.ready before docApi exists; late binding left w.record / w.mode stuck at null / "empty". A follow-up mount effect that cleared stream state whenever docApi was falsy ran after the recordEvent replay and wiped the first row; clearing now happens only after a real disconnect, and cached payloads are re-applied when docApi turns ready. The handshake manager also wires stream subscriptions when negotiation starts. Regression tests in tests/sdk/selection-initial-record.test.tsx.

  • Selection stuck on the first rowuseGristSelection now shallow-copies onRecord / onRecords payloads. Grist can reuse one record object and mutate fields in place; React skipped re-renders when the reference was unchanged, so w.record looked frozen (e.g. always { "id": 1 }).

Changed

  • Widget Pages deploy concurrency — GitHub Actions deploy workflows use queue: max on the shared pages-gh-pages group so multiple widget deploys triggered by one push (e.g. packages/core changes) queue instead of canceling each other while waiting.

  • SDK alerts use classic severity onlyinfo / warning / error on each descriptor; host shells style from severity (template GristSdkAlerts maps warning → amber, info → muted, error → destructive).

  • Full SOTA handshake — no legacy connectivity path — all SDK hooks now route through GristHandshakeManager only. Removed useGristCoreFromLegacy, the inline mergeGristStatus() ladder in the compose path, and the parallel mapping bootstrap in useGristSelection (mappings + columnMappingStatus now project from snapshot.config.mappings via deriveColumnMappingStatus / extractResolvedMappings). Standalone useGrist() / mid-level hooks without <GristWidgetProvider> share a ref-counted page-level embedded manager (acquireEmbeddedHandshakeManager). useGristReady and useGristAvailability are thin wrappers over useGristCore (FSM-backed). useGristCurrentTable feeds CURRENT_TABLE_* actions into the reducer and reads currentTableId / loading / errors from snapshot.sync.currentTable. Mid-level useGristTableOps / useGristRowsFromTable participate in heartbeat coalescence via useRpcHeartbeatCoalesce. Deleted tests/unit/handshake-legacy-equivalence.test.ts (obsolete).

  • Heartbeat auto-coalescence across the slice hookstask-066. Every successful Grist RPC issued through the SDK's slice hooks (useGristWrites().applyActions / .table.* / .getTable(…), useGrist().fetchTable / .fetchTableRows / .fetchRow / .listTables / .getDocName / .fetchSelectedTable / .fetchSelectedRecord / .buildReplicaDocumentFromDocApi, useGrist().getAttachmentUrl / .fetchAttachmentBase64 / .fetchAttachmentBlob / .getAccessToken / .fetchWithAuth, useGrist().getWidgetOptions / .getWidgetOption / .setWidgetOption / .setWidgetOptions / .patchWidgetOptions / .clearWidgetOptions, useGrist().setCursorPosition / .setLinkedRowSelection, useGristSectionApi().configure / .refreshMappings, useGrist().refreshCurrentTable, and useGristActions().apply and derivatives) is now reported to the handshake manager via a single internal useRpcHeartbeatCoalesce() helper. The heartbeat treats each success as a free HEARTBEAT_OK and skips the next scheduled probe; failures shorten the next probe to ≤ 1 s for fast re-confirmation. Chatty widgets cost zero extra round-trips; quiet widgets keep their baseline 30 s health check. Outside <GristWidgetProvider> / <GristHandshakeProvider> the helper is a zero-cost passthrough.

    • The legacy <GristWidgetProvider> was restructured into a two-layer component so the internal manager context is mounted before the slice composer runs (GristWidgetContextTree under the manager). Without this, useRpcHeartbeatCoalesce() resolved to null inside the slice hooks and coalescence silently no-op'd — tests/sdk/handshake-rpc-coalesce.test.tsx pins the contract.
    • Defensive typeof grist === "undefined" guards added to useGristCurrentTable and useGristSelection so late passive effects firing after a test's emulator.dispose() no longer surface ReferenceError: grist is not defined. Pre-existing flake, surfaced by the new test layout, now 0/10 in the stability loop.
  • <GristWidgetProvider> now mounts the handshake manager internallytask-065. The legacy provider creates a single GristHandshakeManager per instance and exposes it through a private context. useGristCore reads from that manager (via useSyncExternalStore) and projects the FSM snapshot into the same { status, isAvailable, isReady, error, docApi, reload } shape the slice composer expects, so every existing slice hook (useGristSelection, useGristReads, useGristWrites, …) transparently benefits from the FSM-driven timing without any API change. Outside the provider, useGristCore falls back to the pre-FSM useGristAvailability + useGristReady chain so standalone escape hatches keep working byte-for-byte.

    • The manager's negotiate effect routes through the existing ensureGristReady() singleton (so any stray useGristReady() user coalesces with the manager's ready call), and the provider passes onBeforeReload: resetGristReadySingleton() so a user-triggered reload() actually re-issues grist.ready instead of replaying the cached promise.
    • Heartbeat is on by default for the provider (same defaults as <GristHandshakeProvider>); pass nothing to keep it, or thread options.heartbeat = false through the manager if a widget needs to opt out.
    • Migration is transparent — no widget code change required. Two pre-existing flaky integration tests (tests/sdk/sdk-react.test.tsx and tests/sdk/column-mapping-pending.test.tsx) were tightened from a "snapshot once" assertion to a single waitFor block on the fully-settled state, because pendingColumnMappingStatus.ok is true (no missing columns reported yet) and the legacy assertion was racing against the transient pending-but-ok window.

Internal

  • Handshake state machine (foundation)task-060. New packages/core/src/sdk/internal/handshake/ module models the widget ↔ Grist relationship as five orthogonal axes (LIFECYCLE, LINK, AUTHZ, CONFIG, SYNC) feeding a pure reducer + status projection + capability derivation.
  • Handshake effects layertask-061. internal/handshake/effects/ wires the pure machine to a real (or stubbed) runtime:
    • detect.ts — exponential-backoff polling for window.grist, adaptive budget driven by navigator.connection.effectiveType (30 s on 4g, 60 s on 3g, 120 s on 2g/slow-2g).
    • negotiate.ts — issues grist.ready with a 30 s timeout and an external AbortSignal; bridges promise/sync ready impls.
    • subscriptions.ts — pluggable binder; default wires SDK singletons, noopSubscriptionsBinder available for tests.
    • mappings.ts — declare + sectionApi.mappings() fetch + stream-payload ingestion + 5 s MAPPING_TIMEOUT fallback to unreported.
    • manager.tsGristHandshakeManager owns the snapshot, runs effects in response to lifecycle transitions, bumps generation on reload(), cancels through a per-generation AbortController. Implements the subscribe / getSnapshot interface React's useSyncExternalStore requires. Exposes recordRpcSuccess() / recordRpcFailure() for external coalescing with the heartbeat.
  • Heartbeat effecttask-063. internal/handshake/effects/heartbeat.ts:
    • Interval probe (default 30 s) calls grist.docApi.getDocName() (or any custom probe); per-probe timeout default 10 s.
    • recordRpcSuccess() coalesces with natural RPC traffic and pushes the next probe out by a full interval — we don't burn requests.
    • recordRpcFailure() immediately degrades the link signal and shortens the next probe to ≤ 1 s for fast re-confirmation.
    • Pauses on visibilitychange:hidden and resumes (with an immediate probe) on visible. Listens to online events for instant network-recovery re-probe.
    • Reducer transitions: connected → stale after staleAfterMissed misses, → lost after lostAfterMissed. lost escalates to global "error" status via deriveStatus.
  • Environment abstractioninternal/handshake/environment.ts exposes a GristEnvironment interface (now, setTimeout, probeGrist, effectiveTypeHint, …) so effects are unit-testable with a virtual clock via createTestEnvironment. Production code uses createBrowserEnvironment.
  • Mapping resolverMappingResolver merges column mappings from section_api / stream_record / stream_records / stream_new_record with a fixed priority order, making the final resolution a pure function of the set of received payloads. Payloads stamped with a stale generation are dropped silently.

Tests

  • Handshake reducer + resolver — 50 new unit tests covering: 24-permutation resolver determinism, generation-stamped action drop, reducer idempotence for duplicate stream payloads, link transitions connected → stale → lost, mapping invalidation/recovery, current-table local-error containment, and capability gates (canRender / canWriteRecords / canWriteSchema / hasFreshSelection).
  • Manager lifecycle — 14 unit tests with virtual time: detect budget exhaustion, NOT_EMBEDDED detection, negotiate success/failure/timeout, reload() generation bump + lifecycle reset, stale-generation drop, capability transitions through online + mapping completion, no error escalation on incomplete mappings, and subscribe / getSnapshot contract for useSyncExternalStore.
  • useGristHandshake() integration — 3 emulator-driven tests in tests/sdk/handshake-react.test.tsx covering ready transition, mapping state propagation, and stream-subscription wiring against renderWithGrist.
  • Heartbeat unit tests — 11 tests in tests/unit/handshake-heartbeat.test.ts: interval start (no t=0 probe), probe success / reject / timeout dispatches, repeated firing, RPC coalesce, visibility pause + resume, online event, cancel() cleanup.
  • Heartbeat ↔ manager integration — 5 tests in tests/unit/handshake-manager.test.ts: link degradation connected → stale → lost, recordRpcSuccess() reset, heartbeat shutdown on stop(), heartbeat: false disablement.
  • <GristHandshakeProvider> integration — 4 tests in tests/sdk/handshake-provider.test.tsx: shared snapshot across consumers, optional vs throwing context hooks.
  • Property-based / chaos teststask-067. 12 tests in tests/unit/handshake-properties.test.ts using fast-check: resolver determinism (200 random runs per property), generation gate, reducer idempotence, terminated absorption, fuzz sequences of up to 30 random actions (200 runs) confirming no throws, monotone generation, and link.state stays in its closed domain. Capability gates are asserted to form a conjunctive chain canWriteSchema ⇒ canWriteRecords ⇒ canRender ⇒ canRead over 300 randomized snapshots.

Dev dependencies

  • Added fast-check ^4.8.0 (used only by tests/unit/handshake-properties.test.ts).

Docs

  • Handshake module documentationtask-068. New API reference page at /api/handshake covering the public hooks (useGristHandshake / useGristCapabilities / <GristHandshakeProvider> / useGristHandshakeContext / useGristHandshakeContextOptional), the full snapshot shape (GristWidgetSnapshot, GristLifecycle, GristLink, GristAuthz, GristConfig, GristSync), derived GristCapabilities, heartbeat coalescence semantics, and reload() vs restart(). New conceptual guide page at /guide/handshake covering when to use the new hooks, the five-axis state machine, the capability chain, the heartbeat, the mapping states, and generation discipline.
  • Updated /api/index.md, /api/provider-boundary.md, /guide/concepts.md, and /guide/error-handling.md to cross-link the new module and to describe the FSM-backed implementation of <GristWidgetProvider>.
  • VitePress navigation: handshake guide listed under "Advanced topics", handshake API ref listed in the unified Reference sidebar.

Process

  • Lightweight workflow. Dropped the seven-step /ITERATION.md cycle; planning lives in chat. Roadmap + task board + apps/docs/work.md replace the formal spec file.

Changed

  • Slice hook return stability — slice hooks memoize their result objects so React context consumers and React.memo children keep stable callable references (table, mapBack, reload, …) across unrelated slice updates.

Tests

  • Slice identityslice-identity.test.tsx asserts zero extra renders for memoized children when selection, writes, theme, or status slices change in isolation; 1000-row records list stays stable on cursor-only changes.

  • Render budget benchpnpm --filter grist-widget-sdk bench runs tests/bench/render-budget.test.tsx and writes packages/core/bench/results.json (full-record / slice / write / schema-fetch render deltas on presets.todoList()).

  • useGristSchema snapshotsuse-grist-schema.snapshot.test.tsx guards blank / todoList / contacts × schema-only / schema+samples / schema+data replica output from the emulator.

Docs

  • Render budgets/guide/performance documents measured re-renders per operation from the bench harness and slice-isolation expectations.

Fixed

  • waitForEvent no longer resolves immediately from bus history; waits for the next matching event. Kind overload (ready, record, records, options, theme, cursor) and clearer timeout errors.
  • Column mapping on loadcolumnMappingStatus.pending stays true until Grist reports mappings (onRecord / onRecords or sectionApi.mappings()). Widgets no longer show a false "Column mapping is incomplete" alert during the brief window after status === "ready". getGristSdkAlertDescriptors ignores pending mapping status.

Changed

  • columns vs safeParse on reads/guide/reading-data documents the contract: columns alone yields plain decoded rows; safeParse adds per-cell issue tracking. Covered by unit tests in grist-table-data.test.ts.
  • Retired /design/api-surface.md — export list lives in public-api.test.ts + /api/index; conventions in /design/principles. tests/docs/structure.test.ts fails CI if the page returns.
  • Cleared shipped items from /design/open-questionsPending API tightenings (0.3+ work stays on the task board).

Tests

  • packages/core/tests/unit/grist-table-data.test.tscolumns vs safeParse materialization shapes.
  • packages/core/tests/docs/structure.test.ts — docs project in Vitest; api-surface.md must not exist.

Learnings: The duplicate api-surface page was pure drift risk once public-api.test.ts existed; documenting columns without safeParse stops readers from wrapping every fetch in safe-parse cell types.

Documentation & DX

  • Developer pathway is now template-first. /guide/getting-started opens with a two-minute degit TL;DR, then keeps manual install + hello-world below the fold.

  • Cheat sheet, cookbook, troubleshooting, templates, and demos. Four new guide pages under /guide/ (cookbook = 10 end-to-end recipes, cheatsheet, troubleshooting, templates) plus a top-level demo catalogue at /demos.

  • Three live demo widgets. form-edit, task-board, and attachment-gallery under apps/playground/src/widgets/, reachable at https://demo.grist-widgets.com/widget.html?id=<id> (raw, pasteable into a Grist Custom Widget URL) and https://demo.grist-widgets.com/?url=widget.html?id=<id> (preview in the playground shell, embedded in /demos).

  • Agent guide consolidation. /files/* is reduced to 9 pages (AGENT.md, architecture.md, testing-patterns.md, replica-document.md deleted). Architecture / replica content lives canonically under /design/. /files/start-here.md becomes the single dense entry: operating contract, eight-step workflow summary, commands, path map, decision tree, anti-patterns, hand-off checklist. Every remaining /files/* page carries an Audience / Companion / Verified-in preamble.

  • llms.txt + llms-full.txt at apps/docs/public/. The first is the standard llmstxt.org index for AI tool discovery; the second concatenates every page in the slim /files/* set in alphabetical order with # <path> delimiters so a single fetch primes a model with the entire agent operating manual.

  • "Choose your path" tiles on the docs home page: four entry points keyed on intent (writing a widget / AI agent / evaluator / see-it-work).

  • Code-block contract. Every fenced tsx / ts block in /guide/getting-started, /guide/cookbook, /guide/cheatsheet, and the landing page (/) whose first line is // @example is type-checked against the SDK at test time. Catches API drift between docs and code automatically. The contract caught five real bugs on first run (gristAddTableAction arg shape, safeParseGristTableData result field name, useGristSchema option name, missing presets.simple, renderWithGrist option shape) — all fixed.

  • Landing page reworked for one-look DX + agent triage. Three hero CTAs (Get started / Reference / Demos), an inline npx degit TL;DR scaffold block, a complete fifteen-line widget shown as a // @example tsx block (type-checked alongside the rest of the docs), and a "Choose your path" markdown table that routes by intent — writing a widget, AI coding agent (links to /files/start-here), evaluating the SDK, or seeing it work. Feature cards rewritten to name the concrete APIs each one represents and link to the most relevant /api/ page.

  • Reference consolidation. Design is no longer a top-level nav entry; Reference collapses Design + API rationale under one umbrella in nav and sidebar. URLs unchanged — /api/* and /design/* still resolve where they did, and both share the same sidebar grouping. apps/docs/design/api-surface.md is retired: its conventions (naming / slot / empty-null / promise) move to /design/principles.md as a ## Conventions section, and its "breaking changes to do" list moves to /design/open-questions.md as ## Pending API tightenings.

  • Landing-page polish. Body region order is now What it looks like → Start with Vite → Choose your path → Highlights: code first, scaffold second, routing third, marketing last. The npx degit block lives under a new ## Start with Vite heading that explicitly flags more templates (Next.js, plain HTML) are planned, linking to /guide/templates as the running roster. YAML feature cards (which rendered above the wayfinder and carried deep links) are replaced by a ## Highlights markdown section below the Choose-your-path table — six capability blurbs with no outbound links so the table is the only navigation surface on the home page.

  • /demos promoted to a top-level URL. The catalogue page was previously at /guide/demos, miscategorising the showcase as a learning step. Now lives at /demos with its own top-nav entry, no sidebar (matches the catalogue shape). All cross- links — landing page CTAs, getting-started "Next steps", llms.txt index, demos.md internal Cookbook links — migrated in the same commit so the build stays green at every HEAD.

  • Hero image. The home page hero now has a real screenshot next to the headline: the form-edit demo widget rendered beside the playground's emulator panel. ~39 KB PNG at apps/docs/public/hero.png, served via VitePress's hero.image frontmatter slot. Real product surface, zero illustration work, zero external network dep on first paint.

  • Consolidated the docs /work/ folder (roadmap + task board + guidelines + release process) into a single iteration workflow described in apps/docs/work.md. The source of truth for in-flight scope is now /ITERATION.md; this CHANGELOG.md is the source of truth for history.

  • Root pnpm test now runs the SDK suite plus the docs build plus the playground's widget bundle. Dead links and unbound widget metadata trip CI without a separate workflow change.

  • New packages/core/tests/docs/ vitest project (node env). Four files: code-blocks.test.ts (type-checks // @example blocks in the three guide pages and the landing page), structure.test.ts (slim /files/* set, preamble blocks, cookbook recipe count, cookbook → demo cross-links, demos catalogue shape, landing-page hero / TL;DR / @example / table shape, /design/api-surface.md retired, /design/principles.md Conventions block, /design/open-questions.md Pending API tightenings, body order (code-before-scaffold), uniform feature- card linkText: "Learn more →", hero image.src: /hero.png with the file on disk, /demos top-level move), links.test.ts (resolves every relative link across apps/docs/** and the three root files), llms-txt.test.ts (asserts both llms.txt and llms-full.txt).

Learnings

  • The single highest-leverage change was the // @example type-check: it caught five real API mismatches between docs and code on the first run. Future iterations should keep adding the sentinel to new code blocks rather than relying on review.
  • Splitting the docs site into two audiences (/guide/ for developers, /files/ for agents) with strict no-duplication rules and a slim, deterministic agent set produced a much cleaner navigation than the previous mixed structure. The /files/ preamble (Audience / Companion / Verified-in) is the gate that keeps the agent surface honest.
  • Embedding demos via the existing playground shell (preview URL = emulated table next to the widget) is a much better reading-experience than a bare widget — the reader sees both the data and the widget's reaction to it. Worth keeping that pattern for any future demo iteration.
  • Including agent-facing top-level files (ITERATION.md, CHANGELOG.md) into VitePress pages via the @include directive is convenient but couples two link-resolution contexts: the source file (read from repo root, GitHub, IDE) and the included page (read from the docs site). Relative links in the source silently break in one of the two. Rule of thumb: any link that lives inside the included range must use an absolute URL — prefer the GitHub permalink so the source file still works outside the docs build.
  • Nav consolidation under Reference (instead of separate API / Design entries) without moving files is a strict improvement: unifies the mental model for readers and AI agents, keeps every external URL stable. Prefer sidebar-level grouping over directory reshuffles whenever the cost is borne by future external links.
  • The opposite intuition holds when a page is genuinely miscategorised: /demos belonged at the top level, not under /guide/. The move broke a handful of cross-links (cookbook, getting-started Next steps, llms.txt, demos.md's own ./cookbook references) but every break was caught at build time by the dead-link detector + the existing links.test.ts, with no manual auditing required. Lesson: trust the safety net, ship the structural fix, watch the build fail loudly, fix the breaks. Cheaper than living with the miscategorisation.
  • Two-pillar landing-page surface — a code block ("what does this look like") plus an image ("what does a real widget look like") — answers more pre-commitment questions than either alone. The image being a real product screenshot (the form-edit demo + emulator side by side) rather than an illustration carries more weight: the reader trusts the surface they're shown is the one they'll be building. Worth preserving as the SDK matures — swap the asset, keep the slot.
  • When build-green-at-HEAD is a hard constraint and two changes are coupled (the /demos URL was simultaneously consumed by the landing page and produced by the moved file), the three-commit plan from the spec collapsed to a two-commit reality. Cleaner to acknowledge this in the commit message and bundle than to leave a broken HEAD or carry a fake "the page exists at the new location but no one links to it" interim state.

Added

  • useGristStatus() now exposes currentTableId, currentTableLoading, refreshCurrentTable, tableError, and the raw docApi handle. The hook is a single subscription point for "status + selected table" UIs.
  • mapBack(patch) reports skipped logical names via w.mapBackSkipped (and on useGristSelection().mapBackSkipped). A new alert descriptor kind: "map-back-skip" surfaces them through getGristSdkAlertDescriptors(...).
  • formatMapBackSkipMessage(skipped, hint?) for hosts that build alert copy themselves.
  • presets (blank, todoList, contacts) are now re-exported from grist-widget-sdk/emulator/testing.
  • grist-widget-sdk/emulator/testing re-exports the most-used @testing-library/react primitives (screen, fireEvent, waitFor, act, cleanup, within, render).
  • emulator.theme.set("light" | "dark") convenience for tests that drive theme transitions.

Changed

  • Breaking: Slice hooks (useGristStatus, useGristSelection, useGristWrites, useGristTheme) now require a parent <GristWidgetProvider> and throw outside of one. Use useGrist() for the standalone single-leaf case.
  • The inline emulator transport emits themeInitialChange / themeChange instead of a single theme event, matching production grist-plugin-api.js. Late listeners get replayed.

Tests

  • Public-API snapshot pins every documented symbol across the four entry points (/, /advanced, /emulator, /emulator/testing).
  • Slice-hook integration tests for status / selection / writes / theme using the emulator (renderWithGrist).
  • Action-builder shape tests for every supported user action.
  • mapBack + allowMultiple end-to-end test exercising the alert path.

Process

  • Consolidated the docs /work/ folder (roadmap + task board + guidelines + release process) into a single iteration workflow described in apps/docs/work.md. The source of truth for in-flight scope is now /ITERATION.md; this CHANGELOG.md is the source of truth for history.

0.1.0

Added

  • Slice hooks: useGristSelection, useGristWrites, useGristStatus, useGristTheme
  • patchWidgetOptions, configure, refreshMappings on useGrist()
  • fetchAttachmentBlob, schema table action builders, brief REST token cache
  • Theme subscription via grist.on("themeInitialChange" | "themeChange")

Changed

  • Breaking: Removed deprecated updateRecord / addRecord / bulk* helpers from useGrist()
  • Breaking: Removed buildDocument() — use buildReplicaDocumentFromDocApi() only
  • useGristSchema() defaults to requiredAccess: "read table"
  • GristBoundary unavailable grace period increased to 5s
  • Refactored useGrist into composable internal hooks + context slices

Fixed

  • currentTableId now refreshes when the selected row changes
  • validateColumnMappings no longer double-counts missing allowMultiple columns

Released under the ISC License.