Changelog
This is the broader project history — widgets, docs, tooling, process — hand-curated at
/CHANGELOG.mdat the repo root. The published npm package's own changelog (Changesets-generated, one entry per release) lives separately atpackages/core/CHANGELOG.mdand ships inside the tarball.
and Semantic Versioning.
Unreleased — 0.2.0 (API surface alignment)
Changed
deploy-apps.yml's Pages deploy no longer needs a human to notice and manually re-trigger it when it gets stuck. Found live, repeatedly:actions/deploy-pagessometimes sits indeployment_in_progressfor its full internal timeout and self-aborts, or occasionally fails outright — backend GitHub Pages flakiness on this repo, confirmed via the Actions API to not be a race (exactly one deployment in flight each time, build already succeeded). Widened the step's owntimeoutfrom thedeploy-pagesdefault (10 min) to 15, and addedretry-deploy-pages.yml, aworkflow_run-triggered follow-up that re-runs just the failed jobs (reusing the already-built artifact) via the "re-run failed jobs" API exactly once, gated onrun_attemptso a genuinely broken deploy (still failing on the retry) surfaces to a human instead of looping forever. Seeapps/docs/files/deploy.md.Consolidated
apps/docs+apps/playground+apps/web's GitHub Pages deploys into one workflow (deploy-apps.yml), and retired the/template/showcase deploy inside this repo entirely. Previously, four independent workflows (deploy-docs.yml,deploy-playground.yml,deploy-web.yml,deploy-template-showcase.yml) each pushed their own commit to a sharedgh-pagesbranch; every push independently triggered GitHub's own hidden native Pages-deployment workflow, and a merge touching multiple apps' trigger paths at once (routine — e.g. a lockfile change) fired several of them in quick succession, queuing multiple native deployments back to back. One of these hitactions/deploy-pages's own ~10-minute timeout live, found during the PR #86 / #87 merge window. Fix:deploy-apps.ymlnow buildsapps/docs,apps/playground, andapps/webin one job, assembles their output into a single tree, and publishes it withactions/upload-pages-artifact+actions/deploy-pages— the modern Actions-native Pages flow, nogh-pagesbranch, no separate hidden listener workflow, no race by construction. Requires this repo's Settings → Pages → Source to be "GitHub Actions." Separately,templates/grist-widget-template-vite's own/template/showcase (deploy-template-showcase.yml+scripts/deploy/template-showcase.mjs, plus its smoke test) has been removed outright rather than folded into the consolidation — its live preview now lives only on the externalgrist-widget-templaterepo viatemplate-canary.yml, which is completely unchanged. This does not affecttemplates/grist-widget-template-vite/.github/workflows/deploy.yml, the bundled pipeline embedded into every scaffolded widget repo — that was never deployed insidegrist-widget-sdkin the first place. Seeapps/docs/files/deploy.md."Use this template" now publishes a clean
v0.0.1the moment the repo is created, instead of publishing nothing until the first real release. Earlier iterations this cycle skipped the repo-creation push entirely (to avoid a copy's later real content inheriting a canary version), but that left a fresh copy with no usable widget URL and only a landing page — confirmed confusing in practice. Reverted the skip: the initial push tomainpublishes a forcedv0.0.1+/latest/+ root right away (a repo's first genuine release always resolves tov0.0.1regardless of the inheritedpackage.jsonversion). To keep the next release clean, the first-release reset step (deploy.mjs'sresetBranchesIfFirstRelease) now also rewritespackage.json's version back to0.0.1in the repo itself (committed tomain/dev), so the second release bumps to0.0.2rather than the inherited canary number. The over-broadcleanupForeignVersionsIfFirstRelease(clears inherited root/latest/, not justv<version>/dirs) is kept — it now runs through the normalplaceTargetfirst-release path. Removed the now-unused--createdflag,cleanup-foreign, andplace-rootsubcommands.
Fixed
- The previous entry's fix (
workflows: writeinrelease.yml'spermissions:) was itself invalid and took the whole release pipeline down harder, found live immediately after merging it: every subsequentrelease.ymlrun failed at workflow-parse time with zero jobs scheduled — worse than the original problem, since that took down npm publishing too, not just the git tag push.workflowsis not one of the fixed set of scopes theGITHUB_TOKENpermissions:block accepts (it's a GitHub Apps installation-level permission, a different concept the error message's wording made easy to conflate with a settable YAML key). Reverted. The narrower original problem — the defaultGITHUB_TOKENcan never create or update a ref whose history introduces.github/workflows/*.ymlchanges, and nopermissions:setting lifts that — remains unfixed and needs a credential other thanGITHUB_TOKENfor that one push (a classic PAT with theworkflowscope, stored as a repo secret); not attempted, since creating one needs repo admin access. npm publishing itself is unaffected by any of this (OIDC Trusted Publishing doesn't go through this token) — only the git tag (and GitHub Release) for a release landing near a workflow-file-editing commit will be missing until that's set up. release.ymlpublishedcreate-grist-widget@0.3.2to npm successfully but then failed to push its git tag, found live:changeset publishreported success and published the package, but the subsequentgit push origin create-grist-widget@0.3.2was rejected —refusing to allow a GitHub App to create or update workflow .github/workflows/deploy-apps.yml without 'workflows' permission. GitHub refuses any ref push (branch or tag) whose history introduces.github/workflows/*.ymlchanges unless the token has theworkflowsscope;release.yml'spermissions:never granted it, and this only surfaced once a release's tag pointed at a commit shortly after actual workflow-file edits (thedeploy-apps.ymlconsolidation, above).create-grist-widget@0.3.2is genuinely live on npm — only the git tag was missing. See the entry above for how the attempted fix went.apps/web404'd every asset and showed a white screen on its first successful live deploy, found live right afterdeploy-apps.yml's first genuinely completed deployment:index.htmlloaded fine, but every/assets/...request 404'd. Root cause:apps/web/vite.config.tsbuilt withbase: "/"(the Vite default), which is only correct if the site is served at the actual domain root — this repo's GitHub Pages is a project site (https://arthurblanchon.github.io/grist-widget-sdk/), where/grist-widget-sdk/is a real path prefix on thegithub.iodomain, not the domain root, so every absolute asset reference resolved one level too high. Local testing viavite previewnever caught this — that serves at the actual domain root oflocalhost, wherebase: "/"genuinely is correct. Fixed by givingapps/webthe sameWEB_BASEbuild-time env varapps/docsandapps/playgroundalready had (DOCS_BASE,PLAYGROUND_BASE), set bydeploy-apps.ymlto/<repo>/. Seeapps/docs/files/deploy.md.- A routine SDK release bumped
templates/grist-widget-template-vite's ownpackage.jsonversion, breaking the nextcreate-grist-widgetscaffold, found live after merging a Version Packages PR:scripts/smoke/create-widget.shfailed withexpected a fresh scaffold's own version to stay 0.0.1, got '0.0.2'. Root cause: Changesets'updateInternalDependencies: "patch"bumps theversionfield of every workspace package that depends on a bumped one,private: trueor not — and the template (plusapps/playground) carries aworkspace:^dependency ongrist-widget-sdkpurely for monorepo DX. The bumped template version rode straight into the nextcreate-grist-widgetpublish viabuild-template.mjs, so every widget it scaffolds would have inherited a wrong starting version. Fixed two ways: both packages are now listed in.changeset/config.json'signoreso Changesets never touches theirversionagain, andbuild-template.mjsforce-sets the embedded template'sversionto0.0.1at the one choke point that actually matters, regardless of what the source repo'spackage.jsonsays. - The ref-created-push skip (below) left a fresh "Use this template" copy's repo-root URL 404ing until its first real release, found live on
ask-genial-widget:https://<you>.github.io/<repo>/was a bare 404. The initial deploy that used to place the showcase-hub landing page at the site root now published nothing, so the root sat empty until a real release. The ref-created push now still builds (with a repo-root base) and places the landing hub at root via a newplace-rootsubcommand — without creating av<version>/orlatest/, so the first-release override is still preserved./latest/and/v<version>/correctly stay absent until a real release exists. Matches the working root a CLI-scaffolded repo gets on its own genuine first push. - The ref-created-push skip (below) left a "Use this template" copy's inherited foreign content completely uncleaned, found live on a fresh
ask-genial-widgetcopy: root/and/latest/rendered blank (still referencing the source template repo's own base path, 404ing on this repo's Pages site), and/v0.0.1/was itself foreign, unrelated content. Root cause: clearing inherited noise (cleanupForeignVersionsIfFirstRelease) used to only run as a side effect of an actual release build — which the ref-created skip now never does. Also tightened the skip's own condition: gating ongithub.event.createdalone would have wrongly swallowed a CLI scaffold's own genuine first release too (its firstgit push -u origin mainalso creates that ref for the first time) — now additionally requires the push be authored by GitHub's web-flow bot, unique to server-side template generation. The cleanup itself is broadened to clear every foreign top-level entry (not justv<version>/dirs) exceptdev/and a small pipeline-managed allowlist, and runs standalone via a newcleanup-foreignsubcommand on the skipped ref-created push. - A widget's real first release could publish under an inherited placeholder version instead of
v0.0.1, found live movingworld-mapout of the monorepo: it published asv0.2.21instead. Root cause: GitHub's "Use this template" fires the bundled deploy workflow once on repo creation — before any real work happens — publishing whatever versionpackage.jsonhad checked in and permanently spending the first-release0.0.1override on that boilerplate.deploy.mjs'splan()now recognizes this exact event (github.event.created, the push that created themainref) and skips it entirely, so nothing gets published and the override survives intact for the widget's actual first release. - The bundled dev-channel self-reload snippet waited a full 5s poll interval before its first freshness check, found live moving
create-email-draftout of the monorepo: opening/dev/fresh showed a black screen for ~5s before self-correcting. Root cause: a plain (non-cache-busted) request for/dev/'sindex.htmlcan hit a stale CDN-cached copy referencing JS assets a newer deploy already deleted — the page can't render until the self-reload script's own poll againstversion.json(always fetched fresh) detects the mismatch and redirects with a cache-busting query param. That poll only started on the firstsetIntervaltick; now it also runs once immediately on load, so the black screen clears in well under a second on a fresh visit instead of up to ~5s. template-canary.yml'sdevandmain/canary/latestpermanently diverged onpackage.json'sname,README.md's heading,index.html's<title>, andApp.tsx's title prop, conflict-marking every promote-PR merge attempt between them (<<<<<<< dev/# grist-widget-template-devvs======= main/# grist-widget-template-latest). Root cause: the two scaffold steps passed different names (grist-widget-template-dev/grist-widget-template-latest) ascreate-grist-widget's target directory, which the CLI's own rename step uses to derive all four of those fields — so every canary run re-diverged them. A first attempt at this fix only patchedpackage.json'snameback to a fixed value afterward, missing the other three (found live:main's README still read# scaffold-latest, its own placeholder directory name). Fixed properly by passing the reference repo's own name ($CANARY_REPO,"grist-widget-template") as the CLI's target for both scaffolds, so the CLI itself produces identical content in all four places — verified locally that running the real CLI against this rawName produces byte-identicalpackage.json/README.md/index.html/App.tsxoutput.
Added
- A repo's first genuine release also prunes stray branches and resets
devtomain. Confirmed live: GitHub's template generation does reliably fire a realpushevent and trigger the bundled deploy workflow right on the initial (squash) commit — no manual push needed. Extendeddeploy.mjs'splan()to expose afirstReleaseflag, and added a newreset-branchesstep (deploy.yml, gated on that flag) that deletes every branch exceptmain/dev/gh-pagesand force-resetsdevtomain's tip — establishingmain == devas the schema every widget starts from, whether scaffolded via the CLI (already true trivially) or copied via "Use this template" (may inherit a stray branch, or adevfull of the source template's own unrelated preview history). - A repo's first genuine release always starts at
v0.0.1, and every scaffold now records whichcreate-grist-widgetversion produced it. Found live: a repo copied via GitHub's "Use this template" inherited0.2.18instead of0.0.1, because the source template repo's ownmainbranch had been promoted fromcanary/latest— a scaffold deliberately stamped with whatever version was currently published, so the canary's own release-triggering logic works.deploy.mjs'splan()now ignorespackage.json'sversionfor a repo's first release only, always resolving it to0.0.1; once a genuine release exists, normal versioning resumes. To avoid losing the (accurate, just misplaced) information that value carried, the CLI (create-grist-widget) now stamps every scaffold'spackage.jsonwith a new, purely informationalcreateGristWidgetVersionfield, rendered as a small footer ("Scaffolded fromcreate-grist-widget@X.Y.Z") on the landing page and channel pages via a newScaffoldFootercomponent — never read by the deploy pipeline's own version logic. - Docs: made explicit that staging/rc pre-releases are SDK-only.
/RELEASING.mdandapps/docs/files/releasing.mdonly ever mentionedpackages/core/package.jsonandgrist-widget-sdk@nextin the staging section, but never stated as a caveat that this is deliberate — thepublish-stagingjob checks and publishespackages/corealone, neverpackages/create-grist-widget(or the template it embeds), even though the two are linked and always bump/publish together in the normal release flow. Both docs now call this out explicitly, since a reader could otherwise reasonably assume the link applies here too. - Template landing page: Quickstart section.
templates/grist-widget-template-vite/src/components/template-landing.tsxnow leads with a 2-step Quickstart — copygrist-widget-templatevia GitHub's "Use this template" button with "Include all branches" checked, then ask your AI coding agent for the/dev/and/latest/URLs. Live-verified (a real repo generated this way came up withgh-pages/Pages already configured and both URLs live, no manual Settings step needed — better than the docs previously assumed). The existing CLI-based flow (npm create grist-widget) moves to a "Prefer the CLI?" section below it. Also adds a warning pointing at the Claude, Codex, and Cursor GitHub App install pages, in case an AI agent can't see the newly copied repo. - New docs page: Releasing & publishing (
apps/docs/files/releasing.md), explaining how Changesets, the staging/rc pre-release flow, and the template canary fit together —/RELEASING.md(repo root) remains the exact command/secret reference. Also fixed two stale docs found while writing it:files/index.mddescribedfiles/deploy.mdas covering npm publishing (it's actually widget/Cloudflare deploys only), andfiles/agent-workflows.md's "Release" task shape still described the old pre-changesets manual bump-tag-publish process. require-changeset.ymlCI check: fails a PR that touchespackages/core/src/,packages/create-grist-widget/{src,scripts}/, ortemplates/grist-widget-template-vite/without adding a.changeset/*.mdfile. This project already hit the exact failure mode it prevents once (a PR shipped published-package source with no changeset, so the merge quietly published nothing) — seeRELEASING.md.- First-release cleanup for repos copied via GitHub's "Use this template" → "Include all branches." That option is a legitimate way to get
gh-pages/Pages already configured with zero manual Settings steps — but it also copies the template repo's owngh-pageshistory (its own past releases) into the new repo.templates/grist-widget-template-vite/scripts/deploy.mjsnow clears everyv<version>/directory before placing a repo's first genuine release, only when that repo has never genuinely published before (versions.json, filtered by therepo-provenance check above, is empty) — at that point everyv<version>/present is provably inherited noise, not this repo's own history, so there's no ambiguity to worry about. Once a genuine release exists, this is permanently a no-op. Verified locally: extendedscripts/smoke/template-deploy.shwith a case that seeds two foreign versions (one with a foreignshowcase-meta.json, one with none at all) and asserts both are cleared and excluded fromversions.jsonafter the first real release. template-canary.yml'sdevrefresh now tracks npm'snextdist-tag (falling back to@latestif no pre-release has ever been published), instead of always scaffolding@latest. Previouslygrist-widget-template's/dev/never showed anything ahead of what was already fully released — now it's a genuine live preview of unreleased changes. Sincedevcan now sit ahead on an unreleased pre-release, it's no longer safe to promote from directly — a newcanary/latestbranch is unconditionally force-pushed every run from a fresh@latest-only scaffold (a puregit push, no GitHub API call, so no new credential needed beyond the existing SSH deploy key). Promote fromcanary/latest→mainfrom now on, never fromdev→main— opening/merging that PR is still a fully manual step, this workflow never touchesmainitself.
Fixed
template-canary.yml'sdev-branch checkout failed on its first real run after the previous fetch fix, found live right after merging the Version Packages PR forcreate-grist-widget@0.2.18:git fetch --depth 1 origin devonly populatesFETCH_HEAD, it never creates a local or remote-trackingdevref — harmless after a normal full clone (an establishedorigin/*fetch refspec pluscheckout's remote-branch DWIM papers over it), but this step clones with--depth 1and no--branch(single-branch mode), so no such refspec exists and the immediatecheckout devfailed outright ("pathspec 'dev' did not match any file(s) known to git"). Since this was the very first step to touch the reference repo, the whole job died before ever reaching thedevpush or the latercanary/latestpush — explaining why no branch appeared to update at all. Fixed with an explicit destination refspec (origin dev:dev), verified against a real shallow clone locally before and after the fix (reproduced the exact failure, then confirmed it resolves it).template-canary.ymlregularly timed out waiting for/dev/'s self-reload snippet aftergrist-widget-template'sdevbranch had been deleted and recreated (e.g. by GitHub's "Automatically delete head branches" setting right after a merge). Recreatingdevtriggers a whole separatedeploy.ymlrun on that repo to rebuild and republish/dev/from scratch — slower than a plain re-push to an already-livedev, which the previous 10-minute polling budget (40 attempts × 15s) assumed. Compounded by the pollingcurlnever cache-busting its requests, so it could keep hitting a stale CDN-cached response from GitHub Pages for a while after a fresh publish. Fixed: budget increased to ~22 minutes (90 attempts × 15s), and every poll now cache-busts with a query param +Cache-Control: no-cacheheader — the same defense the self-reload snippet's own internal polling already uses (fetch(..., { cache: "no-store" })).<GristStatusChip>could silently override a widget's real declaredrequiredAccess/columnson Grist's side. Found live on a freshly exportedgantt-widget: Grist's Creator Panel never showed the Column Mapping section, and asked for only "read" access even though the widget's own code (unchanged from a working copy) declaresrequiredAccess: "full"plus real columns. Root cause:<GristStatusChip>mounts its own bare<GristHandshakeProvider>(no options) alongside<GristWidgetProvider>;GristHandshakeProviderbuilt its manager directly viacreateGristHandshakeManager(options ?? {}), bypassing the page-levelensureGristReady()singleton thatGristWidgetProvideruses. That let the chip's manager call the realgrist.ready()directly, with its own default{requiredAccess: "read table", columns: []}payload — and since Grist only honors a widget's firstready()call, the chip's default won the race whenever it resolved before the real widget's manager did. The widget's own on-page alerts still looked correct because they only read the localGRIST_OPTIONSconfig, never what Grist actually received. The same bypass existed in standaloneuseGristHandshake()(used for the documented "GristWidgetProvider+ observable snapshot" pattern), and the singleton's own reinit check only compared access-level rank — a widget keeping the default"read table"access but declaring realcolumnswould still lose them to a same-rank fallback call. Fixed on three fronts:GristHandshakeProvideranduseGristHandshake()now both defaultnegotiate.readyImplto the shared singleton (callers can still override it);ensureGristReady()'s reinit check now also compares declared columns, not just access rank; and<GristStatusChip>no longer mounts its own manager at all — it now uses a newuseAmbientGristHandshake()hook to observe whatever<GristWidgetProvider>ancestor already published, nested inside it inmain.tsx, eliminating the race structurally for that case instead of just deduping it.- A
gh-pagesbranch manually seeded from another repo's export could permanently squat on a version path. Found live ongantt-widget: itsgh-pagesbranch's very first commit (authored directly, not bygithub-actions[bot]) already containedv0.0.1/andv0.2.14/directories copied from elsewhere — before this repo's own CI had ever built anything. The release job's idempotent-skip check only tested whetherv<version>/existed, so oncepackage.json's version was reset to0.0.1to match, the release step saw the path "already published" and silently skipped, leaving the foreign placeholder content in place (CI reported success). One of the two stale directories didn't even have ashowcase-meta.json; the other had one, but with ashathat didn't belong to this repo's history — proving both were copied, not built here. Fixed intemplates/grist-widget-template-vite/scripts/deploy.mjs:showcase-meta.jsonnow records thereponame it was built for, and both the release "already-published" check and theversions.jsonindex builder now require a matchingrepo, not just file existence — so foreign content is treated as unpublished (safely rebuilt over) and never leaks into a widget's own version index. Verified locally: extendedscripts/smoke/template-deploy.shwith a case that seeds a foreignv<version>/showcase-meta.json(a differentrepo) and assertsplanrefuses to treat it as already-published. template-canary.ymlfailed whenevergrist-widget-template'sdevbranch didn't exist (e.g. deleted by GitHub's "Automatically delete head branches" setting right after mergingdev→main, per the workflow's own recommended step 5). The clone step chained two separategit clonecalls into the same target directory as a fallback (git clone -b dev ... reference-repo || git clone -b main ... reference-repo) — but agit clone -b <missing-branch>failure still leavesreference-repopartially initialized, so the fallback clone then failed too ("destination path already exists and is not an empty directory"). Fixed: a singlegit clone(default branch) followed by an explicitgit ls-remote --heads origin devcheck — fetches and checks out the realdevif it exists, otherwise creates a fresh one offmain. Verified locally against both cases (devmissing,devexisting with its own real history) before trusting it live.- Merging
grist-widget-template'sdevintomainsilently deployed nothing. Found live right after the previous fix let that merge happen at all: the scaffold'spackage.jsonversion is a static"0.0.1"that never changes (correct for a real user's own widget, wrong for this reference repo), so the release step's idempotent-skip logic always sawv0.0.1/already published and skipped the build — the workflow reported "success" for correctly doing nothing. Fixed by stamping the canary's scaffold withcreate-grist-widget's own published version instead, same reasoning the monorepo's own/template/showcase already uses. Now mergingdev→maincarries whatever version was current at scaffold time, triggering a real versioned deploy whenever it's new — no manual bump required. - Every
template-canary.ymlrun permanently severedgrist-widget-template'sdevbranch frommain. Found live: the canary pushed a freshnpm create grist-widgetscaffold's own from-scratchgit initstraight overdevevery run, sodevandmainshared no common ancestor after even one run — GitHub's compare view refused to diff or PR between them ("There isn't anything to compare — main and dev are entirely different commit histories"). Fixed by cloning the reference repo's actualdevbranch and overlaying the fresh scaffold onto its tracked files, then committing and pushing on top of its existing history, instead of replacing that history outright — verified locally against a fake bare repo that this keepsmerge-base(main, dev)resolvable. Recoveredgrist-widget-template'sdevby resetting it back ontomainonce, live. template-canary.yml's very first live run failed immediately, before it ever got to exercise anything:npm create grist-widget's own git auto-init degrades gracefully to a no-op with printed manual instructions when no git identity is configured — true by default on a fresh Actions runner — so the scaffold had no commit and nodevbranch at all, and the next step failed withpathspec 'dev' did not match any file(s) known to git. Fixed by configuring a bot git identity before the scaffold step, same convention every other deploy workflow in this repo already uses.- A real scaffolded widget's showcase hub showed the grist-widget-sdk monorepo's own released versions and links instead of its own. Found live on
grist-widget-template's landing page:src/lib/showcase-versions.ts'sversionsUrl/devUrl/versionUrlwere hardcoded tohttps://arthurblanchon.github.io/grist-widget-sdk/template/...— correct only for the monorepo's own/template/showcase, wrong for every real external scaffold. A second, deeper gap made this worse: the standalonedeploy.mjsbundled into every scaffold never generated aversions.jsonat all (only the monorepo-onlyscripts/deploy/template-showcase.mjsdid), so even a correctly-pointed fetch would 404. Fixed both: the three URL helpers are now derived from the current deploy's own URL (viaparseShowcasePath'shubPath), anddeploy.mjs's release step now writesversions.jsonfrom eachv<version>/showcase-meta.json, same idiom as the monorepo showcase.scripts/smoke/template-deploy.shnow asserts both; also verified with a headless-browser render against a fake multi-version site showing the right versions and self-referential links. - Docs never warned that GitHub Pages must point at
gh-pages, notmain. Found live, right after fixing the bare-root deploy above:template-widget's Pages setting was onmain, which serves this repo's raw, unbuilt source — a blank page with a/src/main.tsx404, even though the deploy workflow itself reports success. The manual "enable Pages" step was already documented in five places (template README, the CLI's own README, the showcase hub's onboarding instructions,deploy.yml's header comment, and therun-grist-widget-template-deployskill) but none of them warned against this specific, easy-to-make mistake. All five now do. - Removed the cosmetic, looping "Grist connected" demo chip from the showcase hub page (
TemplateLanding) — it never reflected a real connection there and read as more confusing than informative. The real, live status chip shown while actually embedded in Grist is unchanged. - A real scaffolded widget's own bare site root (
https://owner.github.io/repo/) 404'd instead of showing the template's built-in showcase hub. Found live, investigating a user report thattemplate-widget's GitHub Pages root "doesn't work." Root cause: the template'smain.tsxwas explicitly written to render the showcase hub (TemplateLanding) at any deployed URL with no recognized channel suffix — including the bare root — but the bundled, externally-shippedtemplates/grist-widget-template-vite/scripts/deploy.mjsnever actually placed a build there; it only ever wrotev<version>/,latest/, anddev/. The monorepo's ownscripts/deploy/template-showcase.mjsalready did this correctly for/template/(and is smoke-tested for it) — the standalone version was never given the same treatment. Fixed by having the release step also copy the release dist to the site root, same aslatest/(its asset references underv<version>/assets/already exist from the versionDir placement, so nothing 404s).scripts/smoke/template-deploy.shnow asserts the rootindex.htmlis placed. - A real scaffolded widget's very first deploy failed:
Error: Dependencies lock file is not found. Found live, mergingtemplate-widget's seed PR — the bundleddeploy.yml's first real-world run. Root cause: the CLI's auto-commit (bin/create-grist-widget.mjs) runs before anyone's firstpnpm install, so a freshly scaffolded repo never has a committedpnpm-lock.yamlunless the user commits again — nothing in the printed next steps prompts them to.actions/setup-node'scache: pnpmandpnpm install --frozen-lockfileboth hard-require a lockfile to exist; the smoke test only ever checkedpnpm install && buildlocally, never the actual GitHub Actions workflow, so this was invisible to CI. Fixed by dropping both — the workflow now installs fresh every run regardless of whether a lockfile was ever committed.scripts/smoke/create-widget.shnow asserts the bundleddeploy.ymlnever reintroduces either (verified the assertion actually catches the regression: reverted the fix locally, confirmed the smoke test fails with the exact same error class, then restored it). - The template showcase deployed to a blank page — GitHub Pages project sites need the repo name in every absolute asset path, which the showcase build never included.
scripts/deploy/template-showcase.mjsbuilt the template with base path/v<version>/and deployed it togh-pagesroot, reasoning that root was otherwise unused so it could act like a standalone external repo's own gh-pages (where the repo IS the whole site). That's wrong for a GitHub Pages project site: this repo is served athttps://arthurblanchon.github.io/grist-widget-sdk/, so every absolute path needs the/grist-widget-sdk/prefix — exactly how every other widget's ownbasePathForinscripts/deploy/publish.mjsalready works. Without it, the deployed HTML referenced JS/CSS at paths that 404 at the bare domain root, so React never mounted and the page was blank. Fixed by moving the showcase from gh-pages root to/<repo>/template/(matching every other widget's own/<repo>/<widget>/convention, withtemplateas the widget name) and adding the missing--repoargument throughout. Also cleaned up the broken root-level artifacts (index.html,assets/,latest/,v0.2.5/,v0.2.6/,versions.json,vite.svg) directly fromgh-pages, since they were dead weight that would never be touched by the corrected pipeline.template-landing.tsx's hardcodedversions.json/preview URLs and the docs' live-preview link were updated to the new/template/path. - A live Claude Code test committed the scaffold to its own working branch instead of
main— the deploy workflow only ever releases frommain, so that silently never deployed. Root cause: the CLI never touched git at all, leavinggit init/commit/branch setup entirely to whoever ran next — and many Claude Code environments default to working on their own branch per task, which the README's command sequence didn't override strongly enough. Fixed by having the CLI initialize git itself: the scaffold is committed directly onmainwith adevbranch created alongside it (ready to push for the live-preview channel), before control ever passes to whatever runs next. Degrades gracefully (still createsmain, just without a commit) if no git identity is configured yet. Also corrected the README's claim that Actions workflow permissions must be set before the first push — the same live test found this wasn't actually required for a personal-account repo; it's now documented as a fallback fix for a permissions error, not a prerequisite.scripts/smoke/create-widget.shnow asserts the scaffold is onmainwith exactly one commit and adevbranch. - Scaffolded projects had no
.gitignoreand nopackageManagerfield — found via a live scaffold + deploy test against a real repo. Root causes: npm always strips a literal.gitignorefile from every published package (same hardcoded exclude list as.git/.npmignore), regardless of thefilesfield — a.gitignoreadded straight to the template source alone would never survive publishing. Fixed the standard way (create-vite,create-next-appuse the same trick): the embedded template ships it as_gitignore, and bothbin/create-grist-widget.mjs(at scaffold time) rename it back to.gitignore. Separately, the scaffoldedpackage.jsonhad nopackageManagerfield, which made the bundleddeploy.yml'spnpm/action-setupstep fail on a freshly scaffolded repo's very first CI run withNo pnpm version is specified— fixed by stamping it from the monorepo's ownpackageManagerpin at build time (same "read live, never hardcode" pattern already used for the SDK version). Both regressions are now asserted directly inscripts/smoke/create-widget.sh. create-grist-widget's first publish failed withnpm error code E404. Merging its introducing PR tomaintriggeredrelease.yml'sreleasejob as usual;changeset publishcorrectly skippedgrist-widget-sdk(already published) but failed publishingcreate-grist-widget@0.1.0for the first time ever, over OIDC. Root cause: npm Trusted Publishing can only be configured on a package that already exists on the registry — there's no Settings → Trusted Publishing page to attach a Trusted Publisher to for a name that's never been claimed. This is the exact same bootstrap constraintgrist-widget-sdk@0.2.0needed a manual first publish for; it just hadn't been hit again yet because no new publishable package had been added since. Fixed by documenting (not automating — this is inherently a manual, human-authenticated step) the requirement inRELEASING.md's "One-time setup" section as applying to every new publishable package, not just the original SDK: a manualpnpm publishto claim the name, then add a Trusted Publisher pointing at the samerepo + release.yml.publish-staging.ymlfailedactions/checkoutwith "Repository not found." Itspermissions:block declared onlyid-token: write. Declaring anypermissions:key drops every unlisted scope tonone— it doesn't layer on top of GitHub's default read access — socontentswas implicitlynoneand checkout had no access to the (private) repo. Addedcontents: readexplicitly. Found and fixed by actually running the workflow end-to-end on a disposable test branch, not just reading the YAML.- npm rejected a second Trusted Publisher workflow file. After fixing the above, the rc publish still failed npm's publish step with
404 Not Found. npm Trusted Publishing scopes trust to an exactrepo + workflow filepair, and its UI only allows one trusted workflow file per package — a separatepublish-staging.ymlcan never be registered alongside the already-trustedrelease.yml, no matter its permissions. Fixed by merging the staging/rc-publish job intorelease.ymlitself as a second job (publish-staging, gatedif: github.ref != 'refs/heads/main', alongsidereleasegated tomain), and deletingpublish-staging.yml. No npm-side config changes were needed sincerelease.ymlwas already the trusted file. release.ymlcrashed on every push tomainonce0.2.1was live. The workflow's "Upgrade npm" step usednpm@latest, which resolved to npm 12 the first time it ran after0.2.1's release. npm 12 changednpm info --jsonto wrap its result in an array instead of returning a bare object;@changesets/cli@2.31.0's already-published check reads.versionsdirectly off that result, so with npm 12 it gotundefinedand crashed (TypeError: Cannot read properties of undefined (reading 'includes')) instead of recognizing the version was already published and skipping cleanly. Reproduced directly: the exact samechangeset publishagainst the real, already-publishedgrist-widget-sdk@0.2.1behaves correctly with npm 11.6.2 onPATHand misdiagnoses "not published" with npm 12.0.1. Pinned tonpm@^11.5.0(the documented OIDC minimum) inrelease.yml's "Upgrade npm" steps instead of floating to@latest. No stable@changesets/clirelease supports npm 12 yet (2.31.0is current; newer are3.0.0-nextprereleases) — revisit the pin once one does.
Added
- A "template canary" workflow (
.github/workflows/template-canary.yml) automates the live end-to-end check this week's incidents kept surfacing by hand: after everyrelease.ymlcompletion, it scaffolds a widget from the actual publishedcreate-grist-widgetpackage, pushes it to the publicgrist-widget-templatereference repo'sdevbranch, and asserts the resulting Pages URLs come up with real content (the self-reload snippet present, no/src/main.tsxreference) — the same two markers that would have caught the lockfile and root-placement bugs, and the Pages-source-on-mainmistake, automatically and on every release going forward. Authenticates with a deploy key (TEMPLATE_CANARY_DEPLOY_KEY) scoped to only that one repo, not a PAT. See RELEASING.md's "Post-release verification" section. - The hub page's "Get started" box is now a
Tabscomponent ("Using Claude Code?" / "Manual setup"), and the hero reverted to "Build a Grist custom widget in minutes" (a brief "Build your own Grist custom widget" rewording didn't read as well). The "Manual setup" tab replaces the old "prefer to run it yourself?" collapsible with an explicit step-by-step: scaffold and try it locally (npm create grist-widget my-widget && cd my-widget && pnpm install && pnpm dev), create a GitHub repo, pushmain/dev, then enable Pages (with the workflow-permissions fallback note). Newtabs.tsxshadcn primitive, copied fromwidgets/upload-with-ai(byte-identicalcomponents.jsonstyle config). - The hub and per-channel showcase pages now have distinct heroes, an inline version switcher, and a live Grist handshake status chip.
TemplateLanding(the hub) got its own hero — "Build a Grist custom widget in minutes" — separate fromChannelNotice's "Grist isn't loaded here", so the two pages read as clearly different rather than reusing the same headline.ChannelNoticenow shows a row of chips (latest / dev / every released version, current one highlighted) to jump directly to another build, instead of only linking back to the hub. Version-fetching logic moved to a new sharedsrc/lib/showcase-versions.tsso both components use the sameversions.jsondata.- New
GristStatusChip(src/components/grist-status-chip.tsx) shows a small pill with a pulsing status dot while actually embedded in Grist: "Connecting to Grist", "Retry Grist connection in Ns" (counting down, resetting each time the SDK's internal handshake retries), or "Grist connected". Built onuseGristHandshakeContext()/GristLifecyclefromgrist-widget-sdk/advanced— a second, independent<GristHandshakeProvider>mounted alongside<GristWidgetProvider>, which the SDK's own docs (apps/docs/api/handshake.md) confirm is safe: both share the page'sensureGristReady()singleton, so this is purely observational and never duplicates the real handshake. The countdown is a fixed, cosmetic approximation (not a mirror of the SDK's actual internal poll backoff, which isn't part of the public API) — verified live against the real handshake state machine via a headless browser: the chip's attempt-driven "Retry in Ns" countdown visibly ticks down and resets in step with the SDK's own internal "Looking for Grist… (attempt N)" fallback text underneath it.
/template/is now a real showcase hub page, and the per-channel pages (/latest/,/dev/,/v<version>/) no longer show the full onboarding content when opened outside Grist — just a minimal notice. Previously every channel showed the same richTemplateLandingcontent (onboarding + version index) when not embedded, but the bare/template/path itself had nothing deployed there at all (404). Split into two components, chosen purely by URL shape at runtime (src/lib/showcase-routing.ts, no router needed): a path with no recognized channel suffix rendersTemplateLanding(the hub: onboarding, the released-version index, and now a link to the/dev/channel too, which was previously missing entirely); a recognized/latest/,/dev/, or/v<version>/suffix renders the new minimalChannelNotice— which build this is, a link back to the hub, and a copy-this-URL helper (newcard.tsx/input.tsxshadcn primitives) for pasting into Grist's custom widget field. The hub always wins over Grist-embedding, since/template/is never meant to function as an actual widget.scripts/deploy/template-showcase.mjs's release channel now additionally places the same build at baretemplate/(reusing the same already-placedv<version>/assets/, same tricklatest/already uses — no extra build pass needed). Verified with a headless browser against a locally mocked directory tree: all three states (hub; channel, not embedded; channel, embedded in an iframe) render correctly.dev/template-showcaseis now a standing branch, giving the template showcase a live/template/dev/preview channel. The workflow already supported it, but nobody had ever pushed the branch, so/template/dev/had never actually gone live. It's now treated as permanent (not deleted after each round) — the same "always develop ondev, release by version-bump + merge tomain" pattern every scaffolded widget already follows, now documented in the template's own README for anyone iterating on the template inside this monorepo.- Template showcase:
templates/grist-widget-template-viteis now deployed live to this monorepo's own GitHub Pages, at the same URL shape a scaffolded external repo gets —/v<version>/,/latest/,/dev/— minus the repo path segment, since the monorepo'sgh-pagesroot was otherwise unused. Newscripts/deploy/template-showcase.mjs(adapted from the template's own bundleddeploy.mjs: samebasePathFor/self-reload/ rebase-and-retry idioms, minus the repo segment) +.github/workflows/ deploy-template-showcase.yml, triggered directly off pushes tomain/dev/template-showcase(idempotent skip whenv<version>already exists, same as every other deploy pipeline in this repo — noworkflow_runrace-avoidance needed since the build never depends on the npm registry: it buildstemplates/grist-widget-template-vitestraight from the workspace, which is exactly what gets embedded verbatim intocreate-grist-widget's package at build time). Trackspackages/create-grist-widget/package.json's version, not the template's own static version field (nothing ever bumps that one) — each realcreate-grist-widgetrelease is one showcase release. A newversions.jsonmanifest atgh-pagesroot lists every released version, newest first.- Root
/is the same build as/latest/, not a separate landing app. Decided against building a dedicated showcase app: the template itself now renders a "Grist isn't loaded here" landing page (src/components/template-landing.tsx) whenever it's opened outside a Grist iframe (window.self === window.top, checked insrc/main.tsx) — the same content for a real scaffolded widget nobody has customized yet as for this repo's own showcase deploy. It shows the Claude Code /npm create grist-widgetonboarding flow (a plain "create a new GitHub repo" link + the CLI commands already increate-grist-widget's README — no second repo to maintain, no GitHub fork button) and fetchesversions.jsonto list every released template version with links to its/v<version>/. - Verified against a local bare git repo standing in for
gh-pages, both by hand and via the new committedscripts/smoke/template-showcase-deploy.shsmoke-template-showcase-deploy.yml(release placement atv<version>/+latest/+ root, idempotent skip,versions.jsoncontents, dev self-reload, and dev removal — no GitHub API or network involved).
- Root
- The scaffolded template ships a working GitHub Pages deploy pipeline (V1-PLAN D4 item 2). New
templates/grist-widget-template-vite/scripts/deploy.mjs.github/workflows/deploy.yml, embedded into everynpm create grist-widgetscaffold (no changes needed tocreate-grist-widgetitself — its existing template copy already picks up any new files). Same two-channel model as the monorepo's owndeploy-widgets.yml: pushmainwith a version bump → immutable/v<version>/+ mutable/latest/(idempotent — re-pushing without a bump is a no-op); push adevbranch → mutable/dev/with a self-reload snippet for live review inside an open Grist document; deletingdevretires the URL.deploy.mjsis a de-widgetified copy ofscripts/deploy/publish.mjs— same rebase-and-retry push and self-reload snippet (both verified dependency-free, copied verbatim), minus the multi-widget folder loop andmanifest.json(a lone repo has nothing to catalog). Verified against a local bare git repo standing in forgh-pages, both by hand and via the new committedscripts/smoke/template-deploy.sh+smoke-template-deploy.yml(release placement, idempotent skip, dev self-reload, concurrent-push rebase-retry, and dev removal — no GitHub API or network involved). The template's own README documents the two manual one-time repo settings the workflow can't do for itself (Pages source =gh-pagesbranch, Actions workflow write permissions).
create-grist-widgetCLI (task-042) — scaffold a new widget withnpm create grist-widget my-widget, replacingdegit. New packagepackages/create-grist-widget, published publicly (as it must be named exactlycreate-grist-widgetfor npm'screate-<x>convention to resolve it). Zero runtime dependencies:build-template.mjscopiestemplates/grist-widget-template-vite/into an embeddedtemplate/dir at build/prepack time, stamps the SDK dependency to the currentpackages/coreversion (read live off disk, never hardcoded), and drops the monorepo-onlyprebuildscript; the CLI itself only validates the name, refuses a non-empty target dir, copies the template, and does 4 fixed string substitutions (package name + title). Versioned in lockstep with the SDK via changesetslinked(.changeset/config.json), so any release touching either package republishes both — a livecreate-grist-widget@latestcan never embed a stale SDK range. Degit is fundamentally incompatible with a private GitHub repo (404s fetching a public tarball), so this was the blocking piece before the monorepo can ever go private. Newscripts/smoke/create-widget.sh+smoke-create-widget.ymlpack both the SDK and the CLI and drive the realnpm create grist-widgetpath outside the workspace, mirroringexternal-install.sh's pattern. Docs (index.md,getting-started.md,templates.md,principles.md) updated to recommend the CLI overnpx degit .... Out of scope for this change: bundling a deploy workflow into the scaffolded template, and the repo-privacy flip itself — both remain open follow-ups. SeeV1-PLAN.mdD4.release.yml'spublish-stagingjob — test an SDK change from npm before it's real. On any non-mainbranch, bumpingpackages/core/package.jsonto a prerelease (0.2.2-rc.0) publishes exactly that version to npm taggednext(neverlatest), via the same OIDC Trusted Publishing as the real release (same workflow file, gated by branch — npm only allows one trusted workflow file per package, see Fixed above).^0.2.xconsumers never resolve it (semver excludes prereleases from plain ranges); install it explicitly to test in a real codebase. Nothing is committed to git andmainis never touched, so there's no persistent "staging mode" to forget to turn off — deliberately not a.changeset/pre.json-style toggle. SeeRELEASING.md.core-ci.ymlbuilds every real product widget against SDK changes. A new matrix job builds eachwidgets/*package (workspace-linked, no publish) on any PR touchingpackages/core. Previously no CI ever built the real widgets against an SDK change — only the playground's demo widgets (via rootpnpm test, not itself wired into CI) and the template (viasmoke-external-install) were exercised, so a change that silently broke e.g.gantt's build could merge undetected until someone next touched that widget specifically.- Changesets-driven releases + npm publish via Trusted Publishing (OIDC).
@changesets/climanages version bumps;.github/workflows/release.ymlrunschangesets/actiononmainto open a "Version Packages" PR and, on merge, publishgrist-widget-sdkto npm with short-lived OIDC credentials (no storedNPM_TOKEN, ahead of npm's 2FA-bypass-token deprecation) plus a git tag + GitHub Release.apps/docsmarkedprivateso onlygrist-widget-sdkis publishable.RELEASING.mddocuments the one-time name-claim + Trusted Publisher setup; provenance deferred while the repo is private. - Two changelogs, split by audience.
packages/core/CHANGELOG.mdis now a real, git-tracked file generated by Changesets — the npm package's own changelog, one entry per published version, shipped in the tarball. This/CHANGELOG.md(repo root) stays the hand-curated, broader project history (widgets, docs, tooling, process) and continues to be maintained manually; the docs/changelogpage now links both. Theprepackhook no longer copies this file intopackages/core(onlyLICENSEstill is). LICENSE(MIT). The repo now carries an MITLICENSE;licenseisMITin the root andpackages/coremanifests. Theprepackhook copies it into the package so it ships in the npm tarball (verified alongsideCHANGELOG.mdviapnpm pack).RELEASING.mddocuments the manual publish steps until the changesets workflow lands.smoke-external-installCI +scripts/smoke/external-install.sh. Packs the SDK, installs the Vite template against the tarball outside the workspace, and builds — guarding the onboarding path against "works only via workspace linking" regressions. On its first run it caught a real consumer blocker (below).
Fixed
- Template
pnpm installfailed for standalone consumers (pnpm 11). esbuild (via Vite) ships a build script that pnpm 11 blocks by default, exiting non-zero. pnpm 11 reads build-script approvals only frompnpm-workspace.yaml(not thepackage.jsonpnpmfield), so the template now ships one withallowBuilds: { esbuild: true }. Inside the SDK monorepo the file is ignored (the root workspace governs). Template README updated for the CLI/npm distribution model (degit removed).
Changed
- Unified, release-driven widget deploys. The seven copy-pasted
deploy-<widget>.ymlworkflows are replaced by one.github/workflows/deploy-widgets.yml+scripts/deploy/publish.mjs(node builtins only). Two channels: push tomainpublishes immutable/<repo>/<widget>/v<version>/+ a mutable/<widget>/latest/alias, and regenerates a rootmanifest.json(Grist widget-repository format, usable viaGRIST_WIDGET_LIST_URL); a widget is (re)built only when itspackage.jsonversionhas nov<version>/dir yet, so bumping the version is the release and apackages/core-only push no longer moves shipped widget URLs. Concurrent runs are serialized and the publisher rebases-and-retries its push; one failing widget build no longer blocks the others. Widgets carry agristmetadata block (name,widgetId,accessLevel, …) consumed by the manifest;v0-minimal-demois intentionally unlisted. The former single mutable/<widget>/URL is kept but deprecated in favor of pinnedv<version>//latest/.
Added
- Dev deploy channel + in-Grist self-reload. Pushing a
dev/<widget>branch publishes to/<repo>/<widget>/dev/with aversion.jsonand a dev-only self-reload snippet that polls for new builds and hot-swaps the widget inside an open Grist document (cache-busting__dev=<sha>navigation that preserves the host's query params). Prod builds are unchanged. Enables a push → CI → review-in-real-Grist loop without a tunnel.
Fixed
- Gantt widget dates — Grist Date cells (UTC midnight) are normalized to local calendar days before timeline layout, so bars align with table dates in all timezones.
- Gantt weekly timeline positioning — event bars and the Today marker now walk the same week columns as the header (weeks that straddle two months are no longer skipped in offset math), fixing ~3-month placement drift on the weekly scale.
- Gantt weekly headers — columns show ISO week numbers (Monday-based, e.g. W26) with the week start date below.
- Upload CORS error message — includes the widget
location.originand notes that changing deploy URL (e.g. ngrok → GitHub Pages) requires updating server CORS.
Added
uploadGristAttachment/w.uploadAttachment— canonical widget upload (POST /attachments,X-Requested-With,?auth=) returning{ ids, firstId }.parseGristAttachmentUploadResponseIds,gristAttachmentCellValues,mergeGristAttachmentCellValue— full id list parsing and Attachments cell encode/merge helpers aligned with Grist’s["L", …]wire format.
Fixed
fetchWithAuthCORS on attachment upload — non-GET REST calls from custom widgets now attach the access token as?auth=(same as downloads) instead ofAuthorization: Bearer, which many Grist hosts block in CORS preflight.parseGristAttachmentUploadResponse/gristAttachmentCellValue— helpers forPOST /attachments(response is a JSON array of ids, e.g.[42]) and writing a single id into an Attachments column as["L", id].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