diff --git a/.claude/skills/ui-framework/SKILL.md b/.claude/skills/ui-framework/SKILL.md new file mode 100644 index 00000000..48cb12ee --- /dev/null +++ b/.claude/skills/ui-framework/SKILL.md @@ -0,0 +1,58 @@ +--- +name: ui-framework +description: "Use this skill when working on the web layer and UI components using the Phoenix framework" +metadata: + managed-by: usage-rules +--- + + +## Additional References + +- [ecto](references/ecto.md) +- [elixir](references/elixir.md) +- [html](references/html.md) +- [liveview](references/liveview.md) +- [phoenix](references/phoenix.md) +- [phoenix_ecto](references/phoenix_ecto.md) +- [phoenix_html](references/phoenix_html.md) +- [phoenix_html_helpers](references/phoenix_html_helpers.md) +- [phoenix_live_dashboard](references/phoenix_live_dashboard.md) +- [phoenix_live_reload](references/phoenix_live_reload.md) +- [phoenix_live_view](references/phoenix_live_view.md) +- [fluxon](references/fluxon.md) + +## Searching Documentation + +```sh +mix usage_rules.search_docs "search term" -p phoenix -p phoenix_ecto -p phoenix_html -p phoenix_html_helpers -p phoenix_live_dashboard -p phoenix_live_reload -p phoenix_live_view -p fluxon +``` + +## Available Mix Tasks + +- `mix compile.phoenix` +- `mix phx` - Prints Phoenix help information +- `mix phx.digest` - Digests and compresses static files +- `mix phx.digest.clean` - Removes old versions of static assets. +- `mix phx.gen` - Lists all available Phoenix generators +- `mix phx.gen.auth` - Generates authentication logic for a resource +- `mix phx.gen.auth.hashing_library` +- `mix phx.gen.auth.injector` +- `mix phx.gen.auth.migration` +- `mix phx.gen.cert` - Generates a self-signed certificate for HTTPS testing +- `mix phx.gen.channel` - Generates a Phoenix channel +- `mix phx.gen.context` - Generates a context with functions around an Ecto schema +- `mix phx.gen.embedded` - Generates an embedded Ecto schema file +- `mix phx.gen.html` - Generates context and controller for an HTML resource +- `mix phx.gen.json` - Generates context and controller for a JSON resource +- `mix phx.gen.live` - Generates LiveView, templates, and context for a resource +- `mix phx.gen.notifier` - Generates a notifier that delivers emails by default +- `mix phx.gen.presence` - Generates a Presence tracker +- `mix phx.gen.release` - Generates release files and optional Dockerfile for release-based deployments +- `mix phx.gen.schema` - Generates an Ecto schema and migration file +- `mix phx.gen.secret` - Generates a secret +- `mix phx.gen.socket` - Generates a Phoenix socket handler +- `mix phx.routes` - Prints all routes +- `mix phx.server` - Starts applications and their servers +- `mix compile.phoenix_live_view` +- `mix phoenix_live_view.upgrade` + diff --git a/.claude/skills/ui-framework/references/ecto.md b/.claude/skills/ui-framework/references/ecto.md new file mode 100644 index 00000000..3d7fd39f --- /dev/null +++ b/.claude/skills/ui-framework/references/ecto.md @@ -0,0 +1,9 @@ +## Ecto Guidelines + +- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` +- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` +- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` +- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed +- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields +- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct +- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied diff --git a/.claude/skills/ui-framework/references/elixir.md b/.claude/skills/ui-framework/references/elixir.md new file mode 100644 index 00000000..79c40822 --- /dev/null +++ b/.claude/skills/ui-framework/references/elixir.md @@ -0,0 +1,54 @@ +## Elixir guidelines + +- Elixir lists **do not support index based access via the access syntax** + + **Never do this (invalid)**: + + i = 0 + mylist = ["blue", "green"] + mylist[i] + + Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: + + i = 0 + mylist = ["blue", "green"] + Enum.at(mylist, i) + +- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc + you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: + + # INVALID: we are rebinding inside the `if` and the result never gets assigned + if connected?(socket) do + socket = assign(socket, :val, val) + end + + # VALID: we rebind the result of the `if` to a new variable + socket = + if connected?(socket) do + assign(socket, :val, val) + end + +- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors +- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets +- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards +- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` +- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option + +## Mix guidelines + +- Read the docs and options before using tasks (by using `mix help task_name`) +- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` +- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason + +## Test guidelines + +- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests +- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests + - Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message: + + ref = Process.monitor(pid) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal} + + - Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages diff --git a/.claude/skills/ui-framework/references/fluxon.md b/.claude/skills/ui-framework/references/fluxon.md new file mode 100644 index 00000000..9b082638 --- /dev/null +++ b/.claude/skills/ui-framework/references/fluxon.md @@ -0,0 +1,2437 @@ +# Fluxon UI Components Usage Rules + +This document provides a guide for Large Language Models (LLMs) on how to use Fluxon UI components. It covers component attributes, slots, and provides usage examples for different scenarios. + +## Accordion + +The Accordion component provides collapsible content sections with headers that toggle panel visibility. + +### Components +- `accordion`: Main container that manages state and accessibility +- `accordion_item`: Individual collapsible sections + +### Attributes + +#### accordion +- `id` (string, optional): Unique identifier for the accordion container +- `class` (any, optional): Additional CSS classes for the accordion container +- `multiple` (boolean, default: false): Allow multiple items to be expanded simultaneously +- `prevent_all_closed` (boolean, default: false): Prevent all items from being closed at once +- `animation_duration` (integer, default: 300): Duration of expand/collapse animation in milliseconds +- `rest`: Additional HTML attributes + +#### accordion_item +- `id` (string, optional): Unique identifier for the accordion item +- `class` (any, optional): Additional CSS classes for the accordion item container +- `expanded` (boolean, default: false): Initial expanded state of the item +- `icon` (boolean, default: true): Show/hide the chevron icon +- `rest`: Additional HTML attributes + +### Slots + +#### accordion +- `inner_block` (required): Contains one or more accordion_item components + +#### accordion_item +- `header` (required): Always-visible clickable area that toggles the panel + - `class` (optional): Additional CSS classes for the header button +- `panel` (required): Expandable content area + - `class` (optional): Additional CSS classes for the panel content + +### Usage Examples + +#### Basic Accordion +```heex +<.accordion> + <.accordion_item> + <:header>What is Fluxon? + <:panel> + Fluxon is a powerful UI component library for Phoenix LiveView applications. + + + + <.accordion_item> + <:header>How do I get started? + <:panel> + Add Fluxon to your dependencies and follow the installation guide. + + + +``` + +#### Multiple Sections and Rich Headers +```heex +<.accordion multiple> + <.accordion_item> + <:header class="flex items-center gap-3"> + <.icon name="document" class="size-5 text-zinc-400" /> +
+

Documentation

+

View the complete documentation

