Research-backed revision of the original installer/command spec. Drafted 2026-06-11. Every load-bearing claim below was verified against primary sources (LiveView/Phoenix source on GitHub, hexdocs, caniuse/MDN/WebKit release notes, hex.pm API, and real installer source code from backpex, mishka_chelekom, salad_ui, ash_authentication_phoenix, tidewave, live_debugger, sutra_ui).
0. Verdict on the original spec, in one table
| Original spec claim | Verdict | What changes |
|---|---|---|
mix igniter.new my_app --with phx.new --install skua | ✅ Correct, verified syntax | Installer task must be named exactly skua.install (matches hex package name) or auto-discovery fails |
Consumers import phoenix-colocated/skua, one JS hook wiring | ✅ Works, with caveats | Officially supported for deps; precedent exists (sutra_ui). Skua's own mix.exs needs compilers: [:phoenix_live_view] ++ Mix.compilers(); hooks must be self-contained vanilla JS (no npm imports) |
| Browser-native positioning (CSS anchor positioning) | ⚠️ Too early | Anchor positioning is only ~81% global (Baseline "newly available" Jan 2026; Firefox ESR + Safari ≤18 excluded). Keep the existing small JS positioner as primary; anchor positioning as progressive enhancement. Popover API itself is safe (Baseline Jan 2025, ~88%) |
| Publish a 0.0.1 stub to reserve the name | ❌ Omit — dangerous | Explicitly violates hex.pm Code of Conduct ("squatting … is not allowed"). If admins remove it, the name is permanently reserved and unusable. Publish a real, minimal, functional 0.1.0 instead — the name is free today (API 404) |
--strip-daisy rewrites core_components inputs | ⚠️ Split by context | Fresh apps (via igniter.new): yes, replace. Existing apps: never rewrite core_components.ex — no installer in the ecosystem does; offer CSS/vendor strip + side-by-side install only |
| Self-healing: components detect missing CSS server-side | ⚠️ Partially impossible | The server cannot know whether CSS loaded. Honest design: dev-only client-side probe (Mapbox GL's pattern) + mix skua.doctor static checks |
| < 10kb JS | ✅ Holds, with phrasing fix | Current hooks: 24.8 KB raw / 6.8 KB min+gzip. Always say "min+gzip" or devs will call it out |
| usage_rules sync into AGENTS.md | ✅ Right idea, API changed | usage_rules v1.x is config-driven (:usage_rules key in mix.exs), not the old CLI-args form. Target the new interface |
| llms.txt on skua.sh | ✅ Mostly free | ExDoc ≥ 0.40 auto-generates llms.txt on hexdocs (verified live). skua.sh copy is optional extra |
| MIT, no npm publish, no paid tier, no generated auth/routes | ✅ Keep all of it |
1. What you already have (zip inventory)
The zip (Component Defaults(1).zip, extracted to _component_defaults/) contains three distinct things:
liveview/— a partial LiveView port:skua.css(token + component layer, 7.7 KB gz),skua_hooks.js(5 hooks + shared PanelStack, 6.8 KB gz),skua_components.ex(7 HEEx components).skua/— the full static styled-layer demo (~20 components incl. live theme console with 5 presets) plus standalone demo JS._ds/— the skua.sh marketing brand system (gold/teal, ~130 tokens, React primitives). This is a different token system from the component layer's 12 tokens — and it collides: both define--skua-bgwith different values. The brand tokens must be renamed (e.g.--skua-brand-*) before both ever load on one page (docs site demoing components).
Done vs. must-build
Done (genuinely reusable):
- The 12-token + 3-motion-token CSS layer and all component CSS (incl. dialog/toast/phone styles that have no components yet).
liveview/skua.cssandskua/skua-base.cssare byte-identical — one artifact. - The PanelStack concept: top-layer
popover="manual"panels, viewport flip/shift, nesting, close-on-animation-end. This is the right architecture and survives the anchor-positioning verdict. - Working hooks for select (single/multi/badge/creatable/searchable), date picker, OTP, autofill suppression — as behavior prototypes.
Must-build (the real engineering scope):
| Gap | Severity | Detail |
|---|---|---|
| Accessibility | 🔴 Launch-blocking | Zero ARIA in listboxes (no role=listbox/option, no aria-expanded, no aria-activedescendant), no arrow-key/typeahead nav, calendar is click-only divs, <.toggle> is keyboard-inoperable (real input has hidden), no focus management on panel open/close, errors not linked via aria-describedby. Shipping this as-is fails WCAG basics and would define Skua's reputation. Implement W3C APG combobox/listbox/grid/dialog patterns |
Phoenix.HTML.FormField | 🔴 Launch-blocking | Confirmed: zero field={@form[:email]} support; components take raw name/value strings. No changeset errors, no used_input?, no derived ids. This contradicts the entire "forms just work" pitch. Every form component needs attr :field, Phoenix.HTML.FormField as the primary API |
| Popover component bug | 🔴 Broken today | The hook <span> has no DOM id (LiveView requires one on phx-hook elements → mount fails) and is display:contents (empty getBoundingClientRect → positions at viewport corner). Also its panel is server-rendered inside the LiveView DOM without a morphdom strategy |
| Missing components with CSS already done | 🟡 | Dialog (use native <dialog> + showModal(), with phx-mounted={JS.ignore_attributes("open")} — morphdom strips the browser-set open attr, LiveView issue #4152), Toast (integrate with Phoenix flash), Phone input, combobox-as-input with server-filtered options, clearable select, menu/menu_item, textarea + affix slots |
| Form param gaps | 🟡 | No hidden "false" companion for checkboxes, no empty hidden input for multi-select → deselect-all silently drops the param from phx-change |
| Net-new (CSS doesn't exist either) | 🟢 v1.1+ | Tabs, tooltip, command palette, table, accordion — defer |
2. The market gap (validated — this is worth building)
As of June 2026, the competitive cell Skua targets is empty:
- Fluxon ($249–$699, closed source) is the only polished full form suite (Select, Autocomplete, DatePicker built from scratch) — proof of demand, but paid, and its install is fully manual.
- mishka_chelekom (free, very active, 80+ components) is generator-style vanilla JS with a combobox and viewport-aware dropdown — but no OTP, no phone input, no date-panel parity, and components are copied into your repo, not a library. This is the most likely competitor to close the gap; it's aiming for 200 components.
- live_select (598k downloads) is the de-facto rich select — single-purpose, renders its dropdown inline in the DOM (clipped by overflow ancestors, no top layer), open bugs on quick_tags/scroll, forces a server round-trip per keystroke.
- petal_components (active) styles native inputs only; salad_ui dormant since Aug 2025; bloom/
phoenix_uidead; doggo headless/no JS; daisy_ui_components/backpex double down on daisyUI. - Phone:
live_phoneexists (20 stars, requires hook) — effectively unowned territory.ex_phone_numberis healthy (30.9M downloads, libphonenumber metadata v9.0.26, compile-time ~0.94 MB XML, server-side only). - Strip-daisy: nobody automates it. The only prior art is a manual blog guide (petar.dev, Aug 2025) and an unanswered ElixirForum feature request for a
--no-daisyflag with 15+ likes — that thread is your demand evidence, citable in the launch post. - The LiveView 1.1 release post officially recommends "trying out the Popover API and native
<dialog>element before resorting to portals" — Skua's architecture is framework-endorsed direction.
Positioning sentence: "Fluxon-quality form inputs — rich select, date picker, OTP, intl phone — free and MIT, with zero third-party JS, server-authoritative state, and native top-layer panels."
3. Architecture decisions (locked by research)
3.1 One package, library-dep model — with a decision you need to make
Your brand docs (_ds/readme.md) say "components you own — everything installs as editable source in your repo," but the spec describes a compiled hex dependency with hooks imported from the dep. These are opposite distribution models:
- Library dep (the spec, recommended): updates flow via
mix deps.update skua; one source of truth; colocated hooks work; smaller consumer diff. This is what Fluxon, petal, live_select do. - Generator (mishka/salad style): users own and edit the source; no dep at runtime; but updates become impossible to ship and every bug lives forever in user repos.
Recommendation: library dep now; add an optional mix skua.eject <component> escape hatch later if demand appears. Update the brand copy to match ("a dependency you can read" rather than "source you own").
Build deviation (Phase 1, 2026-06-11): We shipped JS as a classic prebuilt ES-module bundle (
import { hooks } from "skua", resolved via the deps NODE_PATH convention — the live_select pattern), not colocated hooks. Reason: the sharedPanelStackengine is imported by Select/Date/Popover/Menu, and colocated hook files cannot cleanly import shared code (verified: inter- colocated-file imports are undocumented in the LiveView 1.2 docs). A real module bundle keeps clean architecture with identical consumer ergonomics (one import + spread). The colocated path stays open for later. The:phoenix_live_viewcompiler line was removed from mix.exs accordingly.
3.2 JS delivery: colocated hooks (confirmed viable) — superseded, see deviation above
- Skua ships hooks inside component modules via
Phoenix.LiveView.ColocatedHook. Extraction happens when the dep compiles in the consumer's_build, producing_build/$MIX_ENV/phoenix-colocated/skua/index.js. - Consumer wiring is two lines in app.js (import + spread into
hooks:) — fresh Phoenix 1.8 apps already have the NODE_PATH config (deps+Mix.Project.build_path()) and a sibling import for their own app. - Requirements: Skua's mix.exs gets
compilers: [:phoenix_live_view] ++ Mix.compilers(); hooks are dependency-free vanilla ES (no npm imports — they'd resolve against the consumer's node_modules and break; LiveView issue #3985); hook names dot-prefixed (.Select) so they auto-namespace asSkua.Components.Select.Selectwith zero collision risk — consumers never type hook names. - Compat floor: Phoenix ~> 1.8, LiveView ~> 1.1 (works on 1.1.31 and the just-released 1.2.0 — verified the mechanism is identical). Non-esbuild users (Vite etc.) get a documented one-time config page, not a second distribution channel.
- CI guard: a matrix job that
mix phx.new's a scratch app, adds Skua as a path dep, runsmix compile && mix assets.build, and asserts the manifest exists and hooks are in the bundle. The extraction dir/manifest are internals, not a formal contract — this test is the tripwire.
3.3 Positioning: Popover API now, anchor positioning later
- Rely on natively (no fallback):
popoverattribute + top layer +:popover-open(Baseline Jan 2025, 88%),<dialog>/showModal(),@starting-style+transition-behavior: allow-discretefor enter animations (~87%). - Do NOT rely on: CSS anchor positioning (81%, Baseline only since Jan 2026; Firefox ESR 140 — the enterprise browser until late 2026 — has none; Safari ≤ 18 none). A fifth of users would get popovers rendered at the viewport corner. The OddBird polyfill is pre-1.0 and can't handle dynamically-added anchors (i.e., LiveView patches).
- Therefore: keep the existing ~1.5 KB PanelStack JS positioner as the primary engine (it already does flip/shift/clamp/nesting). Optionally layer
position-area/position-try-fallbacksbehindCSS.supports('anchor-name: --x')as enhancement. Revisit dropping the JS in 2027–2028 when the feature nears Baseline Widely Available. - Tooltips: do not build on
popover=hintorinterestfor— Safari has neither (through the Safari 27 beta) and the hint polyfill is impossible on Safari. Usepopover="manual"+ own hover/focus logic. - Exit animations: the CSS
overlayproperty is Chromium-only; design exits to be short and clip-safe rather than faking it with JS. - Body-parked panels (current select/date approach) can largely be replaced by LiveView 1.1's built-in
Phoenix.Component.portal/1+ top-layer popovers — fewer hand-rolled DOM lifecycles. Note the testing caveat:LiveViewTest.element/3can't query inside portals.
3.4 Component API: FormField-first
<.input field={@form[:email]} label="Work email" />
<.select field={@form[:status]} options={...} />with name/value kept as the escape hatch. Derive ids from the field (sanitize brackets — current code produces invalid ids for teams[]). Auto-render changeset errors through used_input?.
3.5 Naming collisions with CoreComponents
import SkuaComponents next to the default import CoreComponents collides on input/1, button/1, etc.
- Fresh app +
--strip-daisy: replace the relevant core_components functions, so<.input>is Skua. No collision. - Existing app: inject
use Skuaintohtml_helperswhich imports withexcept:for any name already taken, and document module-qualified calls (<Skua.Components.select ...>). Never edit the user'score_components.ex.
4. The installer (revised)
Commands
| Command | Purpose |
|---|---|
mix igniter.new my_app --with phx.new --install skua | Fresh project, one command (verified flag syntax; requires phx_new + igniter_new archives) |
mix igniter.install skua | Existing project. Works even with no igniter dep (the archive shims a temporary dep and strips it after) |
mix skua.install | The actual installer task (auto-discovered by the above — name must match the hex package exactly) |
mix skua.install --strip-daisy | Adds the daisy strip (see §5) |
mix skua.install --with-phone | Adds {:ex_phone_number, "~> 0.4"} + config |
mix skua.doctor | Static diagnostics with copy-pasteable fixes |
mix skua.update | Wrapper: mix deps.update skua + re-sync usage rules. Also ship mix skua.upgrade codemods (see §7) |
mix skua.gen.phone_data | Maintainer-only, CI-run. Keep |
Mechanics (the patterns that actually work, from real installers)
- Scaffold with
mix igniter.gen.task skua.install; keep the generatedif Code.ensure_loaded?(Igniter) … else error-stubwrapper so Skua compiles without igniter. - Deps:
{:igniter, "~> 0.6", optional: true}and{:igniter_js, "~> 0.4", optional: true}— consumers never carry them to prod. - app.js (the riskiest patch): use igniter_js (Rust/SWC AST codemods — the backpex pattern):
insert_imports+extend_hook_object, auto-detectapp.tsvsapp.js, accept--app-js-path, and on any parse failure degrade to a printed manual instruction (Igniter.Util.Warning.warn_with_code_sample) instead of failing. NeverString.replaceJavaScript. - app.css: plain-text append with
String.contains?idempotency guard (the ash_authentication_phoenix/backpex pattern — there is no CSS AST tooling in the ecosystem and that's fine):@source "../../deps/skua/**/*.*ex";+@import "../../deps/skua/assets/css/skua.css";. - AGENTS.md: don't hand-roll markers — add the
:usage_rulesconfig to the consumer's mix.exs and run/recommendmix usage_rules.sync(v1.x config-driven interface). - All mutations idempotent (sentinel checks before every edit); full
--yessupport; noSystem.cmd/npm shell-outs in the default path (keeps--dry-runfaithful — backpex's npm shell-out is the anti-pattern to avoid); run the installer twice in CI and assert a no-op. - Version guards: refuse cleanly (no partial apply) unless Phoenix ~> 1.8, LiveView ~> 1.1, Tailwind v4 (
Application.get_env(:tailwind, :version)). - Umbrella apps: out of scope for igniter.new (it rejects them); document manual install.
Honest caveat to carry: Igniter is still 0.x with real API churn, and Beacon ripped it out in 2026 over the transitive-dep footprint (owl, spitfire, rewrite, sourceror… land in consumers' mix.lock even when optional). For Skua the installer is the product pitch, so keep Igniter — but isolate all Igniter calls in the mix tasks so a future extraction is a contained refactor, and document the manual install path fully.
5. --strip-daisy (three layers; this is the differentiator)
What a fresh Phoenix 1.8.8 app actually contains (verified against installer templates):
assets/vendor/daisyui.js(288 KB) +daisyui-theme.js(47 KB), wired by three@pluginblocks in app.css (one loader + two theme blocks defining ~20 oklch tokens each).core_components.exuses daisy classes in ~9 components:btn/btn-primary/btn-soft,input/input-error,select,textarea,checkbox checkbox-sm,fieldset/label,alert,toast toast-top toast-end,table table-zebra,list,text-error.layouts.exusesnavbar,btn btn-ghost,card, and daisy color utilities (bg-base-100/200/300,border-base-300,text-base-content/70).- The
data-themetoggle script inroot.html.heexis daisy-agnostic (it only sets attributes) — keep it.
Layer 1 — Remove (easy, documented prior art): delete the two vendor files, remove the three @plugin blocks.
Layer 2 — Replace (the unowned hard part): rewrite the daisy-classed core_components and the layouts navbar/theme_toggle on Skua foundations.
Layer 3 — Token bridge: define @theme CSS variables replacing base-100/200/300, base-content, primary, error etc., mapped to Skua's 12 tokens, so the generated @custom-variant dark ([data-theme=dark]) and theme script keep working untouched — and any daisy color utilities in user templates keep resolving.
Defaults: fresh apps (--from-igniter-new flow): default yes — the files are pristine templates, replacement is safe. Existing apps: default no, and even on yes only do Layers 1+3 plus side-by-side component install; print a migration report for Layer 2 instead of editing a file the user has certainly modified. Detect "pristine vs modified" by matching against known template content, not by version string.
6. Packaging & hex (corrected)
- Name:
skuais unclaimed (hex API 404, verified today). Do not publish a stub — squatting is banned by the hex.pm CoC and admin removal would burn the name forever. Get to a real minimal 0.1.0 (even 3 solid components + installer) and publish that. - mix.exs package files (modeled on phoenix_live_view):
~w(lib priv assets/css assets/js usage-rules.md) ++ ~w(CHANGELOG.md LICENSE.md mix.exs package.json README.md .formatter.exs). Hex's default files list does not includeassets/orpackage.json— forgetting this is the classic JS-shipping mistake. package.jsonin the tarball (never published to npm) gives Vite/webpack users an npm-file-path fallback — exactly why LiveView ships one.- Immutability: a release is only revertible for 1 hour (24h for a brand-new package); after that
mix hex.retireis the only remediation. CI must build assets before publish;mix hex.publish --dry-runin CI is cheap insurance. - ex_doc
~> 0.40→ hexdocs auto-servesllms.txt+ per-page Markdown (verified live for LiveView 1.2 docs).
7. Repo, releases, and how updates reach users
Repo layout (single repo, single hex package)
skua/
├── lib/
│ ├── skua/components/ # HEEx components + colocated hooks (one module per component)
│ ├── skua/ # FormField helpers, tokens, dev-probe
│ └── mix/tasks/ # skua.install, skua.doctor, skua.upgrade, skua.gen.phone_data
├── assets/css/skua.css # token + component layer (the DONE artifact)
├── priv/ # phone country data, static fallbacks
├── usage-rules.md # shipped in package, synced into consumer AGENTS.md
├── guides/ # ex_doc extras: install, theming, strip-daisy, bundlers
├── test/ # incl. installer tests (Igniter.Test) + integration app
├── package.json # resolution only, never npm-published
├── CHANGELOG.md / LICENSE.md (MIT) / README.md
└── .github/workflows/ # ci.yml + publish.yml
demo/ (separate repo later: skua.sh Phoenix app, dogfoods the hex package)Release pipeline
- Develop on branches → PR → CI (test matrix: LiveView 1.1.x + 1.2.x; installer idempotency test; the fresh-app colocated-hooks integration test from §3.2).
- Cut a release: bump
@version, update CHANGELOG,git tag v0.x.y, push the tag. - GitHub Action on
push: tags: v*:erlef/setup-beam→mix deps.get→ build/verify assets →mix test→mix hex.publish --yeswithHEX_API_KEYsecret (key frommix hex.user key generate --key-name publish-ci --permission api:write). Docs publish to hexdocs automatically, versioned per release. - Keep git tag == mix version ==
source_refso hexdocs source links resolve.
How consumers get updates
- Routine:
mix deps.update skua(ormix skua.update, which also re-syncs usage rules). - Breaking releases: ship a
Mix.Tasks.Skua.Upgradecodemod task — users onmix igniter.upgrade skuaget automated migration; everyone else gets the written upgrade guide (codemods only run through igniter.upgrade, so docs are still mandatory).
8. Self-healing & AI legibility (honest version)
- Server-side, compile-time-gated to dev (the Tidewave pattern —
Mix.env()doesn't exist at runtime in releases): static checks only — missingfieldattr, ex_phone_number absent when phone validators are used, version mismatches. These render inline fix messages in dev, never in prod. - Client-side dev probe (the Mapbox GL pattern, which is the only honest way to detect CSS): a dev-only sentinel element styled by a known Skua class, checked via
getComputedStylein a hook after mount →console.warnwith the exact@sourceline to add. Agents driving browsers (Tidewave, Claude in Chrome) read the console. The server cannot know CSS loaded; don't claim it. mix skua.doctor: the four feasible static checks — app.css@source/@importlines, app.js import line, Tailwind version fromApplication.get_env(:tailwind, :version), LiveView/Phoenix versions from the lockfile — each printing a copy-pasteable fix. Degrade to "could not determine" (never false-FAIL) when paths are non-standard.- AI legibility at launch: full
attr/slotdeclarations (compile-time warnings agents self-correct from — verified behavior),usage-rules.mdin the package + v1.x config-driven sync, ex_doc ≥ 0.40 llms.txt, and recommenddebug_heex_annotations+debug_attributesin the installer's next-steps (gives agents file:line provenance in rendered HTML). - Fast-follow, not launch (unchanged): MCP server (mishka has prior art reading
priv/components/*.exs), Claude Code skill.
9. Things to genuinely omit or defer
- The 0.0.1 hex stub. Policy violation with a worst case of losing the name permanently. Replaced by: real minimal 0.1.0, early.
- Anchor-positioning-native panels at v1. ~19% breakage. Keep PanelStack JS; enhance later.
- A separate "headless core" package at v1. There is no headless artifact today (behaviors hardcode
sk-*classes), and inventing a class-agnostic API doubles the surface before there's a single user. Do extract behaviors into clean per-component state machines internally (you'll need that for the a11y rewrite anyway), but ship one package with one styled API. Revisit headless when someone actually asks to bring their own CSS. - Rewriting
core_components.exon existing apps. No installer in the ecosystem touches it, for good reason. Fresh apps only. popover=hint/interestfor/ CSSoverlayas load-bearing features. Safari has none of them. Chromium-only sugar at most, feature-detected.- npm publishing, generated LiveViews/auth, paid tier. Already correctly omitted — stay the course.
- The autofill
data-renamefeature. It mutates fieldnames (breaksphx-changeparams), needs a scary doc caveat, and is exactly the kind of cleverness that generates support issues. Keep the ignore-hints; drop the renamer. - Auto-enhancing every native
<select>(the demo's behavior). Explicit components only — magic global enhancement and morphdom don't mix. - Defer: tabs/tooltip/command-palette/table/accordion (no CSS exists yet); ColocatedCSS (LiveView 1.2's new channel — interesting, days old, experimental); MCP server.
10. Phased roadmap
Phase 0 — Repo & skeleton (small) git init, mix project, CI, license, the §3.2 integration test rig, port the token CSS verbatim. Decide the library-vs-generator question (§3.1).
Phase 1 — Foundations (the real work)
PanelStack rewrite with focus management; FormField integration pattern; the a11y machinery (APG listbox/combobox/grid/dialog); fix the popover component (id + anchor + portal strategy); dialog on native <dialog> + JS.ignore_attributes("open"); toast wired to Phoenix flash. Exit criteria: keyboard-only and screen-reader passes on every shipped component.
Phase 2 — Form suite
Select/combobox (incl. async server-filtered options — the live_select killer feature done right), date picker (add min/max/disabled dates), OTP, toggle/checkbox/radio (keyboard-operable, hidden-false inputs), textarea/affixes. Phone input behind --with-phone (ex_phone_number server-side; country dataset via skua.gen.phone_data).
Phase 3 — Installer & strip
skua.install (+ flags), --strip-daisy three layers, skua.doctor, skua.upgrade scaffold, usage-rules sync. Installer-runs-twice CI test.
Phase 4 — Publish & docs First real hex publish (claims the name), hexdocs guides, usage-rules.md, llms.txt verification.
Phase 5 — Dogfood gate & launch
Port Lerty/aif-core forms to Skua; release gate unchanged: flatpickr + custom phone JS deleted from those apps. skua.sh demo app (rename the --skua-brand-* tokens first, §1). Elixir Forum post citing the --no-daisy thread + launch video. README maintenance contract (solo-maintained, repro required, v1 scope closed).
11. Open questions for you
- Library dep vs. generator (§3.1) — the spec and the brand copy currently disagree. My recommendation is library dep; confirm so the brand copy gets fixed.
- Component name strategy on existing apps —
use Skuawithexcept:collision handling, or distinct names (<.skua_input>)? I lean collision-handling + module-qualified fallback. - How early to publish 0.1.0 — after Phase 1 (a few components, real functionality, claims the name legitimately) or wait for the full form suite? I lean after Phase 1; the name being free is a melting asset.
- Scope of v1 component list — is phone input truly launch-gating (it's in your release gate via Lerty), or can the gate be flatpickr-removal only?