Skua — Revised Project Plan

Copy Markdown View Source

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 claimVerdictWhat changes
mix igniter.new my_app --with phx.new --install skua✅ Correct, verified syntaxInstaller 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 caveatsOfficially 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 earlyAnchor 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 nameOmit — dangerousExplicitly 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 contextFresh 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 impossibleThe 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 fixCurrent 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 changedusage_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 freeExDoc ≥ 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:

  1. 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).
  2. skua/ — the full static styled-layer demo (~20 components incl. live theme console with 5 presets) plus standalone demo JS.
  3. _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-bg with 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.css and skua/skua-base.css are 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):

GapSeverityDetail
Accessibility🔴 Launch-blockingZero 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-blockingConfirmed: 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 todayThe 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_ui dead; doggo headless/no JS; daisy_ui_components/backpex double down on daisyUI.
  • Phone: live_phone exists (20 stars, requires hook) — effectively unowned territory. ex_phone_number is 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-daisy flag 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 shared PanelStack engine 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_view compiler 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 as Skua.Components.Select.Select with 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, runs mix 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): popover attribute + top layer + :popover-open (Baseline Jan 2025, 88%), <dialog>/showModal(), @starting-style + transition-behavior: allow-discrete for 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-fallbacks behind CSS.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=hint or interestfor — Safari has neither (through the Safari 27 beta) and the hint polyfill is impossible on Safari. Use popover="manual" + own hover/focus logic.
  • Exit animations: the CSS overlay property 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/3 can'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 Skua into html_helpers which imports with except: for any name already taken, and document module-qualified calls (<Skua.Components.select ...>). Never edit the user's core_components.ex.

4. The installer (revised)

Commands

CommandPurpose
mix igniter.new my_app --with phx.new --install skuaFresh project, one command (verified flag syntax; requires phx_new + igniter_new archives)
mix igniter.install skuaExisting project. Works even with no igniter dep (the archive shims a temporary dep and strips it after)
mix skua.installThe actual installer task (auto-discovered by the above — name must match the hex package exactly)
mix skua.install --strip-daisyAdds the daisy strip (see §5)
mix skua.install --with-phoneAdds {:ex_phone_number, "~> 0.4"} + config
mix skua.doctorStatic diagnostics with copy-pasteable fixes
mix skua.updateWrapper: mix deps.update skua + re-sync usage rules. Also ship mix skua.upgrade codemods (see §7)
mix skua.gen.phone_dataMaintainer-only, CI-run. Keep

Mechanics (the patterns that actually work, from real installers)

  • Scaffold with mix igniter.gen.task skua.install; keep the generated if Code.ensure_loaded?(Igniter) … else error-stub wrapper 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-detect app.ts vs app.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. Never String.replace JavaScript.
  • 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_rules config to the consumer's mix.exs and run/recommend mix usage_rules.sync (v1.x config-driven interface).
  • All mutations idempotent (sentinel checks before every edit); full --yes support; no System.cmd/npm shell-outs in the default path (keeps --dry-run faithful — 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 @plugin blocks in app.css (one loader + two theme blocks defining ~20 oklch tokens each).
  • core_components.ex uses 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.ex uses navbar, btn btn-ghost, card, and daisy color utilities (bg-base-100/200/300, border-base-300, text-base-content/70).
  • The data-theme toggle script in root.html.heex is 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: skua is 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 include assets/ or package.json — forgetting this is the classic JS-shipping mistake.
  • package.json in 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.retire is the only remediation. CI must build assets before publish; mix hex.publish --dry-run in CI is cheap insurance.
  • ex_doc ~> 0.40 → hexdocs auto-serves llms.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

  1. 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).
  2. Cut a release: bump @version, update CHANGELOG, git tag v0.x.y, push the tag.
  3. GitHub Action on push: tags: v*: erlef/setup-beammix deps.get → build/verify assets → mix testmix hex.publish --yes with HEX_API_KEY secret (key from mix hex.user key generate --key-name publish-ci --permission api:write). Docs publish to hexdocs automatically, versioned per release.
  4. Keep git tag == mix version == source_ref so hexdocs source links resolve.

How consumers get updates

  • Routine: mix deps.update skua (or mix skua.update, which also re-syncs usage rules).
  • Breaking releases: ship a Mix.Tasks.Skua.Upgrade codemod task — users on mix igniter.upgrade skua get 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 — missing field attr, 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 getComputedStyle in a hook after mount → console.warn with the exact @source line 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/@import lines, app.js import line, Tailwind version from Application.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/slot declarations (compile-time warnings agents self-correct from — verified behavior), usage-rules.md in the package + v1.x config-driven sync, ex_doc ≥ 0.40 llms.txt, and recommend debug_heex_annotations + debug_attributes in 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

  1. 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.
  2. Anchor-positioning-native panels at v1. ~19% breakage. Keep PanelStack JS; enhance later.
  3. 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.
  4. Rewriting core_components.ex on existing apps. No installer in the ecosystem touches it, for good reason. Fresh apps only.
  5. popover=hint / interestfor / CSS overlay as load-bearing features. Safari has none of them. Chromium-only sugar at most, feature-detected.
  6. npm publishing, generated LiveViews/auth, paid tier. Already correctly omitted — stay the course.
  7. The autofill data-rename feature. It mutates field names (breaks phx-change params), needs a scary doc caveat, and is exactly the kind of cleverness that generates support issues. Keep the ignore-hints; drop the renamer.
  8. Auto-enhancing every native <select> (the demo's behavior). Explicit components only — magic global enhancement and morphdom don't mix.
  9. 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

  1. 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.
  2. Component name strategy on existing appsuse Skua with except: collision handling, or distinct names (<.skua_input>)? I lean collision-handling + module-qualified fallback.
  3. 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.
  4. 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?