Current section
Files
Jump to
Current section
Files
README.md
# LineaUI
Phoenix LiveView components with the LineOne design (Tailwind CSS v4), used the way Fluxon is
used: `use LineaUI`, one function per component, `class` that overrides the defaults, overlays
opened by id, and hooks that survive LiveView patches. No JavaScript dependencies.
> **Credits.** The code of this package is DevAction's. The visual design it reproduces derives from
> [LineOne](https://themeforest.net/search/lineone) by PiniaStudio (ThemeForest) and is credited to its
> author. See [LICENSE](LICENSE).
## Installation
One command adds the package, wires it into a Phoenix app and writes the Claude Code skill, showing the
diff for you to confirm:
```bash
mix igniter.install linea_ui
```
`mix igniter.install` comes with the [`igniter_new`](https://hexdocs.pm/igniter) archive
(`mix archive.install hex igniter_new`), or with the `igniter` dependency if the app has it. The installer
edits:
- `assets/css/app.css`: imports LineaUI's fonts and styles (plus `@source` for its templates when the file
uses `source(none)`, as Phoenix 1.8's does)
- `assets/js/app.js`: imports LineaUI's hooks and spreads them into the `LiveSocket` `hooks`
- the endpoint: a `Plug.Static` for the fonts
- `lib/my_app_web.ex`: `use LineaUI, avoid_conflicts: true` in `html_helpers/0`
- `.claude/skills/linea-ui/` and a pointer to it in `AGENTS.md`/`CLAUDE.md` (see [Claude Code skill](#claude-code-skill))
Each step skips what is already there, so running `mix linea_ui.install` again (after an upgrade, say)
changes only what the new version changed; a step that cannot find its place warns instead of failing.
Flags: `--hugeicons` (the icon set below, with its dependency and config), `--replace-daisyui` (removes the
daisyUI and heroicons plugins from `app.css`; without it they stay, with a warning, as the two stylesheets can
conflict, and Phoenix's `CoreComponents` and `Layouts` still use their classes) and `--no-skill`.
If the package was compiled before `igniter` was in the app, recompile it once:
`mix deps.compile linea_ui --force`.
### By hand
Without Igniter, `mix linea_ui.install` writes the skill and prints the lines to add. They are:
```elixir
# mix.exs
{:linea_ui, "~> 0.4"}
```
```css
/* assets/css/app.css — remove daisyUI and its theme plugins */
@import "tailwindcss";
@import "../../deps/linea_ui/assets/css/fonts.css";
@import "../../deps/linea_ui/assets/css/linea_ui.css";
```
```js
// assets/js/app.js
import {Hooks as LineaHooks} from "linea_ui"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: {...colocatedHooks, ...LineaHooks},
params: {_csrf_token: csrfToken},
})
```
```elixir
# lib/my_app_web/endpoint.ex — serves the Poppins and Inter fonts
plug Plug.Static, at: "/linea_ui", from: {:linea_ui, "priv/static"}
# lib/my_app_web.ex, inside html_helpers/0
use LineaUI, avoid_conflicts: true # keeps CoreComponents' button/input/table/label/error/header/icon and Layouts' theme_toggle
```
Dark mode is LineOne's: the `dark` class on `<html>`, remembered in `localStorage["dark-mode"]`.
To avoid a flash, put this in the root layout's `<head>`:
```html
<script>localStorage.getItem("dark-mode") === "dark" && document.documentElement.classList.add("dark")</script>
```
Errors are translated with the app's own function:
```elixir
config :linea_ui, :translate_error, &MyAppWeb.CoreComponents.translate_error/1
```
## Icons
The small glyphs the components draw themselves (the chevron of a select, the cross of a toast, the
check of a checkbox, the arrows of a pager, the tools of the text editor...) are LineOne's own SVGs
by default. An app can have them drawn with [Hugeicons](https://hugeicons.com) (the free Stroke
Rounded set) instead:
```elixir
# mix.exs: the SVGs are vendored, not compiled
{:hugeicons, github: "hugeicons/hugeicons", ref: "3365154e0ae2461fbfb6249b89649127207a4f9e",
sparse: "icons", app: false, compile: false, depth: 1}
# config/config.exs
config :linea_ui, :icons, :hugeicons # :lineone is the default
```
```css
/* assets/css/app.css, after the linea_ui.css import: turns every huge-* class into a CSS mask */
@plugin "../../deps/linea_ui/assets/css/hugeicons.cjs" {
dir: "deps/hugeicons/icons";
}
```
`dir` is the folder of the SVGs, relative to where Tailwind runs (the app's root). The setting is read when
the page renders; the components whose hooks draw glyphs in the browser (toast, date picker) get it from a
`data-icons` attribute. Restart the app after changing it. Glyphs that are artwork (the two-tone icons of the
sidebar, the arrows of tooltips and popovers, the spinner) and the carets and check marks LineOne bakes into
its CSS (native select, tom-select, checkbox, radio, switch, table sorting) stay LineOne's in both sets.
For your own icons use `<.icon name="huge-search-01" class="size-5" />` (any other class name, such as
`fa-solid fa-house`, passes through). Write the name in full in the source, never interpolated: Tailwind
only sees what is written, and a name that is not in the set renders nothing.
## Claude Code skill
The installer writes the skill (`mix igniter.install linea_ui`, or `mix linea_ui.install` on its own) and adds the
`:linea_ui_skill` compiler to the `compilers` of your `mix.exs`. After `mix deps.update linea_ui`, the next compile
regenerates the skill for the new version by itself; it does nothing when the skill is already current, when the app
has no skill, and in `:prod`. Without the compiler (`--no-skill`, or an installation by hand), refresh it with:
```bash
mix linea_ui.gen.skill
```
It writes `.claude/skills/linea-ui/` — a short `SKILL.md` index (the rules every component shares
and one line per component) and `references/<component>.md` with the attrs, allowed values,
slots and examples of each, generated from the installed components themselves, so it never
drifts from the code. Only the index is loaded up front; a reference is read when its component
is used. The installer also points `AGENTS.md`/`CLAUDE.md` (creating `AGENTS.md` when neither exists) at the
skill, once. `mix linea_ui.gen.skill --path <dir>` writes the skill somewhere else.
## Usage
```heex
<.button color="primary" phx-click={LineaUI.open("new-order")}>New order</.button>
<.modal id="new-order">
<.form for={@form} phx-submit="save">
<.input field={@form[:customer]} label="Customer" />
<.button type="submit" color="primary">Save</.button>
</.form>
</.modal>
```
```elixir
{:noreply, LineaUI.close(socket, "new-order")}
```
### Overlay layers
The overlays take their `z-index` from CSS custom properties, so an app can raise them (above another kit's
overlays during a migration, say) in its own `app.css`, after the import:
```css
:root {
--linea-z-overlay: 100; /* drawers */
--linea-z-modal: 100; /* modals */
--linea-z-popover: 1000; /* dropdowns, popovers, select and picker panels */
--linea-z-tooltip: 10003;
--linea-z-toast: 10010;
}
```
The values above are the defaults. Drawers and modals stack in the order they open: each one already open adds
10 to the next (`--linea-layer`), so a modal opened from a drawer, or a drawer opened from another, is drawn on
top, and Esc closes only the top one.
Dropdown menus, popovers and tooltips do not depend on these layers: an open one sits in the browser's top
layer (`popover="manual"` and `showPopover()`), placed with `position: fixed` coordinates from its trigger, so
no `overflow` (a table's horizontal scroll, a drawer's scrolling body) clips it and no ancestor's `z-index`,
`transform` or `filter` covers or moves it. It stays in the LiveView's DOM where it was rendered, follows
scrolling and closes when its trigger scrolls out of sight. Browsers without the Popover API get the fixed box
on `--linea-z-popover` / `--linea-z-tooltip`.
## Development
```bash
mix test && mix js.test # package
cd preview && LINEA_PREVIEW_PORT=4200 mix phx.server
```
`preview/` is a Phoenix app with one page per LineOne demo page; the original LineOne page is
served next to it at `/lineone/<page>.html`. `node scripts/shot.mjs` takes the screenshots used to
compare them. `CONVENTIONS.md` is the guide for writing components.