alepha@docs:~/docs/framework/packages/@alepha-ui$
cat 0-overview.md | pretty
10 min read
Last commit:

#@alepha/ui

Shared Base UI and Tailwind components for Alepha apps. Edited directly; bugfixes propagate via normal dep updates.

#Installation

npm install @alepha/ui

#Overview

@alepha/ui is the shared component library for Alepha applications, built on Base UI and Tailwind, with lucide icons.

Unlike the rest of the framework, these components are meant to be edited directly: src/ ships alongside the built dist/, so you can copy a component into your app and change it, or depend on the package and let bugfixes arrive through normal dependency updates.

#Import paths

The package is sixteen modules, one subpath each. The primitives are the package root; everything heavier is a subpath of its own, so an app loads only what it renders:

ts
1import { Button, cn, useToast } from "@alepha/ui";2import { AutoForm } from "@alepha/ui/form";3import { AlephaTable } from "@alepha/ui/table";4import { AdminRouter } from "@alepha/ui/admin";

Load the stylesheet once, at your app's entry point:

ts
1import "@alepha/ui/styles.css";

A module's files are private: import from the subpath, never from a file inside it. Inside the package the rule is the opposite - every import is relative and names a concrete file - and check:conventions enforces both the module map and that @alepha/ui (the root) imports no other module.

#What's inside

Subpath What it holds
@alepha/ui The primitives (Button, Input, Card, Badge, Dialog, Sheet, Tooltip, Sidebar, and the rest), cn, useToast + Toaster, useDialog + DialogProvider, useIsMobile, TimeAgo, UserAvatar
@alepha/ui/form AutoForm, Control and the per-type renderers (ControlSelect, ControlDate, ControlUpload, ...), FormField, resizeImage
@alepha/ui/settings The settings kit: SettingsLayout, SettingsNav, SettingsSection, SettingsRow, SettingsDangerSection
@alepha/ui/table AlephaTable and its parts, paginateLocal, useTableSelection, PermissionMatrix
@alepha/ui/tree TreeView, TreeViewResizer, useTreeState, and the tree model (buildTree, flattenTree, resolveDrop, ...)
@alepha/ui/markdown MarkdownView and the diagram parsers. See the section below
@alepha/ui/shell AppShell, NavShell and its Spotlight, PlateLayout, DetailLayout, AppActions, the header buttons, ActionErrorToaster
@alepha/ui/auth AuthRouter and its pages, TurnstileWidget
@alepha/ui/account AccountRouter, $pageAccount, and the account pages
@alepha/ui/admin AdminRouter, $pageAdmin, the admin pages and AdminAnalytics
@alepha/ui/chart The recharts wrapper (ChartContainer and its parts). Opt-in
@alepha/ui/command The cmdk wrapper (Command, CommandDialog). Opt-in
@alepha/ui/calendar The react-day-picker wrapper (Calendar). Opt-in
@alepha/ui/otp The input-otp wrapper (InputOTP). Opt-in
@alepha/ui/resizable The react-resizable-panels wrapper. Opt-in
@alepha/ui/i18n/fr uiFr, the French catalogue for every key the package asks for

The five opt-in wrappers import only the root, so a heavy dependency (recharts, cmdk, react-day-picker, input-otp, react-resizable-panels) is loaded by the app that imports its subpath and by nothing else.

useDialog() throws without a <DialogProvider> above it, and toasts need a <Toaster />. AppShell mounts both. The account and admin routers do not - a second <Toaster /> under an app that already has one shows every toast twice - so a standalone mount (or AppShell with embedded) has to wrap them itself.

#Example

AutoForm pairs with useForm from alepha/react/form. The schema is the single source of truth - field types, validation, and layout hints all come from it:

tsx
 1import { AutoForm } from "@alepha/ui/form"; 2import { z } from "alepha"; 3import { useForm } from "alepha/react/form"; 4  5const profileSchema = z.object({ 6  username: z 7    .string() 8    .min(2) 9    .max(32)10    .meta({ $control: { icon: "user" } }),11  email: z.string(),12  newsletter: z.boolean(),13});14 15export const ProfilePage = () => {16  const form = useForm({17    schema: profileSchema,18    initialValues: { username: "", email: "", newsletter: false },19    handler: (values) => save(values),20  });21 22  return (23    <AutoForm24      form={form}25      icon="cog"26      title="Account profile"27      autoGroup28      disabledIfPristine29    />30  );31};

autoGroup derives field groups from the schema shape; pass groups instead to lay them out yourself.

#Settings cards

layout="row" renders the same shape as the SettingsSection / SettingsRow kit rather than an approximation of it: each group becomes a bordered card of divided rows, label and help on the left, control on the right, and the action bar is the card's own last row. Each group carries its own title and description, rendered through the same SettingsHeading the kit uses.

tsx
 1<AutoForm 2  form={form} 3  layout="row" 4  disabledIfPristine 5  groups={[ 6    { 7      title: "Name", 8      description: "How you are identified to other people.", 9      fields: ["username", "firstName", "lastName"],10    },11  ]}12/>

So a settings card whose rows are all form fields should be an AutoForm. Reach for SettingsSection directly for the rows that are not fields - an avatar picker, a read-only value, a lone button.

Add autoSave to commit on change instead, which hides the action bar. Text fields still never commit on keystroke: they commit on Enter, or on the inline tick that appears in the input once the field is dirty.

#Markdown, and diagrams in it

