v1.7.0
A Systhema site becomes multilingual end to end, alongside an AI assistant in the Payload admin, analytics widgets, a Cloudflare integration, `systhema setup` and Systhema Design.
On this page
✨ HighlightsLink to this section
This release makes a Systhema site multilingual end to end. Three pieces carry it, and each is an independent opt-in:
- Frontend locales declared once in
systhema.config.tsthroughdefineSysthemaLocales({ supported, default, … }), routed by path prefix or one domain per locale, with no generated glue file. - Payload content localization driven from that same object, plus reviewed Admin and frontend message catalogs for
en,hu,fr,nl,cs,skandar. - Per-locale publishing, on by default wherever locales are configured, so the publish button offers "Publish in <locale>" and each locale carries its own draft or published state.
Alongside the multilingual work: a native AI assistant in the Payload admin that now knows the project's own blocks, colour system and content; first-party analytics dashboard widgets for GA4, Plausible, Umami, Matomo, Fathom or an adapter you write; a Cloudflare integration covering edge purging, zone settings and four dashboard widgets; systhema setup, which reconfigures an existing project the way systhema create configures a new one; reviewable upgrade plans, with database-writing hooks behind an explicit --allow-database; a non-interactive PostgreSQL schema migration for push-only production databases; Systhema Design, a design-system generator that runs as a web app and as a CLI command group on one shared engine; production CSS optimization with variable and class obfuscation; composed colours and motion tokens from Figma; and a WCAG 2.2 accessibility pass across every component and page template.
The change that reshapes an existing project most is per-locale publishing: _status moves out of a shared column into the _locales sibling tables of pages, posts and components. systhema upgrade runs that migration for you. A bare package update does not, and on a dev push it would come back as a dropped and re-added column with every page a draft in every locale, so upgrade through the CLI (see "Breaking & Behavioral Changes" §2).
Native multilingual support (#116 (opens in new tab))Link to this section
Frontend locales and Payload Admin translations are two separate opt-ins that meet in one config object. Declare the frontend contract once:
// systhema.config.ts
import { defineSysthemaLocales } from '@systhemaui/core'
export const locales = defineSysthemaLocales({
supported: ['en', 'hu'],
default: 'en',
routing: { localePrefix: 'as-needed' },
})A Payload project passes the same object into SysthemaPayloadPluginOptions.locales to drive content localization; a value that diverges from the config is refused rather than silently reconciled. Routing supports path prefixes (localePrefix: 'always' | 'as-needed'), one domain per locale via routing.domains, custom subfolder segments, per-locale rtl, and a per-locale missing policy of 'fallback' (the default), 'redirect' or 'notFound'.
Enabling locales generates no glue file. withSysthema(nextConfig) from @systhemaui/next/config injects the routing contract at build time, @systhemaui/next/gateway builds the middleware from it, and SysthemaPage, static params and metadata resolve the locale from the route internally. The one shape change is the site shell, which becomes app/(site)/shell.tsx with two managed boundary files under app/(site)/(systhema)/[[...segments]]/. A project with no locales config keeps its existing route tree, and the locale-off starter build was verified byte-identical.
Systhema ships reviewed Admin catalogs and frontend message catalogs for seven languages, and a project can register its own Admin catalogs:
import { definePayloadAdminTranslations } from '@systhemaui/payload'
export const translations = definePayloadAdminTranslations({
namespace: 'acme',
baseLanguage: 'en',
catalogs: { en: { greeting: 'Hello' }, hu: { greeting: 'Szia' } },
})Read them in a custom admin component with usePayloadAdminTranslations from @systhemaui/payload/translations/client.
CLI entry points are systhema create --locales en,hu --default-locale en and systhema setup locales. Both write only supported and default into systhema.config.ts and then run the one-time locale migration through systhema-core sync --migrate-locales.
Switching an existing site with stored content to locales changes the stored shape of every field that becomes
localized: true. Nothing is reshaped silently. Follow the explicit, resumable SQLite and PostgreSQL helpers indocs/payload/localization/migrating-existing-site.md; disabling locales reverses an untouched migration byte for byte.
Full reference: docs/nextjs/index.md and docs/payload/localization/admin-translations.md.
Per-locale publishing (#171 (opens in new tab))Link to this section
On any project with locales, _status becomes an ordinary localized field. The publish button offers "Publish in <locale>" and "Publish all locales", and each locale's state is its own, so a Hungarian draft can sit behind a published English page. Systhema sets both halves Payload needs (experimental.localizeStatus and versions.drafts.localizeStatus on Pages, Posts and Components).
Revalidation follows the new scope. utils/publishScope.ts reads Payload's publishAllLocales and unpublishAllLocales request intent, so a single-locale publish revalidates that locale's paths only, while genuinely shared fields (parent, pageTemplate, a post's author) still fan out to every locale. Globals stay on shared status deliberately.
It is on by default and opts out with:
withSysthema(baseConfig, {
localization: { localizeStatus: false },
})The database move is automated. systhema upgrade runs the migrate-localize-status post-upgrade hook, which boots Payload with PAYLOAD_MIGRATING=true, performs the whole transition in a transaction, derives each locale's state from version history, and writes the matching Payload migration file when the project keeps a migration directory. It is idempotent: a rerun reports "already migrated" and exits 0. A new slow localize-status-schema doctor check compares the config's expectation against the live database in both directions, and the package logs one error line at boot when the option is on and the database still holds a shared status.
Payload still marks
localizeStatusexperimental, with three open upstream bugs named indocs/payload/localization/per-locale-publishing.md. Read that page before turning it on for a large multi-locale site.
The same PR gives all 26 blocks a 480×320 wireframe thumbnail sized for Payload's 3:2 picker box instead of a stretched 20px glyph, and gives systhemaPayloadPlugin a slug: 'systhema' so another plugin can read plugins.systhema?.options off config.plugins.
AI assistant in the Payload admin (#98 (opens in new tab))Link to this section
@systhemaui/payload ships a native, Payload-styled AI assistant: field-level text generation, rich-text editing with layout generation, whole-page generation and translation, image generation and refinement, one-click SEO metadata, vision-derived alt text (single and bulk), and a hidden usage log with subscription quotas and a pace-based forecast.
withSysthema(baseConfig, {
ai: {
packageName: 'Acme AI', // optional plan label shown to editors
text: { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY },
image: { provider: 'openai', apiKey: process.env.OPENAI_API_KEY },
quota: { credits: 5000, period: { interval: 'month', startDay: 1 } },
},
})Providers are openai, anthropic, google, groq and openai-compatible for text (Ollama, vLLM, any OpenAI-compatible endpoint, keyless allowed) and openai or google for images. Pass a models[] array to offer several selectable models; editors pick one per generation from an always-visible dropdown, and locked entries render as disabled upgrade teasers. Everything is gated by a single ai.generate capability, and usage reads by ai.usage.read.
The package reads no environment variables. Credentials travel through the plugin option from the consumer's environment, the same pattern captcha keys use, and a misconfiguration disables the feature with a console warning instead of crashing. The Vercel AI SDK is vendored into the package at build time, so consumers install nothing and @opentelemetry/api never enters their dependency graph.
Full reference: docs/payload/ai/index.md.
The AI assistant learns the project's own vocabulary (#170 (opens in new tab))Link to this section
The assistant knew five design blocks and nothing about the project it was running in. Its layout vocabulary is now built per request from what the editor actually registers and from the project's own data.
section, feature, columns and posts items take a background and a theme constrained to the project's own layoutBg and colour-system keys, and the prompt teaches the band-merge rule (adjacent sections sharing a theme and background merge into one band). Five native kinds join the vocabulary, feature, image, form, component and posts, each gated on the editor registering that block and, for the kinds that point at a document, on a non-empty catalogue.
Short catalogues of published pages, forms, reusable components and image uploads are read with the requesting user's access (overrideAccess: false plus req.user) and capped. Published pages are the only internal link targets, hydrated as document references; an internal-looking path that matches nothing is dropped while its words stay as text. An inline chip node is available for tag rows, composed content reserves h1 for the page title, and whole-page generation treats the hero group as the masthead instead of opening every editor with a second one.
If your project has the AI module on, generated output changes shape, and a kind whose catalogue is empty is withheld from the schema entirely. Nothing to run.
Analytics dashboard widgets (#106 (opens in new tab))Link to this section
A new analytics plugin option connects the Payload dashboard to Google Analytics 4, Plausible, Umami, Matomo, Fathom, or a data source you supply yourself:
withSysthema(baseConfig, {
analytics: {
source: 'plausible',
siteId: 'example.com',
apiKey: process.env.SYSTHEMA_ANALYTICS_API_KEY,
},
})Eight native dashboard widgets register (overview, chart, breakdown, devices, locations, age, gender, live). One shared period selector drives every data widget, persisted per user under the systhema-analytics-range preference, with GA-style presets from Today to Last year, custom ranges, and a compare-to-previous-period overlay on the chart. Five GET endpoints live under /systhema/analytics/*; settings is auth-only and the rest are gated by a new analytics.read capability held by admin, editor and seoManager by default.
Every adapter is plain fetch with no runtime dependency, including GA4's RS256 service-account JWT. Credentials stay server-side and responses are cached in payload.kv. source: 'custom' plugs any backend into the same experience: implement four data methods returning the normalized shapes, declare your own capabilities, and the module supplies range resolution, caching, capability gating, widget seeding and access control.
systhema create and systhema setup analytics carry a questionnaire step with --analytics, --analytics-site-id, --analytics-api-key and --analytics-host, generate an env-gated src/payload/analytics.ts, and a report-only analytics-config doctor check validates the SYSTHEMA_ANALYTICS_* convention.
Full reference: docs/payload/analytics/index.md.
First-party Cloudflare integration (#110 (opens in new tab))Link to this section
A new cloudflare plugin option connects a site to its zone with either a scoped apiToken or the legacy apiKey plus email pair. zoneId is optional and auto-resolves from the site URL's host.
It adds four dashboard widgets (Overview, Traffic, Cache, Security) fed by Cloudflare's GraphQL Analytics API with their own range store and systhema-cloudflare-range preference; a real Payload global at slug cloudflare whose controls autosave (connection status, an autoPurge toggle, a one-click "Optimize for Systhema" bundle, and Cache, Security and Speed tabs covering development mode, purge everything, purge by URL, under-attack mode, security level, HTTPS rewrites, WAF, Always Online and image optimization); and "Clear cache" / "Clear all caches" buttons on the front-end admin bar. Auto-purge mirrors every existing revalidation seam into a targeted or whole-zone edge purge. New capabilities: cloudflare.read, cloudflare.purge and global.cloudflare.update.
Purging is multi-zone. Each registrable domain is its own Cloudflare zone, and Cloudflare drops any URL outside the zone it is handed, so a domain-routed multilingual site would otherwise never purge its second domain. Per-URL purges group by host and run one pass per owning zone; whole-site purges cover every host the site publishes on. The admin global is multi-zone too, with a domain selector, per-setting "differs" badges, an apply-to-all, and a sync-zone-settings action that copies a curated setting list onto the other domains. Every host parameter is checked against the site's own publishing hosts rather than accepting a raw zone id.
systhema create and systhema setup cloudflare carry the questionnaire step and the CLOUDFLARE_* env convention, validated by a report-only cloudflare-config doctor check.
Enabling this module registers new admin components, so the project's Payload import map has to be regenerated once.
systhema upgradedoes not do it for you (see "Breaking & Behavioral Changes" §9).
Full reference: docs/payload/cloudflare/index.md.
systhema setup reconfigures an existing project (#107 (opens in new tab))Link to this section
systhema create could configure a database, an email adapter, Maps and captcha on a new project. systhema setup [feature] does the same on an existing one, through a registry of nine connectors: database, email, maps, captcha, forms, redirects, ai, cloudflare and locales.
systhema setup # pick a feature interactively
systhema setup ai # reconfigure just the AI assistant
systhema setup locales --locales en,hu --default-locale en --yes
systhema setup email --dry-run # plan only, write nothingIt reuses create's flags and env vars verbatim and adds --dry-run, -y/--yes, --force, --no-git-check, --no-install, --confirm-db-switch and --confirm-disable. It requires a clean git tree, rewrites a pristine generated module silently but shows a diff and asks before touching a hand-edited one, and merges .env line by line rather than regenerating it, never touching PAYLOAD_SECRET or SYSTHEMA_API_SECRET and commenting out the keys of a provider you switch away from. Every off-direction is gated: an interactive confirm defaulting to No, or --confirm-disable under --yes.
Forms and Redirects are credential-less module toggles backed by generated src/payload/forms.ts and src/payload/redirects.ts modules, so flipping one is a pristine overwrite rather than AST surgery on payload.config.ts.
Full reference: docs/cli/index.md.
Systhema Design, in the browser and in the terminal (#96 (opens in new tab))Link to this section
Systhema Design is a design-system generator on two surfaces that share one engine. The web app (live at design.systhema.app) configures a project's colours, typography, layout, fonts and brand assets in a sidebar and shows them applied live to realistic @systhemaui/next-powered example pages. The CLI is the same platform in the terminal, built for people who live there and for agents:
# A complete starting design from a brand colour and a font pairing:
systhema design new --seed '#0E4DA4' --heading-font 'Space Grotesk' --body-font Inter --yes
# Refine, then see what changed:
systhema design color set foundations.bg '#FAF7F2'
systhema design type scale --base 18 --ratio majorThird
systhema design diff
# Ship it into the current Systhema project:
systhema design export all --out-dir ./out
systhema design apply --dry-runFour export targets produce the same formats on both surfaces: a systhema.config.ts carrying the customTokens object, a DTCG token .zip for the Systhema Figma plugin, a full project overlay .zip for a fresh scaffold, and a systhema.design.json agent artifact. Every one of them imports back, so a design that round-tripped through Figma, a coded project or an agent can keep being edited. design apply is the thing the browser cannot do: it writes the design straight into a project (tokens, customTokens, fonts, favicons, logos, the SEO module) and then runs the project's sync. It requires a clean git tree, so a bad apply is one git checkout . away from undone.
Both surfaces run on @systhemaui/core/design, a browser-safe, dependency-light engine whose only runtime dependency is fflate; the environment-specific pieces (PNG rasterization, SVG optimization, font and asset bytes) are injected seams. The palette generator is promoted into @systhemaui/core/color.
Every CLI command is non-interactive-capable and --json-outputting, and a bundled systhema:design agent skill drives the path from a client's brand book to an applied site.
Full reference: docs/design/systhema-design/cli.md and docs/design/systhema-design/index.md.
Custom text styles and design-app polish (#142 (opens in new tab))Link to this section
The part that reaches a package consumer is customTokens.textStyles. A composite can now override a shipped text style or add a new one, and each terminal composite becomes a kebab-cased utility class: a style named Display Hero generates .text-display-hero. Token aliases inside the composite resolve to the corresponding CSS variables, so a custom style can be wired straight to customTokens.font.* and customTokens.responsiveSizing.* entries.
Design-authored DTCG exports now identify themselves with the current Systhema version in the manifest's generator block, and core's token import accepts Design and Figma generator manifests interchangeably, as does the Figma plugin.
In the app itself: semantic foundation anchors on primitive palettes, so moving a palette's main stop shifts every connected foundation by the same number of ramp positions while preserving offsets and light/dark polarity; protected theme and base graph roots plus a structural default colorSystem slot with a promote-to-default action; transactional undo and redo (100 transactions, working from the shell and from the preview iframe); a Custom block in the Typography section for author-defined styles; and a batch of editor polish (Figma-style number stepping, a redesigned font selector, breakpoint tabs above the Scale block, heading letter spacing defaulting to zero).
Composed colours and motion tokens from Figma (#174 (opens in new tab))Link to this section
Two Figma capabilities now travel end to end.
Composed colours. A colour built from an alias plus an opacity (Figma's "Control opacity at scale" release) is read from the com.figma.composedColor extension and emitted as a live reference:
color-mix(in srgb, var(--color-foundations-text) 25%, transparent)instead of a flattened #00000040, so recolouring the source token moves the composed one with it. An opacity that is itself a variable stays a reference. The Figma plugin round-trips the composition through Figma's COMPOSE_COLOR variable expression rather than detaching the alias on import, and copies any unrecognised value shape verbatim into $extensions["com.figma.rawValue"].
Motion. Figma's Easing and Timing variable types export and import: easings become easing tokens carrying a CSS cubic-bezier() (springs keep their raw value in $extensions["com.figma.easing"]), timings become duration tokens in milliseconds. Core reads two new optional token collections, easings and transition. When a project's export carries easings, its curves become the --ease-* custom property values, with the built-in easings.net table staying as the per-curve fallback. Systhema's own library exports easings only, 23 curves with no durations, so a stock project emits no --duration-* or --transition-* and every component computes its fallback in its own var(--transition-…, <initial>).
systhema upgrade carries both into existing projects: a token migration writes the easings collection into projects that predate it, and an extension-aware modify turns five foundations colours into composed colours, each guarded on the value Systhema shipped so a project that picked its own colour keeps it and is never offered the change. One of the five is a visible change: foundations.line-muted was plain black (white in dark mode) at 25% and becomes foundations.line at 25%, so a muted line is now a faded version of the real line colour.
Production CSS optimization (#101 (opens in new tab), #123 (opens in new tab))Link to this section
A new optimization key in systhema.config.ts shrinks the generated stylesheet in production builds only. Three levels:
export default {
// false (default) | true | an object
optimization:
process.env.NODE_ENV === 'production'
? { obfuscateVariables: true, obfuscateClasses: { prefix: 'sys-' } }
: false,
}true enables the three structural passes (pruneUnchangedBreakpoints, mergeThemeDuplicates, variableReferencesResolution). The object form adds obfuscateVariables and obfuscateClasses, each accepting true or a style object (prefix, suffix, method, length, ignore, seed). Class obfuscation is a post-build step, systhema-core obfuscate (also reachable as systhema obfuscate), which templates deliberately do not wire up.
Measured on a real production stylesheet at brotli -q11:
| raw | brotli | Δ over the wire | |
|---|---|---|---|
false | 482,640 | 25,791 | baseline |
| three structural passes | 418,424 | 25,975 | +0.7% (a wash) |
| all five passes | 224,261 | 21,856 | −15.3% |
The honest reading: the structural passes are a brotli wash, and the whole download win comes from obfuscation. Templates ship the structural passes only.
Obfuscation is now safe to enable. systhema-core obfuscate runs a variable pass regardless of obfuscateClasses, rebuilds the identical rename map from the same varObfuscationMap() the Tailwind plugin uses, rewrites surviving var() references across the emitted CSS, HTML, RSC and JS including the server bundle, re-points project-authored --name: overrides, and reports every orphan it introduced with the exact optimization.obfuscateVariables.ignore entry to add. Class obfuscation is prerender-only and refuses to run on a CMS-backed or dynamically-rendered site (see "Breaking & Behavioral Changes" §20).
Full reference: docs/reference/core.md.
August 2026 Next.js security refresh (#162 (opens in new tab))Link to this section
next moves to 16.3.3, which patches two critical advisories:
- GHSA-2xp9-vwfh-vxw4, an unauthenticated remote code execution in the Image Optimization API via AVIF (CVSS 9.5, a libheif heap overflow reached through
sharp). - CVE-2026-75604 / GHSA-p293-qw3h-jr36, an unauthenticated remote code execution on Windows-hosted servers.
A project on 1.6.x should take this upgrade. In the same pass, payload and every @payloadcms/* package move to 3.88.0 and @systhemaui/payload's peer floor for the family is raised to ^3.88.0; the bundled @payloadcms/richtext-lexical admin-performance patch is re-targeted to 3.88.0, and consumer projects re-key it automatically through the existing install-lexical-schema-dedup-patch codemod.
sharp stayed at 0.34.5 in that refresh, since Next 16.3.3 disabled AVIF optimization outright and that alone closed the vector. The September refresh below finishes the job with the patched libheif.
September 2026 dependency refresh (#197 (opens in new tab))Link to this section
next moves to 16.3.5, payload and every @payloadcms/* package to 3.89.0, react and react-dom to 19.3.0, and the scaffolds and the monorepo to TypeScript 6.0.3 and sharp 0.35.4. The last two are aligned with Payload's own website template, which is now the baseline for what @systhemaui/payload runs against. sharp 0.35.4 carries libheif 1.23.2, the release that fixes the AVIF heap overflow, and it is required by Next 16.3.4, which re-enabled AVIF optimization; the two move together from here on. Fifteen further majors landed after being verified one at a time (vitest 5, zod 4, commander 15, @types/node 26 among them), and each one held back is recorded with the failure that stopped it. Two consumer-facing consequences: Node 20.9 is now the minimum (§22), and the Payload peer floor moves to ^3.89.0 (§8). @systhemaui/payload's typescript peer widens to ^5 || ^6 and its sharp peer to ^0.34.2 || ^0.35.0, and every React peer now carries Payload's own range instead of a stricter one, so no project is pushed off a version Payload accepts.
Modern browsers only, and nothing legacy on the wire (#203 (opens in new tab), #201 (opens in new tab))Link to this section
Scaffolds declare Chrome 111, Edge 111, Firefox 128 and Safari 16.4, the line Tailwind CSS v4 already requires, and withSysthema drops Next's unconditional client polyfill module on any project that declares it. Existing projects are moved onto the line by a codemod. Lighthouse's legacy-JavaScript audit goes quiet on a stock site. See §23 and the New Features entry.
systhema upgrade no longer strands a half-upgraded project (#159 (opens in new tab), #161 (opens in new tab))Link to this section
systhema upgrade writes package.json in the middle of its pipeline, and three failure modes could leave a project with the dep bumps, codemods and token migrations already on disk and nothing rolled back.
A pnpm patch key is version-scoped. pnpm.patchedDependencies is keyed <package>@<version>, so bumping a patched package orphaned the key and pnpm install aborted the whole install with ERR_PNPM_UNUSED_PATCH. The new engine/patchedDeps.ts re-keys @systhemaui/* entries onto the exact target version and refuses to guess for anything else, stopping the run before the first write when it cannot reconcile a key.
Re-keying is only half of it. The release that implements a consumer's patch natively is exactly the release where the diff stops applying, giving ERR_PNPM_PATCH_FAILED and the same half-upgraded tree one release later. So the pre-flight now test-applies every @systhemaui/* patch against the version it is about to install: it fetches the target with npm pack run inside the project (so the project's own .npmrc authorizes the private scope) into a temp directory, then runs git apply --check with git repository discovery switched off. A patch that still applies is carried forward, and its file is renamed alongside the key. A patch the new version has outgrown is removed behind a per-patch confirm defaulting to Yes, or --drop-stale-patches under --yes, or the run stops with exit 1. A version that cannot be fetched fails closed with exit 1, overridable with --skip-patch-check.
Re-running after an interruption now does work. planUpgrade gains sameVersion plus getMigrationsForVersion(), so an upgrade re-run against a project already at the target version replays that version's own codemods and post-upgrade hooks instead of answering "Already on X. Nothing to do." selectCodemods() narrows that path to codemods whose applicable() returns true, so a genuinely finished project still reports nothing to do.
The third failure mode is the family pin. checkPeerDependencies reads the project's exact Payload family pin from pnpm.overrides (then the dep specs) and writes any @payloadcms/* peer it adds or bumps at that exact version, so a newly added peer cannot resolve a patch ahead of the pinned family and trip Payload's "Mismatching payload dependency versions" boot check.
Reviewable upgrade plans and database consent (#179 (opens in new tab))Link to this section
systhema upgrade --dry-run used to answer with migration counts and titles. It now shows what a project would actually receive: every codemod's file creations, modifications and deletions (both sides of a rename), and every token migration's per-file old and new nodes, extension changes, skipped values and whether onlyWhen matched. Filesystem migrations run in a temporary copy of the project in execution order, so the preview is the real result rather than a guess from the definitions.
A plan can be saved with --json, reviewed, and applied with --apply-plan <file> --yes. The saved plan binds the target, the CLI version, the execution options (--skip, --skip-tokens, --no-install, --no-skills, --drop-stale-patches, --allow-database) and the project inputs; apply verifies that binding and writes the verified source bytes, rejecting an edited file, a drifted project or an overridden option. JSON output carries no banner, spinner or doctor nudge.
Database-writing post-upgrade hooks no longer inherit consent from --yes. They run only when --allow-database is selected; the plan names each hook, its command, its DATABASE_URI target and the exact --skip <id> flag, and an interactive run confirms them on their own. Hooks you leave out are listed again after the upgrade with their systhema migrate <id> commands. That consent is independent of the rename-or-shape classification the hooks already carry.
WCAG 2.2 accessibility pass (#105 (opens in new tab))Link to this section
Every React and Next component and every Payload page template went through an accessibility pass.
- Reduced motion is honored centrally. Fade and
.aosreveals snap to their visible end state instead of animating, andParallaxListenerbails out. Both engines subscribe to the media query'schangeevent rather than reading it once, so toggling the OS preference mid-session takes effect without a reload. - Landmarks and skip link.
RootLayoutrenders<html lang>and a skip link using the new.skip-linkcore class, targeting the<main id="main-content">every page template emits. Hero titles are real headings. - The header mobile menu exposes
aria-expanded,aria-controlsandaria-haspopup, marks the closed menuinert, moves focus into the menu on open and restores it on close, closes on Escape, and marks active linksaria-current="page". - Forms wire
aria-describedbyandaria-invalid, radio and checkbox groups getrole="radiogroup"orrole="group"witharia-labelledby, andFormInvalidMessagedefaults torole="alert". - Icon-only controls carry an
sr-only!label or anaria-label, decorative icons default toaria-hidden="true", and new-tab links getrel="noopener noreferrer"plus an "opens in new tab" hint. - Google Maps embeds render through a titled
GoogleMapEmbedinstead of@next/third-parties, whose title-less iframe failedframe-title. That dependency has been removed.
Additive API: FooterMainNavigationGroupTitle gains as, List items gain checkedLabel and uncheckedLabel, EmailField and PhoneField default autoComplete to email and tel, and cookie-consent translations gain opensInNewTabLabel. In Payload, the Button and Chip blocks gain a "Hide title visually" checkbox and the Icon block gains an Accessible label field with a domain-derived fallback.
The rendered DOM changes on every page as a result; see §11.
Still deferred: video blocks have no
<track>caption field, because caption uploads are not supported yet.
⬆️ UpgradeLink to this section
If you're on 1.6.x and want to land on 1.7.0:
# Update the global CLI first so it carries the 1.7.0 migrations.
pnpm add -g @systhemaui/cli@latest
# Then upgrade your project. Plan first; commit a clean tree before running for real.
cd <your-project>
systhema upgrade --dry-run # every file and token effect, before anything is written
systhema upgrade --yes --allow-database # apply it, data hooks includedThe upgrade will:
- Bump
@systhemaui/core,react,next,payload, and the peer-dep ranges for Payload + Next. One floor moves:payloadand the whole@payloadcms/*family go from^3.85.0to^3.89.0, and@payloadcms/plugin-import-export ^3.89.0is added as a required peer of@systhemaui/payload(see "Breaking & Behavioral Changes" §6 and §8). Thetypescriptandsharppeers widen rather than move, so a project on TypeScript 5 or sharp 0.34 is not bumped; a project that wants the patched libheif takes sharp 0.35.4 itself, on Node 20.9 or newer (§22). - Run nine codemods, in this order:
add-searchparams-to-systhema-page,extract-forms-redirects-modules,host-aware-robots,rename-seo-capability-strings,replace-admin-favicon,allow-pinch-zoom-in-root-layout,repair-fa-icon-picker-import(see "Breaking & Behavioral Changes" §21),raise-browserslist-to-modern-baseline(see §23),rewrite-icon-tag-variantsto back-fill the 1.6.0Icon.<tag>breaking change. - Apply the token migration: one
addCollectionchange that writes the neweasingscollection (23 named curves) plus itsmanifest.jsonentry, sevenmodifyentries that add acom.figma.composedColorextension to fivefoundationscolours (text-muted,surface-shadow-highlighted,surface-shadow-highlighted-hover,overlay-bg,line-muted;text-mutedandline-mutedget one entry per mode), and twomodifyentries that give the body and heading font families a real fallback stack. Every entry is guarded byonlyWhen, so a project that already picked its own value is skipped and never offered the change. - Run
pnpm install,systhema-core payload create-app-files --override, then the project's ownsync,generate:typesandgenerate:importmapscripts whenpackage.jsondefines them (falling back to the localsysthema-core sync,payload generate:typesandpayload generate:importmapbinaries), so a Payload config that imports CSS runs through the project's loader instead of failing withERR_UNKNOWN_FILE_EXTENSION. - Run post-upgrade hooks against the freshly-installed packages:
migrate-seo-data(copies the legacyseoglobal's rows into theseotab ofgeneral-settings, at the database level, on SQLite, Postgres and Mongo) andmigrate-localize-status(moves_statusout of the shared column onpages,postsandcomponentsand their_vversion tables into their_localessiblings, in a transaction, writing the matching Payload migration file when the project keeps a migration directory). Both write to the database, so they run only with--allow-database; an interactive run confirms them separately from the dependency, code and token changes, and--yesalone leaves them out. A hook left out is listed again at the end of the upgrade with itssysthema migrate <id>command, and both have to run before the updated app starts or deploys. Both are idempotent, and--skip <hook-id>still opts one out. - Refresh the bundled agent skills the project already has installed, across both
projectanduserscope and every supported agent. It only refreshes what is already present; skip it with--no-skills.
The patch pre-flight runs before the first write. systhema upgrade now test-applies every @systhemaui/* pnpm patch against the version it is about to install, so a patch the new version has outgrown is caught before package.json changes rather than halfway through pnpm install. A stale patch is dropped behind a per-patch confirm, or with --drop-stale-patches under --yes. A version that cannot be fetched stops the run with exit 1; --skip-patch-check proceeds unverified.
The plan is reviewable. systhema upgrade --dry-run prints the token changes and the per-file codemod effects a project would receive, not only counts and titles. Save it as JSON to approve it and apply it non-interactively later:
systhema upgrade --dry-run --json > /tmp/upgrade-plan.json
systhema upgrade --apply-plan /tmp/upgrade-plan.json --yesThe saved plan binds the target version, the CLI version, the execution options and the project inputs, so an edited source file or a changed option is rejected before anything is written. Store the JSON outside the project, or the output file becomes a planning input.
When the upgrade stops halfway, it now says what is left. A failed run prints the hook that failed and every hook that was deferred or not yet run as systhema migrate <id> commands, in recovery order: Header nav renames first, then schema additions, then the settings copies. The migrate-seo-data and migrate-emails-data scripts also refuse, with exit 1, when their General Settings tab is enabled but its table is missing, instead of reporting success against nothing (#192 (opens in new tab)).
A push-only PostgreSQL database gets its missing tables without a prompt. systhema migrate --schema --dry-run prints the additive DDL the project's resolved schema needs, and systhema migrate --schema applies it in one transaction; run it between the nav renames and the settings copies, and see the New Features entry below for its limits (#193 (opens in new tab)).
One systhema-core sync after the upgrade clears a stale-artifact warning. .systhema/artifacts/ now carries a .generator.json stamp, and a project whose committed artifacts predate it sees a warning on runtime imports until the first sync writes the stamp. Later package updates do not need another sync unless the artifact format changes (#190 (opens in new tab)).
If you've moved managed Systhema files (for example into a locale segment of your own), the --override step in create-app-files will recreate the defaults alongside your moved versions. Delete the duplicates after the upgrade completes.
⚠️ Breaking & Behavioral ChangesLink to this section
The 23 breaking and behavioral changes, with what systhema upgrade adopts for you and what is left to do by hand, have their own page: v1.7.0 breaking and behavioral changes. The "§" references on this page point at its numbered entries.
✨ New FeaturesLink to this section
Posts module (#97 (opens in new tab))Link to this section
An optional, gated blog capability for @systhemaui/payload, behind the posts: boolean | PostsOptions plugin option and off by default. When on it registers:
- Collections.
Posts,CategoriesandTags, grouped under a Posts admin sidebar group, with two extra built-in roles (publisher,author) and theposts.*/categories.*/tags.*capabilities. Public reads gate to published. - A permalink engine. A configurable WordPress-style pattern (
/blog/{slug},/{category}/{slug}, date tokens), resolved after pages in theSysthemaPagechain, with a live permalink preview in the post editor. - Post and archive templates. A
postTemplateregistry mirroringcustomPageTemplates, with built-inDefaultPostandDefaultArchive. The Archive page renders as top-level editor tabs (Hero, Prepend, Posts, Append, SEO). - One unified
PostsListin@systhemaui/reactand@systhemaui/next, powering Latest, Archive, Related and thepostsblock, with a lead-posts split, an add-up count model (the highlight and lead grid are added on top of page 1) andloading: none | pagination | loadMore | infinite. - Three-tier presentation islands. Figma defaults, then
showandclassNamesconfig, then a full TSXisland, per card, hero and template slot. Default components (PostCard,HighlightCard,PostMeta,ShareButtons) consume a serializablePostViewview-model, and@systhemaui/nextdefaults images tonext/imagewith layout-awaresizes. - Archive filtering. Search plus taxonomy filters, exported as a reusable pipeline for custom archives. Load-more and filter fetches route through a server resolver, so appended cards keep their author bylines.
Full guide: docs/payload/posts/index.md.
Wildcard redirects (#104 (opens in new tab))Link to this section
The Redirects collection accepts * in a rule's From path. A trailing * captures the rest of the path including slashes; a * with more pattern after it captures one segment. Targets use a bare * (equivalent to $1) or numbered $1 / $2 captures, and a reference target collapses every match onto that one document.
Each rule gains a Preserve query string checkbox under Advanced Settings, on by default, honoured by exact and wildcard rules alike, with the target's own params winning. Resolution order in the site catch-all is exact redirect, then wildcard redirect, then page, then 404, and the most specific wildcard rule wins. A same-origin guard rejects a target whose off-origin-ness comes from a capture rather than the literal template.
Exact redirects were hardened at the same time to match a from value with or without a leading slash, which previously never matched.
Query preservation needs searchParams threaded through SysthemaPage. Existing projects get that from the add-searchparams-to-systhema-page codemod; without it the redirect still fires, the query is just not carried.
Live preview on locally built sites, and for your own collections (#102 (opens in new tab))Link to this section
Live preview now updates on a locally built site. In the default server mode, /sys/preview re-sets Next's __prerender_bypass draft cookie as SameSite=Lax without Secure, for loopback-over-http requests only, so Safari and other browsers that reject a Secure cookie over plain http keep it. The workaround never weakens HTTPS or a deployment.
A new livePreview.mode: 'client' renders the preview from the editor's unsaved form state through useLivePreview, with no draft cookie at all, which also covers a cross-origin admin and a domain-routed multilingual site.
The same machinery is exported for consumer collections. The simplest form is one 'use client' component used for both the published render and the live overlay:
import { SysthemaLivePreview } from '@systhemaui/payload/next'
export default function ProductPage({ doc }) {
return <SysthemaLivePreview view={ProductView} data={doc} />
}The advanced form keeps the published page server-rendered and passes a 'use client' clientView for the overlay only. Also exported: useSysthemaLivePreview, LivePreviewListener, LivePreviewClientView<T>, getPreviewDepth from @systhemaui/payload/next, and systhemaPreviewAdmin({ collection, prefix?, slugField? }) from @systhemaui/payload.
Every built-in page and post template opts into client-side preview through two optional registry fields, clientView and resolveData(ctx), so a custom template that fetches server-side can move that fetch into resolveData and get a live-updating preview.
Two things the browser-rendered tree could not do on its own are covered. Material Symbols and Apple emoji picks store an identifier only and resolve their markup from a digest cache the server fills, so client-mode preview fetches the handful of ids a page uses through a new GET <routes.api>/systhema/icons/resolve endpoint, icons picked mid-session included (#187 (opens in new tab)). And because client mode makes a project register its customBlocks twice, once in payload.config.ts and once from livePreview.clientSetup, development builds now diff the two registries and name any block the second list forgot, instead of rendering it as unknown node in the preview (#185 (opens in new tab)).
A consumer's
'use client'view must import the listing client components andRichTextfrom@systhemaui/payload/next/client, not the@systhemaui/payload/nextbarrel. The barrel is a server entry and pulls the plugin graph into the browser bundle.
Render a form from a client view (#181 (opens in new tab))Link to this section
RenderForm is exported from @systhemaui/payload/next/client, so a 'use client' view can render a form-builder form. Until now it was reachable only through the server-side @systhemaui/payload/next barrel, which locked any template whose page carries a form out of client-mode live preview entirely.
The country and state option lists live in a server-only store that is empty in the browser by construction, so RenderForm takes an optional fieldOptions prop and the resolver behind it is public as resolveFormFieldOptions on @systhemaui/payload/next. Resolve it in the template's resolveData hook and hand it back from the view:
// a server module: the template's resolveData hook
import { resolveFormFieldOptions } from '@systhemaui/payload/next'
export const resolveData = async ({ data }: PageTemplateDataContext) => ({
fieldOptions: resolveFormFieldOptions(data.form),
})'use client'
import { RenderForm } from '@systhemaui/payload/next/client'
export default function EntryClientView({ data, aux }: LivePreviewClientView) {
return <RenderForm form={data.form} fieldOptions={aux.fieldOptions} />
}Omit the prop on the server, where the store is read directly, and in a client view whose forms use neither field type. The same entry now also carries resolveIconHtml and resolveIconForRender, so a client view has a documented path to a stored icon value rather than a deep fields/iconPicker/… import (#182 (opens in new tab)).
Card-width figure breakout (#103 (opens in new tab))Link to this section
Media can span the full inner width of a card, flush to its rounded edges. @systhemaui/core adds .figure-w-card and .w-card (exact aliases) which consume a --card-width-offset-x custom property published by every .card-*, and the card drops its own block padding when a breakout figure is its first or last child. <Figure> in @systhemaui/react accepts width="card".
In Payload, the image, video, youtube, googleMap and embed blocks show a Width alignment radio with Default and Card width, but only when the block is a direct child of a Card block or a layout: 'card' columns item. A new TierMarkerFeature plus a polling tier-detect field makes that gating update in the admin on insert, move and paste, without a publish and reload.
Form-submission columns and readable exports (#140 (opens in new tab))Link to this section
A form submission stores every answer in one submissionData array, so the Form Submissions list could only show the form and an answer count, and an export flattened by position with the form as a numeric id.
Individual fields are now addable from the Columns menu, in two flavours, both read-only virtual fields resolved from submissionData in an afterRead hook. Nothing is persisted, so toggling a column needs no migration.
- Cross-form columns (Name, Email, Phone, Subject, Company) match ignoring case and separators, try aliases in priority order, and can join fields (Name falls back to
firstNamepluslastName). Declare your own withforms.submissionColumns; an array replaces the built-in set andfalseremoves them. - Per-form columns give every field of every form its own column labelled
"<Form title>: <Field label>", discovered from the database at server start and refreshed on every form save, so a newly added form field surfaces without a restart. Turn them off withforms.autoSubmissionColumns: false.
@payloadcms/plugin-import-export ships wired up wherever the Forms module is on, adding an Export action to the list. The default file reads Submitted at | Form title | one column per question under the field's own label in form order | anything the form does not define, appended last, with the timestamp in the Admin's own time zone and the heading taken from Payload's own createdAt string, so it matches the Fields picker in all seven languages. Exports run synchronously and are not saved. Filtering already works upstream: filter the list, or load a query preset, then export.
Options live under a new importExport plugin option, which accepts every upstream option plus formSubmissionExport.{timeZone,columnLabels,renamedFields}.
See §6 for the peer dependency and the two new tables.
Localize every field by default, share by exception (#141 (opens in new tab))Link to this section
Content localization decided which fields were per-locale by matching bare field names against hand-maintained allowlists, which produced results nobody chose: forms.emails stored the message per locale and the recipient shared, pages.meta.notListed was shared so noindex was all-or-nothing, and a field called url localized wherever it appeared while emailTo never did.
withSysthema(baseConfig, {
localization: {
policy: 'all',
shared: ['pages.customField'], // append your own exceptions
},
})policy: 'all' inverts the rule: every field of every Systhema-owned schema is localized except a path-matched denylist that carries a written reason per entry (submission keys, upload identity, access control, structural keys such as pages.parent and pages.pageTemplate, **.slugLock, **._tier, the cookie inventory, and records of events such as form-submissions.**). shared, sharedRows and localized are per-project exception lists that append. sharedRows keeps a container shared while its leaves localize, which is what forms.fields needs, since its rows are the submission schema.
The same options can live in systhema.config.ts under packages.payload.localization; the plugin option wins on policy and the exception lists append. posts.publishedAt and posts.author localize on purpose.
Dry-run before changing anything with pnpm payload systhema-localization-report. The migration path is in §7.
Editor-controlled video preload and shift-free native controls (#129 (opens in new tab), #134 (opens in new tab))Link to this section
Beyond the Preload select (§14), <Video> in both @systhemaui/react and @systhemaui/next gains deferControls (default false) and deferControlsLabel (default 'Play video'), backed by a shared useDeferredVideoControls hook exported from @systhemaui/react.
A <video controls> bar renders only once metadata loads, and then materialises as an unprompted layout shift. On a real consumer project's page that was 0.185 CLS, its entire CLS. With deferControls, the native control bar is withheld from first paint and enabled on the first click, on Enter or Space, or on the play event; while deferred the video carries role="button", tabIndex={0} and the label, so it stays keyboard-operable. All Systhema-rendered videos with controls opt in.
Autoplaying videos defer too, on their own terms: the reveal rides on the first completed discrete user input anywhere on the page (a pointerup, a keypress including the first Tab, or the bare click an assistive technology dispatches) plus a click on the video itself. An autoplaying video gets no role="button", no tabIndex and no label.
Deferred markup carries no
controlsattribute, so with JavaScript disabled a deferred autoplaying video is moving content with no pause, stop or hide mechanism, a WCAG 2.2.2 failure. Do not setdeferControlson an autoplaying video on a page that must work without JavaScript.
Smaller client bundles (#124 (opens in new tab), #132 (opens in new tab), #154 (opens in new tab))Link to this section
Four payloads were reaching the browser on pages that had no use for them.
The design-token dataset. The main @systhemaui/core entry statically reaches all 14 token JSON files, and JSON modules cannot be tree-shaken, so a single import { … } from '@systhemaui/core' in any client-reachable module shipped the lot. A new @systhemaui/core/client entry carries only the config accessors, the resolved client-token helpers, the pure utils and the types. It is a conditional export with two implementations: dist/client.browser.js under the browser condition, backed by an in-memory config store and a forwarded token snapshot, and dist/client.js everywhere else, backed by the real loader and real tokens, so server rendering is unchanged. Components read resolved scalars at render time through clientVariants(), clientTokenValue() and clientBreakpoints(). Nothing was removed from @systhemaui/core and every existing import keeps working.
Swiper. <Carousel> and <GalleryWrapper> became shells that lazily import their Swiper islands behind a <Suspense> fallback replicating Swiper's own markup, so a page with no carousel never fetches Swiper.
The Payload side. Eight client-reachable modules in @systhemaui/payload still imported the bare core entry; on this repo's built dist the core barrel contributed 83 modules and 628,062 bytes to any client graph that touched it, and dragged fs, path, module, vm, process, url and tailwindcss/plugin into the public client graph (19 external specifiers down to 8).
Two optional payloads. The country and state form fields render fixed lists (250 countries, 56 states, about 10 KB) that every visitor to every site with the Forms module downloaded, including the default scaffold where both field types are off. They now live in a server-only store, resolved at render time and serialized into the flight payload as a fieldOptions prop. Separately, the built-in Posts page and post templates reached the bundle because the registry names their clientView components; both are now React.lazy shells, which is free because they render only under client-mode live preview.
Measured:
| Build | before | after | Δ |
|---|---|---|---|
templates/next home page, eager client JS | 1,121,984 B (8 chunks) | 800,935 B (8 chunks) | −28.6% |
templates/payload home page, raw | 1,069,321 B | 1,059,849 B | −9,472 B |
templates/payload home page, brotli | 264,575 B | 262,178 B | −2,397 B |
Swiper is isolated in a 114,833-byte chunk the page never references. Both packages carry import-graph guards (packages/core/src/client.test.ts, packages/payload/src/clientBundle.test.ts) that walk src and dist and fail on a forbidden edge.
RenderFormbecame a Server Component in the process. It was'use client'with a pure body and is exported only from the server@systhemaui/payload/nextbarrel, so nothing could legally have rendered it from client code.
Troubleshoot tab in General Settings (#108 (opens in new tab))Link to this section
General Settings gains an always-present Troubleshoot tab holding a "Revalidate all pages" click-to-confirm action. It posts to a new POST /api/globals/general-settings/revalidate-site endpoint, which enumerates published Pages and revalidates each path plus / when a homepage is configured, through the same shared helper the machine-facing POST /sys/revalidate route uses.
The tab stores nothing, and saving the global does not trigger revalidation. A new global.general-settings.troubleshoot.update capability is registered, and the endpoint accepts only that or the bulk global.general-settings.update, so a tab-scoped editor cannot trigger a site-wide flush. Editors do not get it by default.
This flushes the Next cache only; edge purging is the Cloudflare module's job.
Extend a named Lexical editor tier without forking it (#177 (opens in new tab), #184 (opens in new tab))Link to this section
Every editor factory (rootLexicalEditor, regionLexicalEditor, fragmentLexicalEditor, slotLexicalEditor) takes an overrides object, so a project can extend a named tier instead of hand-rolling a lexicalEditor() and losing what the tier knows:
slotLexicalEditor({ headings: ['h1'], label: true })headings replaces a tier's heading set or removes it with false, and label adds or removes the Label feature. For anything else, features: ({ defaultFeatures, tier }) => [...] hands over the whole array, mirroring Payload's own lexicalEditor({ features }) convention. The overrides also take admin, typed as Payload's own LexicalFieldAdminProps and merged key by key over the tier's defaults, so slotLexicalEditor({ admin: { hideGutter: false } }) brings the gutter back on one field while the tier's hideInsertParagraphAtEnd stays in place. admin is not a managed key and does not count as an override for the AI vocabulary, since it changes what a field looks like, never which nodes it accepts.
Systhema's own features (LabelFeature, LeadFeature, SmallFeature, BalanceTextFeature) are importable from a new @systhemaui/payload/lexical/features entry point, and the types SysthemaEditorOverrides, SysthemaEditorTier, EditorFeature and HeadingSize are exported from @systhemaui/payload.
The tier survives the override. Payload deduplicates features by key with the last one winning, so blocks are merged rather than replaced (a transform returning its own BlocksFeature would otherwise take every registered customBlock with it, and surface as content silently vanishing), and the tier marker, the text states and the AI vocabulary are re-applied afterwards.
headings is also the fix for a real trap: a tier's toolbar heading dropdown is built from the enabled heading sizes alone, so an h1 stored in a fragment editor renders fine but reads as the wrong level in the dropdown, and one click there rewrites the page's only h1 with no way to restore it.
A Systhema-owned token scope in the admin editors (#186 (opens in new tab))Link to this section
A customTextStates css value is applied as an inline style, so a var() in it is substituted on the element the state sits on. Naming a design token directly works everywhere. Putting a project variable in between breaks in the admin: a var() inside a custom property is substituted where that property is declared, and in the Payload Admin the design tokens sit on the editor root, not on :root. A --brand-gradient declared on :root looks up token colours that do not exist in that scope, becomes guaranteed-invalid, and the state renders as nothing, with no error and no warning. A state pairing it with a literal -webkit-text-fill-color: transparent renders invisible text, which is how a consumer project found it.
Naming that scope used to mean targeting .ContentEditable__root, an internal class of @payloadcms/richtext-lexical. Every Systhema tier editor now stamps its own contenteditable with systhema-editor-tokens, through Lexical's registerRootListener rather than an upstream class name, and richtext.css seeds the tokens on that class as well. Declare a variable a text state depends on there too:
:root,
.systhema-editor-tokens {
--brand-gradient: linear-gradient(135deg, var(--color-brand-a), var(--color-brand-b));
}The substitution rule and this example are documented in docs/payload/editor/text-states.md.
Editor seams from a backend review (#172 (opens in new tab))Link to this section
Four gaps closed, three of them new public seams.
themes.hiddenkeeps a colour-system mode out of every Systhema colour picker while a stored value stays valid, for modes that are page-level accents rather than band colours. A hidden key the colour system does not have logs a warning, and the AI vocabulary never offers one.SysthemaPayloadPageTemplate.sidebarFieldsplaces named fields in the document sidebar right after the template select, shown only while that template is selected.linkFields()is exported from@systhemaui/payload/fields: thelinkType/url/reference/newTabset every built-in block uses, withallowNone,defaultLinkType,relationToandnewTaboptions. The built-in blocks keep their inline link definitions for now, so this is purely additive.- In-editor styles for the inline blocks (button, chip, icon, avatar) were hand-written and only correct for the default project. On a project whose spacing config has no
swaprule they multiplied a length by a length, so a chip kept its colours and lost its padding and an icon measured 0×0; withoptimization.obfuscateVariableson, the button named variables that no longer existed and rendered as unstyled text. They are now generated from core's CSS-in-JS throughspacingSwapParser, and, when obfuscation is on, rewritten with the same rename map the build used. Both helpers are newly exported from@systhemaui/core. Nothing changes on the public page.
The Systhema mark on all built-in branding (#117 (opens in new tab))Link to this section
Every built-in instance of the old placeholder mark becomes the hexagonal Systhema mark: the admin login logo, the admin sidebar icon, the front-end admin bar, and the Payload template's header and footer logo. The admin favicon asset moves from public/gg-icon.svg to public/systhema-icon.svg, withSysthema() defaults admin.meta.icons to the new path, and systhema-core payload create-app-files now scaffolds that asset, closing a gap where a project adding Payload to an existing Next app had a 404'ing admin favicon.
systhema upgrade runs the replace-admin-favicon codemod: it renames the file and writes in the new mark when the artwork is still the shipped placeholder, renames only when the icon was customised, no-ops when the new path already exists, and rewrites a hardcoded '/gg-icon.svg' in project source.
Both templates also gain a full front-end favicon set generated from the mark's vector paths (icon.svg, favicon.ico, apple-icon.png, manifest.json, maskable PNGs). Those, and the template's header and footer logo, are deliberate per-project placeholders: they are your checklist of what a real project replaces, and an upgrade never touches them.
Scaffold into an existing folder (#175 (opens in new tab))Link to this section
systhema create refused any target directory that already existed, which is the common case when a brief or a research folder is already on disk.
systhema create acme-site --path . # scaffold into the current folder
systhema create --path ./client-work --force-p, --path <dir> selects the target directory separately from the package name (relative or absolute, . included, defaulting to [name], so every existing invocation is unchanged), and --force allows a non-empty one. With --path alone the package name comes from the target directory's basename; a positional argument still overrides it.
Because the template copy overwrites silently, the confirmation prompt is preceded by a concrete list of files that would be overwritten (first 10, then a count) and defaults to No. Two cases the walk cannot see are handled explicitly: an existing .env is added to the conflict list because the Payload scaffolder writes one unconditionally, and an existing .git suppresses git init, so --force cannot lay a second initial commit over your history. Under --yes, passing both flags is itself the confirmation.
Token readers for resolved values (#138 (opens in new tab), #139 (opens in new tab))Link to this section
A resolved token value is not a plain length, so parseInt and parseFloat are the wrong tools for it. Three readers ship on both @systhemaui/core and @systhemaui/core/client:
| Reader | Use it for |
|---|---|
resolveTokenPx(value, fb) | a layout number where the fallback is a legitimate answer (Swiper's spaceBetween) |
tryResolveTokenPx(value) | a number where "unresolvable" must be handled distinctly from zero, returns number | null |
isPositiveTokenLength(value) | any boolean gate, reads the sign and needs no viewport |
They resolve --spacing() calls, px / rem / em, vw / vh / % against a live viewport, and a calc() whose body is a pure multiply-divide chain over a single unit, using a hand-rolled parser rather than eval or new Function. A value carrying a CSS comment parses too. A calc() that adds or subtracts across differing units stays deliberately unparseable.
Two visible consequences: the gallery and carousel slide gaps now apply at md and lg (the gallery's regex missed the spacing-function form and fell back to a hard-coded 20; the carousel passed spaceBetween={NaN}, which Swiper wrote as marginRight: "NaNpx"), and the "Container" width option appears in six blocks that had never rendered it (§19). The gallery's Swiper breakpoint keys now use resolveBreakpointPx, which throws in development and logs in production for a present-but-unresolvable breakpoint rather than silently relocating where the layout switches.
MediaWrapper's hard-coded 'Play video' accessible name became a playVideoLabel prop on both twins, with the English default as the fallback so the affordance is never nameless.
Footer band backgrounds and the advanced-footer hairline (#146 (opens in new tab), #168 (opens in new tab))Link to this section
The colorSystem collection ships three footer band-fill tokens and core CSS read only one of them, so setting footer.main.background or footer.bottom.background produced a CSS variable nothing consumed. Both are now read on .footer-main and .footer-bottom and painted full-bleed with a wide box-shadow spread clipped back to the band's own height, so there is no markup change and no horizontal overflow.
.footer-simple moved to :where(.footer-simple), specificity 0, so a class a project passes now wins where it had always been silently out-ranked.
The advanced footer's top hairline was a hard-coded border-t border-black/10 utility pair on the component. It is now a core rule reading --color-foundations-line-muted through a --footer-advanced-border custom property, so it follows the theme, and --footer-advanced-border: none removes it.
Font families ship a fallback stack (#131 (opens in new tab), #135 (opens in new tab))Link to this section
font.default.body.fontFamily and font.default.heading.fontFamily held the bare string Geist with nothing after it. Webfonts load with font-display: swap, so the browser painted its built-in serif on every cold load until the face arrived. Both roots now ship the full list:
Geist, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serifwhich roughly 37 derived font tokens reference. systhema upgrade carries it as two modify token entries guarded onlyWhen: 'Geist', so a project on its own brand font is skipped and never sees the change offered; docs/reference/core.md has the per-category sans, serif and mono tails to write your own.
The Figma plugin was the source of the same defect, exporting the bare name Figma stores, so the very next export undid the fix. Export now appends the same stack in both paths that carry a family (a FONT_FAMILY-scoped or …/fontFamily-named variable, and a text style's $value.fontFamily), and import reduces a stacked value back to its first family before it reaches Figma, so export, import, export is byte-identical.
systhema migrate --schema for push-only PostgreSQL databases (#193 (opens in new tab))Link to this section
A production database that was only ever shaped by dev push has no Payload migration baseline, so payload migrate:create cannot diff it, and a dev push against it can hang on Drizzle's rename prompt. systhema migrate --schema reads the installed project's resolved Payload schema, derives the unchanged subset from PostgreSQL's catalog, lets the adapter's own Drizzle Kit generate the additions, and applies them in one transaction with an advisory lock and bounded lock waits:
NODE_ENV=production systhema migrate migrate-header-nav-dbnames </dev/null
NODE_ENV=production systhema migrate --schema --dry-run </dev/null
NODE_ENV=production systhema migrate --schema </dev/null
NODE_ENV=production systhema migrate migrate-seo-data </dev/nullIt is PostgreSQL-only, explicit (systhema upgrade never runs it), additive only, and it neither creates Payload snapshots nor alters migration history. It keeps extra tables such as the legacy seo table, refuses known nav-table collisions, incompatible column definitions, unsafe required-column additions and unsupported custom objects, and warns about missing indexes or foreign keys on populated columns. Renames, drops, enum changes and concurrent indexes still need a dedicated helper or a reviewed project-owned migration. Run it in a low-traffic window: a lock wait times out after five seconds and reports how to retry.
The same PR corrects earlier guidance: PAYLOAD_FORCE_DRIZZLE_PUSH=true bypasses the schema-cache check, not the confirmation prompts. Worked example and limits: docs/payload/database/schema-migration.md, also shipped inside the Payload package.
Eleven new systhema doctor checksLink to this section
systhema doctor gains eleven checks across this release:
| Check | What it reports | Fixable |
|---|---|---|
pending-db-renames | legacy table names still in the database after a rename hook's release window | no |
localize-status-schema | _status still shared in the database, or per-locale in the database but off in config | no |
lexical-patch | a missing or unapplied @payloadcms/richtext-lexical admin patch | yes |
stale-token-artifacts | the installed @systhemaui/core does not carry this project's tokens | yes |
stale-references | the agent-facing token references are missing, or were generated by a different @systhemaui/core than the one installed | yes |
ai-config | SYSTHEMA_AI_* set with an unknown provider, or without a key, base URL or model | no |
analytics-config | SYSTHEMA_ANALYTICS_* with an unknown provider or missing per-provider credentials | no |
cloudflare-config | the CLOUDFLARE_* convention | no |
obfuscate-build-step | obfuscateClasses on with no post-build pass, or on a PayloadCMS project at all | no |
fluid-spacing-breakpoints | every configured breakpoint that fluid spacing is not re-based at | no |
localization-policy | a multi-locale project that has not declared a localization field policy | no |
pending-db-renames and localize-status-schema open a database connection and are marked slow, so they are excluded from the one-line nudge other commands emit. stale-token-artifacts and stale-references share artifacts-config's fix key, so --fix runs the one curing command once rather than twice.
No more legacy JavaScript from withSysthema (#203 (opens in new tab))Link to this section
Lighthouse's "Avoid serving legacy JavaScript to modern browsers" audit fired on every scaffolded site for two reasons, and browserslist only fixes one. The other is Next's own client polyfill module: app-globals.js requires it unconditionally, so Array.prototype.at, Object.hasOwn, String.prototype.trimStart, URL.canParse and friends ship in every client bundle at any target. withSysthema(nextConfig) now aliases that module to an empty one, for Turbopack and webpack alike, but only when the project's own browserslist (package.json, then .browserslistrc, production list only) declares a >= floor at or above the baseline for all four browsers. A project with no list or a lower floor keeps Next's polyfills; withSysthema(nextConfig, { dropLegacyPolyfills: false }) opts out and true forces it on. Verified on a production build of the Payload starter: the polyfill signature disappears from the chunks and the audit has nothing left to flag. The request strings are Next internals, so @systhemaui/next's test suite reads the installed app-globals.js and fails when a Next release moves them; a miss costs bytes, never correctness.
Token references in TOON 4 (#200 (opens in new tab))Link to this section
The agent-facing references that sync writes to .systhema/references/tokens/ now come from @toon-format/toon 4. Two shapes change in the files: an object whose values are uniform objects is written as a keyed tabular block (typography[3:]{ref,value}: with one key: v1,v2 row per entry), and a string that starts with # is quoted, because a line starting with # is now a comment. The reference set on the Payload template is 14% smaller. Every file is rewritten on each sync, and systhema upgrade runs the project's sync, so an existing project gets the new format with no codemod and no manual step; the new stale-references doctor check catches a tree left behind by a sync whose reference step failed. The understanding-tokens skill's samples are now real generator output instead of approximations.
Consumer skills rewritten around what the framework decides (#147 (opens in new tab), #164 (opens in new tab))Link to this section
The bundled agent skills that systhema skills install distributes opened with STOP guards and NEVER rules and reached actual capabilities late or not at all, so an agent could satisfy every rule and still over-build.
Added: the page skeleton (<main id="main-content"><Article theme layoutBackground>{sections}</Article></main>) and why <Article> is load-bearing; a hard rule for the section-nesting trap, since section, columns, feature, gallery and posts each render their own <section> band; an inventory gate ahead of the custom-block workflow; "emit editor-shaped nodes" guidance for converters; a reach-past-the-defaults section covering named text styles, deriving sizes from tokens, per-instance arbitrary-class overrides, colSpan, figure-w-screen, hero prepend and append, elementProps and the generated safelist.txt as a class catalogue; brand-book colour derivation; and a Figma pixels-to-tokens table.
A second pass adds a Rule 0 spacing model: every content container (a Section's inner slot, Card, Column, Row, FeatureContent, hero content, Article itself) is a rich-text box that spaces its own children from tokens, with a spacing-ownership table, and every <Stack gap>-around-prose example was replaced. systhema:payload-as-backend gains a decision ladder for when a project needs a custom block versus a native block, a page template or a collection.
One correction worth naming: Systhema's component classes land in the utilities.systhema cascade sub-layer, not a components sub-layer. Tailwind v4 has no separate components output layer, so addComponents emits into utilities. Cascade-layer order also reverses for !important declarations, so an override layer has to be declared before the core import:
@layer overrides;
@import '@systhemaui/core/tailwind';systhema upgrade re-copies the upgraded skills into exactly the locations a project already has them installed.
🐛 Fixes & Internal ImprovementsLink to this section
Bug FixesLink to this section
- Custom admin domains route again behind a reverse proxy. The gateway compared
request.nextUrl.hostname, which Next can build from the internal bind address, so a proxied deployment hid the CMS. Admin host matching now usesHostfirst, then the request URL, and ignoresx-forwarded-hostunlesssysthemaGateway({ trustForwardedHost: true })says the proxy overwrites that header; with the option on, the first forwarded value wins and an empty or invalid one falls back toHost. Deployments that preserve the publicHostneed no opt-in (#188 (opens in new tab)) - Server Actions work on sites served through the Systhema Gateway. cloudflared sets
x-forwarded-hostto the tunnel hostname on the hop into the app, and Next rejects a Server Action whoseoriginhost differs from that header, so every Server Action on a gateway-routed site failed with "Invalid Server Actions request". The Payload Admin loads Lexical block forms through one, so an affected block showed stock fields: a visible_tierselect, a colour field without swatches, a plain Advanced settings collapsible.withSysthema(nextConfig)now appends the host ofNEXT_PUBLIC_SERVER_URLtoexperimental.serverActions.allowedOrigins, keeps consumer entries, and leaves the config untouched when the variable is unset. Sites on Railway's own edge were never affected (#195 (opens in new tab)) - Upgrades no longer overwrite a fresh core package with stale token artifacts.
systhema-core payload create-app-files --overrideran before sync, and its bootstrap mirror copied 1.6-era artifacts (bare JSON imports) over the new package'sdist/tmp, after which every later command died onERR_IMPORT_ATTRIBUTE_MISSINGbefore it could reach sync or clean. Sync now generates tokens and types from source first and stamps the output,cleanneeds only filesystem calls, the runtime mirror checks the stamp's format rather than the package version, and unstamped or incompatible artifacts are rejected with a regeneration warning (#190 (opens in new tab)) - Data hooks and the cookie codemod stop reporting success they did not earn. The SEO and emails scripts skip a disabled feature explicitly and exit 1 when an enabled feature's General Settings table is missing; they repair destination columns even without legacy data, honour PostgreSQL
schemaName, and read and write MongoDB globals through Payload'sglobalscollection with itsglobalTypediscriminator. Theoptin-cookie-consent-tabcodemod preserves an existing General Settings object, including an explicitfalse, spreads andsatisfiesoraswrappers, instead of inserting a duplicategeneralSettingskey that TypeScript rejects; unresolved options produce a visible skipped note. The localized-status migration rejects a target whose versions tables are missing rather than reporting it as already migrated (#192 (opens in new tab)) @systhemaui/payload/fieldsno longer drags core's main entry into a client build. The icon picker's Font Awesome module importeddeepMergefrom the bare@systhemaui/core, so a custom block that keeps itsfieldsandconverterin one module failed the browser build underlivePreview.mode: 'client'withModule not found: Can't resolve 'module', an error that named core and got blamed on the icon resolver. The import now comes from@systhemaui/core/utils, a guard walks the fields barrel and fails on any Node builtin or token dataset it reaches, and the icons docs no longer claim the resolver is server-side (#182 (opens in new tab))- Form and rich-text typography came back.
splitImportantRules()kept an at-rule key only when its recursed body still had content, and an@applykey's body is empty by definition, so all 23@applydirectives incss/form.tsandcss/richtext.tswere discarded before Tailwind ever saw them, taking the whole.richtext.is-formattedtypography layer with them. Emitted component CSS goes from about 40 KB to about 84 KB uncompressed as a result. A second pass,pruneUnresolvableApplies(), now degrades an@applynaming a class an off block owns instead of failing the build (#149 (opens in new tab)) - Separated font-weight names resolve correctly.
normalizeFontWeightAndStyle()tested each weight with a plain\b<name>\bregex, soExtra Boldresolved to 700 instead of 800,Semi Boldto 700 instead of 600,Extra Lightto 300 instead of 200 andExtra Blackto 900 instead of 950. Systhema's own tokens use only single-word weights, so the defaults were never affected (#149 (opens in new tab)) - The token-artifact mirror stopped serving scaffold defaults.
refreshTmpFromSystemaDirskipped any file whose source mtime was not newer than the target's, but a package install stampsnode_moduleswith the install time, so on CI, in a Docker layer, or after any reinstall the shipped defaults were newer and every file was skipped. The gate is now a byte comparison, writes go temp-then-rename so a copy cannot write through pnpm's hardlink into the global store, mirrored subdirectories are pruned, and it warns once per process when it cannot run at all (#144 (opens in new tab)) - Orphaned
var()references point at real tokens. Five custom properties were referenced with no fallback and never defined, which makes the whole declaration invalid at computed-value time with no build error and no browser warning. The gallery and carousel nav buttons and the posts pagination read a variant-less--font-button-*, and the avatar rule read a--avatar-shadow-colornothing set. A new test walksgenerateCss()and fails on any bare reference the pipeline does not define (#136 (opens in new tab)) .menu-sub-navigation-wrappergot its declarations back. A Sass-style'&-wrapper'nested key compiled to the unparsable:is(.menu-sub-navigation)-wrapper, which the browser discards silently, so the mobile submenu rendered with no flex column, indentation, row gap, radius or margin compensation. A new test compiles the whole default component tree and re-parses it with lightningcss, so an unparsable selector now fails the suite (#156 (opens in new tab))- Reduced motion outranks the scroll-reveal hiding rule. Both rules sat in the same layer with
!important, and a media query adds no specificity, so the (0,3,0) hiding rule beat the (0,1,0) escape hatch and the escape hatch was dead code for exactly the elements it existed to rescue. A reduced-motion visitor without JavaScript could get a blank page below the fold (#153 (opens in new tab)) <MediaWrapper>is a play affordance only when it wraps a play target. The only gate was "is this rendered as a<div>", so a still image or a Google Map landed in the tab order announcing itself as a play button that did nothing. It now inspects its own direct children, and aplayAffordance?: booleanprop overrides the detection in both directions (#150 (opens in new tab))- Reduced-motion parallax leaves no uncovered strip.
.parallaxcarries a statictranslateY(-16%) scale(1.1666)that is a pre-JS crop rather than a layout, and with the engine deliberately silent that crop became the final state, leaving roughly 7.7% of the wrapper uncovered. The stylesheet now nests its own reduced-motion reset, and the HTML-bundle engine gained the gate it never had (#150 (opens in new tab)) - The feature band gets an aspect-ratio control. A portrait upload dictated the whole band's height, and the feature block was the one media-bearing block with no ratio control.
FeatureBlockand the feature hero now expose the same Aspect ratio select the media blocks have, applied to image, video, YouTube and Google Maps, and to the parallax render path. It defaults to Auto, so existing pages render byte-identically (#150 (opens in new tab)) - Lexical regions that start with a block render again.
hasLexicalContentlooked at only the first top-level node and asked it forchildren, and a serialized block, an upload and a horizontal rule are all childless, so any editor whose first node was one of those read as empty. The Archive template's Prepend and Append fields were the highest-impact case (#143 (opens in new tab)) - Revalidation hooks honour
context.disableRevalidate. Four hook pairs ignored the flag, and becauserevalidatePath()throws outside a Next request and the hooks run inside Payload's transaction, a seed or migration write to redirects, forms, components or uploads was rolled back while the script reported success. A standing audit test now calls every revalidation hook with the flag (#158 (opens in new tab)) - Cookie consent keeps each locale's own footer. The resolver overwrote every locale's translated
consentModal.footerwith markup composed frompolicyLinks; it now composes only when that locale left the footer unset (#158 (opens in new tab)) col-mx-*insets follow every breakpoint. The generator emitted a base span from the firstresponsiveSizingmode's grid count plus a singlemd-and-up span, so a project whoselggrid count differs from itsmdcount rendered every Section's content track at half the container abovelg. It now walks every mode that declares its own count, including a consumer'scustomTokens.responsiveSizing.xl. The shipped counts are 12/24/24, so a default project's CSS is byte-identical (#168 (opens in new tab))- Seven consumer production defects. Cookie consent init failures are caught and logged, with a notice explaining when
hideFromBotssuppresses the banner in an automation browser and a warning when a second initialiser loses therun()race; the banner survives adefaultLanguagewith no matchingtranslationsentry;Header'selementProps.headerContainerandelementProps.menuTogglemerge a consumerclassNameinstead of replacing the component's own; Lexical block validation errors name the failing block and chain nested field messages; theformblock is registered at the fragment tier, so a form can sit inside a column, card or accordion; a dev warning fires when acustomTokens.responsiveSizingoverride writes a unitless value over a px-length token; and the easings.net curves are emitted as--ease-*custom properties at:root, not only as utility classes (#165 (opens in new tab)) - Section theme swatches render their colours. Section derived its swatch slot from a non-existent top-level
bgnode instead oflayout.bg, so the colour resolved toundefinedwhile the identical pickers on Feature, Gallery and Card worked. All seven pickers now share one helper, and the default page template derives the first slot instead of hardcodinglayout.bg.main(#114 (opens in new tab)) - Emoji icon search matches the typed glyph. The picker's Emoji tab matched only an icon's name and upstream keyword tags, so typing or pasting the actual emoji never found it. The composed glyph is now indexed, and the query strips variation selectors and skin-tone modifiers so any presentation matches (#115 (opens in new tab))
customTokenswritten in the token-file shape now warns. An override written with a mode wrapper, a collection root key or DTCG$-prefixed leaves merged silently and did nothing, and inspecting the generated artifacts could not reveal it. Core warns in development when a branch lands off the known token paths and carries positive evidence of the file shape, and suggests the corrected path. Extending the tokens, including brand-new groups, never warns (#128 (opens in new tab))- Uncapped fluid spacing above the top breakpoint warns. A
%screen%spacing rule expresses every layout token as a ratio of its breakpoint's design width, and Systhema declares six breakpoints but exportsresponsiveSizingfor three, so thelgratios keep scaling: 2,225px of container and 1,847px of body copy on a 2,560px display. The cap was already the scaffold default, but nothing said so. A dev and build-time warning now names the affected breakpoints and quotes the real ratio (#145 (opens in new tab)) - Pinch-zoom works again. The generated
src/app/layout.tsxsetmaximumScale: 1anduserScalable: false, which fails WCAG 1.4.4. Removing the keys is the only fix that holds, because Next mergesviewportper field and/_not-foundsits outside every route group. New scaffolds get the corrected generator; existing projects get theallow-pinch-zoom-in-root-layoutcodemod, which deletes exactly those two properties and leaves everything else, includingviewportFit: 'cover', byte for byte (#119 (opens in new tab), #127 (opens in new tab)) systhema upgraderuns the project's generator scripts. The sync, type and import-map steps invokedpayloaddirectly, so a Payload config that imports CSS died withERR_UNKNOWN_FILE_EXTENSIONeven though the project's owngenerate:typesscript carried the loader that handles it. Non-emptysync,generate:typesandgenerate:importmapscripts are now preferred, with the local binaries as the fallback, and the plan, the text preview and every recovery hint print the command that will actually run (#183 (opens in new tab))- Upgrading off a prerelease plans the release's migrations.
systhema upgradesilently skipped an entire release's migrations when a project pinned to aninternalorcanarybuild of that release was upgraded to a later one: the planner coerced both ends of the window to their release form and then required strictly greater. When a project's version is a prerelease of X.Y.Z, that release's migrations are now planned for any target at or above X.Y.Z (#121 (opens in new tab)) - The API tab is hidden on General Settings and the AI usage collection. Both showed the admin edit view's API tab that every other Systhema collection and global hides (#112 (opens in new tab))
- The frontend follows the visitor's locale. The header and footer logo was a literal
href="/", so it dropped a visitor into the default locale;HeaderInstance,FooterSimpleInstanceandFooterAdvancedInstancenow take alogoHrefthatRootHeaderandRootFooterfill with the locale path. The cookie banner picks its language from the page's<html lang>, andShareButtonsgainslabels.opensInNewTabinstead of a hard-coded English suffix (#160 (opens in new tab)) - Blank drafts can be moved to Trash and restored. A slug that formats to nothing was stored as
'', and every empty string is the same value to the unique slug index on Posts, Categories and Tags, so a second unfinished draft, or trashing one whose draft version still held an empty string, failed with a slug uniqueness error.formatSlugHooknow storesnull, which SQL treats as distinct, on every write path including metadata-only updates. Non-empty URLs are untouched and real duplicates are still rejected (#196 (opens in new tab))
Internal / MonorepoLink to this section
- Payload 3.85.0 to 3.86.0 and Next 16.2.6 to 16.2.12 in the July refresh, which carried the 16.2.11 security release (four High advisories: DoS via Server Actions, a middleware bypass under Turbopack with a single locale, and two SSRF vectors), plus
tailwindcss4.3.0 to 4.3.3 andreact/react-dom19.2.6 to 19.2.8 (#133 (opens in new tab)) - The September refresh took every bump that passes. Next 16.3.5, Payload 3.89.0, React 19.3.0, TypeScript 6.0.3 and sharp 0.35.4, then vitest 5, zod 4, commander 15,
@types/node26, esbuild 0.28, chroma-js 3 and a dozen more majors, each its own verified commit.@toon-format/toon4 (changes the emitted references),@lexical/utils0.50 (Payload pins the lexical family), swiper 14 (raises the browser baseline), eslint 10 (plugins cap at 9) and AI SDK 7 (renames every call site) are held back with the failure recorded. Payload's own website template is the baseline from now on, with sharp and TypeScript aligned to it exactly (#197 (opens in new tab)) - Prettier 3.8.3 to 3.9.7 and a repo-wide reformat. Almost the whole diff is one rule, a union type that fits in the print width now stays on one line; no byte-pinned scaffold twin changed, and two unbalanced inline-code runs in the contributor docs were closed before the new markdown parser could mangle them (#199 (opens in new tab))
engines.pnpmaccepts pnpm 11, matching Payload's website template; eslint, typescript, graphql, dotenv and vite were checked against the same template and already sat on its lines (#198 (opens in new tab))pnpm migrations:promoterepoints test imports when it movesmigrations/next/sources into a versioned directory, and the repo's release documentation was corrected to the real stable-cut flow (#99 (opens in new tab))- Systhema Design deploys from its own image, using the same consumer-style pattern as the demo app (it installs the published packages from GitHub Packages rather than building the monorepo), and the demo image's Payload family pin was repaired (#151 (opens in new tab))
- The public demo gained a real contact form, accurate admin login and dashboard notices, and a reset that empties
form-submissionsbeforeformsand posts a revalidation afterwards (#118 (opens in new tab)) - Shipped skill code examples are linted in CI.
pnpm lint:skillsrefreshes the CLI's skill bundle and runs the 14 code assets underskills/through the Payload starter's ESLint config, failing on warnings, so a refreshed example can no longer fail a fresh scaffold's firstpnpm lint(#183 (opens in new tab)) - The Figma plugin gained its first tests, including one that reads core's own font token file and fails when the shared fallback stack drifts (#135 (opens in new tab))
- Live-app deploy pins moved twice through the cycle so the two Railway apps picked up the security bumps and that day's merges (#163 (opens in new tab), #173 (opens in new tab))
List of all changesLink to this section
Every commit between v1.6.0 and v1.7.0, by type and scope, is on its own page: v1.7.0: all changes.