Skip to content

Changelog

This is the broader project history — widgets, docs, tooling, process — hand-curated at /CHANGELOG.md at the repo root. The published npm package's own changelog (Changesets-generated, one entry per release) lives separately at packages/core/CHANGELOG.md and 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-pages sometimes sits in deployment_in_progress for 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 own timeout from the deploy-pages default (10 min) to 15, and added retry-deploy-pages.yml, a workflow_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 on run_attempt so a genuinely broken deploy (still failing on the retry) surfaces to a human instead of looping forever. See apps/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 shared gh-pages branch; 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 hit actions/deploy-pages's own ~10-minute timeout live, found during the PR #86 / #87 merge window. Fix: deploy-apps.yml now builds apps/docs, apps/playground, and apps/web in one job, assembles their output into a single tree, and publishes it with actions/upload-pages-artifact + actions/deploy-pages — the modern Actions-native Pages flow, no gh-pages branch, 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 external grist-widget-template repo via template-canary.yml, which is completely unchanged. This does not affect templates/grist-widget-template-vite/.github/workflows/deploy.yml, the bundled pipeline embedded into every scaffolded widget repo — that was never deployed inside grist-widget-sdk in the first place. See apps/docs/files/deploy.md.

  • "Use this template" now publishes a clean v0.0.1 the 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 to main publishes a forced v0.0.1 + /latest/ + root right away (a repo's first genuine release always resolves to v0.0.1 regardless of the inherited package.json version). To keep the next release clean, the first-release reset step (deploy.mjs's resetBranchesIfFirstRelease) now also rewrites package.json's version back to 0.0.1 in the repo itself (committed to main/dev), so the second release bumps to 0.0.2 rather than the inherited canary number. The over-broad cleanupForeignVersionsIfFirstRelease (clears inherited root/latest/, not just v<version>/ dirs) is kept — it now runs through the normal placeTarget first-release path. Removed the now-unused --created flag, cleanup-foreign, and place-root subcommands.