+
+ <.badge class="ml-auto">New + + <:panel>Detailed documentation content... + + +``` + +#### Custom Animation and No Icon +```heex +<.accordion animation_duration={500}> + <.accordion_item icon={false}> + <:header>Custom Header Without Chevron + <:panel>No chevron icon is shown for this item + + +``` + +## Alert + +The Alert component displays status messages, notifications, and interactive feedback with various visual styles and interactive elements. + +### Attributes +- `id` (string, optional): Unique identifier for the alert element +- `class` (any, optional): Additional CSS classes for the alert element +- `title` (string, optional): Main heading text of the alert +- `subtitle` (string, optional): Secondary text displayed alongside the title +- `color` (string, default: "default"): Visual style color - "default", "primary", "danger", "success", "info", "warning" +- `hide_icon` (boolean, default: false): Hide the alert's status icon +- `hide_close` (boolean, default: false): Hide the alert's close button +- `on_close` (JS, default: %JS{}): LiveView JS commands to execute when closed + +### Slots +- `inner_block` (optional): Main content displayed in the alert body +- `icon` (optional): Custom icon to replace the default status icon + +### Usage Examples + +#### Basic Alert with Colors +```heex +<.alert>Simple alert message + +<.alert color="info" title="Update Available"> + A new version of the application is ready to install. + + +<.alert color="success" title="Order Confirmed"> + Your order has been successfully processed. + + +<.alert color="warning" title="Session Expiring"> + Your session will expire in 5 minutes. + + +<.alert color="danger" title="Connection Lost"> + Unable to connect to the server. + +``` + +#### Alert with Actions +```heex +<.alert + color="warning" + title="Unsaved Changes" + on_close={JS.push("dismiss_warning")}> + You have unsaved changes that will be lost. + +
+ <.button size="xs">Save Changes + <.button size="xs" color="ghost">Discard +
+ +``` + +#### Non-dismissible and Custom Icon +```heex +<.alert hide_close> + This alert cannot be dismissed + + +<.alert> + <:icon> + <.icon name="hero-bell" class="size-5" /> + + Custom notification icon + +``` + +## Autocomplete + +The Autocomplete component provides a text input that filters a list of options as the user types, with keyboard navigation and accessibility features. It supports both client-side and server-side search capabilities. + +### Attributes +- `id` (any, optional): Unique identifier for the autocomplete component +- `name` (any): Form name for the autocomplete (required when not using `field`) +- `field` (Phoenix.HTML.FormField, optional): Form field to bind to +- `class` (any, optional): Additional CSS classes for the field wrapper element +- `label` (string, optional): Primary label for the autocomplete +- `sublabel` (string, optional): Additional context displayed beside the main label +- `help_text` (string, optional): Help text displayed below the autocomplete +- `description` (string, optional): Description displayed below the label +- `placeholder` (string, optional): Text to display when input is empty +- `autofocus` (boolean, default: false): Whether input should have autofocus +- `disabled` (boolean, default: false): Disable the autocomplete component +- `size` (string, default: "md"): Size variant - "xs", "sm", "md", "lg", "xl" +- `search_threshold` (integer, default: 0): Minimum characters before showing suggestions +- `no_results_text` (string, default: "No results found for \"%{query}\"."): Text for no results +- `on_search` (string, optional): LiveView event name for server-side search +- `debounce` (integer, default: 200): Debounce time in milliseconds for server-side searches +- `search_mode` (string, default: "contains"): Search mode - "contains", "starts-with", "exact" +- `open_on_focus` (boolean, default: false): Open listbox when input is focused +- `value` (any, optional): Current selected value +- `errors` (list, default: []): List of error messages +- `options` (list, required): List of options in various formats +- `clearable` (boolean, default: false): Show clear button to remove selection +- `rest`: Additional HTML attributes + +### Slots +- `inner_prefix` (optional): Content inside input field before text +- `outer_prefix` (optional): Content outside and before input field +- `inner_suffix` (optional): Content inside input field after text +- `outer_suffix` (optional): Content outside and after input field +- `option` (optional): Custom option rendering slot +- `empty_state` (optional): Custom empty state rendering +- `header` (optional): Custom header content for listbox +- `footer` (optional): Custom footer content for listbox + +### Usage Examples + +#### Basic and Form Integration +```heex +<.autocomplete + name="country" + options={[{"United States", "us"}, {"Canada", "ca"}]} + placeholder="Search countries..." +/> + +<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save"> + <.autocomplete + field={f[:user_id]} + label="Assigned To" + options={@users} + /> + +``` + +#### Different Option Formats +```heex + +<.autocomplete + name="fruits" + options={["Apple", "Banana", "Cherry"]} + placeholder="Search fruits..." +/> + + +<.autocomplete + name="products" + options={[ + {"Fruits", [{"Apple", "apple"}, {"Banana", "banana"}]}, + {"Vegetables", [{"Carrot", "carrot"}, {"Broccoli", "broccoli"}]} + ]} + placeholder="Search products..." +/> +``` + +#### Size Variants and Labels +```heex +<.autocomplete name="sm" size="sm" placeholder="Small" options={@options} /> +<.autocomplete name="lg" size="lg" placeholder="Large" options={@options} /> + +<.autocomplete + name="language" + label="Programming Language" + sublabel="Required" + description="Choose the web framework for your project" + help_text="Choose from our supported platforms" + placeholder="Select your favorite..." + options={["Elixir", "Phoenix", "LiveView"]} +/> +``` + +#### Affixes and Search Features +```heex + +<.autocomplete name="user_search" options={@users} placeholder="Search users..."> + <:inner_prefix> + <.icon name="hero-magnifying-glass" class="size-4" /> + + + + +<.autocomplete + name="movie" + options={@movies} + on_search="search_movies" + search_threshold={2} + clearable + placeholder="Type to search movies..." +/> +``` + +#### Custom Option Rendering +```heex +<.autocomplete + name="users" + placeholder="Search team members..." + options={[{"John Doe", "john"}, {"Jane Smith", "jane"}]} +> + <:option :let={{label, value}}> +
+
+ {String.first(label)} +
+
+
{label}
+
@{value}
+
+
+ + +``` + +## Badge + +The Badge component provides versatile visual markers for status indicators, categories, and notification counts with semantic colors, size variants, and visual styles. + +### Attributes +- `class` (any, optional): Additional CSS classes for the badge element +- `color` (string, default: "primary"): Semantic color - "primary", "info", "success", "warning", "danger" +- `size` (string, default: "md"): Size variant - "xs", "sm", "md", "lg", "xl" +- `variant` (string, default: "surface"): Visual style - "solid", "soft", "surface", "outline", "dashed", "ghost" +- `rest`: Additional HTML attributes + +### Slots +- `inner_block` (required): Content displayed within the badge (text, icons, or both) + +### Usage Examples + +#### Colors and Variants +```heex +<.badge>Default Badge +<.badge color="success">Active +<.badge color="danger" size="lg">Error +<.badge variant="ghost" color="info">Draft + +<.badge variant="solid" color="success">High Emphasis +<.badge variant="soft" color="info">Medium Emphasis +<.badge variant="outline" color="primary">Outline +``` + +#### Size Variants +```heex +<.badge size="xs">Extra Small +<.badge size="sm">Small +<.badge size="lg">Large +``` + +#### With Icons and Interactive +```heex +<.badge color="success"> + <.icon name="hero-check-circle" class="icon" /> Verified + + + +<.badge + color={if @selected, do: "primary", else: "info"} + variant={if @selected, do: "solid", else: "dashed"} + phx-click="toggle_selection" + class="cursor-pointer" +> + <.icon :if={@selected} name="hero-check" class="icon" /> + Category + +``` + +## Button + +The Button component provides consistent, accessible, and visually appealing interactive elements for actions and navigation. It automatically renders either a ` + + <.dropdown_button>Profile + <.dropdown_button>Settings + + +<.dropdown class="w-64"> + <.dropdown_custom class="flex items-center p-2"> + Avatar +
+ Emma Johnson + emma@acme.com +
+ + + <.dropdown_separator /> + + <.dropdown_header>Account + <.dropdown_link navigate={~p"/profile"}>Profile + <.dropdown_link navigate={~p"/billing"}>Billing + +``` + +#### Hover Interaction and Button Actions +```heex +<.dropdown open_on_hover hover_open_delay={200} hover_close_delay={300}> + <.dropdown_link navigate={~p"/profile"}>Profile + <.dropdown_link navigate={~p"/settings"}>Settings + + +<.dropdown> + <.dropdown_button phx-click="export_data">Export Data + <.dropdown_button phx-click="import_data">Import Data + <.dropdown_separator /> + <.dropdown_button phx-click="delete_all" phx-confirm="Are you sure?"> + Delete All + + +``` + +## Input + +The Input component provides versatile text input fields with support for various types, sizes, form integration, and customizable prefix/suffix content both inside and outside the input field. + +### Components +- `input`: Main text input component +- `input_group`: Container for visually grouping multiple related inputs + +### Attributes + +#### input +- `id` (any, optional): HTML id attribute for the input element +- `name` (any): Form name for the input (required when not using `field`) +- `field` (Phoenix.HTML.FormField, optional): Form field to bind to +- `value` (any, optional): Current input value +- `type` (string, default: "text"): HTML input type +- `label` (string, optional): Primary label for the input +- `sublabel` (string, optional): Additional context beside the main label +- `description` (string, optional): Detailed description below the label +- `help_text` (string, optional): Help text displayed below the input +- `placeholder` (string, optional): Placeholder text +- `size` (string, default: "md"): Size variant - "xs", "sm", "md", "lg", "xl" +- `disabled` (boolean, default: false): Disable the input +- `readonly` (boolean, optional): Make input readonly +- `errors` (list, default: []): List of error messages +- `class` (any, optional): Additional CSS classes +- `rest`: Additional HTML attributes + +#### input_group +- `class` (any, optional): Additional CSS classes for the group container +- `label` (string, optional): Primary label for the input group +- `sublabel` (string, optional): Additional context beside the main label +- `description` (string, optional): Detailed description of the input group +- `help_text` (string, optional): Help text displayed below the group +- `rest`: Additional HTML attributes + +### Slots + +#### input +- `inner_prefix` (optional): Content inside the input border, before the text +- `outer_prefix` (optional): Content outside and before the input field +- `inner_suffix` (optional): Content inside the input border, after the text +- `outer_suffix` (optional): Content outside and after the input field + +#### input_group +- `inner_block` (required): Input elements and controls to be grouped + +### Usage Examples + +#### Basic Input and Size Variants +```heex +<.input name="username" label="Username" placeholder="Enter username..." /> +<.input name="password" type="password" label="Password" /> +<.input name="email" type="email" label="Email" /> + +<.input name="input_sm" size="sm" placeholder="Small" /> +<.input name="input_lg" size="lg" placeholder="Large" /> +<.input name="input_xl" size="xl" placeholder="Extra Large" /> +``` + +#### Complete Labeling and Form Integration +```heex +<.input + name="full_example" + label="Email Address" + sublabel="(required)" + description="We'll send a confirmation link here." + help_text="We never share your email." + placeholder="you@example.com" +/> + +<.form :let={f} for={@form}> + <.input field={f[:email]} type="email" label="Email" /> + <.input field={f[:password]} type="password" label="Password" /> + +``` + +#### Input States and Types +```heex + +<.input name="disabled_input" value="Cannot change" disabled /> +<.input name="readonly_input" value="Readonly value" readonly /> + + +<.input type="date" name="date_input" label="Appointment Date" /> +<.input type="number" name="count" label="Quantity" min="1" max="10" /> +<.input type="search" name="site_search" placeholder="Search site..." /> +``` + +#### Inner Affixes +```heex + +<.input name="email_icon" placeholder="user@example.com"> + <:inner_prefix> + <.icon name="hero-at-symbol" class="icon" /> + + + + +<.input name="password_toggle" type="password" value="secretpassword"> + <:inner_suffix> + <.button variant="ghost" size="icon-sm" title="Show password"> + <.icon name="hero-eye" class="icon" /> + + + + + +<.input name="url_prefix" placeholder="yourdomain.com"> + <:inner_prefix class="pointer-events-none text-zinc-500">https://www. + +``` + +#### Outer Affixes +```heex + +<.input name="invite_user" placeholder="user@example.com"> + <:inner_prefix> + <.icon name="hero-at-symbol" class="icon" /> + + <:outer_suffix> + <.button variant="solid" color="primary"> + <.icon name="hero-paper-airplane" class="icon" /> Invite + + + + + +<.input name="website_url" placeholder="mysite"> + <:outer_prefix class="px-2 text-zinc-500">https:// + <:outer_suffix class="px-2 text-zinc-500">.example.com + +``` + +#### Input Groups +```heex + +<.input_group label="Full Name"> + <.input name="first_name" placeholder="First Name" /> + <.input name="last_name" placeholder="Last Name" /> + + + +<.input_group label="Price Range"> + <.input name="min_price" placeholder="Min price"> + <:inner_prefix>$ + +
+ to +
+ <.input name="max_price" placeholder="Max price"> + <:inner_prefix>$ + + +``` + +## Loading + +The Loading component provides versatile animated loading indicators for displaying loading states with multiple animation styles and customization options. + +### Attributes +- `class` (any, optional): Additional CSS classes for customizing size and color (default: size-5, text-zinc-600) +- `duration` (integer, default: 600): Duration of one complete animation cycle in milliseconds +- `variant` (string, default: "ring"): Animation style - "ring", "ring-bg", "dots-bounce", "dots-fade", "dots-scale" +- `rest`: Additional HTML attributes + +### Usage Examples + +#### Animation Variants +```heex +<.loading /> +<.loading variant="ring-bg" /> +<.loading variant="dots-bounce" /> +<.loading variant="dots-fade" /> +<.loading variant="dots-scale" /> +``` + +#### Size and Color Variations +```heex +<.loading class="size-4" /> +<.loading class="size-8" /> +<.loading class="size-12" /> + +<.loading class="text-blue-500" /> +<.loading class="text-green-500" /> +<.loading class="text-red-500" /> +``` + +#### Button Loading States +```heex +<.button disabled> + <.loading class="size-4" /> Loading... + + +<.button variant="solid" color="primary" disabled> + <.loading class="size-4 text-white" /> Processing + +``` + +#### Page and Section Loading +```heex + +
+ <.loading class="size-8" /> +
+ + +
+
+ <.loading class="size-6" /> +
+ +
+ + +

