Changelog
The canonical source is
/CHANGELOG.mdat the repo root. The package'sprepackhook copies it intopackages/core/so npm publish ships it inside the tarball.
and Semantic Versioning.
Unreleased — 0.2.0 (API surface alignment)
Fixed
mapBackerased unrelated columns on partial updates —grist-plugin-api.js'smapColumnNamesBackapplies transformations for all mapped columns, injectingundefinedfor fields absent from the patch. Thoseundefinedvalues JSON-serialise tonullover RPC, causing Grist to erase the corresponding cells.mapBacknow strips allundefinedentries from the result so only fields explicitly included in the patch are sent in the update.- Access-insufficient alerts for all SDK hooks —
useGristSchema,useGristRowsFromTable, anduseGristAttachmentsRestnow surface access-insufficient errors through the provider'sreadErrorso the SDK alert system displays a smooth "Access level" alert instead of failing silently.useGristSchemaanduseGristRowsFromTabledelegate to the provider's guarded read methods when inside aGristWidgetProvider;useGristAttachmentsRestuses its ownguardedRpcwrapper whosereadErroris merged intoUseGristResult. - Heartbeat false-positive on semantic errors —
applyActionsfailures (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/buildReplicaDocumentFromDocApiaccess 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 setsreadError, preventing a looping RPC failure cycle. Theaccess-insufficientSDK alert is emitted automatically so the<GristSdkAlerts>/useGristSdkAlertDescriptorsshell shows actionable instructions.@accessannotations — correctedfetchTable,fetchTableRows,fetchRow,listColumns, andbuildReplicaDocumentFromDocApifrom@access "read table"to@access "full"inUseGristResultJSDoc.
Added
gristAddVisibleColumnAction(tableId, colId, colInfo)— new action builder that emits["AddVisibleColumn", ...]. UnlikegristAddColumnAction, 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 filtering —listTables()now accepts{ includeSystem?: boolean }and filters system/hidden tables (_grist*,GristHidden_*) by default.useGristWidgetOptionsFromContext<T>()— typed widget options hook designed for use inside<GristWidgetProvider>. Providesoptions,loading,setOptions,patchOptions, andresetwith debounced writes and anamespaceoption. UnlikeuseGristWidgetOptions()(advanced), this hook does not callgrist.ready()and is fully compatible with the provider.UseGristResultJSDoc — documents that all function-typed fields are referentially stable (useCallback-wrapped) and safe inuseEffectdeps, while the container object itself is not.UseGristResultaccess-level annotations — every field now carries an@accessJSDoc tag ("none","read table", or"full") so editors and documentation show the minimumrequiredAccessat a glance. Fields are grouped by access tier in the type definition and in the API reference.suppressAlertsonUseGristOptions— widgets that intentionally operate without a link source can now declaresuppressAlerts: ["section-not-linked"]in theirGRIST_OPTIONS. The alert system (useGristSdkAlertDescriptors) reads it automatically from the widget slice — no extra wiring needed. A lower-levelsuppressKindsoption onGetGristSdkAlertDescriptorsOptionsis also available as an override.source-not-wiredSDK alert — when a widget declaresallowSelectBy: truebut no other section is linked to read from it, a distinctsource-not-wiredalert is emitted instead ofsection-not-linked. This clearly distinguishes "widget expects an incoming link" from "widget is a selector but nothing listens yet".access-insufficientSDK alert — when a write or REST call fails because of insufficient access ("Access not granted", etc.), the alert system now emits a dedicatedaccess-insufficientalert with actionable copy instead of the genericaction-error. Hosts render it as an error-severity callout.- API reference grouped by access tier — the
useGristAPI 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 useslistColumns()for lightweight column discovery.
Changed
- Monorepo tooling — upgrade to pnpm 11 (
packageManagerpin), rootengines(Node 22+, pnpm 11+), andpnpm-workspace.yamlsettings (engineStrict,minimumReleaseAge7 days,allowBuilds). README documentscorepack enable.
Added
<GristBoundary>shell UX — blocking states (booting, unavailable, error, preparing) use centered layout viaGristBoundaryScreenwith neutral typography, visible card borders, and shell background (#f8f8f8fallback). 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. HelpersformatBoundaryUserMessage,GRIST_BOUNDARY_PREPARING_COPY.Host access level enforcement —
interaction.access_levelfromgrist.onOptionsis applied to the handshakeauthzaxis (AUTHZ_REPORT). When Grist grants less thanrequiredAccess(e.g. widget requestsread tablebut the document is set to no access),useGrist().statusbecomeserrorand<GristBoundary>shows the error fallback instead of widget content. After Try reconnecting /reload(), the cachedonOptionslevel is re-checked when the handshake goes online so insufficient access stays blocked even when Grist does not send a freshonOptionsevent.section-not-linkedSDK alert —getGristSdkAlertDescriptorsemits a warning when Grist reportswidgetInteraction.linking.asTarget === null(including when a stale row is still shown).onOptionssettings are normalized (accessLevel→access_level,linkingparsed) before they reachw.widgetInteraction. (section not driven by a linked table/selector). HelpersisWidgetSectionNotLinked,formatSectionNotLinkedAlertMessage; typeGristWidgetLinkingInfo. Older hosts withoutlinkingononOptionsare unchanged (no false positive).grist-widget-sdk/advancedbuild export —advancedentry intsupandpackage.jsonexportsso documented advanced hooks resolve from npm.useGrist().capabilities— projects handshakeGristCapabilities(canRender,canWriteRecords,missingMappings, …) on the primary hook; typeGristCapabilitiesexported from the main entry.Guide: Raw plugin API vs SDK — comparison table and migration snippets vs calling
gristdirectly.Docs home — eight VitePress feature cards (four « One … » product links + four guide links); original hero tagline.
Vite template DX — ESLint
no-restricted-globalsforgrist,grist-types.example.ts,GristBoundary gate="canRender"whencolumnsare set (no bundled tests — see/guide/testing).Handshake-aware boundary + alert helpers —
task-070.deriveBoundaryView,deriveBoundaryBootLabel, extendedgetGristSdkAlertDescriptors(mapping-pending, mapping-unreported, link-stale, current-table-error;title/severityon descriptors),useGristSdkAlertDescriptors, and<GristBoundary gate="canRender">with phase-aware boot labels when the manager is mounted.useGristHandshake()/useGristCapabilities()hooks —task-062. Exported fromgrist-widget-sdk/advanced. Returns the fullGristWidgetSnapshot(lifecycle / link / authz / config / sync), derivedstatus, error message, and pre-computedGristCapabilities(canRead,canRender,canWriteRecords,canWriteSchema,canFetchTable,hasFreshSelection, …). Includesreload()andrestart()controls. Independent of the existinguseGrist*hooks — no breaking change to the current API surface.<GristHandshakeProvider>+useGristHandshakeContext()/useGristHandshakeContextOptional()—task-064. Opt-in React provider that mounts a singleGristHandshakeManagerper 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 types —
GristWidgetSnapshot,GristCapabilities,GristLifecycle*,GristLink*,GristAuthz,GristConfig,GristSync,GristMapping*,GristStreamFreshness,GristCurrentTableState,GristGeneration,GristTerminationReasonre-exported from/advanced.
Fixed
mapBackinjected spuriousidfield —grist-plugin-api.js'smapColumnNamesBackunconditionally copiesfrom.id → to.id(a side-effect of sharing code with forward mapping). When the input patch has noidkey, the result containedid: undefined, causing Grist to reject writes with "Invalid column 'id'". The SDK now strips the injectedidkey.Playground theme-demo stuck on
"light"—useGristThemelistens ongrist.on("message")only (productiongrist-plugin-api.js). Emulator transports post the samemsg.themeobject shape (appearance,name,colors); removed emulator-onlythemeInitialChange/themeChange. Playground shell theme (d) is mirrored viaemulator.theme.set.useGrist()cursor updates in production Grist — merged widget state is rebuilt from live slice contexts (useGristFromProvider) instead of a memoizedGristContextsnapshot that could keepw.record.idon the first row afteronRecordfired again.recordEventalso listens for hostmessageevents with a new numericrowIdand refetches viafetchSelectedRecord(same path asgrist-plugin-api.jsonRecord).Playground iframe: row stuck on first selection — iframe transport now sends
dataChange: trueoncursor-change(same contract as inlinepushRecord), sogrist-plugin-api.jsrefetches the record when the inspector changes the cursor. Cursor-only messages leftw.record.idfrozen on the initial row.Selected row missing after handshake —
useGristSelectionnow bindsgrist.onRecord/onRecordson mount instead of waiting fordocApi(lifecycle.phase === "online"). The host can push the initial cursor record duringgrist.readybeforedocApiexists; late binding leftw.record/w.modestuck atnull/"empty". A follow-up mount effect that cleared stream state wheneverdocApiwas falsy ran after therecordEventreplay and wiped the first row; clearing now happens only after a real disconnect, and cached payloads are re-applied whendocApiturns ready. The handshake manager also wires stream subscriptions when negotiation starts. Regression tests intests/sdk/selection-initial-record.test.tsx.Selection stuck on the first row —
useGristSelectionnow shallow-copiesonRecord/onRecordspayloads. Grist can reuse one record object and mutate fields in place; React skipped re-renders when the reference was unchanged, sow.recordlooked frozen (e.g. always{ "id": 1 }).
Changed
Widget Pages deploy concurrency — GitHub Actions deploy workflows use
queue: maxon the sharedpages-gh-pagesgroup so multiple widget deploys triggered by one push (e.g.packages/corechanges) queue instead of canceling each other while waiting.SDK alerts use classic
severityonly —info/warning/erroron each descriptor; host shells style fromseverity(templateGristSdkAlertsmaps warning → amber, info → muted, error → destructive).Full SOTA handshake — no legacy connectivity path — all SDK hooks now route through
GristHandshakeManageronly. RemoveduseGristCoreFromLegacy, the inlinemergeGristStatus()ladder in the compose path, and the parallel mapping bootstrap inuseGristSelection(mappings +columnMappingStatusnow project fromsnapshot.config.mappingsviaderiveColumnMappingStatus/extractResolvedMappings). StandaloneuseGrist()/ mid-level hooks without<GristWidgetProvider>share a ref-counted page-level embedded manager (acquireEmbeddedHandshakeManager).useGristReadyanduseGristAvailabilityare thin wrappers overuseGristCore(FSM-backed).useGristCurrentTablefeedsCURRENT_TABLE_*actions into the reducer and readscurrentTableId/ loading / errors fromsnapshot.sync.currentTable. Mid-leveluseGristTableOps/useGristRowsFromTableparticipate in heartbeat coalescence viauseRpcHeartbeatCoalesce. Deletedtests/unit/handshake-legacy-equivalence.test.ts(obsolete).Heartbeat auto-coalescence across the slice hooks —
task-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, anduseGristActions().applyand derivatives) is now reported to the handshake manager via a single internaluseRpcHeartbeatCoalesce()helper. The heartbeat treats each success as a freeHEARTBEAT_OKand 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 (GristWidgetContextTreeunder the manager). Without this,useRpcHeartbeatCoalesce()resolved tonullinside the slice hooks and coalescence silently no-op'd —tests/sdk/handshake-rpc-coalesce.test.tsxpins the contract. - Defensive
typeof grist === "undefined"guards added touseGristCurrentTableanduseGristSelectionso late passive effects firing after a test'semulator.dispose()no longer surfaceReferenceError: grist is not defined. Pre-existing flake, surfaced by the new test layout, now 0/10 in the stability loop.
- The legacy
<GristWidgetProvider>now mounts the handshake manager internally —task-065. The legacy provider creates a singleGristHandshakeManagerper instance and exposes it through a private context.useGristCorereads from that manager (viauseSyncExternalStore) 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,useGristCorefalls back to the pre-FSMuseGristAvailability+useGristReadychain so standalone escape hatches keep working byte-for-byte.- The manager's negotiate effect routes through the existing
ensureGristReady()singleton (so any strayuseGristReady()user coalesces with the manager's ready call), and the provider passesonBeforeReload: resetGristReadySingleton()so a user-triggeredreload()actually re-issuesgrist.readyinstead of replaying the cached promise. - Heartbeat is on by default for the provider (same defaults as
<GristHandshakeProvider>); pass nothing to keep it, or threadoptions.heartbeat = falsethrough 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.tsxandtests/sdk/column-mapping-pending.test.tsx) were tightened from a "snapshot once" assertion to a singlewaitForblock on the fully-settled state, becausependingColumnMappingStatus.okistrue(nomissingcolumns reported yet) and the legacy assertion was racing against the transient pending-but-ok window.
- The manager's negotiate effect routes through the existing
Internal
- Handshake state machine (foundation) —
task-060. Newpackages/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 layer —
task-061.internal/handshake/effects/wires the pure machine to a real (or stubbed) runtime:detect.ts— exponential-backoff polling forwindow.grist, adaptive budget driven bynavigator.connection.effectiveType(30 s on 4g, 60 s on 3g, 120 s on 2g/slow-2g).negotiate.ts— issuesgrist.readywith a 30 s timeout and an externalAbortSignal; bridges promise/sync ready impls.subscriptions.ts— pluggable binder; default wires SDK singletons,noopSubscriptionsBinderavailable for tests.mappings.ts— declare +sectionApi.mappings()fetch + stream-payload ingestion + 5 sMAPPING_TIMEOUTfallback tounreported.manager.ts—GristHandshakeManagerowns the snapshot, runs effects in response to lifecycle transitions, bumps generation onreload(), cancels through a per-generationAbortController. Implements thesubscribe/getSnapshotinterface React'suseSyncExternalStorerequires. ExposesrecordRpcSuccess()/recordRpcFailure()for external coalescing with the heartbeat.
- Heartbeat effect —
task-063.internal/handshake/effects/heartbeat.ts:- Interval probe (default 30 s) calls
grist.docApi.getDocName()(or any customprobe); 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:hiddenand resumes (with an immediate probe) onvisible. Listens toonlineevents for instant network-recovery re-probe. - Reducer transitions:
connected → staleafterstaleAfterMissedmisses,→ lostafterlostAfterMissed.lostescalates to global"error"status viaderiveStatus.
- Interval probe (default 30 s) calls
- Environment abstraction —
internal/handshake/environment.tsexposes aGristEnvironmentinterface (now,setTimeout,probeGrist,effectiveTypeHint, …) so effects are unit-testable with a virtual clock viacreateTestEnvironment. Production code usescreateBrowserEnvironment. - Mapping resolver —
MappingResolvermerges column mappings fromsection_api/stream_record/stream_records/stream_new_recordwith 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, andsubscribe/getSnapshotcontract foruseSyncExternalStore. useGristHandshake()integration — 3 emulator-driven tests intests/sdk/handshake-react.test.tsxcovering ready transition, mapping state propagation, and stream-subscription wiring againstrenderWithGrist.- 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,onlineevent,cancel()cleanup. - Heartbeat ↔ manager integration — 5 tests in
tests/unit/handshake-manager.test.ts: link degradationconnected → stale → lost,recordRpcSuccess()reset, heartbeat shutdown onstop(),heartbeat: falsedisablement. <GristHandshakeProvider>integration — 4 tests intests/sdk/handshake-provider.test.tsx: shared snapshot across consumers, optional vs throwing context hooks.- Property-based / chaos tests —
task-067. 12 tests intests/unit/handshake-properties.test.tsusingfast-check: resolver determinism (200 random runs per property), generation gate, reducer idempotence,terminatedabsorption, fuzz sequences of up to 30 random actions (200 runs) confirming no throws, monotone generation, andlink.statestays in its closed domain. Capability gates are asserted to form a conjunctive chaincanWriteSchema ⇒ canWriteRecords ⇒ canRender ⇒ canReadover 300 randomized snapshots.
Dev dependencies
- Added
fast-check ^4.8.0(used only bytests/unit/handshake-properties.test.ts).
Docs
- Handshake module documentation —
task-068. New API reference page at/api/handshakecovering the public hooks (useGristHandshake/useGristCapabilities/<GristHandshakeProvider>/useGristHandshakeContext/useGristHandshakeContextOptional), the full snapshot shape (GristWidgetSnapshot,GristLifecycle,GristLink,GristAuthz,GristConfig,GristSync), derivedGristCapabilities, heartbeat coalescence semantics, andreload()vsrestart(). New conceptual guide page at/guide/handshakecovering 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.mdto 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.mdcycle; planning lives in chat. Roadmap + task board +apps/docs/work.mdreplace the formal spec file.
Changed
- Slice hook return stability — slice hooks memoize their result objects so React context consumers and
React.memochildren keep stable callable references (table,mapBack,reload, …) across unrelated slice updates.
Tests
Slice identity —
slice-identity.test.tsxasserts zero extra renders for memoized children when selection, writes, theme, or status slices change in isolation; 1000-rowrecordslist stays stable on cursor-only changes.Render budget bench —
pnpm --filter grist-widget-sdk benchrunstests/bench/render-budget.test.tsxand writespackages/core/bench/results.json(full-record / slice / write / schema-fetch render deltas onpresets.todoList()).useGristSchemasnapshots —use-grist-schema.snapshot.test.tsxguardsblank/todoList/contacts×schema-only/schema+samples/schema+datareplica output from the emulator.
Docs
- Render budgets —
/guide/performancedocuments measured re-renders per operation from the bench harness and slice-isolation expectations.
Fixed
waitForEventno 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 load —
columnMappingStatus.pendingstays true until Grist reports mappings (onRecord/onRecordsorsectionApi.mappings()). Widgets no longer show a false "Column mapping is incomplete" alert during the brief window afterstatus === "ready".getGristSdkAlertDescriptorsignores pending mapping status.
Changed
columnsvssafeParseon reads —/guide/reading-datadocuments the contract:columnsalone yields plain decoded rows;safeParseadds per-cell issue tracking. Covered by unit tests ingrist-table-data.test.ts.- Retired
/design/api-surface.md— export list lives inpublic-api.test.ts+/api/index; conventions in/design/principles.tests/docs/structure.test.tsfails CI if the page returns. - Cleared shipped items from
/design/open-questions→ Pending API tightenings (0.3+ work stays on the task board).
Tests
packages/core/tests/unit/grist-table-data.test.ts—columnsvssafeParsematerialization shapes.packages/core/tests/docs/structure.test.ts— docs project in Vitest;api-surface.mdmust 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-startedopens with a two-minutedegitTL;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, andattachment-galleryunderapps/playground/src/widgets/, reachable athttps://demo.grist-widgets.com/widget.html?id=<id>(raw, pasteable into a Grist Custom Widget URL) andhttps://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.mddeleted). Architecture / replica content lives canonically under/design/./files/start-here.mdbecomes 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.txtatapps/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/tsblock in/guide/getting-started,/guide/cookbook,/guide/cheatsheet, and the landing page (/) whose first line is// @exampleis 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 degitTL;DR scaffold block, a complete fifteen-line widget shown as a// @exampletsxblock (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.
Designis no longer a top-level nav entry;Referencecollapses 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.mdis retired: its conventions (naming / slot / empty-null / promise) move to/design/principles.mdas a## Conventionssection, and its "breaking changes to do" list moves to/design/open-questions.mdas## 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 degitblock lives under a new## Start with Viteheading that explicitly flags more templates (Next.js, plain HTML) are planned, linking to/guide/templatesas the running roster. YAML feature cards (which rendered above the wayfinder and carried deep links) are replaced by a## Highlightsmarkdown 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./demospromoted to a top-level URL. The catalogue page was previously at/guide/demos, miscategorising the showcase as a learning step. Now lives at/demoswith its own top-nav entry, no sidebar (matches the catalogue shape). All cross- links — landing page CTAs, getting-started "Next steps",llms.txtindex, 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-editdemo widget rendered beside the playground's emulator panel. ~39 KB PNG atapps/docs/public/hero.png, served via VitePress'shero.imagefrontmatter 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 inapps/docs/work.md. The source of truth for in-flight scope is now/ITERATION.md; thisCHANGELOG.mdis the source of truth for history.Root
pnpm testnow 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// @exampleblocks 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.mdretired,/design/principles.mdConventions block,/design/open-questions.mdPending API tightenings, body order (code-before-scaffold), uniform feature- cardlinkText: "Learn more →", heroimage.src: /hero.pngwith the file on disk,/demostop-level move),links.test.ts(resolves every relative link acrossapps/docs/**and the three root files),llms-txt.test.ts(asserts bothllms.txtandllms-full.txt).
Learnings
- The single highest-leverage change was the
// @exampletype-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@includedirective 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 separateAPI/Designentries) 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:
/demosbelonged 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./cookbookreferences) but every break was caught at build time by the dead-link detector + the existinglinks.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
/demosURL 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 exposescurrentTableId,currentTableLoading,refreshCurrentTable,tableError, and the rawdocApihandle. The hook is a single subscription point for "status + selected table" UIs.mapBack(patch)reports skipped logical names viaw.mapBackSkipped(and onuseGristSelection().mapBackSkipped). A new alert descriptorkind: "map-back-skip"surfaces them throughgetGristSdkAlertDescriptors(...).formatMapBackSkipMessage(skipped, hint?)for hosts that build alert copy themselves.presets(blank,todoList,contacts) are now re-exported fromgrist-widget-sdk/emulator/testing.grist-widget-sdk/emulator/testingre-exports the most-used@testing-library/reactprimitives (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. UseuseGrist()for the standalone single-leaf case. - The inline emulator transport emits
themeInitialChange/themeChangeinstead of a singlethemeevent, matching productiongrist-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+allowMultipleend-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 inapps/docs/work.md. The source of truth for in-flight scope is now/ITERATION.md; thisCHANGELOG.mdis the source of truth for history.
0.1.0
Added
- Slice hooks:
useGristSelection,useGristWrites,useGristStatus,useGristTheme patchWidgetOptions,configure,refreshMappingsonuseGrist()fetchAttachmentBlob, schema table action builders, brief REST token cache- Theme subscription via
grist.on("themeInitialChange" | "themeChange")
Changed
- Breaking: Removed deprecated
updateRecord/addRecord/bulk*helpers fromuseGrist() - Breaking: Removed
buildDocument()— usebuildReplicaDocumentFromDocApi()only useGristSchema()defaults torequiredAccess: "read table"GristBoundaryunavailable grace period increased to 5s- Refactored
useGristinto composable internal hooks + context slices
Fixed
currentTableIdnow refreshes when the selected row changesvalidateColumnMappingsno longer double-counts missingallowMultiplecolumns