Skip to content

Migrating to Karni 0.4

Every change below is recorded in Karni's machine-readable changelog (packages/karni/karni/changelog/0.4.0.toml). None needs a code rewrite, so karni upgrade reports each one with a link here and changes no files. Nothing here breaks a 0.3 project's build. Some names in generated code and some test expectations change.

Upgrading

pipx upgrade karni-cli        # the CLI carries the changelog
karni upgrade --dry-run       # lists the notes below and writes nothing
# then pin karni to 0.4 in pyproject.toml, reinstall, and run:
karni rules sync              # rewrite the agent files in the new wording
karni types                   # regenerate the TypeScript client
karni check                   # and the tests

Modules move with core: karni-payments, karni-approvals and karni-impersonation 0.4 require karni>=0.4,<0.5, and the module UIs peer on @karni/ui ^0.4.0.

Changed in 0.4

Generated API clients: operation IDs

A route that ends in a path parameter now has _by_<param> in its OpenAPI operation ID, so two routes no longer share one ID. Before, GET <resource>/import/ and GET <resource>/import/{job}/ both became ..._import_retrieve, and a generated client named the second one ..._retrieve_2. Now the second is ..._import_by_job_retrieve. The standard retrieve, update, partial_update and destroy routes keep their names. Run karni types and rename the calls the compiler reports.

Enum names in the OpenAPI schema

When two choice fields share a name but not their choices (two status fields, say), drf-spectacular named their enums with a hash, such as Status469Enum. Karni now names them after the model and the field: RefundStatusEnum, BookingStatusEnum. A field name used by one choice set keeps its plain name, so most schemas do not change. Run karni types, then accept the project's OpenAPI snapshot (KARNI_UPDATE_SNAPSHOTS=1 karni test). To keep drf-spectacular's names, put "drf_spectacular.hooks.postprocess_schema_enums" back in SPECTACULAR_SETTINGS["POSTPROCESSING_HOOKS"]. ENUM_NAME_OVERRIDES still wins over both. See enum names.

The agent files: run karni rules sync

The rules, the procedures and the header of the generated AGENTS.md, CLAUDE.md and the other adapter files are reworded in plain sentences. The facts are the same. Until you run karni rules sync, karni rules sync --check (and make check) reports the files as stale.

Messages and log lines are reworded

What Karni prints no longer uses semicolons or em dashes. Two formats that a log search may match changed. The development query log now reads GET /path 200: 3 queries, 2 duplicated where it used to read GET /path 200 — 3 queries, 2 duplicated. A budget overrun now reads ran 28 queries against a budget of 8 where it used to read ran 28 queries; budget is 8. karni explain - reads both. Empty values show Django's - in the admin and EmptyValue in @karni/ui. Update tests that match the old wording.

ResourceTable is a grid

ResourceTable is now a WAI-ARIA grid with one Tab stop: Tab enters and leaves it, and arrow keys move inside. Its accessible role is grid, so a test that finds it with getByRole("table") must use getByRole("grid"). The element is still a <table>. Pass keyboardNavigation="native" for the previous behaviour, where every control in a row is its own Tab stop.

Focus moves after navigation

Layout moves focus to the new page's heading after a client-side route change and announces the page title. A test that expects focus to stay on the clicked link after navigating needs updating. Pass routeFocus={false} to turn it off.

Deprecated in 0.4

The 0.3 colour token names

Every visual value in @karni/ui now resolves from a token in one of the families --karni-color-*, --karni-space-*, --karni-radius-*, --karni-font-* and --karni-shadow-* (see the styling section of the @karni/ui README).

Colours are now plain CSS colours: --karni-color-primary: oklch(0.55 0.22 285) or var(--your-primary). The 0.3 names held HSL triplets (--karni-primary: 262 83% 58%). Each --karni-color-* defaults to hsl() of its 0.3 name, so a 0.3 theme keeps working unchanged. The 0.3 names are deprecated in 0.4 and removed in 1.0.

To move a theme, rename each property and wrap the triplet in hsl():

/* 0.3 */
:root { --karni-primary: 262 83% 58%; }

/* 0.4 */
:root { --karni-color-primary: hsl(262 83% 58%); }

The renamed properties are background, foreground, card, card-foreground, popover, popover-foreground, primary, primary-foreground, secondary, secondary-foreground, muted, muted-foreground, accent, accent-foreground, destructive, destructive-foreground, border, input, ring and chart-1 to chart-5: --karni-<name> becomes --karni-color-<name>. --karni-radius keeps its name, and --karni-radius-sm, -md, -lg and -xl derive from it.

Opacity modifiers on Karni colours in your own code (bg-primary/50) keep working under Tailwind 3 and 4. Inside @karni/ui they are replaced by named shades (bg-primary-hover, bg-muted-subtle, bg-destructive-subtle), which you can theme on their own.

Changed defaults in 0.4

The admin wears @karni/ui's brand

The admin's default UNFOLD setting now loads @karni/ui's design tokens and reads its colours from them (admin theming): the primary is @karni/ui's (near-black in light mode, near-white in dark) instead of indigo, the neutral greys and the dark background are the tokens' gray, the radius is 6px instead of 8px and the font is the system stack instead of Inter. Nothing breaks. The admin only looks different.

A project that set UNFOLD["COLORS"]["primary"]["600"] alone now gets that step over Karni's primary scale. Either set the whole scale ("50" to "950"), or put the colour in a token override instead, which also reaches buttons' text colour and Karni's own admin pages:

/* static/css/admin-theme.css, appended to UNFOLD["STYLES"] */
:root,
.dark {
  --karni-color-primary: oklch(0.55 0.2 250);
  --karni-color-primary-foreground: white;
}

To keep unfold's own look, set UNFOLD["STYLES"] = [] and delete UNFOLD["COLORS"] after karni_settings().

AuthGuard will explain a refusal by default

An AuthGuard can now say why it refuses. With explain, a visitor who is not signed in sees a sign-in prompt (a LoginForm, and signing in reveals the content in place), and a signed-in user whose role lacks access sees PermissionDenied. In 0.4 a guard with no fallback and no explain still renders nothing, as in 0.3, and logs one warning in development. 1.0 makes explain the default. Before 1.0, decide for each guard:

  • A guard that protects a page: add explain (or anonymous / denied for your own nodes).
  • A guard that hides a piece of UI (a nav link, a button): pass fallback={null} to keep rendering nothing, or use usePermission().hasRole(...) instead.
<AuthGuard roles={["admin"]} fallback={null}>
  <AdminLink />
</AuthGuard>