Processing your request <.loading class="size-4 inline" variant="dots-bounce" />

+``` + +## Modal + +The Modal component provides a powerful and accessible modal overlay for displaying focused content with LiveView integration, supporting both client-side and server-side control. + +### Attributes +- `id` (string, required): Unique identifier for the modal component +- `open` (boolean, default: false): Whether the modal is initially open +- `on_close` (JS, optional): JavaScript commands to execute when the modal closes +- `on_open` (JS, optional): JavaScript commands to execute when the modal opens +- `class` (any, optional): Additional CSS classes for the modal content container +- `container_class` (any, optional): CSS classes for the outer modal container +- `close_on_esc` (boolean, default: true): Whether pressing ESC closes the modal +- `close_on_outside_click` (boolean, default: true): Whether clicking outside closes the modal +- `prevent_closing` (boolean, default: false): Prevent all client-side closing behaviors +- `hide_close_button` (boolean, default: false): Hide the default close button +- `animation` (string, default: "transition duration-300 ease-out"): Animation classes +- `animation_enter` (string, default: "opacity-100 scale-100"): Enter animation classes +- `animation_leave` (string, default: "opacity-0 scale-95"): Leave animation classes +- `backdrop_class` (string, optional): Custom classes for the modal backdrop +- `placement` (string, default: "center"): Modal positioning on screen +- `rest`: Additional HTML attributes + +### Slots +- `inner_block` (required): Content displayed within the modal + +### Usage Examples + +#### Basic Modal +```heex +<.button phx-click={Fluxon.open_dialog("basic-modal")}>Open Modal + +<.modal id="basic-modal"> +
+

Modal Title

+

This is the modal content.

+ +
+ <.button phx-click={Fluxon.close_dialog("basic-modal")}>Close +
+
+ +``` + +#### Server-Side Control +```heex +<.button phx-click="show_modal">Open Server Modal + +<.modal id="server-modal" open={@show_modal} on_close={JS.push("hide_modal")}> +
+

Server Controlled Modal

+

This modal is controlled by server state.

+ <.button phx-click="hide_modal" class="mt-4">Close +
+ + + +<.modal id="secure-modal" open={@show_secure_modal} prevent_closing> +
+

Critical Operation

+

This modal can only be closed through server commands.

+ <.button phx-click="hide_secure_modal" color="danger" class="mt-4"> + Complete Action + +
+ +``` + +#### Custom Styling and Form Modal +```heex + +<.modal + id="large-modal" + class="w-full max-w-4xl max-h-[80vh] overflow-auto" + container_class="p-4" +> +
+

Large Modal

+

This modal has custom sizing and scrolling behavior.

+
+ + + +<.modal id="form-modal" on_close={JS.push("reset_form")}> +
+

Create User

+ + <.form :let={f} for={@changeset} phx-change="validate" phx-submit="create_user"> + <.input field={f[:name]} label="Full Name" /> + <.input field={f[:email]} type="email" label="Email" /> + +
+ <.button type="submit" disabled={!@changeset.valid?}> + Create User + + <.button + type="button" + variant="outline" + phx-click={Fluxon.close_dialog("form-modal")} + > + Cancel + +
+ +
+ +``` + +#### Confirmation Modal +```heex +<.modal id="confirm-delete" class="w-96"> +
+
+ <.icon name="hero-exclamation-triangle" class="size-6 text-red-600" /> +
+ +

Delete Item

+

+ Are you sure you want to delete this item? This action cannot be undone. +

+ +
+ <.button + color="danger" + phx-click="confirm_delete" + phx-value-id={@delete_item_id} + > + Delete + + <.button + variant="outline" + phx-click={Fluxon.close_dialog("confirm-delete")} + > + Cancel + +
+
+ +``` + +## Navlist + +The Navlist component provides a comprehensive navigation system for building structured, accessible navigation menus with support for sections, headings, and interactive links. + +### Components +- `navlist`: Main navigation container that provides structure and spacing +- `navheading`: Optional section headers for organizing navigation groups +- `navlink`: Interactive navigation items with LiveView integration + +### Attributes + +#### navlist +- `heading` (string, optional): Primary heading for the navigation section +- `class` (any, optional): Additional CSS classes for the navigation container +- `rest`: Additional HTML attributes for the nav container + +#### navheading +- `class` (any, optional): Additional CSS classes for the heading element +- `rest`: Additional HTML attributes + +#### navlink +- `class` (any, optional): Additional CSS classes for the navigation link +- `active` (boolean, default: false): Whether this navigation item is currently active +- `rest`: Additional HTML attributes (supports navigation attributes like `navigate`, `patch`, `href`, etc.) + +### Slots + +#### navlist +- `inner_block` (required): Navigation items and headings + +#### navheading +- `inner_block` (required): Heading content + +#### navlink +- `inner_block` (required): Link content (text, icons, badges, etc.) + +### Usage Examples + +#### Basic Navigation +```heex +<.navlist heading="Main Navigation"> + <.navlink navigate={~p"/dashboard"} active> + <.icon name="hero-home" class="size-5" /> Dashboard + + <.navlink navigate={~p"/projects"}> + <.icon name="hero-folder" class="size-5" /> Projects + + <.navlink navigate={~p"/settings"}> + <.icon name="hero-cog-6-tooth" class="size-5" /> Settings + + +``` + +#### Multiple Sections +```heex +<.navlist heading="Main"> + <.navlink navigate={~p"/dashboard"} active> + <.icon name="hero-home" class="size-5" /> Dashboard + + <.navlink navigate={~p"/projects"}> + <.icon name="hero-folder" class="size-5" /> Projects + + + +<.navlist heading="Settings"> + <.navlink navigate={~p"/profile"}> + <.icon name="hero-user" class="size-5" /> Profile + + <.navlink navigate={~p"/preferences"}> + <.icon name="hero-cog-6-tooth" class="size-5" /> Preferences + + +``` + +#### Navigation with Badges +```heex +<.navlist heading="Inbox"> + <.navlink navigate={~p"/inbox/unread"}> + <.icon name="hero-envelope" class="size-5" /> + Unread + <.badge variant="solid" color="danger" class="ml-auto">23 + + + <.navlink navigate={~p"/inbox/starred"}> + <.icon name="hero-star" class="size-5" /> + Starred + <.badge variant="soft" color="warning" class="ml-auto">5 + + +``` + +#### External Links and Actions +```heex +<.navlist heading="Resources"> + <.navlink href="https://docs.example.com" target="_blank"> + <.icon name="hero-document-text" class="size-5" /> + Documentation + <.icon name="hero-arrow-top-right-on-square" class="size-4 ml-auto text-gray-400" /> + + + +<.navlist heading="Actions"> + <.navlink phx-click="export_data"> + <.icon name="hero-arrow-down-tray" class="size-5" /> + Export Data + + + <.navlink phx-click="refresh_data"> + <.icon name="hero-arrow-path" class="size-5" /> + Refresh + + +``` + +## Popover + +A powerful and accessible popover component that displays floating content anchored to a trigger element. + +### Attributes +- `id` (string, default: auto-generated): Optional unique identifier for the popover +- `target` (string, default: nil): CSS selector of an external element to use as positioning reference +- `class` (any, default: nil): Additional CSS classes for the popover content container +- `open_on_hover` (boolean, default: false): Opens popover on hover for tooltip-like behavior +- `open_on_focus` (boolean, default: false): Opens popover when trigger receives focus +- `placement` (string, default: "top"): Popover position - "top", "top-start", "top-end", "right", "right-start", "right-end", "left", "left-start", "left-end", "bottom", "bottom-start", "bottom-end" + +### Slots +- `inner_block`: The trigger element that will open the popover (optional when using target) +- `content` (required): The content to display in the popover + +### Usage Examples + +#### Basic Tooltip +```heex +<.popover open_on_hover> + <.icon name="hero-information-circle" class="text-zinc-400" /> + + <:content> +

The invoice will be generated at the end of the month.

+ + +``` + +#### Interactive Menu and Form Help +```heex +<.popover placement="bottom-end" class="w-64"> + <.button variant="ghost"> + <.icon name="hero-cog-6-tooth" /> Settings + + + <:content> +
+
+ Dark Mode + <.switch name="dark_mode" checked /> +
+ <.button size="sm" class="w-full"> + Reset Preferences + +
+ + + +<.input name="api-key" label="API Key" value="sk_test_..." class="font-mono"> + <:inner_suffix> + <.popover open_on_hover placement="right"> + <.icon name="hero-question-mark-circle" class="text-zinc-400" /> + + <:content> +
+

About API Keys

+

+ Your API key is used to authenticate requests. Keep it secure. +