Fixed

  • The previous entry's fix (workflows: write in release.yml's permissions:) was itself invalid and took the whole release pipeline down harder, found live immediately after merging it: every subsequent release.yml run 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. workflows is not one of the fixed set of scopes the GITHUB_TOKEN permissions: 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 default GITHUB_TOKEN can never create or update a ref whose history introduces .github/workflows/*.yml changes, and no permissions: setting lifts that — remains unfixed and needs a credential other than GITHUB_TOKEN for that one push (a classic PAT with the workflow scope, 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.yml published [email protected] to npm successfully but then failed to push its git tag, found live: changeset publish reported success and published the package, but the subsequent git push origin [email protected] was 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/*.yml changes unless the token has the workflows scope; release.yml's permissions: never granted it, and this only surfaced once a release's tag pointed at a commit shortly after actual workflow-file edits (the deploy-apps.yml consolidation, above). [email protected] is genuinely live on npm — only the git tag was missing. See the entry above for how the attempted fix went.
  • apps/web 404'd every asset and showed a white screen on its first successful live deploy, found live right after deploy-apps.yml's first genuinely completed deployment: index.html loaded fine, but every /assets/... request 404'd. Root cause: apps/web/vite.config.ts built with base: "/" (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 the github.io domain, not the domain root, so every absolute asset reference resolved one level too high. Local testing via vite preview never caught this — that serves at the actual domain root of localhost, where base: "/" genuinely is correct. Fixed by giving apps/web the same WEB_BASE build-time env var apps/docs and apps/playground already had (DOCS_BASE, PLAYGROUND_BASE), set by deploy-apps.yml to /<repo>/. See apps/docs/files/deploy.md.
  • A routine SDK release bumped templates/grist-widget-template-vite's own package.json version, breaking the next create-grist-widget scaffold, found live after merging a Version Packages PR: scripts/smoke/create-widget.sh failed with expected a fresh scaffold's own version to stay 0.0.1, got '0.0.2'. Root cause: Changesets' updateInternalDependencies: "patch" bumps the version field of every workspace package that depends on a bumped one, private: true or not — and the template (plus apps/playground) carries a workspace:^ dependency on grist-widget-sdk purely for monorepo DX. The bumped template version rode straight into the next create-grist-widget publish via build-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's ignore so Changesets never touches their version again, and build-template.mjs force-sets the embedded template's version to 0.0.1 at the one choke point that actually matters, regardless of what the source repo's package.json says.
  • 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 new place-root subcommand — without creating a v<version>/ or latest/, 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-widget copy: 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 on github.event.created alone would have wrongly swallowed a CLI scaffold's own genuine first release too (its first git push -u origin main also 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 just v<version>/ dirs) except dev/ and a small pipeline-managed allowlist, and runs standalone via a new cleanup-foreign subcommand 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 moving world-map out of the monorepo: it published as v0.2.21 instead. Root cause: GitHub's "Use this template" fires the bundled deploy workflow once on repo creation — before any real work happens — publishing whatever version package.json had checked in and permanently spending the first-release 0.0.1 override on that boilerplate. deploy.mjs's plan() now recognizes this exact event (github.event.created, the push that created the main ref) 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-draft out 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/'s index.html can 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 against version.json (always fetched fresh) detects the mismatch and redirects with a cache-busting query param. That poll only started on the first setInterval tick; 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's dev and main/canary/latest permanently diverged on package.json's name, README.md's heading, index.html's <title>, and App.tsx's title prop, conflict-marking every promote-PR merge attempt between them (<<<<<<< dev / # grist-widget-template-dev vs ======= main / # grist-widget-template-latest). Root cause: the two scaffold steps passed different names (grist-widget-template-dev / grist-widget-template-latest) as create-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 patched package.json's name back 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-identical package.json/README.md/index.html/App.tsx output.

Added

  • A repo's first genuine release also prunes stray branches and resets dev to main. Confirmed live: GitHub's template generation does reliably fire a real push event and trigger the bundled deploy workflow right on the initial (squash) commit — no manual push needed. Extended deploy.mjs's plan() to expose a firstRelease flag, and added a new reset-branches step (deploy.yml, gated on that flag) that deletes every branch except main/dev/gh-pages and force-resets dev to main's tip — establishing main == dev as 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 a dev full 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 which create-grist-widget version produced it. Found live: a repo copied via GitHub's "Use this template" inherited 0.2.18 instead of 0.0.1, because the source template repo's own main branch had been promoted from canary/latest — a scaffold deliberately stamped with whatever version was currently published, so the canary's own release-triggering logic works. deploy.mjs's plan() now ignores package.json's version for a repo's first release only, always resolving it to 0.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's package.json with a new, purely informational createGristWidgetVersion field, rendered as a small footer ("Scaffolded from [email protected]") on the landing page and channel pages via a new ScaffoldFooter component — never read by the deploy pipeline's own version logic.
  • Docs: made explicit that staging/rc pre-releases are SDK-only./RELEASING.md and apps/docs/files/releasing.md only ever mentioned packages/core/package.json and grist-widget-sdk@next in the staging section, but never stated as a caveat that this is deliberate — the publish-staging job checks and publishes packages/core alone, never packages/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.tsx now leads with a 2-step Quickstart — copy grist-widget-template via 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 with gh-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.md described files/deploy.md as covering npm publishing (it's actually widget/Cloudflare deploys only), and files/agent-workflows.md's "Release" task shape still described the old pre-changesets manual bump-tag-publish process.
  • require-changeset.yml CI check: fails a PR that touches packages/core/src/, packages/create-grist-widget/{src,scripts}/, or templates/grist-widget-template-vite/ without adding a .changeset/*.md file. 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) — see RELEASING.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 own gh-pages history (its own past releases) into the new repo. templates/grist-widget-template-vite/scripts/deploy.mjs now clears every v<version>/ directory before placing a repo's first genuine release, only when that repo has never genuinely published before (versions.json, filtered by the repo-provenance check above, is empty) — at that point every v<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: extended scripts/smoke/template-deploy.sh with a case that seeds two foreign versions (one with a foreign showcase-meta.json, one with none at all) and asserts both are cleared and excluded from versions.json after the first real release.
  • template-canary.yml's dev refresh now tracks npm's next dist-tag (falling back to @latest if no pre-release has ever been published), instead of always scaffolding @latest. Previously grist-widget-template's /dev/ never showed anything ahead of what was already fully released — now it's a genuine live preview of unreleased changes. Since dev can now sit ahead on an unreleased pre-release, it's no longer safe to promote from directly — a new canary/latest branch is unconditionally force-pushed every run from a fresh @latest-only scaffold (a pure git push, no GitHub API call, so no new credential needed beyond the existing SSH deploy key). Promote from canary/latestmain from now on, never from devmain — opening/merging that PR is still a fully manual step, this workflow never touches main itself.

Fixed

  • template-canary.yml's dev-branch checkout failed on its first real run after the previous fetch fix, found live right after merging the Version Packages PR for [email protected]: git fetch --depth 1 origin dev only populates FETCH_HEAD, it never creates a local or remote-tracking dev ref — harmless after a normal full clone (an established origin/* fetch refspec plus checkout's remote-branch DWIM papers over it), but this step clones with --depth 1 and no --branch (single-branch mode), so no such refspec exists and the immediate checkout dev failed 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 the dev push or the later canary/latest push — 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.yml regularly timed out waiting for /dev/'s self-reload snippet after grist-widget-template's dev branch had been deleted and recreated (e.g. by GitHub's "Automatically delete head branches" setting right after a merge). Recreating dev triggers a whole separate deploy.yml run on that repo to rebuild and republish /dev/ from scratch — slower than a plain re-push to an already-live dev, which the previous 10-minute polling budget (40 attempts × 15s) assumed. Compounded by the polling curl never 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-cache header — 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 declared requiredAccess/columns on Grist's side. Found live on a freshly exported gantt-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) declares requiredAccess: "full" plus real columns. Root cause: <GristStatusChip> mounts its own bare <GristHandshakeProvider> (no options) alongside <GristWidgetProvider>; GristHandshakeProvider built its manager directly via createGristHandshakeManager(options ?? {}), bypassing the page-level ensureGristReady() singleton that GristWidgetProvider uses. That let the chip's manager call the real grist.ready() directly, with its own default {requiredAccess: "read table", columns: []} payload — and since Grist only honors a widget's first ready() 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 local GRIST_OPTIONS config, never what Grist actually received. The same bypass existed in standalone useGristHandshake() (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 real columns would still lose them to a same-rank fallback call. Fixed on three fronts: GristHandshakeProvider and useGristHandshake() now both default negotiate.readyImpl to 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 new useAmbientGristHandshake() hook to observe whatever <GristWidgetProvider> ancestor already published, nested inside it in main.tsx, eliminating the race structurally for that case instead of just deduping it.
  • A gh-pages branch manually seeded from another repo's export could permanently squat on a version path. Found live on gantt-widget: its gh-pages branch's very first commit (authored directly, not by github-actions[bot]) already contained v0.0.1/ and v0.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 whether v<version>/ existed, so once package.json's version was reset to 0.0.1 to 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 a showcase-meta.json; the other had one, but with a sha that didn't belong to this repo's history — proving both were copied, not built here. Fixed in templates/grist-widget-template-vite/scripts/deploy.mjs: showcase-meta.json now records the repo name it was built for, and both the release "already-published" check and the versions.json index builder now require a matching repo, 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: extended scripts/smoke/template-deploy.sh with a case that seeds a foreign v<version>/showcase-meta.json (a different repo) and asserts plan refuses to treat it as already-published.
  • template-canary.yml failed whenever grist-widget-template's dev branch didn't exist (e.g. deleted by GitHub's "Automatically delete head branches" setting right after merging devmain, per the workflow's own recommended step 5). The clone step chained two separate git clone calls into the same target directory as a fallback (git clone -b dev ... reference-repo || git clone -b main ... reference-repo) — but a git clone -b <missing-branch> failure still leaves reference-repo partially initialized, so the fallback clone then failed too ("destination path already exists and is not an empty directory"). Fixed: a single git clone (default branch) followed by an explicit git ls-remote --heads origin dev check — fetches and checks out the real dev if it exists, otherwise creates a fresh one off main. Verified locally against both cases (dev missing, dev existing with its own real history) before trusting it live.
  • Merging grist-widget-template's dev into main silently deployed nothing. Found live right after the previous fix let that merge happen at all: the scaffold's package.json version 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 saw v0.0.1/ already published and skipped the build — the workflow reported "success" for correctly doing nothing. Fixed by stamping the canary's scaffold with create-grist-widget's own published version instead, same reasoning the monorepo's own /template/ showcase already uses. Now merging devmain carries whatever version was current at scaffold time, triggering a real versioned deploy whenever it's new — no manual bump required.
  • Every template-canary.yml run permanently severed grist-widget-template's dev branch from main. Found live: the canary pushed a fresh npm create grist-widget scaffold's own from-scratch git init straight over dev every run, so dev and main shared 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 actual dev branch 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 keeps merge-base(main, dev) resolvable. Recovered grist-widget-template's dev by resetting it back onto main once, 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 no dev branch at all, and the next step failed with pathspec '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's versionsUrl/devUrl/versionUrl were hardcoded to https://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 standalone deploy.mjs bundled into every scaffold never generated a versions.json at all (only the monorepo-only scripts/deploy/template-showcase.mjs did), so even a correctly-pointed fetch would 404. Fixed both: the three URL helpers are now derived from the current deploy's own URL (via parseShowcasePath's hubPath), and deploy.mjs's release step now writes versions.json from each v<version>/showcase-meta.json, same idiom as the monorepo showcase. scripts/smoke/template-deploy.sh now 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, not main. Found live, right after fixing the bare-root deploy above: template-widget's Pages setting was on main, which serves this repo's raw, unbuilt source — a blank page with a /src/main.tsx 404, 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 the run-grist-widget-template-deploy skill) 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 that template-widget's GitHub Pages root "doesn't work." Root cause: the template's main.tsx was 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-shipped templates/grist-widget-template-vite/scripts/deploy.mjs never actually placed a build there; it only ever wrote v<version>/, latest/, and dev/. The monorepo's own scripts/deploy/template-showcase.mjs already 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 as latest/ (its asset references under v<version>/assets/ already exist from the versionDir placement, so nothing 404s). scripts/smoke/template-deploy.sh now asserts the root index.html is placed.
  • A real scaffolded widget's very first deploy failed: Error: Dependencies lock file is not found. Found live, merging template-widget's seed PR — the bundled deploy.yml's first real-world run. Root cause: the CLI's auto-commit (bin/create-grist-widget.mjs) runs before anyone's first pnpm install, so a freshly scaffolded repo never has a committed pnpm-lock.yaml unless the user commits again — nothing in the printed next steps prompts them to. actions/setup-node's cache: pnpm and pnpm install --frozen-lockfile both hard-require a lockfile to exist; the smoke test only ever checked pnpm install && build locally, 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.sh now asserts the bundled deploy.yml never 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.mjs built the template with base path /v<version>/ and deployed it to gh-pages root, 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 at https://arthurblanchon.github.io/grist-widget-sdk/, so every absolute path needs the /grist-widget-sdk/ prefix — exactly how every other widget's own basePathFor in scripts/deploy/publish.mjs already 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, with template as the widget name) and adding the missing --repo argument throughout. Also cleaned up the broken root-level artifacts (index.html, assets/, latest/, v0.2.5/, v0.2.6/, versions.json, vite.svg) directly from gh-pages, since they were dead weight that would never be touched by the corrected pipeline. template-landing.tsx's hardcoded versions.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 from main, so that silently never deployed. Root cause: the CLI never touched git at all, leaving git 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 on main with a dev branch created alongside it (ready to push for the live-preview channel), before control ever passes to whatever runs next. Degrades gracefully (still creates main, 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.sh now asserts the scaffold is on main with exactly one commit and a dev branch.
  • Scaffolded projects had no .gitignore and no packageManager field — found via a live scaffold + deploy test against a real repo. Root causes: npm always strips a literal .gitignore file from every published package (same hardcoded exclude list as .git/.npmignore), regardless of the files field — a .gitignore added straight to the template source alone would never survive publishing. Fixed the standard way (create-vite, create-next-app use the same trick): the embedded template ships it as _gitignore, and both bin/create-grist-widget.mjs (at scaffold time) rename it back to .gitignore. Separately, the scaffolded package.json had no packageManager field, which made the bundled deploy.yml's pnpm/action-setup step fail on a freshly scaffolded repo's very first CI run with No pnpm version is specified — fixed by stamping it from the monorepo's own packageManager pin at build time (same "read live, never hardcode" pattern already used for the SDK version). Both regressions are now asserted directly in scripts/smoke/create-widget.sh.
  • create-grist-widget's first publish failed with npm error code E404. Merging its introducing PR to main triggered release.yml's release job as usual; changeset publish correctly skipped grist-widget-sdk (already published) but failed publishing [email protected] for 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 constraint [email protected] needed 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 in RELEASING.md's "One-time setup" section as applying to every new publishable package, not just the original SDK: a manual pnpm publish to claim the name, then add a Trusted Publisher pointing at the same repo + release.yml.
  • publish-staging.yml failed actions/checkout with "Repository not found." Its permissions: block declared only id-token: write. Declaring any permissions: key drops every unlisted scope to none — it doesn't layer on top of GitHub's default read access — so contents was implicitly none and checkout had no access to the (private) repo. Added contents: read explicitly. 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 exact repo + workflow file pair, and its UI only allows one trusted workflow file per package — a separate publish-staging.yml can never be registered alongside the already-trusted release.yml, no matter its permissions. Fixed by merging the staging/rc-publish job into release.yml itself as a second job (publish-staging, gated if: github.ref != 'refs/heads/main', alongside release gated to main), and deleting publish-staging.yml. No npm-side config changes were needed since release.yml was already the trusted file.
  • release.yml crashed on every push to main once 0.2.1 was live. The workflow's "Upgrade npm" step used npm@latest, which resolved to npm 12 the first time it ran after 0.2.1's release. npm 12 changed npm info --json to wrap its result in an array instead of returning a bare object; @changesets/[email protected]'s already-published check reads .versions directly off that result, so with npm 12 it got undefined and crashed (TypeError: Cannot read properties of undefined (reading 'includes')) instead of recognizing the version was already published and skipping cleanly. Reproduced directly: the exact same changeset publish against the real, already-published [email protected] behaves correctly with npm 11.6.2 on PATH and misdiagnoses "not published" with npm 12.0.1. Pinned to npm@^11.5.0 (the documented OIDC minimum) in release.yml's "Upgrade npm" steps instead of floating to @latest. No stable @changesets/cli release supports npm 12 yet (2.31.0 is current; newer are 3.0.0-next prereleases) — 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 every release.yml completion, it scaffolds a widget from the actual published create-grist-widget package, pushes it to the public grist-widget-template reference repo's dev branch, and asserts the resulting Pages URLs come up with real content (the self-reload snippet present, no /src/main.tsx reference) — the same two markers that would have caught the lockfile and root-placement bugs, and the Pages-source-on-main mistake, 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 Tabs component ("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, push main/dev, then enable Pages (with the workflow-permissions fallback note). New tabs.tsx shadcn primitive, copied from widgets/upload-with-ai (byte-identical components.json style 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 from ChannelNotice's "Grist isn't loaded here", so the two pages read as clearly different rather than reusing the same headline.
    • ChannelNotice now 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 shared src/lib/showcase-versions.ts so both components use the same versions.json data.
    • 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 on useGristHandshakeContext()/GristLifecycle from grist-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's ensureGristReady() 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 rich TemplateLanding content (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 renders TemplateLanding (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 minimal ChannelNotice — which build this is, a link back to the hub, and a copy-this-URL helper (new card.tsx/input.tsx shadcn 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 bare template/ (reusing the same already-placed v<version>/assets/, same trick latest/ 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-showcase is 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 on dev, release by version-bump + merge to main" 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-vite is 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's gh-pages root was otherwise unused. New scripts/deploy/template-showcase.mjs (adapted from the template's own bundled deploy.mjs: same basePathFor/self-reload/ rebase-and-retry idioms, minus the repo segment) + .github/workflows/ deploy-template-showcase.yml, triggered directly off pushes to main / dev/template-showcase (idempotent skip when v<version> already exists, same as every other deploy pipeline in this repo — no workflow_run race-avoidance needed since the build never depends on the npm registry: it builds templates/grist-widget-template-vite straight from the workspace, which is exactly what gets embedded verbatim into create-grist-widget's package at build time). Tracks packages/create-grist-widget/package.json's version, not the template's own static version field (nothing ever bumps that one) — each real create-grist-widget release is one showcase release. A new versions.json manifest at gh-pages root 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 in src/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-widget onboarding flow (a plain "create a new GitHub repo" link + the CLI commands already in create-grist-widget's README — no second repo to maintain, no GitHub fork button) and fetches versions.json to 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 committed scripts/smoke/template-showcase-deploy.sh
      • smoke-template-showcase-deploy.yml (release placement at v<version>/ + latest/ + root, idempotent skip, versions.json contents, dev self-reload, and dev removal — no GitHub API or network involved).
  • 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 every npm create grist-widget scaffold (no changes needed to create-grist-widget itself — its existing template copy already picks up any new files). Same two-channel model as the monorepo's own deploy-widgets.yml: push main with a version bump → immutable /v<version>/ + mutable /latest/ (idempotent — re-pushing without a bump is a no-op); push a dev branch → mutable /dev/ with a self-reload snippet for live review inside an open Grist document; deleting dev retires the URL. deploy.mjs is a de-widgetified copy of scripts/deploy/publish.mjs — same rebase-and-retry push and self-reload snippet (both verified dependency-free, copied verbatim), minus the multi-widget folder loop and manifest.json (a lone repo has nothing to catalog). Verified against a local bare git repo standing in for gh-pages, both by hand and via the new committed scripts/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-pages branch, Actions workflow write permissions).
  • create-grist-widget CLI (task-042) — scaffold a new widget with npm create grist-widget my-widget, replacing degit. New package packages/create-grist-widget, published publicly (as it must be named exactly create-grist-widget for npm's create-<x> convention to resolve it). Zero runtime dependencies: build-template.mjs copies templates/grist-widget-template-vite/ into an embedded template/ dir at build/prepack time, stamps the SDK dependency to the current packages/core version (read live off disk, never hardcoded), and drops the monorepo-only prebuild script; 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 changesets linked (.changeset/config.json), so any release touching either package republishes both — a live create-grist-widget@latest can 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. New scripts/smoke/create-widget.sh + smoke-create-widget.yml pack both the SDK and the CLI and drive the real npm create grist-widget path outside the workspace, mirroring external-install.sh's pattern. Docs (index.md, getting-started.md, templates.md, principles.md) updated to recommend the CLI over npx 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. See V1-PLAN.md D4.
  • release.yml's publish-staging job — test an SDK change from npm before it's real. On any non-main branch, bumping packages/core/package.json to a prerelease (0.2.2-rc.0) publishes exactly that version to npm tagged next (never latest), 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.x consumers never resolve it (semver excludes prereleases from plain ranges); install it explicitly to test in a real codebase. Nothing is committed to git and main is never touched, so there's no persistent "staging mode" to forget to turn off — deliberately not a .changeset/pre.json-style toggle. See RELEASING.md.
  • core-ci.yml builds every real product widget against SDK changes. A new matrix job builds each widgets/* package (workspace-linked, no publish) on any PR touching packages/core. Previously no CI ever built the real widgets against an SDK change — only the playground's demo widgets (via root pnpm test, not itself wired into CI) and the template (via smoke-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/cli manages version bumps; .github/workflows/release.yml runs changesets/action on main to open a "Version Packages" PR and, on merge, publish grist-widget-sdk to npm with short-lived OIDC credentials (no stored NPM_TOKEN, ahead of npm's 2FA-bypass-token deprecation) plus a git tag + GitHub Release. apps/docs marked private so only grist-widget-sdk is publishable. RELEASING.md documents the one-time name-claim + Trusted Publisher setup; provenance deferred while the repo is private.
  • Two changelogs, split by audience. packages/core/CHANGELOG.md is 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 /changelog page now links both. The prepack hook no longer copies this file into packages/core (only LICENSE still is).
  • LICENSE (MIT). The repo now carries an MIT LICENSE; license is MIT in the root and packages/core manifests. The prepack hook copies it into the package so it ships in the npm tarball (verified alongside CHANGELOG.md via pnpm pack). RELEASING.md documents the manual publish steps until the changesets workflow lands.
  • smoke-external-install CI + 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 install failed 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 from pnpm-workspace.yaml (not the package.json pnpm field), so the template now ships one with allowBuilds: { 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>.yml workflows are replaced by one .github/workflows/deploy-widgets.yml + scripts/deploy/publish.mjs (node builtins only). Two channels: push to main publishes immutable/<repo>/<widget>/v<version>/ + a mutable /<widget>/latest/ alias, and regenerates a root manifest.json (Grist widget-repository format, usable via GRIST_WIDGET_LIST_URL); a widget is (re)built only when its package.jsonversion has no v<version>/ dir yet, so bumping the version is the release and a packages/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 a grist metadata block (name, widgetId, accessLevel, …) consumed by the manifest; v0-minimal-demo is intentionally unlisted. The former single mutable /<widget>/ URL is kept but deprecated in favor of pinned v<version>/ / latest/.

Added

  • Dev deploy channel + in-Grist self-reload. Pushing a dev/<widget> branch publishes to /<repo>/<widget>/dev/ with a version.json and 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.origin and 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

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

Added

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

Changed

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

Added

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

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

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

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

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

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

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

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

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

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

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

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

Fixed

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

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

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

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

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

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

Changed

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

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

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

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

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

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

Internal

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

Tests

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

Dev dependencies

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

Docs

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

Process

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

Changed

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

Tests

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

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

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

Docs

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

Fixed

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

Changed

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

Tests

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

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

Documentation & DX

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Learnings

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

Added

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

Changed

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

Tests

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

Process

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

0.1.0

Added

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

Changed

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

Fixed

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

Released under the ISC License.