PhoenixKit — foundation for building Elixir/Phoenix apps (SaaS, ERP, marketplaces, AI apps, community platforms). Library-first architecture with Phoenix/PostgreSQL: auth + Magic Links, role-based access (Owner/Admin/User), admin dashboard, daisyUI 5 themes, versioned migrations, layout integration with parent apps.
- First clone only:
git config core.hooksPath .githooks— enables the tracked pre-commit hook (.githooks/pre-commit). Git deliberately will not do this for you: a clone must not be able to run code on checkout, so one manual command is the floor.mix phoenix_kit.doctorreports it under "Git Hooks" when it is missing, and distinguishes "not enabled" from "could not check". - Make changes
mix precommit— compile (warnings as errors) +deps.unlock --check-unused+quality.ci(format-check, credo --strict, dialyzer) + JS tests. Does NOT runmix test— see "CI/CD" below.- Fix problems
git diff/git status→ commit
mix setup— full setup;mix deps.get— deps only;mix ecto— list ecto commandsmix format,mix credo --strict,mix dialyzer,mix quality,mix quality.ci
Two levels: unit (test/phoenix_kit/, test/modules/ — no DB) and integration (test/integration/, test/modules/*/integration/ — real PostgreSQL via Ecto sandbox).
mix test.setup # create DB + run migrations (first time)
mix test # run all (migrations auto via test_helper)
mix test.reset # drop + recreateTest DB phoenix_kit_test uses embedded PhoenixKit.Test.Repo (test/support/test_repo.ex). Schema comes from the versioned migration chain — test_helper.exs runs PhoenixKit.Migration.ensure_current/2 on every boot. Do not swap in Ecto.Migrator.run(repo, [{0, PhoenixKit.Migration}], :up, all: true) — it goes silently stale (see ensure_current/2 moduledoc).
Without PostgreSQL: integration tests are auto-excluded; unit tests still run (banner printed, exit 0).
DB tests: use PhoenixKit.DataCase, async: true — auto-tags :integration.
Core has no phoenix_kit dep of its own — the flow matters from the consumer side. Every feature module wraps its phoenix_kit* deps in a pk_dep/3 helper, so a module's suite can run against uncommitted local core without publishing. From inside the module's directory:
PHOENIX_KIT_PATH=../phoenix_kit mix testVar name = dep app upper-cased + _PATH; unset = published Hex pin (mix hex.publish / CI unaffected). phoenix_kit_parent does the same permanently — use it to exercise the whole tree against local core. Details: workspace AGENTS.md → "Testing a module against local deps".
rg— text/regex/strings/commentsast-grep— structural patterns; prefer over text grep for code searches:ast-grep --lang elixir --pattern 'def $FUNC($$$ARGS) do $$$BODY end' lib/
- Branch: PRs against
main(gh pr create --base main --head <fork-owner>:<branch>). Thedevbranch was retired 2026-06-01; do not target it. - CI/CD:
.github/workflows/ci.ymlis manual-only (workflow_dispatch) — nothing runs on push or PR. When dispatched:postgres:16+mix format --check-formatted,mix credo --strict,mix dialyzer,mix deps.unlock --check-unused,mix test.setup+mix test. ⚠️ Nothing runs the Elixir suite automatically — not CI, notprecommit. For anything touching the schema, runmix testyourself: with no DB reachable,test_helper.exsexcludes every:integrationtest but still exits 0 — a green summary proves nothing about a migration.- Point the suite at an existing DB (avoids needing
CREATEDB; bound concurrency on shared servers):PGHOST=… PGUSER=… PGPASSWORD=… PGDATABASE=my_scratch_db PGPOOL=20 mix test --max-cases 8 ⚠️ Database-less runs setconfig :phoenix_kit, :update_mode, true(test_helper.exs): everyPhoenixKit.Settingsread short-circuits tonil, so every settings-dependent unit assertion runs against "nothing is configured". A test that needs a value must prime the cache itself (startPhoenixKit.Cache.Registry+{PhoenixKit.Cache, name: :settings}, thenPhoenixKit.Cache.put/3— consulted before the short-circuit). Worked example:test/phoenix_kit/utils/safe_destination_settings_test.exs.- Commit messages: start with
Add,Update,Fix,Remove,Merge. - Versioning: bump
mix.exs@version+CHANGELOG.md; runmix compile,mix test,mix format,mix credo --strictbefore committing. Latest migration version:ls lib/phoenix_kit/migrations/postgres/v*.ex | sed 's/.*\/v\([0-9]*\)\.ex/\1/' | sort -rn | head -1
- CHANGELOG entries: write against the bumped
@versionheading; match existing style (Added / Changed / Fixed / i18n, bullets from PR scopes + post-merge review fixes). - PR reviews:
dev_docs/pull_requests/{year}/{pr_number}-{slug}/{AGENT}_REVIEW.md(CLAUDE_REVIEW.mdfor Claude). Severities:BUG - CRITICAL/HIGH/MEDIUM,IMPROVEMENT - HIGH/MEDIUM,NITPICK. - Publish:
mix hex.build,mix hex.publish,mix docs.
- Schemas use
@primary_key {:uuid, UUIDv7, autogenerate: true} - New migrations use
uuid_generate_v7()(NOTgen_random_uuid()) - Oban-style versioned migrations in
lib/phoenix_kit/migrations/postgres/
The chain supports running into a named Postgres schema (prefix: opt / --prefix). Full reference + incident history: dev_docs/guides/2026-07-27-prefix-safe-migrations.md. Rules for any new execute-built SQL:
- Index names stay bare on CREATE — qualify only on
DROP INDEX. - Every existence check needs a schema anchor —
table_schemaoninformation_schema.*,schemanameonpg_indexes, name-basedpg_class+pg_namespaceJOIN forpg_constraint(never'p.table'::regclassin an IMMEDIATE check — it raises when the relation doesn't exist yet and aborts the whole transaction). - Schema-qualify functions via
PhoenixKit.Migrations.Postgres.Helpers.ensure_uuid_v7_function/1+uuid_v7_call/1; never bareCREATE EXTENSION(useHelpers.ensure_extension!/1) or bareCREATE SCHEMA(checkinformation_schema.schematafirst; threadcreate_schema: falseto external migrators like Oban). - Prefix validated at the entry points (
Helpers.validate_prefix!/1); tooling resolves--prefix→config :phoenix_kit, prefix:→"public". - New table-backed schemas must
use PhoenixKit.SchemaPrefixright afteruse Ecto.Schema— enforced bytest/phoenix_kit/schema_prefix_test.exs. Prefix is compile-time config (config.exs, neverruntime.exs). - Oban rides the same prefix — the host's
config :app, Obanmust carryprefix: "...". - Oracle:
test/integration/prefix_migration_test.exsruns the full chain into a scratch schema (bad SQL queued by one version often blows up at a later version'sflush()).
Centralized OAuth / API key / bot token / credential management. Full reference: dev_docs/guides/2026-07-27-integrations-system.md; design: dev_docs/plans/integrations-system.md.
- Storage:
phoenix_kit_settingsJSONB, keysintegration:{provider}:{name}. Consumers reference connections by storage-row uuid — all public API exceptadd_connection/3and the read shims is uuid-strict. - Owner scopes:
:system(website-wide UI/admin/settings/integrations/website) vs{:user, uuid}(personal UI/admin/settings/integrations). Every context call takes an:owneropt, default:system— passowner:explicitly on the personal path or a forgotten owner silently births a SYSTEM row. Never encryptowner_uuid. - Module callbacks:
required_integrations/0,integration_providers/0, optionalmigrate_legacy/0(run viaModuleRegistry.run_all_legacy_migrations/0).
PhoenixKitWeb.Components.Core.{Input, Select, Textarea, Checkbox} — canonical form primitives. Use over raw <input>/<select>/<textarea> in new code. They handle phx-feedback-for, gettext error display, label wiring, daisyUI styling. Reference: lib/phoenix_kit_web/users/user_form.html.heex.
classattr → merges onto the styled element (input/label/textarea/checkbox). Pass daisyUI modifiers here:input-sm,select-primary,checkbox-accent, etc.<.input>also haswrapper_class→ goes to the outer<div phx-feedback-for>.- Prefer FormField binding:
<.input field={@form[:email]} type="email" label="Email" />. Rawname=/value=still works for dynamic field names.
The canonical toolkit for admin list views — DnD reorder, bulk-select, sort, strategy reorder, load-more pagination. All live in lib/phoenix_kit_web/components/core/. Reference call sites: phoenix_kit_projects' projects_live.ex / tasks_live.ex / templates_live.ex.
- Sortable —
<.sortable_tbody enabled={…} event="reorder_x" id="…">+<.sortable_row item_id={uuid}>;enabled={false}omits the hook so DnD turns off when sort_by ≠ position. Pair with<.drag_handle_cell>/<.drag_handle_header_cell>(render the.pk-drag-handlethe SortableGrid hook reads). - TreeTable —
<.tree_name_cell depth expandable expanded toggle_event value icon>is the file-explorer name cell (indent, disclosure chevron, type icon) that composes intotable_defaultrows. The consumer owns the walk and the expanded set. - BulkSelect —
<.bulk_select_scope>wraps the table; selection lives client-side, the hook pushes%{"uuids" => […]}on action-click. Children:<.bulk_select_header_cell>,<.bulk_select_cell value={uuid}>,<.bulk_actions_toolbar>. Consumer LVs collapse 0–1 captured uuids to:all(a single-row "reorder" is a no-op). - ReorderModal —
<.reorder_modal>strategy-picker dialog. The consumer LV owns the strategy whitelist (hardcoded string→atom map — neverString.to_existing_atomon attacker input). - Modal
keep_in_dom—<.modal keep_in_dom>renders the<dialog>always; visibility flips viadata-show. Pass an explicitid=— the auto-derived id collides when two kept-in-DOM modals share a close-event name. - SortSelector —
<.sort_selector sort_by sort_dir options manual_field>; select sends onlysort_by, arrow onlysort_dir(race-free).manual_field={:position}hides the direction toggle. Acceptsid(default"pk-sort-selector-#{event}"). - Pagination —
<.load_more>for embeddable / DnD-aware lists (rows append, selection persists);<.pagination>for standalone pages with deep-linkable state.
PhoenixKitWeb.Components.MultilangForm — <.multilang_tabs>, <.multilang_fields_wrapper>, <.translatable_field>, plus helpers mount_multilang/1, handle_switch_language/2, merge_translatable_params/4. Forms import it and call mount_multilang(socket) in mount/3.
Wrapper scope rule (load-bearing): <.multilang_fields_wrapper> wraps translatable fields only. The wrapper's id includes @current_lang, so a switch causes morphdom to re-mount everything inside. Non-translatable fields (pricing, status, actions) render as siblings outside the wrapper or they lose state on every switch.
Language switching: client-side skeleton toggle + 150ms trailing debounce on the server. mount_multilang/1 attaches a :handle_info hook via Phoenix.LiveView.attach_hook/4 that intercepts the timer message — consumers don't need a handle_info clause. LiveComponent fallback: rescue ArgumentError from attach_hook and add the clause manually. The switching_lang attr is a backwards-compat no-op.
Translatable fields: <.translatable_field> takes changeset={@changeset} (not FormField) — its behavior changes with the active tab (primary-language vs JSONB-backed secondary). When mixed with <.input>/<.select>, the LV keeps both :changeset and :form = to_form(changeset) in sync via a private helper from mount/validate/save-error paths.
Tabs, subtabs, badges, context selectors: see lib/phoenix_kit/dashboard/README.md.
Auth surface: session persistence, post-auth destinations, email confirmation. Full reference: dev_docs/guides/2026-07-28-login-and-registration.md. All settings live on /admin/settings/users.
- Session persistence:
remember_me_enabled(default true) is the master switch — off hides the checkbox everywhere AND hard-blocks the cookie insidemaybe_write_remember_me_cookie/3, so no caller or forged param can persist a session.remember_me_default(default true) = checkbox starts checked. Policy:Auth.remember_me_enabled?/0/remember_me_default?/0; flows with no UI to tick (magic-link, OAuth) useAuth.remember_me_params/0. Never hardcode%{"remember_me" => "true"}. - Post-auth destination: one resolver,
Routes.post_auth_path/1. Precedence: explicitreturn_to(param or gate-stashed session key) >after_registration_path>after_login_path>/admin(log_in_user/3honors"return_to"too). Tail is/admin, not"/"— core declares/adminunconditionally;"/"belongs to the host and may 404. Both settings validate as local paths on save, re-guarded on read. ⚠️ Routes.local_path?/1is the only redirect guard — rejects//,/\, and ASCII control characters (browsers strip tab/CR/LF, so"/\t/evil.com"lands as//evil.com; LiveView'svalidate_local_url!does not block these). Every LiveViewredirect(to: ...)of user-influenced input MUST go through it. Path settings are also refused if they bounce an authenticated visitor — including/users/log-out, a real GET route that would sign every user straight back out.- Carrying
return_to:Routes.return_to_query/1threads it across login/register/magic-link/QR/OAuth links and the magic-link email URL. A new sign-in entry point must thread it too. ⚠️ Public auth endpoints rate-limit BEFORE the lookup — limiting inside the send throttles only addresses that resolve to a user, turning the deliberately generic copy into an account-existence oracle.- Email confirmation:
require_email_confirmation(default true) gates enforcement only; emails always send. Honored at eleven sites: theensure_authenticated,ensure_authenticated_scope,ensure_owner,ensure_admin,ensure_module_accesson_mount hooks; therequire_authenticated_user/require_authenticated_scopeplugs; and the role/permission plugsrequire_owner/require_admin/require_module_access/require_role(these four share the privateconfirmation_gate/2, since the shipped:phoenix_kit_admin_onlypipeline runs them with NO precedingrequire_authenticated_*). Add it to any new gate. - The parked
/users/confirmpage advances users instead of stranding them: on mount when already confirmed (covers a direct DB flip + refresh) and live off the{:user_confirmed, _}broadcast. It subscribes before re-reading the user. ⚠️ Soft-failure paths needrescueANDcatch :exit— an unreachable DB raises on an unowned checkout but exits on a dead pool. Bites on a settings cache miss; presents as suite flakiness. Never evict a real settings key in a test; read a unique probe key instead.⚠️ Flash belongs inside the LiveView tree (LayoutWrapper / dashboard / host layout).root.html.heexdeliberately has none — a copy there double-rendered every message with duplicate ids.⚠️ Rate limiting keys onPlug.Conn.get_peer_data/1, notconn.remote_ip, and the test adapter reports one peer for every conn — a login-heavy test file shares a single bucket. Give each test its own peer (with_peer/2inauth_flows_test.exs).⚠️ Adisabled<.checkbox>still submits its un-disabled hiddenvalue="false"fallback, silently rewriting the setting on save. Don't usedisabledto mean "inactive right now" in a settings form.
PhoenixKit.Users.Permissions — allowlist model (row present = granted, absent = denied); Owner always has full access, enforced in code. The moduledoc is the source of truth; highlights:
- Module keys gate admin sections/feature modules; custom keys via
register_custom_key/2. The two integration keys are independent flat keys:integrations_systemis auto-granted to Admin,integrationsis opt-in (never auto-granted). "*"superadmin key (Permissions.superadmin_key/0) — a blanket Owner-equivalent grant honored byScope.superadmin?/1/has_module_access?/2/accessible_modules/1. Inall_module_keys/0but NOTenabled_module_keys/0(never a required key); cannot be registered as a custom key.- Admin-area gate:
Scope.can_access_admin_area?/1— true for Owner, Admin, OR any single permission holder (admin?/1is a deprecated alias).Scope.holds_all_enabled_permissions?/1is the "can do everything, like Owner" check. - Sub-permissions — dotted keys under a base (
"calendar.view_others"), declared inpermission_metadata/0'ssub_permissions, checked by the module viaScope.can?/2. A sub implies its base (granting a sub auto-grants the base; revoking the base cascades). Grant/revoke run under a per-{role, base-key}advisory lock. - Edit protection:
can_edit_role_permissions?/2— users cannot edit their own role; only Owner can edit Admin.
Tracks business-level actions. Admin UI: /admin/activity (and /admin/activity/:uuid). Core: lib/phoenix_kit/activity/.
PhoenixKit.Activity.log(%{
action: "post.created", # required — "resource.verb"
module: "posts", # filterable
mode: "manual", # "manual" | "auto" | "cron" | "script"
actor_uuid: user.uuid,
resource_type: "post",
resource_uuid: post.uuid,
target_uuid: nil, # who was affected (drives notifications)
metadata: %{"actor_role" => "user", "title" => post.title}
})- Profile/field changes —
log_user_change("user.profile_updated", user, changeset, actor_uuid: …, target_uuid: …, mode: "manual", actor_role: "admin")auto-extractsfield_from/field_tofrom a changeset; skips logging if nothing changed. - Conventions:
action=resource.verb;module= key string;actor_rolebaked at log time;resource_typeusually equalsmodule. Examples:rg 'Activity.log' lib/phoenix_kit/users/. - External modules — guard with
Code.ensure_loaded?(PhoenixKit.Activity)before calling. - Cleanup:
activity_retention_dayssetting (default 90).PhoenixKit.Activity.PruneWorkerruns daily via Oban.
Per-user inbox driven by the activity log. Full reference: dev_docs/guides/2026-07-27-notifications.md.
- Generation:
Activity.log/1withtarget_uuid != actor_uuidfans out aphoenix_kit_notificationsrow viaNotifications.maybe_create_from_activity/1— never insert directly. Kill switch:notifications_enabled(default"true"). - API:
PhoenixKit.Notifications—list_for_user/2,recent_for_user/2,count_unread/1,mark_seen/mark_all_seen/dismiss/dismiss_all,get_notification/2(recipient-scoped). Render viaNotifications.Render.render/1, which honorsnotification_text/notification_icon/notification_linkmetadata keys. - UI: embeddable
PhoenixKitWeb.Live.NotificationsBell(sticky nested LV, owns its PubSub sub); "seen" only on explicit user action. PubSub topic:Notifications.Events.topic_for_user(uuid). - Per-user preferences: mute by type; types merge core + modules'
notification_types/0callback.Notifications.Prefs.user_wants?/2is fail-open. - External delivery channels (Telegram, Email; modules add via
notification_channels/0): parallel routing layer —Channelbehaviour,Channelsregistry,ChannelConfigundercustom_fields["notification_channel:<key>"], fail-closedRouting, ObanDeliveryWorker(:notificationsqueue) +DigestWorkercron. Inbox insert is decoupled from channel enqueue.⚠️ DigestWorkerruns ONLY from cron, somix phoenix_kit.updatemust backfill those entries into existing hosts (ObanConfig.ensure_digest_cron_entries/2) — a digest cadence suppresses the per-event inbox row, so a missing entry drops the notification entirely. Registry lookups needCode.ensure_loaded?alongsidefunction_exported?/3(false for an unloaded module under a release); the settings LVMap.takes params against known keys. - Cleanup:
Notifications.PruneWorkerdaily; retentionnotifications_retention_days→activity_retention_days(default 90).
Embeddable media UI (folder tree, grid/list, upload, search, selection, trash): lib/phoenix_kit_web/components/media_browser.ex. Full attrs/behavior: dev_docs/guides/2026-07-27-media-browser.md.
One-line embed — the macro injects upload setup, the "validate" stub, and the handle_info delegator:
use PhoenixKitWeb.Components.MediaBrowser.Embed<.live_component module={PhoenixKitWeb.Components.MediaBrowser}
id="media-browser" parent_uploads={@uploads} />parent_uploads={@uploads} is required (LiveView allow_upload constraint). Key attrs: scope_folder_id, on_navigate={:navigate} (controlled mode), initial_params, admin, select_mode.
URL sync (shareable folder deep links): use …Embed, url_sync: true puts folder/search/page/view in the URL via lifecycle hooks (attach_hook, not injected clauses) — composes with a host LV that has its own handle_params/handle_info. The patch appends the query to the current path, so locale/resource segments are preserved. Reference: lib/phoenix_kit_web/live/users/media.ex.
Standalone module packages must include :phoenix_kit in extra_applications:
def application, do: [extra_applications: [:logger, :phoenix_kit]]Without this, PhoenixKit.ModuleDiscovery won't find it and routes 404. Template: phoenix_kit_hello_world.
Modules with UI implement css_sources/0:
@impl PhoenixKit.Module
def css_sources, do: [:phoenix_kit_my_module]Discovery is automatic at compile time via the :phoenix_kit_css_sources compiler — generates assets/css/_phoenix_kit_sources.css. Parent setup (one-time, by mix phoenix_kit.install): add the compiler to compilers: in mix.exs (before :phoenix_live_view) and @import "./_phoenix_kit_sources.css"; in app.css. After setup, adding/removing modules is zero-config.
PhoenixKit ships hooks (RowMenu, TableCardView, SortableGrid, etc.) in priv/static/assets/phoenix_kit.js, exposed as window.PhoenixKitHooks. Parent spreads into LiveSocket:
hooks: { ...window.PhoenixKitHooks, ...colocatedHooks }Parent setup (by mix phoenix_kit.install): copy phoenix_kit.js to priv/static/assets/vendor/, add <script src={~p"/assets/vendor/phoenix_kit.js"}></script> before app.js in root layout. mix phoenix_kit.update refreshes it. External modules add hooks via inline <script> on window.PhoenixKitHooks (see hello_world).
PhoenixKit LiveView templates use <PhoenixKitWeb.Components.LayoutWrapper.app_layout> (NOT Layouts.app):
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash} page_title={@page_title} current_path={@url_path}
project_title={@project_title} phoenix_kit_current_scope={@phoenix_kit_current_scope}
current_locale={assigns[:current_locale]}>
<!-- content -->
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>Only flash is required. Note: the assign is @url_path, the attr is current_path. Full attr list: lib/phoenix_kit_web/components/layout_wrapper.ex.
NEVER hardcode PhoenixKit paths. Use prefix helpers:
| Scenario | Use |
|---|---|
| Template links | <.pk_link navigate="/path"> or patch |
| LV navigate/patch | Routes.path("/path") |
| Controller redirect | Routes.path("/path") |
| Email URLs | Routes.url("/path") |
alias PhoenixKit.Utils.Routes
push_navigate(socket, to: Routes.path("/dashboard"))
url = Routes.url("/users/confirm/#{token}")<.pk_link navigate="/dashboard">Dashboard</.pk_link>
<.pk_link_button navigate="/admin/users" variant="primary">Manage Users</.pk_link_button>Every <form phx-change=…> needs a unique id — without it LiveView form recovery is silently disabled and host test suites warn missing_form_id. LiveComponent → derive from @id; inside a comprehension → include the row uuid; <.form for={%{}}> needs an explicit id (only for={@changeset} supplies one for free). Detect with a multi-line-aware scanner, not line-oriented grep. Full audit: dev_docs/investigations/2026-07-27-missing-form-id-audit.md.
mix phoenix_kit.install— install (use--help)mix phoenix_kit.update— updatemix phoenix_kit.status— installation statusmix phoenix_kit.gen.migration— custom migration
Features: versioned migrations, table prefix, idempotent ops, PostgreSQL validation, mailer templates.
Routes auto-discovered at compile time via ModuleDiscovery beam scanning. The host router auto-recompiles when module deps change — phoenix_kit_routes() injects __mix_recompile__?/0 with a hash of the discovered set.
Two patterns:
- Single page — set
live_view: {Module.Web.IndexLive, :index}on a tab inadmin_tabs/0orsettings_tabs/0. Route auto-generated. Used by: hello_world, sync, catalogue, document_creator, emails (settings), user_connections, legal. - Multi-page — implement
route_module/0returning a module withadmin_routes/0andadmin_locale_routes/0. Required for sub-routes (/new,/edit,/:id). Do NOT also setlive_view:on the main tab. Used by: ai, entities, publishing, newsletters.
Only one live_view: per path (core deduplicates, first wins — avoid). Fallback for failed auto-discovery:
config :phoenix_kit, route_modules: [PhoenixKitEntities.Routes]Publishing's /:language/:group/*path catch-all matches every 2+ segment URL and Phoenix has no fall-through — host routes declared after phoenix_kit_routes() shaped /:locale/<literal>/... were silently shadowed. Fix: compile_publishing_routing/1 in integration.ex emits an internal __phoenix_kit_publishing_dispatch scope plus a host-router call/2 override that prepends the internal prefix on a RouterDispatch.maybe_rewrite/1 cache hit (restore_path/2 un-rewrites after route bind). Known blind spot: mix phx.routes shows publishing routes under the internal prefix, not the user-facing URL. The mechanism generalizes — lift to a registry shape when a second module needs it.
The daisyUI plugin lives in the host app (assets/vendor/daisyui.js + daisyui-theme.js). Core only declares a designed-for minimum (PhoenixKit.Install.DaisyUI.minimum_version/0, currently 5.6.0) and warns below it from phoenix_kit.install / phoenix_kit.update / phoenix_kit.doctor — advisory, never touching host files. Do NOT re-add scrollbar-gutter overrides in layouts, PkDialog, or modules (removed 2026-07-12; daisyUI ≥ 5.1 handles the gutter). Rationale for not vendoring daisyUI in core: dev_docs/investigations/2026-07-12-daisyui-version-management-investigation.md.
Workspace-tracked items not ready for inline # TODO in lib/.
Partial coverage exists in test/phoenix_kit_web/components/core/. Remaining gaps:
<.draggable_list>— three branches need rendered-HTML asserts: (a):draggable=false→ no SortableJS hook, nocursor-grab; (b)draggable, sortable_handle=nil→ hook + full-itemcursor-grab; (c)draggable, sortable_handle=".pk-drag-handle"→ hook +data-sortable-handleset, nocursor-grabon the item wrapper (caller's responsibility).<.table_default>card-view branch — pinphx-hook="SortableGrid",data-sortable-*,data-id,class="sortable-item", drag-handle footer.<.input>,<.select>,<.textarea>— inline error rendering, daisyUI variant classes, FormField vs rawname=/value=dispatch.<.flash>if complexity has grown.
In lib/modules/storage/services/url_signer.ex (the upload and /info
authorization gaps were closed in lib/phoenix_kit_web/controllers/{upload,file}_controller.ex):
- Token is 16-bit — first 4 hex chars of an MD5, ~65k space, brute-forceable. Widen it (consider HMAC over MD5).
- Tokens never expire, yet the 401 says "Invalid or expired token." Add real expiry or fix the message.
- Fails open on a nil
secret_key_base— the token degrades to a predictable no-secret hash. Fail closed.
Low urgency (current use is public post images), but don't rely on the "capability URL" framing for sensitive files.