+
+ + + + +``` + +#### Search Suggestions +```heex +<.popover open_on_focus placement="bottom-start" class="w-80"> + <.input type="search" placeholder="Search users..." phx-debounce="300" /> + + <:content> +
+ <.loading /> +
+
+
{user.name}
+
{user.email}
+
+ + +``` + +## Radio + +A versatile radio component for building single-selection interfaces with rich styling options. + +### Attributes +- `id` (any, default: nil): The unique identifier for the radio group +- `name` (string, required when not using field): The form name for the radio group +- `value` (any): The current selected value of the radio group +- `label` (string, default: nil): The primary label for the radio group +- `sublabel` (string, default: nil): Additional context displayed beside the main label +- `description` (string, default: nil): Detailed description below the label +- `errors` (list, default: []): List of error messages +- `class` (any, default: nil): Additional CSS classes for the radio group container +- `field` (Phoenix.HTML.FormField): The form field to bind to +- `disabled` (boolean, default: false): Disables all radio buttons in the group +- `variant` (string, default: nil): Visual variant - nil (default) or "card" +- `control` (string): Controls radio position in card variants - "left" or "right" +- `rest` (global): Additional attributes + +### Slots +- `radio` (required): Defines individual radio buttons with attributes: `value`, `label`, `sublabel`, `description`, `disabled`, `class`, `checked` + +### Usage Examples + +#### Basic Radio Group +```heex +<.radio_group name="system" value="debian" label="Operating System"> + <:radio value="ubuntu" label="Ubuntu" /> + <:radio value="debian" label="Debian" /> + <:radio value="fedora" label="Fedora" /> + +``` + +#### With Context and Form Integration +```heex +<.radio_group + name="system" + label="Operating System" + sublabel="Choose your preferred OS" + description="Select the operating system that best suits your needs" +> + <:radio + value="ubuntu" + label="Ubuntu" + sublabel="Popular and user-friendly" + description="Ubuntu is a Debian-based Linux operating system" + /> + <:radio + value="debian" + label="Debian" + sublabel="Stable and reliable" + description="Debian is composed of free and open-source software" + /> + + +<.form :let={f} for={@form} phx-change="validate" phx-submit="save"> + <.radio_group + field={f[:subscription]} + label="Subscription Plan" + description="Choose your preferred subscription plan" + > + <:radio value="basic" label="Basic Plan" sublabel="$10/month" /> + <:radio value="pro" label="Pro Plan" sublabel="$20/month" /> + <:radio value="enterprise" label="Enterprise Plan" sublabel="$50/month" /> + + +``` + +#### Card Variant +```heex +<.radio_group + name="plan" + label="Choose a plan" + description="Choose the plan that best suits your needs." + variant="card" + control="left" + class="gap-0" +> + <:radio value="basic" label="Basic" sublabel="Perfect for small projects" class="rounded-none -my-px rounded-t-lg" /> + <:radio value="pro" label="Professional" checked sublabel="Most popular for growing teams" class="rounded-none -my-px" /> + <:radio value="business" label="Business" sublabel="Advanced features for larger teams" class="rounded-none -my-px rounded-b-lg" /> + + +<.radio_group name="category" label="Category" variant="card" class="grid grid-cols-3"> + <:radio value="web-design" class="flex-1 group has-checked:border-blue-500 has-checked:bg-blue-50"> +
+ <.icon name="hero-computer-desktop" class="size-6 text-zinc-500 group-has-checked:text-blue-500" /> + Web Design +
+ + <:radio value="ui-ux" class="flex-1 group has-checked:border-blue-500 has-checked:bg-blue-50"> +
+ <.icon name="hero-pencil" class="size-6 text-zinc-500 group-has-checked:text-blue-500" /> + UI/UX Design +
+ + +``` + +## Select + +A select component that implements a modern, accessible selection interface with support for single and multiple selections. + +### Attributes +- `id` (any, default: nil): The unique identifier for the select component +- `name` (any, required when not using field): The form name for the select +- `field` (Phoenix.HTML.FormField): The form field to bind to +- `native` (boolean, default: false): Renders a native HTML select element instead of custom select +- `class` (any, default: nil): Additional CSS classes for the select component +- `label` (string, default: nil): The primary label for the select +- `sublabel` (string, default: nil): Additional context beside the main label +- `help_text` (string, default: nil): Help text displayed below the select +- `description` (string, default: nil): Description below the label but above select +- `placeholder` (string, default: nil): Text displayed when no option is selected +- `searchable` (boolean, default: false): Adds search input to filter options (custom select only) +- `disabled` (boolean, default: false): Disables the select component +- `size` (string, default: "md"): Controls select size - "xs", "sm", "md", "lg", "xl" +- `search_input_placeholder` (string, default: "Search..."): Placeholder for search input +- `search_no_results_text` (string, default: "No results found for %{query}."): No results text +- `search_threshold` (integer, default: 0): Minimum characters before filtering +- `debounce` (integer, default: 300): Debounce time for server searches +- `on_search` (string, default: nil): LiveView event name for server searches +- `multiple` (boolean, default: false): Allows selecting multiple options (not with native) +- `value` (any): Current selected value(s) +- `errors` (list, default: []): List of error messages +- `options` (list, required): List of options for the select +- `max` (integer, default: nil): Maximum selections when multiple +- `clearable` (boolean, default: false): Shows clear button +- `rest` (global): Additional attributes + +### Slots +- `option`: Optional slot for custom option rendering +- `toggle`: Optional slot for custom toggle rendering +- `header`: Optional slot for custom header content +- `footer`: Optional slot for custom footer content +- `inner_prefix`: Content inside select field before value display +- `outer_prefix`: Content outside and before select field +- `inner_suffix`: Content inside select field after value display +- `outer_suffix`: Content outside and after select field + +### Usage Examples + +#### Basic and Full-featured Select +```heex +<.select + name="country" + options={[{"United States", "US"}, {"Canada", "CA"}]} +/> + +<.select + name="payment_method" + label="Payment Method" + sublabel="Select payment type" + description="Choose your preferred payment method" + help_text="We securely process all payment information" + placeholder="Select payment method" + options={[ + {"Credit Card", "credit_card"}, + {"PayPal", "paypal"}, + {"Bank Transfer", "bank_transfer"} + ]} +/> +``` + +#### Native, Searchable and Multiple +```heex +<.select + name="country" + native + options={[{"United States", "US"}, {"Canada", "CA"}]} +/> + +<.select + name="country" + searchable + search_input_placeholder="Search for a country" + search_no_results_text="No countries found for %{query}" + options={@countries} +/> + +<.select + name="countries" + multiple + max={3} + clearable + options={[{"United States", "US"},{"Canada", "CA"},{"Mexico", "MX"}]} +/> +``` + +#### Form Integration and Affixes +```heex +<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save"> + <.select + field={f[:country]} + label="Country" + options={@countries} + /> + + +<.select name="category" options={@categories} placeholder="Select category"> + <:inner_prefix> + <.icon name="hero-folder" class="icon" /> + + <:outer_suffix> + <.button size="md">Apply + + +``` + +#### Custom Option Rendering +```heex +<.select + name="role" + placeholder="Select role" + options={[{"Admin", "admin"}, {"Editor", "editor"}, {"Viewer", "viewer"}]} +> + <:option :let={{label, value}}> +
+
+
{label}
+
+ {case value do + "admin" -> "Full access to all features" + "editor" -> "Can create and modify content" + "viewer" -> "Read-only access to content" + end} +
+
+ <.icon :if={value == "admin"} name="hero-shield-check" class="size-4 text-blue-500" /> +
+ + +``` + +## Separator + +A versatile separator component for creating visual boundaries between content sections. + +### Attributes +- `text` (string, default: nil): Optional text to display in the center of the separator +- `vertical` (boolean, default: false): Renders a vertical separator instead of horizontal +- `class` (any, default: nil): Additional CSS classes + +### Usage Examples + +#### Basic Separators +```heex +
Content above
+<.separator /> +
Content below
+ +
+ Left + <.separator vertical /> + Right +
+ +<.separator text="or" /> +<.separator text="Section" class="my-6" /> +``` + +#### Form and Navigation Usage +```heex +
+ <.input name="email" type="email" placeholder="Email" /> + <.input name="password" type="password" placeholder="Password" /> + <.button type="submit" class="w-full">Sign In + + <.separator text="or" /> + + <.button type="button" variant="outline" class="w-full"> + Sign in with Google + +
+ +
+ Profile + <.separator vertical /> + Settings + <.separator vertical /> + Logout +
+``` + +## Sheet + +A powerful and accessible sheet component that provides a sliding panel interface for displaying content from screen edges. + +### Attributes +- `id` (string, required): The unique identifier for the sheet component +- `open` (boolean, default: false): Whether the sheet is initially open +- `on_close` (JS, default: %JS{}): JavaScript commands to execute when sheet closes +- `on_open` (JS, default: %JS{}): JavaScript commands to execute when sheet opens +- `class` (any, default: ""): Additional CSS classes for sheet content container +- `close_on_esc` (boolean, default: true): Whether to close sheet when Escape is pressed +- `close_on_outside_click` (boolean, default: true): Whether to close when clicking outside +- `prevent_closing` (boolean, default: false): Prevents sheet from being closed through standard interactions +- `hide_close_button` (boolean, default: false): Whether to hide the close button +- `animation` (string, default: "transition duration-200 ease-in-out"): Base animation classes +- `animation_enter` (string): Classes applied when sheet enters (auto-set based on placement) +- `animation_leave` (string): Classes applied when sheet leaves (auto-set based on placement) +- `backdrop_class` (string, default: ""): Additional CSS classes for backdrop overlay +- `placement` (string, default: "left"): Edge the sheet slides from - "left", "right", "top", "bottom" + +### Slots +- `inner_block` (required): The content of the sheet + +### Usage Examples + +#### Basic Client-Side Control +```heex +<.button phx-click={Fluxon.open_dialog("filters-sheet")}>Filters + +<.sheet id="filters-sheet"> +

Filters

+ +
+ +
+ + <.button phx-click={Fluxon.close_dialog("filters-sheet")}>Apply + +``` + +#### Server-Side Control +```heex +<.button phx-click="show-sheet">Open Filters + +<.sheet id="filters-sheet" open={@show_filters}> +

Server-controlled Sheet

+ + + +<.sheet + id="filters-sheet" + open={@show_filters} + on_close={JS.push("hide_filters")} +> +

Filters with State Sync

+ +``` + +#### Different Placements +```heex + +<.sheet id="nav-sheet" placement="left" class="w-80"> + + + + +<.sheet id="details-sheet" placement="right" class="w-96"> +

Details

+

Detailed information goes here.

+ + + +<.sheet id="actions-sheet" placement="bottom" class="h-96"> +
+ <.button class="w-full">Share + <.button class="w-full">Copy Link + <.button class="w-full">Delete +
+ +``` + +#### Form in Sheet +```heex +<.sheet id="new-user-sheet" placement="right" class="w-96"> + <.form for={@form} phx-submit="save_user"> +
+
+

New User

+

Create a new user account.