MarkdownView renders markdown as formatted prose. Raw HTML is always escaped to text, never promoted to markup: it renders content authored by one user to another, so a live raw tag would be an injection point on every surface built on this package.

#Spoilers

||text|| renders as a covered box that reveals on click, on Enter or on Space. Discord's syntax, and Discord's behaviour: inline markdown inside it survives (||see [the docs](/d) **now**|| hides a link and a bold word, not the text of them), a code span and a fence keep their pipes literally, and an unterminated || renders as the two characters that were typed rather than swallowing the rest of the paragraph. A pair cannot cross a paragraph break.

⚠️ It is not a security feature, and must never be described as one. The covered text is in the DOM from the first paint - the box is a colour, not an absence - and it is also in the raw markdown, in an export, in whatever an API or an MCP tool serves, and in any search snippet built from the source. It hides a plot point from a reader's eye. It does not store a secret, and the same words go in any UI that explains it.

A revealed spoiler stays revealed: re-hiding on blur would make it unreadable with a keyboard, since reading what is around it is exactly what a reader does next.

#Diagrams

A ```mermaid fence containing a **flowchart** or a **sequenceDiagram** is drawn as an SVG diagram instead of a code block. The renderer is in-house rather than mermaid itself: mermaid is roughly 500-900 kB gzip in a browser and cannot run without a DOM, because it measures text in a hidden element. Here the only imported piece is graphre (dagre in TypeScript, ~15.5 kB gzip), and only the flowchart uses it; parsing, text measurement and drawing are ours. The whole thing is one lazy chunk of about 22 kB gzip, imported only when a document actually contains a fence, so a document with no diagram pays nothing.

The two are separate pipelines that share only the text metrics and the theming. A flowchart has to be ranked, which is what graphre does; a sequence diagram has both axes decided by the source - participants left to right in declaration order, rows top to bottom in statement order - so its layout is arithmetic with no library at all.

Drawing it ourselves is what makes the diagram look like the app: the SVG uses --card, --border, --muted-foreground and --muted, so dark mode works with no second palette and no theme prop.

The syntax is mermaid's so a document stays portable to GitHub, Obsidian and anywhere else, but only a subset is drawn.

#Flowcharts

Header flowchart / graph, TD TB LR RL BT
Nodes [rect] (rounded) {diamond} ((circle)); ([ ]) [[ ]] [( )] {{ }} > ] [/ /] [\ \] ((( ))) are consumed and mapped onto those four
Edges --> --- -.-> ==> <-->; --o and --x parse, but the emitter has one end marker, so they draw the same arrowhead as --> rather than mermaid's circle and cross
Edge labels both -->|text| and -- text -->
Structure chains A --> B --> C, fans A & B --> C, nested subgraph
Text <br/> becomes a line break; quoted and backtick-quoted labels

⚠️ A node label must not contain a link operator. The statement is scanned for links before anything knows where the labels are, so a --, == or -. sequence inside [...] is read as an edge and the label is cut. A[--o] yields an empty A and a bogus node named ]; A[pre--post] truncates to pre. Quoting does NOT protect it - A["-->"] is damaged identically, because splitOnLinks runs with no quote awareness. A single hyphen (A[well-known]) is safe. There is no escape that works today, and the failure is silent: the graph still draws, with the wrong text.

#Sequence diagrams

Participants participant A, participant A as Alice, actor U (an actor draws a stick figure); declared implicitly by first use, in order of appearance
Arrows -> --> ->> -->> -x --x -) --); the double hyphen dashes the line, and the four heads (none, filled, cross, open) are drawn distinctly
Activation ->>+ / -->>- and activate / deactivate parse and are DISCARDED - activation bars are not drawn
Notes Note left of A:, Note right of A:, Note over A:, Note over A,B:
Fragments alt / else / opt / loop, nested, each closed by end
Other autonumber (including autonumber 10 10), self-messages, <br/> line breaks

A sequence diagram keeps its natural width in a horizontal scroll frame rather than scaling into the prose column. A flowchart is roughly as tall as it is wide and shrinks gracefully; a sequence diagram's width comes from its participant count with nothing to wrap, so scaling eight lifelines into a phone column puts the labels at around 5px with no way for the reader to recover. The frame is focusable and carries an accessible name, because a scroll container that cannot take focus cannot be scrolled from a keyboard.

Two constructs are refused rather than approximated. par, critical, break, create and destroy send the WHOLE diagram back to the code block: drawing par branches one under the other would assert an ordering that is false, and silently wrong output about a protocol is worse than no output. rect, box, links, link, menu and style are skipped in silence, being decorative.

#Everything else

Degrades to the code block it renders as today, silently: classDiagram, gantt and mindmaps are not drawn, style and classDef are ignored (the theme picks the colours), and a malformed diagram, a parse failure, a graph past the 200-node / 400-edge cap or a sequence diagram past the 30-participant / 300-row cap all render the plain fence rather than an error.

The font is pinned rather than inherited. Layout needs node sizes before it can place anything, and node width comes from a generated per-character width table measured against Inter at one size; inheriting the surrounding face would make text and box disagree, differently on every surface.

#Adding a component

The package owns every file in it: there is no registry to pull from and no generator to run. A new primitive is written by hand in src/core/, one file per family, named after it (DropdownMenu.tsx holds the menu and all its parts), and re-exported from src/core/index.ts. An upstream component (shadcn's, Base UI's own examples) is a fine starting point, copied in and then edited like any other file here: cn comes from ./utils.ts, and a data-slot attribute on each part keeps it addressable from styles.css.