Docs
Systhema Design (opens in new tab)
Changelog

v1.7.1

The release that follows four real 1.7.0 upgrades: `systhema upgrade` keeps edited managed files and always installs the Lexical patch, and a denied role write is refused instead of emptied.

On this page

✨ HighlightsLink to this section

1.7.1 is the release that follows four real 1.7.0 upgrades. Every change here came out of a consumer project moving to 1.7.0, hitting something, and writing it down. The through-line is that the upgrade itself is now a lot safer to run unattended:

  • systhema upgrade no longer overwrites a managed file you edited. It records what it last wrote, refreshes only files that still match that record, and leaves an edited one in place with the new version beside it as <path>.new. Two projects lost local edits to their gateway and sitemap files on 1.7.0; that cannot happen again.
  • The bundled Lexical admin patch is installed on every upgrade. It was previously gated on a codemod that could never fire across a Payload bump, which is every real upgrade, and on some version ranges the stale patch key aborted pnpm install after package.json had already been rewritten.
  • A denied role write is refused, not silently emptied. An admin without users.roles.update could wipe the only administrator's roles with a PATCH, locking the site out of its own admin. Both escalation guards now throw a translated error and otherwise leave the value untouched.
  • systhema migrate --schema tells you what is wrong with an enum. Order-only differences are informational, additions emit their own DDL, and a removal prints the values, the rows still using them and the full recreate script instead of a one-line refusal.
  • Generated Payload types and the import map are no longer a function of which secrets happen to be in your shell. The email schema follows the declared emails option; the upgrade also names any import-map key or type export that disappeared during its own regeneration.

Smaller but visible: parallax media never falls short of its wrapper at the defaults, the cookie-consent preferences close button finally has an accessible name in all seven languages, sync stops dirtying .systhema/.meta.json on every run, the link helper keeps your onClick and handles /#hash, and the Payload scaffold pins the Payload family to the version the Lexical patch targets.


⬆️ UpgradeLink to this section

pnpm add -g @systhemaui/cli@latest
cd <your-project>
systhema upgrade --dry-run
systhema upgrade --yes --allow-database

The upgrade will:

  1. Bump @systhemaui/core, react, next and payload. No peer floor moves.
  2. Run three codemods: declare-email-schema (rewrites a pristine generated src/payload/email.ts and the emails: ternary so the module is always declared and only the key comes from the environment), rewrite-icon-tag-variants (<Icon.svg> and every other removed Icon.<tag> member become <Icon as="svg">), and add-postinstall-cache-refresh (adds a postinstall that restores the package token mirror; it declines when a postinstall already exists).
  3. Reconcile the bundled @payloadcms/richtext-lexical patch in the dependencies phase, after the peer bumps and before the install, sweeping any prior owned entry. The 1.6.0 install-lexical-schema-dedup-patch codemod is now a no-op and systhema doctor --fix uses the same helper.
  4. Run pnpm install --no-frozen-lockfile (or the yarn equivalent), because a frozen install is meaningless right after the tool rewrote the manifest. The updated lockfile is part of what you commit.
  5. Refresh managed app files through the new ledger (see "Breaking & Behavioral Changes" §1), then run the project's own sync, generate:types and generate:importmap. If that regeneration removed import-map keys or type exports, the run says so and names them; that almost always means an env-gated module was off on this machine.
  6. Run the post-upgrade hooks in the documented order: nav renames first, then everything else, then the settings copies. One hook is new: migrate-capability-strings, which rewrites stored per-user capability strings after the 1.6.0 and 1.7.0 capability renames (the codemods only ever rewrote source). It writes to the database, so it runs only with --allow-database.

Read the dry run. The token section now prints only the files a change would touch plus a count of the ones it would not; --verbose restores the full per-file listing and --json is unchanged.

On a push-mode PostgreSQL project, run systhema migrate --schema --dry-run between the nav renames and the settings copies, as before. The difference is what it prints when it stops: see "New Features".