+
+ + <.input field={@form[:name]} label="Name" /> + <.input field={@form[:email]} type="email" label="Email" /> + +
+ <.button phx-click={Fluxon.close_dialog("new-user-sheet")}> + Cancel + + <.button type="submit" phx-disable-with="Creating..."> + Create User + +
+
+ + +``` + +## Switch + +A toggle switch component for binary choices and settings with immediate effect. + +### Attributes +- `id` (any, default: nil): The unique identifier for the switch +- `name` (string, required when not using field): The form name for the switch +- `class` (any, default: nil): Additional CSS classes for the switch wrapper +- `checked` (boolean): Whether the switch is in the on position +- `disabled` (boolean, default: false): Disables the switch +- `value` (any): The value associated with the switch +- `label` (string, default: nil): The primary label for the switch +- `sublabel` (string, default: nil): Additional context beside the main label +- `description` (string, default: nil): Detailed description below the label +- `size` (string, default: "md"): Controls switch size - "sm", "md", "lg" +- `color` (string, default: "primary"): Color theme when on - "primary", "danger", "success", "warning", "info" +- `field` (Phoenix.HTML.FormField): The form field to bind to +- `rest` (global): Additional attributes + +### Usage Examples + +#### Basic Switch and Size Variants +```heex +<.switch + name="notifications" + label="Enable Notifications" + checked={@settings.notifications} + phx-click="toggle_setting" + phx-value-setting="notifications" +/> + +<.switch name="small_switch" label="Small Switch" size="sm" checked={@settings.compact_mode} /> +<.switch name="large_switch" label="Large Switch" size="lg" checked={@settings.important_setting} /> +``` + +#### Labels and Context +```heex +<.switch name="simple" label="Enable Feature" checked={@feature_enabled} /> + +<.switch + name="with_sublabel" + label="Auto Save" + sublabel="Recommended" + checked={@settings.auto_save} +/> + +<.switch + name="comprehensive" + label="Advanced Analytics" + sublabel="Beta" + description="Share detailed usage patterns to help improve the product experience" + checked={@settings.analytics_enabled} +/> +``` + +#### Color Variants and Form Integration +```heex +<.switch name="primary" label="Default Setting" color="primary" checked={@settings.default} /> +<.switch name="success" label="Enable Backup" color="success" checked={@settings.backup_enabled} /> +<.switch name="danger" label="Public Profile" color="danger" checked={@settings.public_profile} /> + +<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save"> + <.switch + field={f[:notifications_enabled]} + label="Push Notifications" + sublabel="Real-time updates" + description="Receive notifications about important account activity" + /> + + <.switch + field={f[:email_marketing]} + label="Email Marketing" + sublabel="Optional" + description="Receive promotional emails about new features" + /> + +``` + +#### Disabled States +```heex +<.switch + name="disabled_off" + label="Unavailable Feature" + description="This feature is currently unavailable" + disabled + checked={false} +/> + +<.switch + name="disabled_on" + label="Premium Feature" + sublabel="Upgrade required" + description="This feature requires a premium subscription" + disabled + checked={true} +/> +``` + +## Table + +A comprehensive table system for displaying structured data with rich customization options. + +### Components +- `table`: The main container providing structure and responsive behavior +- `table_head`: Header section with column definitions +- `table_body`: Content section containing rows of data +- `table_row`: Individual data rows with cell content + +### Attributes +- All components have `class` (optional) and `rest` attributes for customization + +### Slots + +#### table +- `inner_block` (required): Usually contains table_head and table_body components + +#### table_head +- `col` (required): Defines table columns rendered as th elements + +#### table_body +- `inner_block` (required): Usually contains table_row components + +#### table_row +- `cell` (required): Defines table cells with custom content and styling + +### Usage Examples + +#### Basic Table +```heex +<.table> + <.table_head> + <:col>Name + <:col>Status + <:col>Email + + + <.table_body> + <.table_row> + <:cell>Alice Smith + <:cell>New + <:cell>alice@example.com + + <.table_row> + <:cell>Bob Johnson + <:cell>In Progress + <:cell>bob@example.com + + + +``` + +#### Rich Content Table +```heex +<.table> + <.table_head> + <:col>Lead + <:col>Stage + <:col>Contact + <:col> + + <.table_body> + <.table_row> + <:cell class="w-full flex items-center gap-2"> + +
+ Sarah Johnson + Product Manager +
+ + <:cell> + <.badge color="green">Active + + <:cell>sarah.j@example.com + <:cell> + <.icon name="hero-ellipsis-horizontal" class="size-5" /> + + + + +``` + +#### Sortable Columns and Clickable Rows +```heex +<.table> + <.table_head> + <:col phx-click="sort" phx-value-column="name"> +
+ Lead <.icon name="hero-chevron-up-down" class="size-4 text-zinc-500" /> +
+ + <:col phx-click="sort" phx-value-column="stage"> +
+ Stage <.icon name="hero-chevron-up-down" class="size-4 text-zinc-500" /> +
+ + + <.table_body> + <.table_row> + <:cell>John Smith + <:cell>New Lead + + + + +<.table> + <.table_head> + <:col>Customer + <:col>Status + <:col>Last Order + + + <.table_body> + <.table_row + :for={customer <- @customers} + class="cursor-pointer hover:bg-zinc-50" + phx-click="show_customer" + phx-value-id={customer.id} + > + <:cell> +
+ + {customer.name} +
+ + <:cell><.badge color="green">Active + <:cell>{customer.last_order_date} + + + +``` + +#### Data Table with Actions +```heex +<.table> + <.table_head> + <:col>User + <:col>Role + <:col>Status + <:col>Actions + + + <.table_body> + <.table_row :for={user <- @users}> + <:cell> +
+ +
+
{user.name}
+
{user.email}
+
+
+ + <:cell> + + {user.role} + + + <:cell> + <.badge color={if user.active, do: "green", else: "red"}> + {if user.active, do: "Active", else: "Inactive"} + + + <:cell> +
+ <.button size="sm" variant="ghost" phx-click="edit_user" phx-value-id={user.id}> + Edit + + <.button size="sm" variant="ghost" color="danger" phx-click="delete_user" phx-value-id={user.id}> + Delete + +
+ + + + +``` + +#### Empty State Table +```heex +<.table> + <.table_head> + <:col>Name + <:col>Email + <:col>Status + + + <.table_body> + <.table_row :if={Enum.empty?(@users)}> + <:cell colspan="3" class="text-center py-12 text-zinc-500"> +
+ <.icon name="hero-users" class="size-12 text-zinc-300" /> +
+

No users found

+

Get started by creating your first user.

+
+ <.button size="sm" phx-click="new_user">Add User +
+ + + + <.table_row :for={user <- @users}> + <:cell>{user.name} + <:cell>{user.email} + <:cell>{user.status} + + + +``` + +## Tabs + +A tabs system for creating accessible, interactive tabbed interfaces with keyboard navigation support. + +### Components +- `tabs`: The main container providing structure and JavaScript functionality +- `tabs_list`: Navigation container holding the interactive tab buttons +- `tabs_panel`: Content panels associated with each tab, displayed one at a time + +### Attributes + +#### tabs +- `id` (string): A unique identifier for the tabs container +- `class` (any, default: nil): Additional CSS classes for the tabs container +- `rest` (global): Additional HTML attributes + +#### tabs_list +- `class` (any, default: nil): Additional CSS classes for the tablist container +- `active_tab` (string): The name of the tab that should be initially active +- `variant` (string, default: "default"): The visual style variant - "default", "segmented", "ghost" +- `size` (string, default: "md"): The size of the tabs container - "xs", "sm", "md" + +#### tabs_panel +- `name` (string, required): The unique identifier for this panel +- `class` (any, default: nil): Additional CSS classes for the panel element +- `active` (boolean, default: false): Controls the visibility of the panel +- `rest` (global): Additional HTML attributes + +### Slots + +#### tabs +- `inner_block` (required): Typically contains one tabs_list and one or more tabs_panel components + +#### tabs_list +- `tab` (required): Defines an individual interactive tab button with required name attribute +- `inner_block` (required): Main content area containing the tab slots + +#### tabs_panel +- `inner_block` (required): The content displayed when corresponding tab is active + +### Usage Examples + +#### Basic Static Tabs +```heex +<.tabs id="my-tabs"> + <.tabs_list active_tab="settings"> + <:tab name="profile">Profile + <:tab name="settings">Settings + <:tab name="notifications">Notifications + + + <.tabs_panel name="profile"> + Profile content here... + + + <.tabs_panel name="settings" active> + Settings content here... + + + <.tabs_panel name="notifications"> + Notifications content here... + + +``` + +#### Visual Variants and Sizes +```heex + +<.tabs_list variant="default"> + <:tab name="tab1">Default Tab + + + +<.tabs_list variant="segmented"> + <:tab name="tab1">Segmented Tab + + + +<.tabs_list variant="ghost"> + <:tab name="tab1">Ghost Tab + + + +<.tabs_list size="xs"> + <:tab name="tab1">Extra Small Tab + + +<.tabs_list size="sm"> + <:tab name="tab1">Small Tab + +``` + +#### Rich Tab Content +```heex +<.tabs_list> + <:tab name="messages"> + <.icon name="hero-envelope" class="icon" /> + Messages + <.badge class="ml-2">3 + + + <:tab name="settings"> + <.icon name="hero-cog-6-tooth" class="icon" /> + Settings + + +``` + +#### LiveView Integration +```heex +<.tabs id="lv-sync-tabs"> + <.tabs_list active_tab={@active_tab}> + <:tab name="profile" phx-click={JS.push("set_active_tab", value: %{tab: "profile"})}> + Profile + + <:tab name="settings" phx-click={JS.push("set_active_tab", value: %{tab: "settings"})}> + Settings + + + + <.tabs_panel name="profile" active={@active_tab == "profile"}> + Profile content... + + <.tabs_panel name="settings" active={@active_tab == "settings"}> + Settings content... + + +``` + +#### Complex Form Tabs +```heex +<.tabs id="user-form-tabs"> + <.tabs_list active_tab={@active_tab} variant="segmented"> + <:tab name="basic" phx-click={JS.push("set_tab", value: %{tab: "basic"})}> + <.icon name="hero-user" class="icon" /> + Basic Info + + <:tab name="contact" phx-click={JS.push("set_tab", value: %{tab: "contact"})}> + <.icon name="hero-envelope" class="icon" /> + Contact + + + + <.tabs_panel name="basic" active={@active_tab == "basic"} class="space-y-4"> + <.input field={@form[:name]} label="Full Name" /> + <.input field={@form[:username]} label="Username" /> + + + <.tabs_panel name="contact" active={@active_tab == "contact"} class="space-y-4"> + <.input field={@form[:email]} type="email" label="Email" /> + <.input field={@form[:phone]} label="Phone Number" /> + + +``` + +## Textarea + +A versatile textarea component for capturing multi-line text input with form integration and size variants. + +### Attributes +- `id` (string, default: nil): The unique identifier for the textarea +- `field` (Phoenix.HTML.FormField): The form field to bind to +- `class` (any, default: nil): Additional CSS classes for the textarea +- `help_text` (string, default: nil): Optional help text displayed below the textarea +- `label` (string, default: nil): The primary label for the textarea +- `sublabel` (string, default: nil): Additional context beside the main label +- `description` (string, default: nil): Detailed description below the label +- `value` (string): The current value of the textarea +- `errors` (list, default: []): List of error messages +- `name` (string, required when not using field): The form name for the textarea +- `rows` (integer, default: 3): The number of visible text lines +- `disabled` (boolean, default: false): Disables the textarea +- `size` (string, default: "md"): Controls textarea size - "sm", "md", "lg", "xl" +- `rest` (global): Additional HTML attributes + +### Usage Examples + +#### Basic Textarea and Size Variants +```heex +<.textarea + name="description" + label="Description" + placeholder="Enter description..." +/> + +<.textarea name="description" label="Small" size="sm" placeholder="A compact textarea" /> +<.textarea name="description" label="Large" size="lg" placeholder="Larger textarea" /> +<.textarea name="description" label="Extra Large" size="xl" placeholder="Maximum emphasis" /> +``` + +#### With Labels and Form Integration +```heex +<.textarea + name="bio" + label="Biography" + help_text="Tell us about yourself" + rows={5} +/> + +<.form :let={f} for={@form} phx-change="validate" phx-submit="save"> + <.textarea + field={f[:description]} + label="Description" + help_text="Provide a detailed description of your project" + /> + + +<.form :let={f} for={@form} phx-change="validate"> + <.textarea + field={f[:description]} + label="Project Description" + sublabel="Optional" + description="Provide details about your project's goals and scope" + help_text="Be specific and concise" + rows={6} + /> + +``` + +#### Custom Styling and States +```heex +<.textarea + name="notes" + label="Meeting Notes" + size="lg" + class="font-mono" + rows={10} +/> + +<.textarea + name="readonly_field" + label="Read Only Field" + value="This content cannot be edited" + disabled +/> + +<.textarea + name="content" + label="Article Content" + rows={15} + maxlength={5000} + placeholder="Write your article content here..." + spellcheck="true" +/> +``` + +#### Rich Form Example +```heex +<.form :let={f} for={@form} phx-change="validate" phx-submit="save"> +
+ <.textarea + field={f[:summary]} + label="Summary" + sublabel="Required" + rows={3} + maxlength={500} + help_text="Brief overview of the content" + /> + + <.textarea + field={f[:content]} + label="Full Content" + description="The main body of your article or post" + rows={12} + help_text="Use markdown formatting if needed" + /> + + <.textarea + field={f[:notes]} + label="Internal Notes" + sublabel="Optional" + size="sm" + rows={2} + help_text="Private notes for internal use" + /> +
+ + <.button type="submit">Save Article + +``` + +## Tooltip + +A lightweight and accessible tooltip component for displaying informative content on hover or focus. + +### Attributes +- `id` (string, optional): Unique identifier for the tooltip +- `value` (string, optional): Text content for simple tooltips +- `class` (any, optional): Additional CSS classes for the tooltip container +- `arrow` (boolean, default: true): Whether to show the pointing arrow indicator +- `placement` (string, default: "top"): Tooltip position - "bottom", "left", "top", "right" +- `delay` (integer, default: 0): Delay in milliseconds before showing tooltip + +### Slots +- `inner_block` (required): The trigger element that shows tooltip on hover/focus +- `content` (optional): Rich content slot for complex tooltip content + +### Usage Examples + +#### Basic and Rich Content +```heex +<.tooltip value="Opens in a new window"> + <.button>Open + + +<.tooltip> + <.button>View details + + <:content> +
+ +

