Packages
An opinionated, agentic-first design-system kit for Phoenix + daisyUI. Ships a component manifest, a /design page, a /design.json endpoint for AI agents, contract-clean core wrappers, a wrapper-contract linter, and mix tasks for installing into existing apps or generating new ones.
Current section
Files
Jump to
Current section
Files
CHANGELOG.md
# Changelog
> **Convention for core-component fixes:** if a change touches a
> component a host might have forked (`import JobyKit.CoreComponents,
> except: [...]`), say so explicitly — "if you've forked `table/1`, apply
> this to your copy". Forks don't receive upstream fixes, and the 0.2.1
> `table/1` fix reached a component one app never rendered while its fork
> kept the identical bug. `mix joby_kit.lint` now reports
> `:forked_wrapper`, and `/design.json` carries `forked_from_kit`.
## v0.3.2
Two fixes to the same flaw, found while migrating the first app: the
`/design` page was host-specific when its entire promise is being
identical across every JobyKit consumer.
Nothing here requires host action, and nothing fails to compile. The
changes affect the two documentation surfaces — `/design` and
`/custom-designs` — not the components an app renders.
**One thing to know before upgrading:** if you customised the previews
or summaries for *kit* components in your own `DesignPreviews` /
`DesignManifest`, those no longer appear on `/design` — the kit's
canonical versions do. That is the point of the change, but it means a
local tweak you liked will quietly stop showing. Previews for your own
components are untouched.
### `/design` is the kit's page, structurally
**The kit now owns its own page end to end.** `page_component/1` renders
`JobyKit.KitManifest` — the kit's own registrations, with previews from
`JobyKit.Previews` — instead of filtering whatever the host declared.
Three things used to drift, and all three were found in the fleet:
* **Which components appear.** Generated files are written once and never
updated, so an app installed at 0.1 still advertised the 0.1 inventory.
One app showed **8 of the 14** components its kit version shipped, with
nothing indicating the other six existed.
* **What each preview renders.** Previews for kit components lived in the
host's `DesignPreviews` and were edited locally, so two apps on the same
kit version demonstrated the same `<.button>` with different examples —
one missing the icon-button case entirely.
* **What the summary says.** Same component, different prose per app.
A page that differs per app cannot be the thing an agent learns once.
**Generated apps no longer register kit components.** The install
template registers only the host's own, and its `daisy_overrides/0`
starts empty — the kit declares the primitives it wraps, so the
catalogue is right without the host restating it. `/design.json` merges
the kit's entries with the host's and drops duplicates, so it remains
the single source of truth.
**Existing apps need no change.** Kit registrations left in a host
manifest are ignored for `/design` (the kit's list wins) and de-duplicated
in `/design.json`. Deleting them is tidy-up, not migration — and worth
doing, since they otherwise pin a snapshot of an old inventory.
**Which page a component lands on is now decided by who owns the
module, not by the category the host declared.**
`category` is a free atom the host picks, so keying the two pages off it
made the split a convention rather than a rule — and conventions get
broken. Found during the first fleet upgrade: an app had registered two
of its own components under `category: :core`, so they rendered on
`/design`, the page whose entire promise is *"identical across every
JobyKit consumer"*. To anyone reading that page — or any agent scraping
it — they looked like components JobyKit ships.
`page_component/1` now renders only entries whose module the kit owns
(`JobyKit.PageComponent.kit_component_modules/0`); `custom_page_component/1`
renders everything else. Category still groups entries within whichever
page owns them.
Deliberately an explicit module list rather than a `JobyKit.` prefix
check: "what the kit provides" is finite and knowable, and prefix
matching would hand the kit page to anything a host chose to namespace
under `JobyKit`.
**No host action needed.** A misfiled component moves to
`/custom-designs` on upgrade with no manifest change. Recategorising it
to `:composite` is still worth doing so the heading reads right, but
nothing is broken if you don't.
## v0.3.1
Clears the audit's remaining polish, hardens the linter, and removes an
internal codename from public surfaces. No breaking changes to component
APIs; the linter does gain rules, so **expect new findings** — they are
real, and each is explained below.
### New lint rules
* **`:unmarked_component` (warning)** — a public function component
renders markup but carries no `data-component` at all.
`:unregistered_wrapper` keys off that attribute, so it could only ever
see components that had already done half the registration; a
component with no marker — the most common way to skip the contract —
was invisible to it. Excluded by convention: `render/1` (a LiveView
callback), `*_preview/1` (the manifest's preview harness), and
`<App>Web.Layouts` functions. Those exclusions exist because the first
cut produced eighteen findings on the kit's own generated app, every
one structural.
* **`:assign_new_default` (error)** — `assign_new(assigns, :x, ...)` on
an attr declared with a `default:`. The key is always present, so the
fallback is dead code. This is not hypothetical: it shipped as the
flash nil-id bug in 0.2.0, where every toast rendered without an id
and the dismiss handler became `JS.hide(to: "#")`.
* **`:missing_data_component` no longer false-positives** on a wrapper
that builds its marker dynamically (`data-component={@dc}`), and is
now scoped to the component's own `def` — including every clause of a
multi-clause component. Checking the whole file let one compliant
wrapper vouch for every other function in it; scoping it naively to
the first clause then reported `input/1`, which has six, as
non-compliant. Both caught by a new end-to-end test that runs the
linter against JobyKit's own components.
### Fixes
* **`Bardo` is gone from public API.** The design page's
scraper-facing DOM ids are now `design-system-index` and
`design-category-<x>`. No fleet app referenced the old ones, and the
kit promises these are stable, so this was the last cheap moment.
* **`flash/1` accepts `:success` and `:warning`.** The enum was
`[:info, :error]`, so apps with a success or warning message could not
use the kit's flash at all and hand-rolled alerts instead.
* **The md patchers no longer duplicate their block.** A file that kept
the start marker but lost the end one got a second block appended —
and then, with both markers present, looked done forever, making the
duplicate permanent.
* `<.table>` gains `table_id`, since `id` is required for stream
containers and lands on `<tbody>`; `attr` docs now cover the enums
that agents read out of `/design.json`.
### Housekeeping
* A `test_coverage` threshold of 85% is configured, so `mix test --cover`
in CI has a real floor. Current coverage is 90.9%.
* The kit's spacing-and-typography principle — own your box, nothing
outside it — is now in the `AGENTS.md` and `CLAUDE.md` blocks that
every install writes, so agents building new wrappers get the rule
that 0.3.0 was built around.
* Stale release tarballs, an `erl_crash.dump`, and the resolved
`NOTES-flash-nil-id.md` scratch file removed from the repo.
Polish pass over the items the audit had left open, plus removal of an
internal codename from public surfaces.
* **`Bardo` is gone from public API.** The design page's scraper-facing
DOM ids were `design-system-bardo-index` and
`design-bardo-category-<x>`, and the manifest docs said "One Bardo
wrapper per daisyUI primitive". Now `design-system-index` and
`design-category-<x>`. Renaming was safe because no app in the fleet
referenced them; the kit promises these ids are stable, so this was the
last moment to change them cheaply.
* **`app_css` derives the Tailwind `@source` path** from
`Mix.Project.deps_paths()` instead of hardcoding
`../../deps/joby_kit/lib`. The hardcoded form failed silently twice
over: umbrella apps keep deps at the root so it never resolved, and an
app switched from a hex dep to a path dep keeps the old `deps/joby_kit/`
directory, so the `@source` kept resolving — to a stale copy of the
previous version. Observed live: Tailwind generating CSS from 0.2.1
while the app compiled against 0.2.3, so new classes silently never
reached the stylesheet.
* **`NavPatcher` no longer writes into the wrong element.** The `</ul>`
search ran to end-of-file, so a header without its own list would adopt
the first list anywhere later in the document — a footer, a sidebar —
and still report success. It is now bounded to the enclosing
nav/header; a nav we can't place links inside returns `:no_nav_found`
and the install task prints manual instructions.
* **Undeclared categories are a compile error.** An entry registered
under a typo'd `category:` was dropped by `by_category/0` and vanished
from `/design` and `/custom-designs` while still appearing in
`entries/0` and `/design.json`. Silent invisibility is the failure this
kit exists to prevent. The error names the component and lists the
categories that do exist. Anonymous `preview:` functions are rejected
the same way, instead of dying inside `Macro.escape` with a message
that names nothing.
* **`daisy_overrides/0` is a declared optional callback**, so
misspelling it is a compile warning rather than a silent no-op that
leaves every wrapped primitive showing as unwrapped.
* **`category_label/1` and `category_description/1` agree.** One raised
on an unknown category and the other returned `""`, so a caller got
either a crash or silently blank prose depending on which it happened
to call.
* **One broken preview no longer takes down `/design`.** Previews render
inline, so a `KeyError` from a preview that reads an assign 500'd every
other component's card with it. Now contained, with the error rendered
in place of that one preview.
* **`ManifestController` returns its JSON error for a typo'd module.**
Any atom passed the guard, so `DesignManifst` raised
`UndefinedFunctionError` deep inside the payload and surfaced as a
generic HTML 500 — instead of the helpful JSON response written for
exactly this case.
* `card` uses daisy's `card-border` rather than hand-rolled border
classes; `<.input type="file" multiple>` actually accepts multiple
files (the attr was only rendered in the select branch); and an unknown
`<.icon>` name explains itself instead of raising a bare
`FunctionClauseError` inside the kit.
Closed as won't-do: `list` stays a `ul`. daisy's `list` requires
`ul`/`li.list-row`, and `title_class` in 0.3.0 addressed the actual
complaint.
## v0.3.0
**Breaking, on purpose.** See [MIGRATING-0.3.md](MIGRATING-0.3.md) for the
upgrade, including the list of compensating hacks to delete.
Removes the spacing and typography opinions host apps were routing
around, and fills in the variants they were hand-rolling. Driven by
usage data from three consumer apps: adoption turned out to be inversely
proportional to baked-in opinion — `button`/`input` (shape and behaviour,
nothing to undo) got hundreds of uses, while `card`/`header`/`list`
(typography and margin opinions) got bypassed. One app carried 127
hand-rolled panels against 10 `<.card>` uses; another had zero
`<.header>` uses and the same header class string pasted ten times.
* **`input/1`: `class` moves to the root.** It landed on the control, so
the field group's own box was unreachable — callers resorted to
nudging neighbouring elements (`class="mb-0.5"` on an adjacent button
that didn't even work). `class` and layout now target the root
`<fieldset>`, matching every other wrapper; the new `input_class`
styles the control. Global attributes still reach the control, since
`placeholder`/`required`/etc. would be meaningless on a fieldset.
* **`input/1`: no outer margin.** The hardcoded `mb-2` is gone —
containers own spacing via `space-y-*`/`gap-*`. Width now works
through the root (`class="w-32"`); the control is always `w-full`.
The root is a semantic `<fieldset>`, and errors are associated with
the control via `aria-invalid` and `aria-describedby`, so screen
readers get more than a red border.
* **`card/1`: body typography is opt-in.** The forced
`text-sm text-base-content/70` wrapper is gone, and body content
renders as direct children of `card-body` — so the card's own `gap`
applies instead of being dead weight behind a single wrapper div.
`prose` opts into the old treatment; `body_class` sets your own.
`card-actions` lost its hardcoded `mt-3`.
* **`header/1`: usable at last.** `pb-4` removed; new `:eyebrow` slot
(both header-consuming apps hand-rolled one), `level` so a section
header stops emitting a second `<h1>`, `size` (`page`/`section`), and
`title_class` for apps with their own display face.
* **`button/1`: tones, `xs`, and shapes.** `variant` takes
`soft | primary | neutral | ghost | danger`. Destructive actions can
finally read as destructive — previously Revoke, Delete and Purge
rendered identically to Refresh. The default is unchanged and is now
nameable as `soft` for computed callers. `shape` (`circle`/`square`)
covers icon-only buttons, replacing stacks like
`btn btn-primary btn-soft btn-sm btn-ghost btn-circle` where four
competing classes were resolved only by stylesheet order.
* **`table/1`: additive hooks.** An `:empty` slot (five pages in one app
repeated the `:if={@rows != []}` + `<.empty_state>` pair), `zebra`
opt-out, a `size` density enum instead of leaking `table-xs` through
`class`, and `data-table-actions` on the action cell so overrides can
target it precisely — one app's `:last-child` workaround was hitting a
data column on tables with no `:action` slot.
### Linter
The `:raw_html_primitive` rule is the kit's headline check, and it was
both over- and under-firing. **Expect new findings after upgrading** —
they are real, previously hidden.
* **The exemption is now scoped to the enclosing `def`, not the file.**
Any file containing `data-component=` *anywhere* — including in a
comment or a docstring — silenced the rule for the whole file. One
small wrapper in a 500-line LiveView zeroed coverage for the entire
render, and a `# data-component=` comment disabled the check
wholesale without the audit trail the documented escape hatch leaves.
A `def` whose body carries `data-component=` is still treated as
wrapper territory; everything else in the file is checked. The kit's
own fixtures gained a finding the moment this landed.
* **Prose is no longer mistaken for markup.** `@doc`/`@moduledoc`
heredocs and HEEx/HTML comments are blanked (newlines preserved)
before scanning. The kit's own `composite_components.ex` template
spells out `data-component="<App>Web.CompositeComponents.<name>"` and
says "never raw `<button>`/`<input>`/`<textarea>`" — so **every
freshly installed host opened with four phantom warnings**, one of
them quoting the sentence telling them not to do it. A clean install
now lints clean.
* **Every primitive on a line is reported**, not just the first
(`Regex.scan`, not `Regex.run`) — fixing one used to reveal the next.
* **Capitalised remote components are no longer flagged.** The tag
regex was case-insensitive, so `<Input.autocomplete />` read as a raw
`<input>`. HEEx reserves lowercase for HTML and capitalised for
components; the regex now matches accordingly.
* `~H'''` heredocs are scanned (the sigil pattern missed the
single-quote delimiter, so those files were skipped entirely).
* The violation message no longer suggests `# jobykit:allow-raw-html`
inside a template — in a `~H` block a `#` line is literal text that
renders into the page.
Two new rules, both from demonstrated harm in consumer apps:
* **`:forked_wrapper` (warning)** — flags components excluded from
`import JobyKit.CoreComponents` and replaced with a host copy. One app
forked `button/1` and `table/1`; the 0.2.1 `table/1` fix shipped into
a component it never renders, while its fork carried the identical
bug. Nothing surfaced that. Entries now also carry
`forked_from_kit` in `/design.json`, so "did that fix reach us?" is a
lookup instead of an archaeology exercise.
* **`:duplicated_class_string` (warning)** — the same ≥25-character
`class` string appearing three or more times. The kit's own CLAUDE.md
names this as the symptom that markup wants lifting into a wrapper,
but nothing checked it: one app pasted the same header class string
ten times and linted clean.
### `mix joby_kit.new` moved to its own package
**Action required if you install the archive:**
```sh
mix archive.uninstall joby_kit
mix archive.install hex joby_kit_new
```
The generator now lives at
[joby_kit_new](https://github.com/JobyCorp/joby_kit_new). Nothing else
changes — `joby_kit.install`, `bootstrap`, `gen.wrapper` and `lint` stay
in this package, which is the point.
`joby_kit.new` has to run before a project exists, so it can only be a
Mix archive — and an archive puts *everything it contains* on the global
code path. With the in-project tasks in the same package, the archive's
copy of them shadowed each project's own dependency. Concretely: with a
project pinned to 0.2.3 and a 0.2.0 archive installed,
`mix joby_kit.install` generated 0.2.0 scaffolding, and re-running
changed nothing. **An app's `mix.exs` did not control which JobyKit
generated its files** — whichever archive was on the machine did. That
also explains generated-file drift across a fleet.
Only `joby_kit.lint` escaped, by accident: it declares
`@requirements ["compile"]`, which loads the project's dependencies
first, so the dependency's copy won.
Splitting removes the possibility instead of detecting it, and follows
Phoenix's arrangement — `phx_new` is a separate hex package from
`phoenix`. A version guard was considered and rejected: Mix gives no way
to prefer the dependency's task over an archive's, so a warning would
have left users stuck rather than fixed.
### New components
Three of the four components consumers had built for themselves. The
fourth, an icon button, is covered by `button/1`'s new `shape`.
* **`badge/1`** — status chip with a semantic tone
(`neutral | ok | warn | danger | info`). One app maintained five
separate tone-to-class functions mapping the same states to
border+bg+text triples, and had copied one of them verbatim into a
second file — precisely the drift the kit's guidance warns about.
`neutral` and `danger` deliberately mean the same thing here as on
`button/1`.
* **`eyebrow/1`** — the small uppercase label. This was the single
most-duplicated string in the fleet: 326 hand-typed instances in one
app, with letter-spacing drifting across nine values and six font
sizes. `card/1` and `header/1` now render their `:eyebrow` slots
through it, so the kit stops carrying three copies of the string
itself.
* **`modal/1`** — server-driven dialog. Two apps built one and both hit
the same three problems, so those are what it solves: visibility is a
plain assign rather than client state, so it can't disagree with the
LiveView that owns it; one `on_cancel` covers the close button, the
backdrop, and Escape, instead of the separate close/dismiss handlers
apps ended up writing; and `static` renders the box in flow for design
pages, since `.modal` is `position: fixed` and would otherwise cover
the page it's being previewed on.
* **`list/1` loosened rather than demoted.** It had zero uses in one app
and was bypassed in another because the forced `font-bold` title
couldn't be overridden; `title_class` replaces it. It stays a `ul`
because daisy's `list` requires that shape. The starter page's build
order now uses it.
Registering these surfaced a small proof the new linter works: the
shipped previews template repeated one layout string four times and
tripped `:duplicated_class_string` on a fresh install. Extracted to a
private helper — a fresh install lints clean again.
### Starter app and theming
The generated app is the kit's own worked example, so it has to be
exemplary. It wasn't: it hand-rolled markup the linter would flag, and
it dropped the theme switching every `mix phx.new` app ships with.
* **New `theme_toggle/1`** — a segmented system / light / dark control,
registered and previewed like any other wrapper. `mix joby_kit.new`
restores the theme script Phoenix puts in `root.html.heex` (which
applies the stored choice before first paint, so there's no flash) and
wires the control into the layout. Phoenix builds its version from raw
`<button>` elements; this one composes `<.button shape="square">`, so
the kit's own chrome satisfies the contract the kit enforces.
* **`simple_nav/1` no longer paints its own surface, and accepts globals.**
It carried `bg-base-100 border-b`, which produced a visible seam
wherever a layout wrapped it in its own sticky bar: the bar's
translucent background showed at the edges while the nav painted an
opaque strip only as wide as its max-width container. Surface belongs
to the container. It also gains an `:actions` slot for trailing
controls, `aria-current="page"` on the active link, and the
`attr :rest, :global` it was missing — the kit's own component had
been violating the contract.
* **`simple_nav` and `theme_toggle` are registered** in the shipped
manifest, with previews, so both appear on `/design` and in
`/design.json`. The daisy catalogue's `navbar` and `theme-controller`
entries now read as wrapped.
* **The landing page is a worked example rather than a welcome page.**
It hand-rolled a header, nested a second `<main>` inside the layout's,
and styled sections with one-off classes. It's now built entirely from
wrappers — `<.header>` with the new `:eyebrow`, `<.card>`, `<.list>`
for the build order (a real sequence, so the numbering carries
information) — and each section names the component that renders it.
* **Signature cards: long attr defaults no longer collide with the attr
name.** The row is a two-column grid whose second track sized to
max-content, so a default like `simple_nav`'s link list overflowed
across the label. Long defaults now take the full-width row that
`values:` already used.
### Generators
* **`mix joby_kit.new --no-dashboard` / `--no-mailer` produced apps that
don't compile.** Both flags are advertised as forwarded to `phx.new`,
but the router template referenced `Phoenix.LiveDashboard.Router` and
`Plug.Swoosh.MailboxPreview` unconditionally. The template is now
conditional, and drops the whole `/dev` scope when neither is present.
All four flag combinations are parse-checked in the suite.
* **`mix joby_kit.new` no longer silently half-succeeds.** If the
`defp deps do [` regex didn't match the generated `mix.exs`,
`Regex.replace/4` returned the source unchanged — the task printed
"* updating mix.exs" and the failure surfaced much later as an
unrelated-looking error about undefined `JobyKit` modules. It now
verifies the dep landed and raises with the line to add by hand.
* **The generated web module keeps Gettext wired.** It dropped
phx.new's `use Gettext, backend: …` while the app still shipped the
backend, so any `gettext(...)` call in a template failed to compile.
Now included, and omitted only for `--no-gettext`.
* **`mix joby_kit.bootstrap`'s home page no longer models the
anti-pattern the kit polices.** It hand-rolled
`card card-bordered` / `btn btn-primary` markup instead of using the
wrappers — and `card-bordered` has been dead since daisyUI 5 (the v5
name is `card-border`), so the "bordered" card wasn't even bordered.
Now uses `<JobyKit.CoreComponents.card>` / `.button`, fully qualified
because bootstrap runs against an existing app whose own
`CoreComponents` may still be imported.
* **`mix joby_kit.gen.wrapper --category composite` scaffolded a module
that couldn't hold a real composite.** A fresh
`composite_components.ex` got a bare `use Phoenix.Component`, so the
first `<.icon>` or `~p"/..."` in the new composite failed to compile —
and the kit's own worked example uses both. It now matches the install
template: `use <App>Web, :html`.
* Tests: generated output is parsed (`Code.string_to_quoted`) across
install, bootstrap, and gen.wrapper. Nothing had ever checked that the
code these tasks write is syntactically valid; the suite asserted on
strings only. Install also asserts the expected file count, so a
template added without a test can't slip by.
### daisyUI catalogue
Reconciles `DaisyCatalogue` with daisyUI 5.7.16, verified against the
published package rather than the docs prose.
* **Two demos taught removed daisyUI 4 classes.** The card demo used
`card-compact` (v5 replaced the single compact modifier with the
`card-xs/sm/md/lg/xl` scale) and the label demo used `label-text` (v5
dropped the `form-control`/`label-text` pairing entirely). Both are
absent from 5.7.16's CSS — confirmed by grepping the shipped
`components/*.css`. The page that exists to be the reference was
teaching a dead API.
* **Nine docs links 404'd.** `docs_url/1` derived the URL from the
display name, which breaks wherever our label differs from daisy's
page name — "Chat bubble" → `/components/chat-bubble/`, "Text Input"
→ `/components/text-input/`, all four mockups reversed
(`browser-mockup` vs `mockup-browser`), and so on. Entries now carry
an optional `:docs_slug`, and `docs_url/1` accepts a catalogue entry
(the bare-name form still works). Every one of the 68 links was
checked against daisyui.com and now resolves.
* **Six primitives were missing**, all added after 5.0: `hover-gallery`
(5.1), `hover-3d` and `text-rotate` (5.5), `aura`, `megamenu`, and
`otp` (5.6). Each carries a `:since` key and says so in its note,
because hosts vendor their own `assets/vendor/daisyui.js` — a host on
an older bundle simply does not have those classes. `daisy_version/0`
now reports which daisy release the catalogue was verified against.
* **`merged/1` silently dropped every daisy override for a manifest
module that happened not to be loaded yet.** `function_exported?/3`
answers false for an unloaded module, so wrapped primitives showed as
unwrapped depending on load order. Now guarded with
`Code.ensure_loaded?/1`.
* Tests: a `:external`-tagged case verifies every docs link over the
network (excluded by default; `mix test --include external`), plus
guards that no demo reintroduces a removed v4 class and that every
`:since` entry names its version in its note. Also fixed a
seed-dependent flake in the new lint-task tests, where Mix's own
`==> app` banner interleaved with captured task output.
## v0.2.3
Fixes a crash that takes down any form with an array-typed field, plus
two `CoreComponents` corrections and the missing half of the shipped
manifest.
* **`translate_error/1`: no longer raises on non-`String.Chars` error
opts.** It stringified every opt eagerly instead of deferring to
`String.replace/4`'s function form, so the opts Ecto attaches to a
cast error on an `{:array, _}` field —
`[type: {:array, :string}, validation: :cast]` — raised
`Protocol.UndefinedError` on the tuple. Every `<.input field={...}>`
routes errors through this, so the first invalid submit on any form
with an array or composite-typed field returned a 500. Same class of
crash for `validate_subset`/`validate_inclusion` opts carrying atom
lists. If you worked around this by overriding `translate_error/1`,
you can drop the override.
* **`button/1`: `type` now passes through.** It was neither a declared
attr nor in the `:global` include list, so `<.button type="button">`
emitted an "undefined attribute" warning and the attribute was
dropped — every button inside a form submitted it, with no way to opt
out. Omitting `type` still leaves the attribute off, so existing
submit buttons are unaffected. `form` passes through too.
* **`flash/1` + `flash_group/1`: one toast container per page instead
of one per notice.** Each `flash/1` rendered its own fixed-position
`toast toast-top toast-end` container, so simultaneous notices — an
`:info` and an `:error` from the same action, or a flash plus a
disconnect toast — stacked at the identical fixed position and
occluded each other. The `toast` container now lives on
`flash_group/1` and every notice stacks inside it.
**Behavior change:** `flash/1` rendered on its own is now an inline
`alert` and no longer positions itself. If you call `flash/1` outside
`flash_group/1` and relied on it floating, wrap it in your own
positioned container. Calling `flash_group/1` from your root layout —
the documented path — needs no change.
The container is `pointer-events-none` (notices re-enable it), so the
now-always-present fixed element can't intercept clicks in an empty
corner. `:info` notices are `role="status"` rather than the
interrupting `role="alert"`; errors stay assertive. Both components
now declare `attr :class`, so a caller class merges into the root
instead of colliding with the identity classes.
* **The install manifest registers all nine shipped wrappers.** It
listed only `button`, `card`, `icon`, `input`, and `flash`, while the
kit also ships `header`, `list`, `table`, and `flash_group` — shipped,
documented, and invisible on `/design` and in `/design.json`, so
agents following the build order re-wrapped or hand-rolled them.
`daisy_overrides/0` likewise now reports every primitive the kit
actually wraps (alert, toast, list, table, and the four input types)
instead of just button and card. Existing apps: re-run
`mix joby_kit.install --force` to pick up the new registrations, or
copy the entries into your `DesignManifest` by hand.
(`flash_group` is registered without a preview on purpose — it's a
fixed-position container, so an inline preview would float over the
design page rather than sit in its card.)
Note for hosts that **forked** a core component (`import
JobyKit.CoreComponents, except: [...]`): the `button/1` and `flash/1`
fixes above land in the kit's copy, not yours. Apply them to your fork.
## v0.2.2
Docs-only release. No functional change — skip it if 0.2.1 is working
for you.
* ExDoc no longer tries to link the internal patcher modules
(`JobyKit.AgentsMd`, `JobyKit.ClaudeMd`, `JobyKit.NavPatcher`,
`JobyKit.AppCss`), which are `@moduledoc false` on purpose, or
`Phoenix.Component.__components__/0`, which is hidden upstream. They
render as plain code via `skip_code_autolink_to`, so the published
docs build warning-free.
## v0.2.1
Two rendering fixes, both in `CoreComponents`:
* `flash/1`: every toast gets a usable `id` again. `attr :id` already
puts `:id` in assigns, so the `assign_new/3` default never fired and
any flash rendered without an explicit id — both toasts
`flash_group/1` shows, i.e. the ones users actually see — rendered
with no `id` attribute and a dismiss handler of `JS.hide(to: "#")`.
`#` is not a valid selector, so clicking a flash threw
`Failed to execute 'querySelectorAll' on 'Document'` and the toast
never faded out. The `lv:clear-flash` push runs first, so the flash
still cleared — which is how this stayed hidden behind a
working-looking dismiss.
* `table/1`: action cell gains `whitespace-nowrap`. Wrappable button
text let the cell under-report min-content under the `w-0` width
hack, so action buttons painted past the table edge on full-width
tables. Host apps carrying a scoped nowrap override for
`[data-component="JobyKit.CoreComponents.table"]` can drop it after
upgrading.
## v0.2.0
Wrapper-contract enforcement: agent-experience fixes for the failure
mode where an agent skips the manifest, drops raw HTML primitives into
`.heex`, and gets a green lint check anyway. This release closes that
loop with a real lint rule, an auto-loaded `CLAUDE.md`, and a worked
composite example. Existing consumers may see new warnings on first
`mix joby_kit.lint` run after upgrading — that's the rule firing on
pre-existing violations; silence per-line with
`<%!-- jobykit:allow-raw-html --%>` or lift the markup into a wrapper.
### Linter
* New `:raw_html_primitive` rule (warning). Scans `.heex` and `~H`
blocks in `.ex` for raw `<button>`, `<input>`, `<textarea>`, and
`<select>` outside of wrapper definitions. Files containing
`data-component=` are treated as wrapper territory and skipped.
Per-line opt-out via `<%!-- jobykit:allow-raw-html --%>` (heex) or
`# jobykit:allow-raw-html` on the preceding `.ex` line. Inside
Elixir string literals (heuristically detected) doesn't fire.
* `mix joby_kit.lint` default `--paths` now includes `lib/**/*.heex`
alongside `lib/**/*.ex`.
### CLAUDE.md
* `mix joby_kit.install` now writes a `CLAUDE.md` block at the project
root (auto-loaded by Claude Code) that inlines the highest-priority
wrapper-contract diagnostics — including "Symptoms you skipped step
1" and the per-line opt-out syntax. Idempotent and marker-bracketed
like `AGENTS.md`.
* New `JobyKit.ClaudeMd` patcher mirrors `JobyKit.AgentsMd`.
### Worked composite example
* `mix joby_kit.install` now scaffolds `<App>Web.CompositeComponents`
with a working `empty_state/1` composite (icon + title + supporting
text + optional action slot). Pre-registered in the generated
`DesignManifest` and previewed on `/custom-designs`. Pattern-match on
it when adding your own composites.
### Bootstrap stdout
* `mix joby_kit.new`, `mix joby_kit.install`, and `mix joby_kit.bootstrap`
end-of-run summaries now inline the "Symptoms you skipped step 1"
diagnostic (raw `<button>`/`<input>`, private function components
styled as primitives, components missing `data-component`/`:rest`/
manifest entry). Calling out the failure modes in stdout means an
agent doesn't have to know to open `AGENTS.md` to find them.
## v0.1.1
Documentation + first-run UX fixes for hex consumers:
* `mix joby_kit.new` now defaults to a hex dep (`{:joby_kit, "~> X.Y"}`)
when no `--joby-kit-path` is given. The flag stays for kit
development; the README and moduledoc lead with the hex install path
(`mix archive.install hex joby_kit`).
* `README.md` and `mix joby_kit.new` moduledoc rewritten to drop
references to local checkouts and `.ez` build steps.
* `mix joby_kit.new` skips the path-dep `app.css` rewrite when running
in hex mode (the install task's default `@source` already resolves
for hex deps).
No runtime API changes.
## v0.1.0
Initial release. JobyKit ships:
### Manifest + design pages
* `JobyKit.Manifest` — behaviour + `__using__` macro for declaring a host
component manifest. `category/2` and `component/3` macros register entries;
`@before_compile` generates `entries/0`, `by_category/0`, `categories/0`,
`category_label/1`, `category_description/1`, `fetch/2` callbacks. The
runtime `enrich/1` helper introspects each component's attrs/slots via
`Phoenix.Component.__components__/0` so prop signatures never drift from
source.
* `JobyKit.Contract` — universal contract content (5-step build order,
5-rule wrapper checklist, 3-layer module taxonomy) as plain data.
* `JobyKit.DaisyCatalogue` — canonical list of every daisyUI primitive
(62 entries across 7 daisy categories), with stable atom IDs, default
statuses, a `merged/1` overlay that applies host overrides, and a
`demo/1` function component dispatched per primitive.
* `JobyKit.SignatureComponent` — per-component signature card renderer.
* `JobyKit.PageComponent` — two function components for the design surfaces:
* `page_component/1` — the kit's curated `/design` page (filters to
`:core` only). Optional `:custom_path` attr renders an
agent-redirect callout pointing new components to the host's
custom-designs page.
* `custom_page_component/1` — host's `/custom-designs` page (renders
only non-`:core` entries) with a breadcrumb back to the kit page.
* `JobyKit.ManifestController` — JSON endpoint serving the *combined*
manifest (kit core + composites + domain) at `/design.json`. Reads the
manifest module from `conn.private[:joby_kit_manifest]`.
### Core components
* `JobyKit.CoreComponents` — kit-shipped wrappers that hosts import:
`button/1`, `card/1`, `header/1`, `icon/1`, `input/1`, `flash/1`,
`flash_group/1`, `list/1`, `table/1`. Each carries
`data-component="JobyKit.CoreComponents.<name>"`, declares attrs with
`values:` enums, accepts `attr :rest, :global`, and treats `class` as
additive on top of the daisy primitive class set. Plus
`show/2`/`hide/2` JS helpers and a Gettext-free `translate_error/1`.
* `JobyKit.NavComponent` — `simple_nav/1`, a daisyUI navbar primitive
with active-link highlighting. Used in the kit-flavored `Layouts.app`
generated by `mix joby_kit.new`.
### Patchers (idempotent host-file editors)
* `JobyKit.AgentsMd` — patches the host's `AGENTS.md` to (a) append the
JobyKit guidelines section and (b) walk a list of rule-rewrites that
replace stale Phoenix-default rules now superseded by the kit. Four
rewrites ship: anti-daisy line, `<.input>` source rule, `<.input>`
class-override rule, `<.icon>` source rule.
* `JobyKit.AppCss` — adds `@source "../../deps/joby_kit/lib";` to the
host's `assets/css/app.css` so Tailwind v4 scans the kit for class
names.
* `JobyKit.NavPatcher` — locates the first `<nav>` or `<header>` + `</ul>`
in the host's `app.html.heex` (or `layouts.ex` as a fallback) and
inserts kit nav links wrapped in `<%!-- jobykit:nav-* --%>` markers.
Idempotent.
### Linting
* `JobyKit.Lint` — engine that verifies the wrapper contract by
introspecting a manifest module and scanning the host's source. Four
rules: `:manifest_drift` (entry points at non-existent function),
`:missing_data_component` (registered wrapper missing the attribute),
`:missing_rest_global` (registered wrapper missing
`attr :rest, :global`), `:unregistered_wrapper` (function emits
`data-component` but isn't in the manifest).
### Mix tasks
* `mix joby_kit.install` — installs into an existing Phoenix project:
generates `design_manifest.ex`, `design_previews.ex`, the two design
LiveViews, patches `AGENTS.md`, patches `assets/css/app.css`, and
patches the host's nav with `/design` / `/custom-designs` links.
* `mix joby_kit.bootstrap` — composes `install` with greenfield steps
for an already-generated `phx.new` project: replaces the default
HomeLive, rewires `router.ex`, and removes the unused
PageController/PageHTML.
* `mix joby_kit.new <app_name>` — wraps `mix phx.new` to generate a new
Phoenix app with JobyKit baked in: replaces `<app>_web.ex`, layouts,
and router with kit-flavored variants; deletes the redundant Phoenix
scaffolding; runs `mix joby_kit.install`; runs `mix assets.setup`
and `mix assets.build` so `mix phx.server` works on the first try.
* `mix joby_kit.gen.wrapper <name>` — scaffolds a new wrapper component
end-to-end: function skeleton with the contract baked in, manifest
entry registered, preview function added.
* `mix joby_kit.lint` — CLI for the lint engine. Auto-detects the
host's manifest, supports `--format json` for agent consumption, and
`--strict` to fail on warnings.