⚠️ Breaking & Behavioral ChangesLink to this section

Nothing here changes a stored data shape. Every entry is either a behaviour the previous version got wrong, or a scaffold default. systhema upgrade handles §5, §8 and §10; the rest need no action unless the entry says so.

1. Edited managed files are preserved, not overwritten (#207 (opens in new tab))Link to this section

create-app-files --override, which the upgrade runs, rewrote every managed file unconditionally: no content check, no backup, no per-file line in the output. Systhema now records the sha256 of every managed file it writes in .systhema/managed-files.json and decides from it. A file still matching the record is a stale shipped version and is refreshed. A file that no longer matches was edited, so it stays put and the new template lands beside it as <path>.new, listed in the output with the instruction to diff and fold. A project from before the ledger existed takes a backup-then-refresh path exactly once, leaving <path>.bak, after which the record is exact.

The Payload scaffold tracks .systhema/, so commit the ledger. syncLocaleFiles uses the same write path, so the managed locale boundary files get the same treatment on plain sync.

Two related scaffold changes: the managed sitemap route is now force-dynamic instead of revalidate = 3600, so a build container that cannot reach the database no longer fails on it (the route sets its own Cache-Control, so the edge still caches it); and the robots.txt Route Handler is not scaffolded while src/app/robots.ts still exists, since Next refuses to build a project carrying both.

2. Un-grantable roles and capabilities are refused (#209 (opens in new tab))Link to this section

preventRoleEscalation and preventCapabilityEscalation were written as value filters, and a field beforeChange hook's return value replaces the field wholesale, so a request that should have been rejected instead succeeded with whatever survived the filter. When nothing survived, that was an empty array. Both now throw a translated error naming the offending values (errors:cannotAssignRole, errors:cannotAssignCapability) and otherwise return the value unchanged, so a request that does not carry the field can no longer collapse it. A third guard refuses a change that would empty the acting user's own roles.

An API client that relied on the silent narrowing now gets an error. Trusted server-side writes with no request user, including the seed scripts, take the existing early return and are unaffected.

3. Parallax motion at the defaults is narrower (#206 (opens in new tab))Link to this section

A translate of t percent needs scale >= 1 + |t| / 50 or the media falls short of its wrapper at the extremes. The shipped defaults violated that: 16 percent of travel against a 1.1666 scale left roughly 50px of the overflow: hidden wrapper uncovered. The scale is kept at 1.1666 and the translate range is now clamped to what that scale covers, about 8.3 percent. Raise scale to allow a wider range; a configured range the scale already covers is left alone.

The same release fixes the React and Next listeners reading their config at module scope, where the browser store is still empty, so a project's parallax settings never reached the engine at all.

4. The rich-text block-margin rule matches Systhema classes only (#206 (opens in new tab))Link to this section

It matched [class*="card"] and [class*="accordion"], so any project class containing the word "card" as a direct child of .richtext picked up an !important doubled margin. The selector is now built from the token-derived card and accordion class lists. .figure-w-card, .w-card, .post-card and .highlight-card are no longer captured; a figure.figure-w-card directly inside rich text takes the figure margin instead of the doubled card margin.

5. Email schema follows the declared option, not the adapter (#215 (opens in new tab))Link to this section

Registration of the General Settings email fields, the email capabilities, the send-test-email endpoint and the form notification email array was gated on an email adapter being present, and the scaffolded module built its adapter from an environment variable. On a machine without production credentials the whole surface vanished from the resolved config, and payload generate:types committed a smaller schema. Registration now follows the declared emails option. The adapter remains the delivery gate: a boot warning fires when emails are configured without one, and the test-email endpoint returns a clear error instead of disappearing.

A project that declares emails but has never had an adapter anywhere gains the email columns on its next push. The declare-email-schema codemod rewrites a pristine generated email module to the new shape.

The first-init seeder wrote the General Settings global from inside a page render without disableRevalidate, so a production next build ran the general-settings afterChange hook during static generation, repeatedly and across workers, revalidating every path and firing a Cloudflare whole-zone purge. The write now opts out, a per-process latch stops the repetition, and the revalidation log line names its global and operation.

7. Post-upgrade hooks execute in the documented order (#216 (opens in new tab))Link to this section

The recovery output has always said nav renames first, settings copies last. Execution ran hooks in registration order, so a settings copy could run before the table it needed existed. Execution now uses the same ordering, and the recovery listing after a failure includes only the failed hook and the ones after it.

8. The Payload family is pinned exactly in the scaffold (#213 (opens in new tab))Link to this section

templates/payload shipped ^3.89.0. The bundled Lexical patch is keyed to one exact version, so the moment a Payload patch release lands the range resolves past it and pnpm install aborts with ERR_PNPM_UNUSED_PATCH. New scaffolds pin payload and every @payloadcms/* dependency to the patch target, and systhema doctor now warns when the resolved version differs from it instead of staying silent. An existing project is not re-pinned by the upgrade; do it by hand if you want the same protection.

9. The scaffold's start is not the platform's start command (#213 (opens in new tab))Link to this section

Behind a package manager, a platform's SIGTERM on redeploy produces a non-zero exit and an ELIFECYCLE line, so a successful redeploy reports as a crash. Run node_modules/.bin/next start directly as the platform start command so Next is the process that receives the signal, and put migrations in a pre-deploy step. The demo container does this now and the deployment docs say so. The template's own start script is unchanged.

A rule in the scaffold's admin custom.scss that applied a Systhema theme class with @apply is removed. Tailwind v4 cannot resolve @apply in a file that never references the theme, so it was a build error, not styling.

10. Icon.<tag> is gone, and now there is a codemod (#210 (opens in new tab))Link to this section

1.6.0 removed every Icon.<tag> member when it replaced the Icon Proxy, filed under drive-by improvements with "no usages". That was only true inside the monorepo; the members were part of the exported type. The rewrite-icon-tag-variants codemod turns <Icon.svg …>…</Icon.svg> into <Icon as="svg" …>…</Icon>, resolves the local import binding so ButtonIcon. and friends are untouched, and reports anything it declines to rewrite. The 1.6.0 notes now list the removal as breaking.

preferencesModal.closeIconLabel is part of the translation type with a built-in default in all seven shipped languages, overridable per locale. consentModal.closeIconLabel is added to the type but deliberately never defaulted: vanilla-cookieconsent creates the banner's dismiss button only when that string is present, and adding a consent control to every existing banner is not an accessibility fix. The Payload resolver also stops dropping translation keys the Cookie Consent tab has no field for, which previously affected opensInNewTabLabel too.

12. sync is idempotent, and postinstall refreshes the token mirror (#214 (opens in new tab))Link to this section

.systhema/.meta.json gains artifactsHash and keeps its generatedAt when nothing changed, so a type check or a build no longer dirties the tree. The scaffolds gain "postinstall": "systhema-core refresh-cache || true", which restores the package-local token mirror a fresh install resets; without it a plain tsc before the first sync resolves theme unions to the shipped defaults. The artifacts-config doctor check now actually reports that state, sharing the sync fix key with its siblings.


✨ New FeaturesLink to this section

Actionable schema-migration blockers (#216 (opens in new tab))Link to this section

systhema migrate --schema treated any enum inequality as one opaque blocker. It now classifies the difference. Same values in a different physical order is a warning that prints both orders and generates no DDL. Pure additions emit ALTER TYPE … ADD VALUE. A removal still blocks, but the report names each removed value, counts the rows in every column still using it, and prints the complete recreate script (rename the old type, create the new one, move each column with a USING cast, restore defaults, drop the old type) for an operator to review. Applying it automatically is deliberately not done.

Constraints are matched by normalized definition as well as by name, so a live foreign key carrying the expected definition under a different name is claimed rather than duplicated. The header nav rename hook warns when it walks a source name PostgreSQL had truncated to its 63-byte limit, which is what left one project with two identical foreign keys.

The upgrade reports what its own regeneration removed (#208 (opens in new tab))Link to this section

generate:types and generate:importmap resolve the project's Payload config through the ambient environment. The upgrade now snapshots payload-types.ts and the admin import map around that step and, when entries disappear, names the removed import-map keys and type exports and says the modules were likely disabled during generation. Removals only; additions stay quiet.

systhema-core refresh-cache (#214 (opens in new tab))Link to this section

Refreshes the installed package's token mirror from the project's generated artifacts without running the full sync pipeline. It never throws, prints the sync command when .systhema/artifacts is absent, and is what the new scaffold postinstall runs.

Both the react and next link helpers now treat an href whose path matches the current pathname, such as /#contact from a header call to action, as the same-page anchor it is. The fragment is resolved with getElementById rather than querySelector, so an id starting with a digit no longer throws and leaves the link dead. Modified clicks, middle clicks and _blank targets stay native. The react helper also composes a consumer onClick instead of discarding it, which the next twin already did.


🐛 Fixes & Internal ImprovementsLink to this section

  • Zero-valued custom tokens stop flooding the build. getResolvedValue tested the resolved value for truthiness where it meant existence, so a single letterSpacing: 0 in customTokens produced over a hundred "Unable to resolve" warnings per build. The check is now for null or undefined (#206 (opens in new tab))
  • The Lexical patch is reconciled on every upgrade. The 1.6.0 codemod ran before install and gated on the version already in node_modules, so it skipped on every upgrade that also bumped Payload, and outside the 1.6.0 window a stale owned patch key survived the pre-flight and aborted the install. Reconciliation now runs after the peer bumps and before the install, gated on the range the upgrade is about to write (#208 (opens in new tab))
  • CI=1 no longer breaks the upgrade after package.json is rewritten. The install argv is explicit per package manager (#208 (opens in new tab))
  • The dry run is readable. The token section prints what changes plus a count of the skips, with --verbose for the old listing (#208 (opens in new tab))
  • Stored capability strings follow the 1.6.0 and 1.7.0 renames. The migrate-capability-strings hook rewrites users.capabilities rows on PostgreSQL, SQLite and Mongo; the codemods only ever rewrote source (#216 (opens in new tab))
  • The cookie-consent close button has a 24px hit area. A transparent pseudo-element widens the pointer target without changing the glyph size or any token (#211 (opens in new tab))
  • .parallax no longer declares object-position: center. It is the initial value, so nothing changes, and it was the only declaration in that rule that could ever conflict with a consumer's object-* utility (#206 (opens in new tab))
  • Child-reveal suppression is documented for every component that does it, not only .stack, along with the escape hatches and the disableAnimation interaction a custom block author needs (#212 (opens in new tab))
  • The 1.7.0 notes record the TypeScript 6 incompatibility with @payloadcms/plugin-mcp. The plugin evaluates transpiled schema code through new Function, and TypeScript 6 prepends a strict-mode prologue, so every tool registration throws and the MCP endpoint hangs. Not a Systhema bug; Systhema's own loader uses vm.Script and is unaffected (#213 (opens in new tab))

List of all changesLink to this section

Every commit between v1.7.0 and v1.7.1.

🚀 FeaturesLink to this section

cliLink to this section

🐛 Bug fixesLink to this section

cliLink to this section

  • fix(cli): reconcile the lexical patch on every upgrade and make the run honest about its install (#208) (b40847b1 (opens in new tab))

core,cliLink to this section

core,reactLink to this section

  • fix(core,react): cover the parallax box, read parallax config at call time, and stop warning on zero tokens (#206) (5f09e5f4 (opens in new tab))

core,react,payloadLink to this section

payloadLink to this section

payload,cliLink to this section

react,nextLink to this section

templates/payload,apps/demoLink to this section

  • fix(templates/payload,apps/demo): pin the Payload family and fix the scaffold's deployment shape (#213) (f146a67d (opens in new tab))