Preview of the document layout and structure.

+
+ + +``` + +#### Different Placements and Icon Tooltips +```heex +
+ <.tooltip value="Top placement" placement="top"> + <.button>Top + + + <.tooltip value="Right placement" placement="right"> + <.button>Right + +
+ +
+ <.tooltip value="Share"> + <.button variant="ghost"><.icon name="hero-share" /> + + + <.tooltip value="Add to favorites"> + <.button variant="ghost"><.icon name="hero-star" /> + +
+``` + +#### Form Field Help and Custom Styling +```heex +
+ <.input type="text" name="api_key" value=""> + <:inner_suffix> + <.tooltip value="Your API key can be found in the developer settings"> + <.icon name="hero-question-mark-circle" class="text-zinc-400" /> + + + +
+ +<.tooltip + value="Draft saved" + class="bg-green-600 text-white" + arrow={false} +> + <.badge>Draft + + +<.tooltip value="Archived items are hidden from the main view" delay={300}> + <.icon name="hero-archive" class="text-zinc-400" /> + +``` + +#### User Profile Preview +```heex +<.tooltip class="max-w-xs"> + <.link navigate={"/users/#{@user.id}"}> + {@user.name} + + + <:content> +
+

{@user.name}

+

{@user.title}

+

{@user.department}

