Current section
Files
Jump to
Current section
Files
usage_rules.md
# Sutra UI Usage Rules for LLMs
This document provides guidelines for AI assistants when working with the Sutra UI component library.
For application-level composition, start with `SKILL.md`, then use
`cheatsheets/components.cheatmd` for component examples and
`cheatsheets/forms.cheatmd` for form patterns. Use this file when editing
Sutra UI internals, adding components, or reviewing implementation quality.
## Overview
Sutra UI is a pure Phoenix LiveView UI component library with no external JavaScript/npm dependencies. Components use colocated JavaScript hooks where interactivity is needed; most are runtime hooks, with extracted exceptions documented in the hook guide.
**Requirements:**
- Phoenix 1.8+ (for colocated hooks)
- Phoenix LiveView 1.2+
- Tailwind CSS v4 (CSS-first configuration)
## Core Principles
### Reference Order
- Start with `SKILL.md` for Sutra's design philosophy and reference routing.
- Use `skill/patterns.md` for API selection and composition rules.
- Use `skill/recipes.md` for common full-interface compositions.
- Use `cheatsheets/components.cheatmd` and `cheatsheets/forms.cheatmd` for
compact examples.
- Inspect `lib/sutra_ui/*.ex` before relying on exact attrs, slots, events, or
helper function names.
### 1. CSS-First Styling
All component styling is defined in `priv/static/sutra_ui.css`. When modifying components:
- **DO**: Use CSS classes from `sutra_ui.css`
- **DO**: Add new CSS classes to `sutra_ui.css` when needed
- **DON'T**: Add inline Tailwind classes directly in component templates
- **DON'T**: Use helper functions to generate Tailwind class strings
**Pattern for class attributes:**
```elixir
# Good - uses CSS class with optional user override
class={["component-class", @class]}
# Avoid - inline Tailwind
class={["flex items-center gap-2", @class]}
```
### 2. Required ID Attributes
Components that require JavaScript hooks or stable DOM references expose an `id`
attribute. Hook-only components generally require it; progressive-enhancement
components may enable extra behavior only when an `id` is provided:
```elixir
# Good - explicit required ID
attr(:id, :string, required: true, doc: "Unique identifier for the component")
# Avoid - auto-generated IDs
attr(:id, :string, default: fn -> "component-#{System.unique_integer()}" end)
```
**Rationale:**
- Predictable IDs aid debugging
- Prevents LiveView diffing issues on reconnect
- User maintains control over their DOM
### 3. Event Naming Convention
Custom JavaScript events use the `sutra-ui:` namespace:
```javascript
// Good
this.el.dispatchEvent(new CustomEvent('sutra-ui:component-action', { detail: {...} }))
// Avoid
this.el.dispatchEvent(new CustomEvent('component-action', { detail: {...} }))
```
### 4. Attribute Types
Use appropriate attribute types:
| Attribute | Type | Notes |
|-----------|------|-------|
| `class` | `:string` or `:any` | Prefer `:string`; use `:any` when list-style class values are intentional |
| `id` | `:string` | Required when hook behavior needs a stable DOM target; optional only for progressive enhancements |
| `disabled` | `:boolean` | Boolean attributes |
| `value` | `:string` or `:any` | Depends on component needs |
| `errors` | `:list` or `:map` | Lists for field messages; maps for keyed workflows like steppers |
### 5. Global Attributes
Use `:global` for attributes passed through to rendered HTML. Phoenix already
accepts all `phx-*`, `data-*`, and `aria-*` attributes on `:global`; the
`include:` option only adds extra allowed attributes.
```elixir
attr(:rest, :global, include: ~w(form type name value), doc: "Additional HTML attributes")
```
```heex
<.button phx-click="select_page" phx-value-page="2" data-testid="next-page">
Next
</.button>
```
Do not treat examples like `phx-value-id` as a whitelist. Any `phx-value-*`
payload supported by Phoenix LiveView can be passed through component globals.
### 6. Slot Conventions
Slots should follow consistent patterns:
```elixir
# Simple content slot
slot(:inner_block, doc: "Main content")
# Named slot with attributes
slot :item, doc: "List items" do
attr(:value, :string, required: true)
attr(:disabled, :boolean)
end
```
### 7. Icons
Sutra UI does not export a general icon component or bundled icon set. Library
components use inline SVG only where they own the icon.
- **DO** use a host application's existing icon helper if it has one.
- **DO** use inline SVG for local examples when no app helper exists.
- **DON'T** invent `SutraUI.Icon`, `icon/1`, `icon_name`, or a new icon package.
- **DON'T** copy the demo app's `<.icon name="lucide-...">` helper into generic
library guidance unless the target app provides it.
Icon-only buttons and controls must have an accessible name such as
`aria-label`.
## Component Patterns
### Basic Component Structure
```elixir
defmodule SutraUI.ComponentName do
@moduledoc """
Brief description of the component.
## Examples
<.component_name id="my-component" required_attr="value">
Content here
</.component_name>
## Accessibility
- List ARIA attributes used
- Keyboard navigation support
- Screen reader considerations
"""
use Phoenix.Component
@doc """
Renders the component.
"""
attr(:id, :string, required: true, doc: "Unique identifier")
attr(:class, :string, default: nil, doc: "Additional CSS classes")
attr(:rest, :global, doc: "Additional HTML attributes")
slot(:inner_block, required: true, doc: "Main content")
def component_name(assigns) do
~H"""
<div id={@id} class={["component-class", @class]} {@rest}>
{render_slot(@inner_block)}
</div>
"""
end
end
```
### Hook-Based Component Structure
```elixir
defmodule SutraUI.InteractiveComponent do
use Phoenix.Component
alias Phoenix.LiveView.ColocatedHook
attr(:id, :string, required: true, doc: "Required for JavaScript hook")
# ... other attrs
def interactive_component(assigns) do
~H"""
<div id={@id} class="component-class" phx-hook=".ComponentHook" data-option={@option}>
{render_slot(@inner_block)}
</div>
<script :type={ColocatedHook} name=".ComponentHook" runtime>
{
mounted() {
// Initialize component
},
updated() {
// Handle LiveView updates
},
destroyed() {
// Cleanup
}
}
</script>
"""
end
end
```
Most Sutra UI hooks are runtime colocated hooks. Extracted hook exceptions must
document the generated `phoenix-colocated/sutra_ui` LiveSocket import.
## Available Components
### Foundation
- `button/1` - Buttons with variants: primary, secondary, outline, ghost, link, destructive
- `badge/1` - Status badges
- `spinner/1` - Loading indicators
- `kbd/1` - Keyboard shortcut display
### Form Controls
- `label/1` - Form labels
- `input/1` - Unified form fields with label, description, native select, and error support
- `textarea/1` - Multi-line text input
- `checkbox/1` - Checkbox input
- `switch/1` - Toggle switch
- `radio_group/1`, `radio/1` - Radio button groups
- `simple_form/1` - Styled plain form shell; use Phoenix `<.form class="form">` for changeset-backed LiveView forms
- `select/1` - Custom select dropdown (JS hook)
- `slider/1` - Range slider (JS hook)
- `range_slider/1` - Dual-handle range slider (JS hook)
- `SutraUI.LiveSelect` - LiveComponent async searchable select; render with `<.live_component module={SutraUI.LiveSelect} ... />`
- `input_otp/1` - One-time password input (JS hook)
- `file_upload/1` - LiveView upload dropzone configured with `allow_upload/3`
### Layout & Data Display
- `card/1` - Card container with header/content/footer slots
- `header/1` - Page header with title/subtitle/actions
- `table/1` - Raw styled table wrapper
- `data_table/1` - Data table with column definitions
- `skeleton/1` - Loading placeholder
- `empty/1` - Empty state display
- `alert/1` - Alert/callout messages
- `flash/1` - Phoenix flash messages
- `progress/1` - Progress bar
- `stepper/1` - Multi-step progress indicator
- `stepper_wizard/1` - Multi-step wizard shell for large forms and flows
- `timeline/1` - Chronological event list with item slots
- `tree_view/1`, `tree_item/1` - Hierarchical tree navigation (`id` enables keyboard hook)
### Navigation & Interactive
- `breadcrumb/1` - Breadcrumb navigation
- `pagination/1` - Page navigation
- `accordion/1` - Collapsible content sections
- `tabs/1` - Tab panels (JS hook)
- `dropdown_menu/1` - Dropdown menu (JS hook)
- `context_menu/1` - Right-click menu (JS hook)
- `toast_container/1`, `toast/1` - Toast notifications (JS hook)
### Advanced UI
- `avatar/1` - User avatars with fallback
- `tooltip/1` - Hover and focus tooltip (JS hook)
- `hover_card/1` - Rich hover preview (JS hook)
- `dialog/1` - Modal dialogs
- `popover/1` - Click-triggered popups
- `command/1` - Command palette with search (JS hook)
- `carousel/1` - CSS scroll-snap carousel
- `calendar/1` - Monthly calendar grid
- `marquee/1` - CSS-only scrolling content banner
- `separator/1` - Visual or semantic divider
### AI Primitives
- `response/1` - Text responses with optional reveal styles, or streamed Markdown
- `activity/1` - Safe user-facing agent progress with slot-owned row content
### Layout Helpers
- `filter_bar/1` - Filter bar for index pages
- `input_group/1` - Input with prefix/suffix
- `item/1` - Versatile list item
- `loading_state/1` - Loading indicator with message
### Navigation
- `drawer/1` - Collapsible drawer navigation
- `tab_nav/1` - Responsive server-side routed tab navigation
- `theme_switcher/1` - Light/dark theme event button
## CSS Class Naming
CSS classes in `sutra_ui.css` follow these conventions:
```css
/* Component base */
.component-name { }
/* Variants */
.component-name-variant { }
/* States */
.component-name-disabled { }
.component-name-active { }
/* Sub-elements */
.component-name-header { }
.component-name-content { }
.component-name-footer { }
```
## Testing Components
Tests are in `test/sutra_ui/`. Most existing tests use `ExUnit.Case` with
`Phoenix.LiveViewTest.rendered_to_string/1`; `SutraUI.ComponentCase` is
available when its helpers are useful:
```elixir
defmodule SutraUI.ComponentNameTest do
use SutraUI.ComponentCase, async: true
import SutraUI.ComponentName
describe "component_name/1" do
test "renders with required attributes" do
html = render_component(&component_name/1, id: "test", required: "value")
assert html =~ ~s(id="test")
end
test "applies custom class" do
html = render_component(&component_name/1, id: "test", class: "custom")
assert html =~ "custom"
end
end
end
```
## Common Mistakes to Avoid
1. **Missing required `id`** on hook-based components
2. **Inline Tailwind** instead of CSS classes
3. **Auto-generating IDs** instead of requiring them
4. **Missing accessibility attributes** (ARIA, roles, keyboard support)
5. **Not updating moduledoc examples** when changing required attrs
6. **Forgetting to add new components** to `lib/sutra_ui.ex` imports
## File Structure
```
lib/
sutra_ui/
component_name.ex # Component module
sutra_ui.ex # Main module with imports
priv/
static/
sutra_ui.css # All component styles
test/
sutra_ui/
component_name_test.exs
support/
component_case.ex # Test helpers
```
## Adding a New Component
1. Create `lib/sutra_ui/component_name.ex` with moduledoc and examples
2. Add CSS classes to `priv/static/sutra_ui.css`
3. Add import to `lib/sutra_ui.ex` in `__using__` macro
4. Update component list in `lib/sutra_ui.ex` moduledoc
5. Create tests in `test/sutra_ui/component_name_test.exs`
6. Update this file if the component introduces new patterns
## Common Recipes
### Modal Dialog with Form
```heex
<%!-- Server-controlled dialog (recommended) --%>
<.dialog id="edit-user-dialog" show={@show_edit_dialog} on_cancel="close_edit_dialog">
<:title>Edit User</:title>
<:description>Update user information below.</:description>
<.form for={@form} class="form" phx-submit="save_user">
<.input field={@form[:name]} label="Name" />
<.input field={@form[:email]} label="Email" type="email" />
<div class="flex justify-end gap-2">
<.button variant="outline" phx-click="close_edit_dialog">
Cancel
</.button>
<.button type="submit">Save Changes</.button>
</div>
</.form>
</.dialog>
<.button phx-click="open_edit_dialog">
Edit User
</.button>
```
```elixir
# In your LiveView
def handle_event("open_edit_dialog", _, socket) do
{:noreply, assign(socket, show_edit_dialog: true)}
end
def handle_event("close_edit_dialog", _, socket) do
{:noreply, assign(socket, show_edit_dialog: false)}
end
def handle_event("save_user", params, socket) do
case save_user(params) do
{:ok, _user} ->
{:noreply, assign(socket, show_edit_dialog: false)}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
```
### Data Table with Pagination
```heex
<.data_table rows={@users}>
<:col :let={user} label="Name">{user.name}</:col>
<:col :let={user} label="Email">{user.email}</:col>
<:col :let={user} label="Status">
<.badge variant={status_variant(user.status)}>{user.status}</.badge>
</:col>
<:action :let={user}>
<.dropdown_menu id={"user-actions-#{user.id}"}>
<:trigger>Actions</:trigger>
<.dropdown_item><.link navigate={~p"/users/#{user.id}"}>View</.link></.dropdown_item>
<.dropdown_item><.link navigate={~p"/users/#{user.id}/edit"}>Edit</.link></.dropdown_item>
<.dropdown_separator />
<.dropdown_item variant="destructive" phx-click="delete_user" phx-value-id={user.id}>
Delete
</.dropdown_item>
</.dropdown_menu>
</:action>
</.data_table>
<.pagination
page={@page}
total_pages={@total_pages}
on_page_change="page_changed"
/>
```
### Form with Validation Errors
```heex
<.form for={@form} class="form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} label="Name" required />
<.input field={@form[:email]} type="email" label="Email" required />
<.input
field={@form[:role]}
type="select"
label="Role"
options={[{"Administrator", "admin"}, {"Standard User", "user"}, {"Viewer", "viewer"}]}
/>
<.button type="submit" loading={@saving}>Save</.button>
</.form>
```
### Confirmation Dialog Pattern
```heex
<.dialog
id="confirm-delete-dialog"
show={@show_delete_dialog}
on_cancel="cancel_delete"
>
<:title>Delete Item</:title>
<:description>This action cannot be undone. Are you sure?</:description>
<:footer>
<.button variant="outline" phx-click="cancel_delete">
Cancel
</.button>
<.button variant="destructive" phx-click="delete_confirmed">
Delete
</.button>
</:footer>
</.dialog>
```
```elixir
# In your LiveView
def handle_event("confirm_delete", %{"id" => id}, socket) do
{:noreply, assign(socket, delete_target_id: id, show_delete_dialog: true)}
end
def handle_event("cancel_delete", _, socket) do
{:noreply, assign(socket, show_delete_dialog: false)}
end
def handle_event("delete_confirmed", _, socket) do
MyApp.delete_item(socket.assigns.delete_target_id)
{:noreply,
socket
|> assign(show_delete_dialog: false)
|> put_flash(:info, "Item deleted")
|> push_navigate(to: ~p"/items")}
end
```
### Tabs with Dynamic Content
```heex
<.tabs id="content-tabs" default_value="overview">
<:tab value="overview">Overview</:tab>
<:tab value="activity">Activity</:tab>
<:tab value="settings">Settings</:tab>
<:panel value="overview">
<.card>
<:header>
<h2>Overview</h2>
</:header>
<:content>
<p>Overview content here...</p>
</:content>
</.card>
</:panel>
<:panel value="activity">
<.card>
<:header>
<h2>Recent Activity</h2>
</:header>
<:content>
<ul>
<.item :for={activity <- @activities}>
<:title>{activity.description}</:title>
</.item>
</ul>
</:content>
</.card>
</:panel>
<:panel value="settings">
<.form for={@settings_form} class="form" phx-submit="save_settings">
<.input field={@settings_form[:notifications]} type="switch" label="Enable notifications" />
<.input field={@settings_form[:dark_mode]} type="switch" label="Dark mode" />
</.form>
</:panel>
</.tabs>
```
### Toast Notifications from LiveView
```elixir
# Show toast on successful action
def handle_event("save", params, socket) do
case save_data(params) do
{:ok, _record} ->
{:noreply,
socket
|> put_flash(:info, "Changes saved successfully")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
# Or use push_event for more control
def handle_event("export_complete", _, socket) do
{:noreply,
push_event(socket, "toast", %{
variant: "success",
title: "Export Complete",
description: "Your data has been exported to CSV.",
duration: 5000
})}
end
```
## Troubleshooting
### Component hooks not working
**Symptoms:** Hook-based components (Select, Dialog, Tabs, etc.) don't respond to interactions.
**Solutions:**
1. **Check Phoenix version** - Colocated hooks require Phoenix 1.8+:
```elixir
# mix.exs
{:phoenix, "~> 1.8"}
```
2. **Check component ID** - many hook-based components require unique IDs:
```heex
<%# Wrong - missing ID %>
<.select name="country">
<.select_option value="us" label="United States" />
</.select>
<%# Correct %>
<.select id="country-select" name="country">
<.select_option value="us" label="United States" />
</.select>
```
3. **Check rendered hook markup** - Runtime hooks are emitted with the
component. If the hook script is missing, recompile the app:
```bash
mix compile --force
```
### CSS styles not applied
**Symptoms:** Components render but look unstyled or broken.
**Solutions:**
1. **Check Tailwind v4 setup** - Verify `@source` directive in `app.css`:
```css
@import "tailwindcss";
@source "../../deps/sutra_ui/lib";
@import "../../deps/sutra_ui/priv/static/sutra_ui.css";
```
2. **Check import order** - Sutra UI CSS must come after Tailwind:
```css
@import "tailwindcss";
@import "../../deps/sutra_ui/priv/static/sutra_ui.css";
/* Your overrides come last */
```
3. **Clear cache** - After changing CSS config:
```bash
mix assets.clean
mix phx.server
```
### Form values not updating
**Symptoms:** Select or other form controls don't update the form value.
**Solutions:**
1. **Check name attribute** - Ensure `name` matches the form field:
```heex
<.select id="role-select" name={@form[:role].name} value={@form[:role].value}>
<.select_option value="admin" label="Administrator" />
</.select>
```
2. **Check phx-change** - Form needs `phx-change` for live validation:
```heex
<.form for={@form} class="form" phx-change="validate" phx-submit="save">
```
### Dialog not opening/closing
**Symptoms:** Dialog doesn't show or hide properly.
**Solutions:**
1. **Use server-controlled pattern (recommended)**:
```heex
<%!-- Control via show attribute and on_cancel event --%>
<.dialog id="my-dialog" show={@show_dialog} on_cancel="close_dialog">
...
</.dialog>
<.button phx-click="open_dialog">Open</.button>
```
```elixir
def handle_event("open_dialog", _, socket) do
{:noreply, assign(socket, show_dialog: true)}
end
def handle_event("close_dialog", _, socket) do
{:noreply, assign(socket, show_dialog: false)}
end
```
2. **Or use JS commands** - If using `show_dialog`/`hide_dialog`:
```heex
<%!-- Use correct module path --%>
<.button phx-click={SutraUI.Dialog.show_dialog("my-dialog")}>
<%!-- ID must match exactly --%>
<.dialog id="my-dialog" on_cancel="close">
```
3. **Check on_cancel handler** - Close button only renders when `on_cancel` is set
### LiveView disconnects on component interaction
**Symptoms:** Page refreshes or socket disconnects when clicking components.
**Solutions:**
1. **Check event handlers** - Ensure `phx-click` handlers exist in LiveView:
```elixir
def handle_event("my_action", params, socket) do
{:noreply, socket}
end
```
2. **Prevent default on links** - Use `phx-click` instead of `onclick` for LiveView events.
### Theme variables not working
**Symptoms:** Custom CSS variables are ignored.
**Solutions:**
1. **Check variable syntax** - Sutra UI defaults use OKLCH; other valid CSS
color formats also work if they give sufficient contrast:
```css
/* Valid, but less consistent with the default palette */
--primary: #3b82f6;
/* Preferred for Sutra UI themes */
--primary: oklch(0.623 0.214 259.815);
```
2. **Check override order** - Custom variables must come after Sutra UI CSS:
```css
@import "../../deps/sutra_ui/priv/static/sutra_ui.css";
:root {
--primary: oklch(0.65 0.20 145); /* This overrides */
}
```
### Component tests failing
**Symptoms:** Tests can't find components or hooks.
**Solutions:**
1. **Import correctly** - Test files need explicit imports:
```elixir
defmodule SutraUI.ButtonTest do
use SutraUI.ComponentCase, async: true
import SutraUI.Button
```
2. **Use render_component** - Not `render`:
```elixir
html = render_component(&button/1, variant: "primary")
```
## Migration Notes
### From Phoenix 1.7 to 1.8+
1. Update dependencies in `mix.exs`
2. Remove manual hook registrations from old `hooks.js` files
3. Keep generated `phoenix-colocated/sutra_ui` imports when using extracted hooks
### From Tailwind v3 to v4
1. Replace `tailwind.config.js` with `@source` directives in CSS
2. Update color values from HSL to OKLCH if customizing theme
3. Update any custom CSS using Tailwind's `@apply` with new syntax