+
+ + +``` diff --git a/.claude/skills/ui-framework/references/html.md b/.claude/skills/ui-framework/references/html.md new file mode 100644 index 00000000..e47e7964 --- /dev/null +++ b/.claude/skills/ui-framework/references/html.md @@ -0,0 +1,76 @@ +## Phoenix HTML guidelines + +- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` +- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated +- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` +- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) +- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) + +- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. + + **Never do this (invalid)**: + + <%= if condition do %> + ... + <% else if other_condition %> + ... + <% end %> + + Instead **always** do this: + + <%= cond do %> + <% condition -> %> + ... + <% condition2 -> %> + ... + <% true -> %> + ... + <% end %> + +- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+      
+        let obj = {key: "val"}
+      
+
+  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
+
+- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
+
+      Text
+
+  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
+
+  and **never** do this, since it's invalid (note the missing `[` and `]`):
+
+       ...
+      => Raises compile syntax error on invalid HEEx attr syntax
+
+- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
+- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
+- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
+
+  **Always** do this:
+
+      
+ {@my_assign} + <%= if @some_block_condition do %> + {@another_assign} + <% end %> +
+ + and **Never** do this – the program will terminate with a syntax error: + + <%!-- THIS IS INVALID NEVER EVER DO THIS --%> +
+ {if @invalid_block_construct do} + {end} +
diff --git a/.claude/skills/ui-framework/references/liveview.md b/.claude/skills/ui-framework/references/liveview.md new file mode 100644 index 00000000..96e83b7d --- /dev/null +++ b/.claude/skills/ui-framework/references/liveview.md @@ -0,0 +1,231 @@ +## Phoenix LiveView guidelines + +- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews +- **Avoid LiveComponent's** unless you have a strong, specific need for them +- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` + +### LiveView streams + +- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations: + - basic append of N items - `stream(socket, :messages, [new_msg])` + - resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items) + - prepend to stream - `stream(socket, :messages, [new_msg], at: -1)` + - deleting items - `stream_delete(socket, :messages, msg)` + +- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be: + +
+
+ {msg.text} +
+
+ +- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**: + + def handle_event("filter", %{"filter" => filter}, socket) do + # re-fetch the messages based on the filter + messages = list_messages(filter) + + {:noreply, + socket + |> assign(:messages_empty?, messages == []) + # reset the stream with the new messages + |> stream(:messages, messages, reset: true)} + end + +- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes: + +
+ +
+ {task.name} +
+
+ + The above only works if the empty state is the only HTML block alongside the stream for-comprehension. + +- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items + along with the updated assign: + + def handle_event("edit_message", %{"message_id" => message_id}, socket) do + message = Chat.get_message!(message_id) + edit_form = to_form(Chat.change_message(message, %{content: message.content})) + + # re-insert message so @editing_message_id toggle logic takes effect for that stream item + {:noreply, + socket + |> stream_insert(:messages, message) + |> assign(:editing_message_id, String.to_integer(message_id)) + |> assign(:edit_form, edit_form)} + end + + And in the template: + +
+
+ {message.username} + <%= if @editing_message_id == message.id do %> + <%!-- Edit mode --%> + <.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit"> + ... + + <% end %> +
+
+ +- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections + +### LiveView JavaScript interop + +- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute +- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised + +LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx, +and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor. + +#### Inline colocated js hooks + +**Never** write raw embedded ` + +- colocated hooks are automatically integrated into the app.js bundle +- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber` + +#### External phx-hook + +External JS hooks (`
`) must be placed in `assets/js/` and passed to the +LiveSocket constructor: + + const MyHook = { + mounted() { ... } + } + let liveSocket = new LiveSocket("/live", Socket, { + hooks: { MyHook } + }); + +#### Pushing events between client and server + +Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle. +**Always** return or rebind the socket on `push_event/3` when pushing events: + + # re-bind socket so we maintain event state to be pushed + socket = push_event(socket, "my_event", %{...}) + + # or return the modified socket directly: + def handle_event("some_event", _, socket) do + {:noreply, push_event(socket, "my_event", %{...})} + end + +Pushed events can then be picked up in a JS hook with `this.handleEvent`: + + mounted() { + this.handleEvent("my_event", data => console.log("from server:", data)); + } + +Clients can also push an event to the server and receive a reply with `this.pushEvent`: + + mounted() { + this.el.addEventListener("click", e => { + this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply)); + }) + } + +Where the server handled it via: + + def handle_event("my_event", %{"one" => 1}, socket) do + {:reply, %{two: 2}, socket} + end + +### LiveView tests + +- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions +- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions +- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests +- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc +- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")` +- Instead of relying on testing text content, which can change, favor testing for the presence of key elements +- Focus on testing outcomes rather than implementation details +- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be +- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie: + + html = render(view) + document = LazyHTML.from_fragment(html) + matches = LazyHTML.filter(document, "your-complex-selector") + IO.inspect(matches, label: "Matches") + +### Form handling + +#### Creating a form from params + +If you want to create a form based on `handle_event` params: + + def handle_event("submitted", params, socket) do + {:noreply, assign(socket, form: to_form(params))} + end + +When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys. + +You can also specify a name to nest the params: + + def handle_event("submitted", %{"user" => user_params}, socket) do + {:noreply, assign(socket, form: to_form(user_params, as: :user))} + end + +#### Creating a form from changesets + +When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema: + + defmodule MyApp.Users.User do + use Ecto.Schema + ... + end + +And then you create a changeset that you pass to `to_form`: + + %MyApp.Users.User{} + |> Ecto.Changeset.change() + |> to_form() + +Once the form is submitted, the params will be available under `%{"user" => user_params}`. + +In the template, the form form assign can be passed to the `<.form>` function component: + + <.form for={@form} id="todo-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:field]} type="text" /> + + +Always give the form an explicit, unique DOM ID, like `id="todo-form"`. + +#### Avoiding form errors + +**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**: + + <%!-- ALWAYS do this (valid) --%> + <.form for={@form} id="my-form"> + <.input field={@form[:field]} type="text" /> + + +And **never** do this: + + <%!-- NEVER do this (invalid) --%> + <.form for={@changeset} id="my-form"> + <.input field={@changeset[:field]} type="text" /> + + +- You are FORBIDDEN from accessing the changeset in the template as it will cause errors +- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset diff --git a/.claude/skills/ui-framework/references/phoenix.md b/.claude/skills/ui-framework/references/phoenix.md new file mode 100644 index 00000000..79c2c495 --- /dev/null +++ b/.claude/skills/ui-framework/references/phoenix.md @@ -0,0 +1,15 @@ +## Phoenix guidelines + +- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. + +- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: + + scope "/admin", AppWeb.Admin do + pipe_through :browser + + live "/users", UserLive, :index + end + + the UserLive route would point to the `AppWeb.Admin.UserLive` module + +- `Phoenix.View` no longer is needed or included with Phoenix, don't use it diff --git a/AGENTS.md b/AGENTS.md index e4781f5e..d9c490e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,55 +113,6 @@ mix usage_rules.search_docs "Enum.zip" --query-by title - Use `dbg/1` to print values while debugging. This will display the formatted value and other relevant information in the console. - -## usage_rules usage -_A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies_ - -## Using Usage Rules - -Many packages have usage rules, which you should *thoroughly* consult before taking any -action. These usage rules contain guidelines and rules *directly from the package authors*. -They are your best source of knowledge for making decisions. - -## Modules & functions in the current app and dependencies - -When looking for docs for modules & functions that are dependencies of the current project, -or for Elixir itself, use `mix usage_rules.docs` - -``` -# Search a whole module -mix usage_rules.docs Enum - -# Search a specific function -mix usage_rules.docs Enum.zip - -# Search a specific function & arity -mix usage_rules.docs Enum.zip/1 -``` - - -## Searching Documentation - -You should also consult the documentation of any tools you are using, early and often. The best -way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have -found what you are looking for, use the links in the search results to get more detail. For example: - -``` -# Search docs for all packages in the current application, including Elixir -mix usage_rules.search_docs Enum.zip - -# Search docs for specific packages -mix usage_rules.search_docs Req.get -p req - -# Search docs for multi-word queries -mix usage_rules.search_docs "making requests" -p req - -# Search only in titles (useful for finding specific functions/modules) -mix usage_rules.search_docs "Enum.zip" --query-by title -``` - - - ## usage_rules:otp usage # OTP Usage Rules @@ -189,682 +140,4 @@ mix usage_rules.search_docs "Enum.zip" --query-by title - Use `Task.async_stream/3` for concurrent enumeration with back-pressure - -## phoenix:ecto usage -## Ecto Guidelines - -- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` -- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` -- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` -- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed -- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields -- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct -- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied - - - -## phoenix:elixir usage -## Elixir guidelines - -- Elixir lists **do not support index based access via the access syntax** - - **Never do this (invalid)**: - - i = 0 - mylist = ["blue", "green"] - mylist[i] - - Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: - - i = 0 - mylist = ["blue", "green"] - Enum.at(mylist, i) - -- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc - you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: - - # INVALID: we are rebinding inside the `if` and the result never gets assigned - if connected?(socket) do - socket = assign(socket, :val, val) - end - - # VALID: we rebind the result of the `if` to a new variable - socket = - if connected?(socket) do - assign(socket, :val, val) - end - -- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors -- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets -- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) -- Don't use `String.to_atom/1` on user input (memory leak risk) -- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards -- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` -- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option - -## Mix guidelines - -- Read the docs and options before using tasks (by using `mix help task_name`) -- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` -- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason - -## Test guidelines - -- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests -- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests - - Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message: - - ref = Process.monitor(pid) - assert_receive {:DOWN, ^ref, :process, ^pid, :normal} - - - Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages - - - - -## phoenix:html usage -## Phoenix HTML guidelines - -- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` -- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated -- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` -- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) -- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) - -- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. - - **Never do this (invalid)**: - - <%= if condition do %> - ... - <% else if other_condition %> - ... - <% end %> - - Instead **always** do this: - - <%= cond do %> - <% condition -> %> - ... - <% condition2 -> %> - ... - <% true -> %> - ... - <% end %> - -- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
-
-      
-        let obj = {key: "val"}
-      
-
-  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
-
-- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
-
-      Text
-
-  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
-
-  and **never** do this, since it's invalid (note the missing `[` and `]`):
-
-       ...
-      => Raises compile syntax error on invalid HEEx attr syntax
-
-- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
-- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
-- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
-
-  **Always** do this:
-
-      
- {@my_assign} - <%= if @some_block_condition do %> - {@another_assign} - <% end %> -
- - and **Never** do this – the program will terminate with a syntax error: - - <%!-- THIS IS INVALID NEVER EVER DO THIS --%> -
- {if @invalid_block_construct do} - {end} -
- - - -## phoenix:liveview usage -## Phoenix LiveView guidelines - -- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews -- **Avoid LiveComponent's** unless you have a strong, specific need for them -- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` - -### LiveView streams - -- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations: - - basic append of N items - `stream(socket, :messages, [new_msg])` - - resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items) - - prepend to stream - `stream(socket, :messages, [new_msg], at: -1)` - - deleting items - `stream_delete(socket, :messages, msg)` - -- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be: - -
-
- {msg.text} -
-
- -- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**: - - def handle_event("filter", %{"filter" => filter}, socket) do - # re-fetch the messages based on the filter - messages = list_messages(filter) - - {:noreply, - socket - |> assign(:messages_empty?, messages == []) - # reset the stream with the new messages - |> stream(:messages, messages, reset: true)} - end - -- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes: - -
- -
- {task.name} -
-
- - The above only works if the empty state is the only HTML block alongside the stream for-comprehension. - -- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items - along with the updated assign: - - def handle_event("edit_message", %{"message_id" => message_id}, socket) do - message = Chat.get_message!(message_id) - edit_form = to_form(Chat.change_message(message, %{content: message.content})) - - # re-insert message so @editing_message_id toggle logic takes effect for that stream item - {:noreply, - socket - |> stream_insert(:messages, message) - |> assign(:editing_message_id, String.to_integer(message_id)) - |> assign(:edit_form, edit_form)} - end - - And in the template: - -
-
- {message.username} - <%= if @editing_message_id == message.id do %> - <%!-- Edit mode --%> - <.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit"> - ... - - <% end %> -
-
- -- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections - -### LiveView JavaScript interop - -- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute -- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised - -LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx, -and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor. - -#### Inline colocated js hooks - -**Never** write raw embedded ` - -- colocated hooks are automatically integrated into the app.js bundle -- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber` - -#### External phx-hook - -External JS hooks (`
`) must be placed in `assets/js/` and passed to the -LiveSocket constructor: - - const MyHook = { - mounted() { ... } - } - let liveSocket = new LiveSocket("/live", Socket, { - hooks: { MyHook } - }); - -#### Pushing events between client and server - -Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle. -**Always** return or rebind the socket on `push_event/3` when pushing events: - - # re-bind socket so we maintain event state to be pushed - socket = push_event(socket, "my_event", %{...}) - - # or return the modified socket directly: - def handle_event("some_event", _, socket) do - {:noreply, push_event(socket, "my_event", %{...})} - end - -Pushed events can then be picked up in a JS hook with `this.handleEvent`: - - mounted() { - this.handleEvent("my_event", data => console.log("from server:", data)); - } - -Clients can also push an event to the server and receive a reply with `this.pushEvent`: - - mounted() { - this.el.addEventListener("click", e => { - this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply)); - }) - } - -Where the server handled it via: - - def handle_event("my_event", %{"one" => 1}, socket) do - {:reply, %{two: 2}, socket} - end - -### LiveView tests - -- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions -- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions -- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests -- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc -- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")` -- Instead of relying on testing text content, which can change, favor testing for the presence of key elements -- Focus on testing outcomes rather than implementation details -- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be -- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie: - - html = render(view) - document = LazyHTML.from_fragment(html) - matches = LazyHTML.filter(document, "your-complex-selector") - IO.inspect(matches, label: "Matches") - -### Form handling - -#### Creating a form from params - -If you want to create a form based on `handle_event` params: - - def handle_event("submitted", params, socket) do - {:noreply, assign(socket, form: to_form(params))} - end - -When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys. - -You can also specify a name to nest the params: - - def handle_event("submitted", %{"user" => user_params}, socket) do - {:noreply, assign(socket, form: to_form(user_params, as: :user))} - end - -#### Creating a form from changesets - -When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema: - - defmodule MyApp.Users.User do - use Ecto.Schema - ... - end - -And then you create a changeset that you pass to `to_form`: - - %MyApp.Users.User{} - |> Ecto.Changeset.change() - |> to_form() - -Once the form is submitted, the params will be available under `%{"user" => user_params}`. - -In the template, the form form assign can be passed to the `<.form>` function component: - - <.form for={@form} id="todo-form" phx-change="validate" phx-submit="save"> - <.input field={@form[:field]} type="text" /> - - -Always give the form an explicit, unique DOM ID, like `id="todo-form"`. - -#### Avoiding form errors - -**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**: - - <%!-- ALWAYS do this (valid) --%> - <.form for={@form} id="my-form"> - <.input field={@form[:field]} type="text" /> - - -And **never** do this: - - <%!-- NEVER do this (invalid) --%> - <.form for={@changeset} id="my-form"> - <.input field={@changeset[:field]} type="text" /> - - -- You are FORBIDDEN from accessing the changeset in the template as it will cause errors -- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset - - - -## phoenix:phoenix usage -## Phoenix guidelines - -- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. - -- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: - - scope "/admin", AppWeb.Admin do - pipe_through :browser - - live "/users", UserLive, :index - end - - the UserLive route would point to the `AppWeb.Admin.UserLive` module - -- `Phoenix.View` no longer is needed or included with Phoenix, don't use it - - - -## usage_rules usage -_A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies_ - -## Using Usage Rules - -Many packages have usage rules, which you should *thoroughly* consult before taking any -action. These usage rules contain guidelines and rules *directly from the package authors*. -They are your best source of knowledge for making decisions. - -## Modules & functions in the current app and dependencies - -When looking for docs for modules & functions that are dependencies of the current project, -or for Elixir itself, use `mix usage_rules.docs` - -``` -# Search a whole module -mix usage_rules.docs Enum - -# Search a specific function -mix usage_rules.docs Enum.zip - -# Search a specific function & arity -mix usage_rules.docs Enum.zip/1 -``` - - -## Searching Documentation - -You should also consult the documentation of any tools you are using, early and often. The best -way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have -found what you are looking for, use the links in the search results to get more detail. For example: - -``` -# Search docs for all packages in the current application, including Elixir -mix usage_rules.search_docs Enum.zip - -# Search docs for specific packages -mix usage_rules.search_docs Req.get -p req - -# Search docs for multi-word queries -mix usage_rules.search_docs "making requests" -p req - -# Search only in titles (useful for finding specific functions/modules) -mix usage_rules.search_docs "Enum.zip" --query-by title -``` - - - - -## usage_rules:elixir usage -# Elixir Core Usage Rules - -## Pattern Matching -- Use pattern matching over conditional logic when possible -- Prefer to match on function heads instead of using `if`/`else` or `case` in function bodies -- `%{}` matches ANY map, not just empty maps. Use `map_size(map) == 0` guard to check for truly empty maps - -## Error Handling -- Use `{:ok, result}` and `{:error, reason}` tuples for operations that can fail -- Avoid raising exceptions for control flow -- Use `with` for chaining operations that return `{:ok, _}` or `{:error, _}` - -## Common Mistakes to Avoid -- Elixir has no `return` statement, nor early returns. The last expression in a block is always returned. -- Don't use `Enum` functions on large collections when `Stream` is more appropriate -- Avoid nested `case` statements - refactor to a single `case`, `with` or separate functions -- Don't use `String.to_atom/1` on user input (memory leak risk) -- Lists and enumerables cannot be indexed with brackets. Use pattern matching or `Enum` functions -- Prefer `Enum` functions like `Enum.reduce` over recursion -- When recursion is necessary, prefer to use pattern matching in function heads for base case detection -- Using the process dictionary is typically a sign of unidiomatic code -- Only use macros if explicitly requested -- There are many useful standard library functions, prefer to use them where possible - -## Function Design -- Use guard clauses: `when is_binary(name) and byte_size(name) > 0` -- Prefer multiple function clauses over complex conditional logic -- Name functions descriptively: `calculate_total_price/2` not `calc/2` -- Predicate function names should not start with `is` and should end in a question mark. -- Names like `is_thing` should be reserved for guards - -## Data Structures -- Use structs over maps when the shape is known: `defstruct [:name, :age]` -- Prefer keyword lists for options: `[timeout: 5000, retries: 3]` -- Use maps for dynamic key-value data -- Prefer to prepend to lists `[new | list]` not `list ++ [new]` - -## Mix Tasks - -- Use `mix help` to list available mix tasks -- Use `mix help task_name` to get docs for an individual task -- Read the docs and options fully before using tasks - -## Testing -- Run tests in a specific file with `mix test test/my_test.exs` and a specific test with the line number `mix test path/to/test.exs:123` -- Limit the number of failed tests with `mix test --max-failures n` -- Use `@tag` to tag specific tests, and `mix test --only tag` to run only those tests -- Use `assert_raise` for testing expected exceptions: `assert_raise ArgumentError, fn -> invalid_function() end` -- Use `mix help test` to for full documentation on running tests - -## Debugging - -- Use `dbg/1` to print values while debugging. This will display the formatted value and other relevant information in the console. - - - -## usage_rules:otp usage -# OTP Usage Rules - -## GenServer Best Practices -- Keep state simple and serializable -- Handle all expected messages explicitly -- Use `handle_continue/2` for post-init work -- Implement proper cleanup in `terminate/2` when necessary - -## Process Communication -- Use `GenServer.call/3` for synchronous requests expecting replies -- Use `GenServer.cast/2` for fire-and-forget messages. -- When in doubt, use `call` over `cast`, to ensure back-pressure -- Set appropriate timeouts for `call/3` operations - -## Fault Tolerance -- Set up processes such that they can handle crashing and being restarted by supervisors -- Use `:max_restarts` and `:max_seconds` to prevent restart loops - -## Task and Async -- Use `Task.Supervisor` for better fault tolerance -- Handle task failures with `Task.yield/2` or `Task.shutdown/2` -- Set appropriate task timeouts -- Use `Task.async_stream/3` for concurrent enumeration with back-pressure - - - -## usage_rules usage -_A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies_ - -## Using Usage Rules - -Many packages have usage rules, which you should *thoroughly* consult before taking any -action. These usage rules contain guidelines and rules *directly from the package authors*. -They are your best source of knowledge for making decisions. - -## Modules & functions in the current app and dependencies - -When looking for docs for modules & functions that are dependencies of the current project, -or for Elixir itself, use `mix usage_rules.docs` - -``` -# Search a whole module -mix usage_rules.docs Enum - -# Search a specific function -mix usage_rules.docs Enum.zip - -# Search a specific function & arity -mix usage_rules.docs Enum.zip/1 -``` - - -## Searching Documentation - -You should also consult the documentation of any tools you are using, early and often. The best -way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have -found what you are looking for, use the links in the search results to get more detail. For example: - -``` -# Search docs for all packages in the current application, including Elixir -mix usage_rules.search_docs Enum.zip - -# Search docs for specific packages -mix usage_rules.search_docs Req.get -p req - -# Search docs for multi-word queries -mix usage_rules.search_docs "making requests" -p req - -# Search only in titles (useful for finding specific functions/modules) -mix usage_rules.search_docs "Enum.zip" --query-by title -``` - - - - -## usage_rules:elixir usage -# Elixir Core Usage Rules - -## Pattern Matching -- Use pattern matching over conditional logic when possible -- Prefer to match on function heads instead of using `if`/`else` or `case` in function bodies -- `%{}` matches ANY map, not just empty maps. Use `map_size(map) == 0` guard to check for truly empty maps - -## Error Handling -- Use `{:ok, result}` and `{:error, reason}` tuples for operations that can fail -- Avoid raising exceptions for control flow -- Use `with` for chaining operations that return `{:ok, _}` or `{:error, _}` - -## Common Mistakes to Avoid -- Elixir has no `return` statement, nor early returns. The last expression in a block is always returned. -- Don't use `Enum` functions on large collections when `Stream` is more appropriate -- Avoid nested `case` statements - refactor to a single `case`, `with` or separate functions -- Don't use `String.to_atom/1` on user input (memory leak risk) -- Lists and enumerables cannot be indexed with brackets. Use pattern matching or `Enum` functions -- Prefer `Enum` functions like `Enum.reduce` over recursion -- When recursion is necessary, prefer to use pattern matching in function heads for base case detection -- Using the process dictionary is typically a sign of unidiomatic code -- Only use macros if explicitly requested -- There are many useful standard library functions, prefer to use them where possible - -## Function Design -- Use guard clauses: `when is_binary(name) and byte_size(name) > 0` -- Prefer multiple function clauses over complex conditional logic -- Name functions descriptively: `calculate_total_price/2` not `calc/2` -- Predicate function names should not start with `is` and should end in a question mark. -- Names like `is_thing` should be reserved for guards - -## Data Structures -- Use structs over maps when the shape is known: `defstruct [:name, :age]` -- Prefer keyword lists for options: `[timeout: 5000, retries: 3]` -- Use maps for dynamic key-value data -- Prefer to prepend to lists `[new | list]` not `list ++ [new]` - -## Mix Tasks - -- Use `mix help` to list available mix tasks -- Use `mix help task_name` to get docs for an individual task -- Read the docs and options fully before using tasks - -## Testing -- Run tests in a specific file with `mix test test/my_test.exs` and a specific test with the line number `mix test path/to/test.exs:123` -- Limit the number of failed tests with `mix test --max-failures n` -- Use `@tag` to tag specific tests, and `mix test --only tag` to run only those tests -- Use `assert_raise` for testing expected exceptions: `assert_raise ArgumentError, fn -> invalid_function() end` -- Use `mix help test` to for full documentation on running tests - -## Debugging - -- Use `dbg/1` to print values while debugging. This will display the formatted value and other relevant information in the console. - - - -## usage_rules:otp usage -# OTP Usage Rules - -## GenServer Best Practices -- Keep state simple and serializable -- Handle all expected messages explicitly -- Use `handle_continue/2` for post-init work -- Implement proper cleanup in `terminate/2` when necessary - -## Process Communication -- Use `GenServer.call/3` for synchronous requests expecting replies -- Use `GenServer.cast/2` for fire-and-forget messages. -- When in doubt, use `call` over `cast`, to ensure back-pressure -- Set appropriate timeouts for `call/3` operations - -## Fault Tolerance -- Set up processes such that they can handle crashing and being restarted by supervisors -- Use `:max_restarts` and `:max_seconds` to prevent restart loops - -## Task and Async -- Use `Task.Supervisor` for better fault tolerance -- Handle task failures with `Task.yield/2` or `Task.shutdown/2` -- Set appropriate task timeouts -- Use `Task.async_stream/3` for concurrent enumeration with back-pressure - - - -## fluxon usage -_Fluxon UI Components_ - -@deps/fluxon/usage-rules.md - diff --git a/mix.exs b/mix.exs index d0a4daab..d44674c4 100644 --- a/mix.exs +++ b/mix.exs @@ -179,13 +179,21 @@ defmodule MusicLibrary.MixProject do [ file: "AGENTS.md", usage_rules: [ - :elixir, - :otp, - "phoenix:all", - :usage_rules, - "usage_rules:all", - # link fluxon as it's HUGE - {:fluxon, link: :at} + "usage_rules:all" + ], + skills: [ + location: ".claude/skills", + build: [ + "ui-framework": [ + description: + "Use this skill when working on the web layer and UI components using the Phoenix framework", + usage_rules: [ + :phoenix, + ~r/^phoenix_/, + :fluxon + ] + ] + ] ] ] end