# App shell Source: https://ui.tale.dev/docs/components/app-shell `AppShell` provides the shared theme, translation, and tooltip context. It does not draw your page header or navigation. Compose those separately with `PageLayout`, the adaptive header components, and `ContentArea`. ## Mount the providers [Installation](/docs/getting-started/installation) includes a complete entry point. A router-based application uses this composition: ```tsx import { AppShell } from '@tale/ui/app-shell'; import { RouterProvider } from '@tanstack/react-router'; // i18n and router are initialized by the host application. ; ``` | Option | What it adds | | --- | --- | | `i18n` | Required service instance, supplied to `I18nextProvider`. | | `locale={{ mode: 'client' }}` | Preference/browser locale detection and synchronization. `onChange` can load additional locale data; `defaultLocale` supplies a fallback. | | `theme` | The shared theme provider with the normal system-preference behavior. | | `children` | Your router or application content. | The full optional stack is ThemeProvider, TooltipProvider, LocaleProvider, I18nextProvider, then locale synchronization and content. Do not add a TooltipProvider around each button; the outer provider shares tooltip timing across controls. For a URL-driven locale, omit `locale` and synchronize the route's language through `LocaleSync`. Query clients, authentication, authorization, branding, and a toast viewport remain host responsibilities. `AppShell` imports the shared Inter fonts. ## Compose the page This is a labelled layout illustration. Its nested application header and controls are inert; inspect **Code** to see how the pieces compose without adding a second accessible application to this page. | Piece | Responsibility | | --- | --- | | `PageLayout` | Flex page and scroll container; wraps an optional `header` in `StickyHeader`. | | `AdaptiveHeaderRoot` | The title/action row, with optional border and responsive treatment. | | `AdaptiveHeaderTitle` | The page title, rendered as `h1`. | | `ContentArea` | Content spacing and width: `page`, `narrow`, or `panel`. | Give flex ancestors a usable height and `min-h-0` when the page should scroll inside them. `PageLayout` reserves scrollbar space to reduce sideways movement as row counts change. `ContentArea` includes clearance for mobile floating actions. Choose one divider between header and content. Use `showBorder` for a plain header; avoid adding another border when a tab strip already supplies the divider. ## Plan the mobile header Adaptive headers coordinate through `AdaptiveHeaderProvider`. The desktop root alone is not a complete mobile header: the host must render the receiving `AdaptiveHeaderSlot` in its mobile chrome. The mobile treatment removes the desktop title from the accessibility tree and presents the active title through that slot. Check a real phone-width page after composing the providers and slots. It should have one accessible `h1`, visible actions, and enough bottom clearance for any floating action bar. A desktop layout illustration cannot prove this integration for your service. ## Add breadcrumbs `HeaderBreadcrumbs` renders a labelled navigation list whose leaf is the current page's `h1`. Supply ancestor links or buttons through the crumb content and use `HEADER_CRUMB_LINK_CLASS` for their shared treatment. The component does not resolve application routes for you. Below `md`, the trail collapses toward an immediate-parent back control. `showImmediateParentOnMobile` can preserve the parent's visible name where there is room. Long titles still need testing in the full header, including the action buttons beside them. ## Add a section rail `SubPanel` provides the bordered panel a section's navigation lives in, full height beside the page. Choose its width by the rows it holds: the default 224px for single-line rows, `wide` (256px) for longer labels, and `list` (280px) when each row carries a second line of context. It is hidden below `md`, so the host needs a mobile navigation path, such as a tab strip or a list screen of its own. ```tsx import { SubPanel, SubPanelHeader } from '@tale/ui/sub-panel'; import { SubPanelRowLink, SubPanelSectionHeader } from '@tale/ui/sub-panel-list'; ``` Head the panel with `SubPanelHeader`: the section's name and at most one or two actions, in the same `h-13` row as the page header beside it, so the two bottom rules meet as one line. The caller supplies rows and scrolling. Use `SubPanelRowLink` for destinations and `SubPanelSectionHeader` for groups. For a custom row, reuse `SUB_PANEL_ROW_CLASS` and `useSubPanelRowTreatment` rather than reproducing selection styles. When the section's navigation is a fixed set of pages, `SectionNavPanel` assembles the whole panel: the `list`-width frame, the header, and one highlight that glides to the open page instead of blinking between rows. ```tsx import { SectionNavPanel, SectionNavRow } from '@tale/ui/section-nav';
``` `activeKey` names the `href` of the row the highlight rests on; each `SectionNavRow` marks itself with that key. Pass `layoutVersion` when something moves rows without changing the open page, such as a disclosure opening above it. The highlight comes from `useSlidingIndicator` (`@tale/ui/use-sliding-indicator`), which you can use directly for any control whose selection should glide: the item carries `data-indicator-key`, the hook measures it against its positioned container, and the indicator moves with a CSS transform that respects reduced motion. For a panel list of your own, draw that highlight with `SlidingHighlight` (from `@tale/ui/section-nav`) inside the list's positioned scroller, and let the rows keep only their text treatment above it (`relative z-10`, no fill of their own): ```tsx import { SlidingHighlight } from '@tale/ui/section-nav'; import { useSlidingIndicator } from '@tale/ui/use-sliding-indicator'; const { containerRef, ...indicator } = useSlidingIndicator(openKey, rowOrder);
{rows}
``` The hook applies one motion to every highlight: with nothing active it fades out where it stood, it lands on the next active item with a fade instead of sliding in from a corner, and it glides only between two places the reader can see. Take its `transitionClassName` rather than a transition of your own, so the rail, the segmented controls and the panels all move alike. ## Head a conversation page `ThreadHeader` (`@tale/ui/thread-header`) is the title row of every conversation-shaped page — a chat, a task's discussion, a customer conversation — so each reads the same way: controls before the identity (a panel toggle, a phone's back button), a 32px identity mark, the title, one line of context, and the page's actions. ```tsx import { ThreadHeader, ThreadHeaderSeparator } from '@tale/ui/thread-header'; } title={

{subject}

} meta={ <> {contact} {age} } actions={} /> ``` It keeps the page header's `h-13` height and bottom rule; pass `floating` to draw it over scrolling content without the rule, as the chat does. The title slot takes a heading or an in-place editor for the name — the page supplies its single `h1`. The row is a size container named `thread-header`, so what you put in it answers to the header's width rather than the window's: beside the rail and a section panel the header is only ~435px wide in a 768px window. Show an action's label from `@xl/thread-header:` and leave it icon-only below. Order the context line by importance, and when its items don't all fit, drop the last ones whole rather than truncating every item to a letter: a wrapping row clipped to one line (`flex-wrap` with a one-line height and `overflow-hidden`) keeps what fits and hides the rest, with each separator grouped with the item it introduces. When the page opens another item in the same place — the next chat, task or conversation — let the swap read as new content rather than a flicker with `useSwapFade` (`@tale/ui/use-swap-fade`). Give it the open item's key and put its ref on the view that shows the item: ```tsx import { useSwapFade } from '@tale/ui/use-swap-fade'; const viewRef = useSwapFade(itemId);
{item}
``` It fades opacity only, through the Web Animations API, so the view stays mounted and nothing it measures moves: a transcript restoring its scroll, a composer that keeps a draft. The first render never fades, nor does a change to nothing open, and reduced motion skips it. Pass `fromEmpty: false` when an item is born in place — a chat created by its first message is the same conversation continuing. Start the application with a `SkipLink` targeting `
`. Name each navigation landmark. Scrolling a rail to its active row should not steal the reader's initial keyboard position. Use the [list-page](/docs/patterns/list-page) or [settings-page](/docs/patterns/settings-page) pattern for the content inside this shell. # Button Source: https://ui.tale.dev/docs/components/button Use `Button` for an action on the current screen. Give it a specific verb, such as **Save changes** or **Export**, and choose its appearance according to the action's importance and consequence. For navigation, use a link with button styling. ```tsx import { Button, LinkButton } from '@tale/ui/button'; ``` ## Variants | Variant | Appropriate use | | --- | --- | | `primary` (default) | The main action in the current task. | | `secondary` | A supporting action such as Cancel. | | `ghost` | A quiet action in a toolbar or row. | | `destructive` | A consequential removal, such as deleting or revoking. | | `warning` | A consequential action that requires caution. | | `success` | A positive action or completion treatment where the meaning is clear. | | `link` | A text-style action; this remains a button unless you change its element. | Use one primary action per decision area. A destructive color does not ask for confirmation or enforce permissions; the host owns those behaviors. Use a [confirmation dialog](/docs/components/dialog) when the decision needs explanation. ## Sizes and responsive labels `default` is 36px high (`h-9`), and `sm` is 32px (`h-8`). `icon` and `icon-sm` are square controls at those heights. There is no `lg` size. The text-style `link` variant uses an automatic height instead of the fixed control box. Pass a Lucide component through `icon`; Button supplies a 16px decorative icon and spacing. `collapseLabel` visually hides the text below `sm` while preserving the accessible name. Pair it with an icon so the mobile control still has visible content. ## Show work in progress Choose **Save changes** to see a short simulated operation. `isLoading` displays a spinner, sets `aria-busy`, and disables activation. The label remains in place. The example resets after 1.5 seconds; a real screen should clear loading when its request settles. Set `type="submit"` for form submission and `type="button"` for other form actions. The component does not generally override the browser's default button type. Disable duplicate submissions in your handler as well, and keep a failed save visible near the form. ## Explain an unavailable action Focus **Publish** with the keyboard to read why it is unavailable. When `disabled` and a nonempty `disabledReason` are present, the component uses `aria-disabled` instead of native `disabled`, preserves keyboard focus, and blocks clicks, Enter, and Space. A plain disabled button leaves the tab order. The reason only applies while `disabled` is true. Describe what would make the action available; use visible nearby text when the explanation is essential to completing the task. A disabled UI control is not an authorization check. ## Navigate with a link `asChild` merges styling onto one child element, such as an anchor. Keep link behavior on that child. Tooltips and `disabledReason` are suppressed in this mode, and an anchor does not acquire native button disabling. Do not use `disabled` as a way to prevent a link from navigating. For TanStack Router destinations, `LinkButton` accepts `href`, `params`, `search`, and `prefetch`. It requires router context. For an external URL, an anchor inside `Button asChild` keeps normal browser link behavior. ## Props Native button attributes pass through. These are the component-specific choices: | Prop | Values or default | Behavior | | --- | --- | --- | | `variant` | `primary`, `secondary`, `ghost`, `destructive`, `warning`, `success`, `link`; default `primary` | Visual emphasis. | | `size` | `default`, `sm`, `icon`, `icon-sm`; default `default` | Control dimensions. | | `icon`, `iconClassName` | Lucide component; optional extra classes | Leading decorative icon. | | `isLoading` | `false` | Spinner, busy state, and activation blocking. | | `disabledReason` | Optional React content | Focusable explanation while disabled; unavailable with `asChild`. | | `fullWidth` | `false` | Fills the available width. | | `collapseLabel` | `false` | Hides text visually below `sm`. | | `asChild` | `false` | Styles one child instead of rendering a button. | | `title` | Optional string | Tooltip; also names an icon-sized Button unless `aria-label` overrides it. | | `tooltip` | Optional React content | Overrides the visible tooltip text. | | `tooltipSide` | `top`, `right`, `bottom`, `left`; default `top` | Tooltip placement. | | `tooltipOpen`, `onTooltipOpenChange` | Optional controlled state | Lets a caller control a state-announcing tooltip. | ## Accessibility and alternatives Icon-sized Buttons require `aria-label` or `title` at the type level. Text-sized buttons still need meaningful children; the type system cannot judge the label's quality. A tooltip alone is a description, not the control's name. `title` on a text Button adds a tooltip without replacing its visible accessible name. Use `IconButton` from `@tale/ui/icon-button` for a toolbar glyph: it requires `aria-label`, defaults to `ghost`, and supplies a tooltip. For a table's create action, prefer `DataTable.addAction`; for a row menu, use the table's column builders. Check keyboard focus, the disabled explanation, and the loading state in both themes. # Data table Source: https://ui.tale.dev/docs/components/data-table `DataTable` renders a table and its surrounding search, filters, create action, and paging controls. You provide the data and state transitions. The component does not fetch rows, authorize actions, or filter a backend query for you. ```tsx import { DataTable } from '@tale/ui/data-table/data-table'; import type { ColumnDef } from '@tanstack/react-table'; ``` ## Define the visible columns Each TanStack `ColumnDef` supplies an accessor or cell renderer and a header. Use a stable domain ID through `getRowId` when rows can be selected, expanded, reordered, or refreshed. Otherwise row-index identity can attach state to the wrong item after a data change. Pass a descriptive `caption`, such as **Agents in this workspace**. It becomes a screen-reader table caption. It is optional in the TypeScript interface, but a data table still needs an accessible name in the page. The builders exported from `@tale/ui/data-table/column-builders` include text, date, creation-time, selection, and action columns. Reuse them for those common shapes; use custom cells when the content requires them. ## Lead the row with a name and a glyph `TableIconCell` from `@tale/ui/data-table/table-icon-cell` is a list's icon-and-name cell: a 20px mark, 8px, then the name. The slot and the gap are the contract — a list that sets its own leaves its labels a few pixels off every other list's. Pass the icon bare. The default `tile` variant frames a monochrome glyph — a Lucide icon, an Iconify ``, a `ConfigIcon` — in a muted square and sets its size and colour for you. Use `variant="plain"` for a mark that carries its own shape and colour: a file-type icon, a vendor logo, an avatar. It keeps the same slot, so the labels still line up. `badges` places chips beside the name; they hold their width while the label truncates. `caption` adds a second line for an address the reader needs next to the name, such as an automation's slug. Leave it off for a single-line row. A string `label` gets the shared label style and truncation. Pass a node instead when the name has to be a link or a button — the cell renders it untouched, and that label owns its own truncation and `title`. Pair the column with `tableIconCellSkeleton()` — `{ lines: 2 }` when the cell renders a caption. It reserves the tile's footprint rather than the skeleton's bare-icon default, so rows do not jump when the data arrives. Reach for it in any column that leads with a glyph, not only the first one. A secondary column that frames its own mark — an audience, an owner, a state — sits its text a few pixels off the row's name; the same cell keeps the whole row on one offset. Pass `label` a node when the content is chips rather than a name. ## Wire search to the rows Activate **Search automations**, then type `Weekly`: only **Weekly digest** remains. Type a value that matches nothing to see the shared **No results found** state. Clear the query to restore all four rows. The example filters names in memory; it does not search the trigger column. `search={{ value, onChange, placeholder }}` renders and controls the search field. Your callback updates the query and the rows. For a backend search, send the new query to the backend, reset the paging cursor, and pass the resulting rows back to the table. Preserve meaningful query/filter state in the URL when the screen needs shareable results. `emptyState` describes an initially empty collection. An active query or filter with zero rows uses the table's shared no-results copy instead. If cursor pages remain, the table treats zero visible matches as still loading rather than declaring the entire source empty. ## Choose toolbar controls | Prop | Host responsibility | | --- | --- | | `search` | Own the query and apply it to the data source. | | `filters`, `dateRange` | Supply available choices, selected values, and handlers. | | `onClearFilters` | Reset the relevant filters consistently. | | `filtersContent` | Place an additional filter-side control inside the toolbar. | | `addAction` | Supply a label and a click handler, destination, or create-menu items. | | `actionMenu` | Supply bespoke primary-side toolbar content; it takes precedence over `addAction`. | When an initially empty table has no search/filter toolbar, `addAction` moves into the empty state. With toolbar controls present, it stays in the header. Pass the permission-dependent disabled state from your service; the table does not decide access. Over an empty collection that no query or filter narrows, the table disables its search box and **Filter**, because there is nothing to narrow. A query or filter that narrowed the rows to none keeps both usable so the reader can undo it, and a filter marked `widensResultSet` keeps **Filter** usable because it can reveal rows the default view hides. Neither control is disabled under the reader's focus. A search box whose last character was erased stays editable until focus leaves it. When **Clear all** or **Escape** leaves nothing to narrow, **Filter** takes the focus back and reads as unavailable (`aria-disabled`); it leaves the tab order once focus moves on. For a toolbar built outside a table, derive its `disabled` flags from `isFilterAffordanceDisabled` in `@tale/ui/filters/filter-panel`, passing the read's loading and error states so an unknown set never reads as empty. **Filter** opens with the focus on its first facet's header, so the keyboard reaches every option from there: Enter or Space expands a facet, and Tab moves between the facets and into the one that is open. A single-choice facet is a radio group (one per heading when its options are grouped) with one Tab stop, on its chosen option or else its first. The arrow keys, Home and End move the focus and the choice together. Space chooses the focused option, and on the chosen option clears it; a facet with `defaultValues` goes back to them instead. A multi-choice facet lists one checkbox per option. Escape closes the panel and gives the focus back to **Filter**. The toolbar wraps rather than overflows. When its column cannot hold the controls and the primary action on one line, the action moves to a line of its own on the right, and the search box gives up width before anything is pushed past the edge; on a phone the action takes a full-width row. A list that builds its own toolbar outside a table uses the same `DataTableToolbar` from `@tale/ui/data-table/data-table-filters`. When an `addAction` opens a dialog, pass a button ref as `addAction.triggerRef` and the same ref as the dialog's `restoreFocusRef`. The ref follows the button when creating the first row moves it from the empty state into the toolbar, so closing the dialog returns focus to the new button. When a `DataTableActionMenu` or `EntityRowActions` item opens a dialog, closing the dialog returns focus to the menu's button. The menu item disappears when the dialog opens, so the dialog returns to the button the menu names as its label. Pass a button ref as `triggerRef` and the same ref as the dialog's `restoreFocusRef` only when that button can itself unmount or move while the dialog is open. ## Decide what scrolls `stickyLayout` turns the table into a fixed frame: the toolbar, the header row and the footer hold their place while the rows scroll in the table's own scrollport. It measures itself against its parent, so it needs a bounded one — `ContentArea variant="list"` on a collection screen. Without that bound the frame collapses. On a short viewport (`short-viewport:`, under 30rem tall) the list variant lifts the bound, the frame grows with its rows and the page scrolls; the table's infinite loading then watches the page scroll instead of its own. Leave it off for a table embedded in a page that scrolls as a whole, such as a section of a settings page. Every collection screen takes it; see the [list-page pattern](/docs/patterns/list-page). A sticky frame is still only as tall as its rows, so a short list ends high on the page. Add `fillHeight` when the table is the whole screen and nothing follows it: the frame then takes the full bounded height, the count footer stays on the bottom edge, and the rows scroll inside it at any count. The empty, no-results and error states opt out of the stretch on their own — a line of copy centred in an empty frame reads worse than a frame that hugs it. Leave `fillHeight` off wherever the page continues below the table. ## Loading and errors Set `isLoading` while fetching the initial data. `approxRowCount` helps reserve space: an unknown count gives the default skeleton; a positive estimate gives skeleton rows up to the component's cap; zero allows the supplied initial empty state. Do not pass zero merely because a request has not returned yet. Pass `error` and `onRetry` for a failed query. A load failure should explain recovery rather than masquerade as an empty collection. Keep filter state when retrying so the request still matches what the reader sees. A refresh the reader did not start, such as the tab regaining focus or another session's change, replaces the error state with the loading state while it runs. Pass `onErrorFocusLost` a stable, named target around the table, such as the list's region, so that the focus **Try again** held lands there instead of on the page. Focus the reader moved elsewhere stays where it is. Rows already on screen stay through a failed refetch, and the host names that failure above the table with the same retry. When a cursor source fails while more rows may exist, set `infiniteScroll.loadFailed` until the retry: the table stops asking for more on scroll, a search that matches none of the loaded rows says it searched only those instead of showing a skeleton, and the count footer says the rest could not be loaded, never "all", even when nothing more is paged. [`useListPage`](/docs/patterns/list-page) sets the flag from its data source. While the body is a skeleton the footer counts nothing, so rows a host lists beside a first read still in flight are never called all of them. ## Pick one paging model On a collection screen, let [`useListPage`](/docs/patterns/list-page) choose: it drives the cursor model below for an in-memory set and for backend pages alike, so every list loads more on scroll and ends on the same count footer. The table's own models remain for a table outside that pattern. | Model | Configuration | | --- | --- | | All rows already loaded | `pagination.clientSide: true`; the table slices the in-memory data. | | Server pages | Supply `pagination` callbacks/counts and the one-based `currentPage`; replace rows after each request. | | Cursor loading | Supply `infiniteScroll.hasMore`, `onLoadMore`, and loading state; append returned rows in the host. | Cursor loading is automatic by default and also provides a load-more control. Supply `entityLabel: { one, other }` for count-aware footer text. `totalCount` is the unfiltered total; `displayedCount` is useful when one visible row represents multiple entities. Do not report an estimate as an exact total. ## Selection, sorting, and row actions `enableRowSelection` accepts a boolean or a per-row predicate. Pair controlled `rowSelection` with `onRowSelectionChange`, a stable `getRowId`, and a selection column. A disabled UI row is not a server-side permission boundary. The `sorting` configuration enables sorting and carries `initialSorting` with `onSortingChange`. Verify whether your host is sorting the complete local set or requesting a sorted backend set; sorting only the currently loaded page is not a global ordering. `onRowClick` receives a TanStack `Row`, so domain data is in `row.original`. `isRowClickable` can exclude aggregate or restricted rows. Keep a named keyboard-accessible link or action in the row; a pointer click handler alone is not equivalent to a navigation link. Use `onRowMouseEnter` for optional route preloading. `enableExpanding` and `renderExpandedRow` reveal inline detail. Keep the expansion control distinct from row navigation and test both with the keyboard. For the surrounding screen, use the [list-page pattern](/docs/patterns/list-page); for a short static table without this chrome, use `Table` from `@tale/ui/table`. # Dialog Source: https://ui.tale.dev/docs/components/dialog Use a dialog when someone needs to complete a focused task or make a decision before returning to the current page. `Dialog` supplies the modal structure; your application supplies its state, content, and callbacks. ```tsx import { Dialog } from '@tale/ui/dialog/dialog'; import { ConfirmDialog } from '@tale/ui/dialog/confirm-dialog'; ``` ## Open and close a dialog Choose **Invite a member**, enter a sample email, and press Escape or **Cancel**. Focus returns to the opener. **Send invitation** only closes this local example; it sends no email and performs no validation or persistence. `open`, `onOpenChange`, and `title` are required by `Dialog`. Pass a `trigger` when the opener is available in the same composition. Otherwise update `open` from your own button or menu and let the dialog capture the active opener. The title is the accessible name. `description` gives context under it. Place required instructions where they remain visible, and label every form control independently. The footer is caller-owned: dismissing action first, confirming action second. ## Explain a consequential decision Choose **Delete project**, then cancel or confirm. Confirmation updates only the example's local message. The sample consequence text demonstrates where an application would explain its own deletion rules; it is not a specification of Tale project deletion. `ConfirmDialog` renders Cancel and Confirm actions for you. `confirmText` and `cancelText` override shared translated defaults. `variant` selects `default`, `destructive`, or `warning` styling; it does not implement the underlying operation. Your `onConfirm` handler owns the request and closing behavior. Set `isLoading` while it runs: confirmation, cancellation, and close requests are blocked until it settles. On failure, keep the decision context and explain the problem. `disableConfirm` disables confirmation without disabling cancellation. For a type-to-confirm decision, set `requireConfirmPhrase`. The trimmed input must match the phrase exactly, including case. It resets when the dialog opens again. This deliberate UI step is additional confirmation, not a substitute for authorization. ## Choose the right wrapper | Component subpath | Use | | --- | --- | | `dialog/dialog` | Custom content and footer. | | `dialog/confirm-dialog` | A decision with paired cancel/confirm actions. | | `dialog/delete-dialog` | Entity-specific deletion wording. | | `dialog/form-dialog` | A form with submission state and actions. | | `dialog/view-dialog` | Read-only detail. | | `entity/entity-view-dialog` | One record's details in the shared record layout, with an edit handoff. | | `overlays/responsive-dialog` | The responsive wrapper's dialog/drawer composition. | The base Dialog itself uses a bottom-sheet layout below `md` and a centered modal above it. Its header and footer remain outside the scrollable body. Test long content on a phone; choosing a large desktop size does not remove the need for that check. ## Keep record details scannable `EntityViewDialog` uses the `lg` reading width and sizes its height to the content. The record name, status and actions form one header; the generic `title` remains available to assistive technology. Use `name` for the main heading, a short `summary` only when it adds information, and `content` for the primary reading area. Omit generic descriptions that repeat the dialog title. `facts` renders aligned label/value rows; items with `colSpan: 2` use the full width for longer content. Put pages or version history in `EntityViewSection`, and pass `identifier={{ label, value, hint? }}` for a compact copyable ID after all sections; `hint` is a caption saying what the value does and does not identify. Give a section `focusRef` when the host moves focus into it — for instance when a retry replaces the control that held focus — so focus lands on the named section rather than the dialog. Keep descriptions in one place rather than repeating them in the summary and body. ## Base dialog options | Prop | Purpose | | --- | --- | | `size` | `sm`, `default`, `md`, `lg`, `xl`, `3xl`, `entity`, or `wide`; default `default`. `entity` gives record forms a shared width and content-driven height. `EntityViewDialog` uses the wider `lg` reading measure. | | `children`, `footer` | Body and action content; either may be omitted. | | `icon`, `headerActions` | Additional header content. | | `onBack`, `backLabel` | A labelled back control for an in-dialog subview. | | `customHeader` | Replaces the visible header; the required title remains available to assistive technology. | | `hideClose` | Hides the close control; provide an accessible dismiss path unless the current operation deliberately blocks it. | | `className`, `headerClassName`, `bodyClassName`, `footerClassName` | Targeted layout adjustments. | | `restoreFocusRef` | Stable fallback when the captured opener unmounts or moves, for example a toolbar button the first row replaces. A dialog opened from a menu item needs none: it returns to that menu's button. | | `preventCloseAutoFocus` | Opt out of automatic restoration only when the caller explicitly manages the next focus target. | ## Handle lifecycle and focus deliberately The modal traps focus while open. Escape and the close control request dismissal; controlled state determines whether the request is accepted. Restore focus to a useful surviving control after close, especially when a successful action removes the original row. Content can remain mounted through a closing animation. Do not assume `open=false` immediately stops its subscriptions or requests. If hook-heavy content has a closing-lifecycle problem, move it into a separate component and conditionally mount that component; do not call hooks conditionally inside one component. Use an inline error for repairable form problems and a [toast](/docs/components/toast) for an optional completion notice. Use a persistent page or side panel when the task needs more room or the reader needs to refer to the surrounding content continuously. # Input Source: https://ui.tale.dev/docs/components/input `Input` combines an input element with a label, help text, and validation feedback. Supply a visible `label` for ordinary forms; if the surrounding UI already labels the control, provide the corresponding accessible name yourself. ```tsx import { Input } from '@tale/ui/input'; ``` ## Give the field enough context Use `label` for the field's name, `description` for context before entry, and `hint` for a short format or usage rule below the control. A placeholder is an example value, not a replacement for a label: it disappears when the person types. The component generates an ID unless you pass one. It associates the label, description, hint, and error with the input, and preserves additional IDs you supply through `aria-describedby`. ## Validate and explain the fix `errorMessage` displays an inline alert and sets the invalid state automatically; `Select` takes the same `errorMessage` and renders it the same way, under the control. `isInvalid` can also set the state without an error string, for example when a separately rendered error summary explains the problem. The component displays validation supplied by the host; it does not decide whether an email, URL, or identifier is valid for your application. For a controlled field, pass `value` and update it from `event.target.value` in `onChange`. For an uncontrolled example, use `defaultValue`. Keep the user's draft after a failed submission and explain how to repair the value. ## Choose read-only or disabled behavior | Need | Use | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Show a value that can be focused and copied but not edited | Native `readOnly`; it automatically selects the borderless read-only appearance unless you specify a variant. | | Keep the outlined appearance while preventing edits | `readOnly` with `variant="default"`. | | Make the control unavailable | `disabled`. | | Explain why it is unavailable on hover and focus | `disabled` with `disabledReason`. | `variant="readOnly"` changes styling only. Pass `readOnly` as well to prevent editing. A disabled field with a reason stays focusable and read-only with `aria-disabled`; native disabled fields leave the tab order and are excluded from normal form submission. Account for that difference when reading form values. ## Distinguish account passwords from secrets For an account sign-in field, use `type="password"` with `autoComplete="current-password"`. For a new account password, use `autoComplete="new-password"`. Explicit autocomplete lets these fields retain normal password-manager behavior. For an API key or token, use `sensitive`. A password field with no explicit `autoComplete` is also treated as sensitive. This branch uses a text input masked with CSS, `autocomplete="off"`, and password-manager opt-out hints. These reduce unwanted autofill; they do not encrypt the value or guarantee that every browser extension ignores it. Never use a real secret in a demonstration. The reveal toggle is enabled by default for password or sensitive fields. Set `passwordToggle={false}` to hide it. In the example, enter a sample value and use **Show password** and **Hide password** to inspect the two states. ## Props | Prop | Type or default | Purpose | | --------------------- | --------------------------------- | ------------------------------------------------------------------------------------ | | `label` | Optional string | Visible field name; supply another accessible name if omitted. | | `description`, `hint` | Optional React content | Context above and below the control. | | `errorMessage` | Optional string | Error text and invalid state. | | `isInvalid` | Optional boolean | Additional way to mark invalid. | | `variant` | `default`, `unstyled`, `readOnly` | Appearance; native `readOnly` automatically selects the last when no variant is set. | | `passwordToggle` | `true` | Reveal control for a password or sensitive value. | | `sensitive` | Optional boolean | Secret-entry behavior described above. | | `disabledReason` | Optional React content | Explanation while `disabled`. | | `prefix`, `suffix` | Optional React content | Fixed text inside the outlined field; do not combine with the password toggle. | | `labelInfo` | Optional React content | Additional label tooltip. | | `wideControl` | `false` | Lets the control fill a layout-owned frame instead of the settings control column. | | `wrapperClassName` | Optional string | Classes on `FieldShell`. | Other native input attributes pass through, except native `size`; `prefix` is reserved for the component's fixed addon. A prefix or suffix is visual context, not part of the submitted input value. Your host must construct and validate any combined value. ## Layout and related controls `ContentArea variant="narrow"` selects the shared settings field layout: stacked until the surface is 36rem wide, label beside control from there. See [Settings page](/docs/patterns/settings-page) before adding per-field widths. Use `Textarea` for multiple lines, `Select` for a fixed set, `SearchableSelect` for a searchable set, `JsonInput` for structured JSON, and `CopyableField` for a value primarily meant to be copied. A table search belongs in [`DataTable.search`](/docs/components/data-table), where it can stay associated with the filtered results. ## Related controls `NumberStepper` from `@tale/ui/number-stepper` edits a small whole number, often inside a sentence. It is a text field with `role="spinbutton"`: the arrow keys step by `step` (1), Page Up and Page Down by `pageStep` (10), and Home and End jump to `min` and `max`. It accepts digits only. A number in range is committed as you type; anything else is clamped when the field loses focus or on Enter, which also calls `onEnter`, and an emptied field returns to the last value. The − and + buttons stay out of the tab order and are disabled at the bounds. Name the field with `aria-label`, or with `aria-labelledby` listing the words around it and the field's own `id`, as the example does, so it reads "Keep 2 backup copies". `ToggleChipGroup` from `@tale/ui/toggle-chip-group` picks several of a few short options. It is one tab stop; the arrow keys move between chips and Space or Enter toggles one. `minSelected` ignores only turning a chip off when that would leave fewer chips on than the minimum; turning one on always counts. The example therefore always keeps one day. Give abbreviated chips an `aria-label` that contains the visible text, such as **Monday** for **Mo**, and name the group with `aria-label` or `aria-labelledby`. Both controls also make up the Custom view of the [recurrence picker](/docs/components/recurrence-picker). # Recurrence picker Source: https://ui.tale.dev/docs/components/recurrence-picker `RecurrencePicker` edits a repeat rule, such as "every 2 weeks on Tuesday and Thursday". It fits a property row: a compact trigger shows the rule, and its popover offers one-click presets plus a **Custom** view for any other rule. The component stores nothing and does no calendar arithmetic. Your host saves the rule, and supplies the day the presets are read from and, if you want them listed, the dates a rule produces. ```tsx import type { RecurrenceReference, RecurrenceRule } from '@tale/ui/recurrence'; import { RecurrencePicker } from '@tale/ui/recurrence-picker'; ``` ## Pick a repeat Open **Repeat** and choose a row. The presets are **Never**, **Daily**, **Every weekday** (Monday to Friday), and weekly, monthly and yearly rules read from `reference`: with a due date of Tuesday, September 29, they read **Weekly on Tuesday**, **Monthly on day 29** and **Yearly on Sep 29**. A preset saves at once: the popover closes, `onChange` is called once with the new rule, and focus returns to the trigger. Choosing the preset that is already saved calls nothing, and **Never** calls `onChange(null)`. `reference` is usually the item's due date, or today when there is none. Pass it as a `RecurrenceReference` — `year`, `month` (1 is January), `day`, and `weekday` as a `Date#getDay` number, where 0 is Sunday. The host works out the weekday in its own time zone; the package never reads the clock. The trigger shows the rule compactly, such as **Weekly · Tue, Thu** or **Monthly · day 30**. Its tooltip holds the full sentence. `presets` limits or reorders the one-click rows; **Never** always comes first. ## Build a custom rule **Custom** replaces the preset list with an editor in the same popover. Choose **Day**, **Week**, **Month** or **Year**, set the interval ("Every 2 weeks"), and then the weekdays, the day of the month, or the month and day. The number fields clamp to their range and one weekday always stays on, so the editor cannot hold an invalid rule and **Save** is never blocked. Custom edits are a draft for the popover session. **Save**, Enter in a number field, or Ctrl+Enter (Cmd+Enter on a Mac) calls `onChange` once. **Cancel**, Escape, or a click outside throws the draft away. **Back to presets** keeps the draft, and the list then checks whichever preset equals it — the footer stays until you save or cancel. In the example, only saving increments the counter. A monthly rule on day 29, 30 or 31 explains that shorter months use their last day, and a yearly rule on February 29 names the date it falls on in other years. The picker only says so: what a short month does with the 31st is the host's decision. `frequencies` limits the units the editor offers, and `maxInterval` (99 by default) caps the interval. ## Show the dates a rule produces Pass `nextDates` to list the next dates under the presets or the editor. It receives the draft rule and returns `CalendarDay` values (`year`, `month`, `day`); the picker shows the first three with their weekday, and adds the year when it differs from the reference year. Label the list with `nextDatesLabel`, such as **Next due dates**. Without `nextDates`, no list is shown. The picker does not step a rule itself. Time zones, daylight-saving changes and clamping the 31st to a short month depend on your storage and business rules, so the dates come from the same code that will create the next item. The examples on this page use a small UTC stepper that is good enough for a demonstration. ## Add a host option When a host option belongs with the rule, pass its saved value as `extra` and render its control with `renderExtra`. The control appears below the dates in both views and receives `{ rule, extra, setExtra }`, where `rule` is the draft rule (`null` while **Never** is chosen). Its changes are drafted like a custom rule, so `onChange(rule, extra)` saves both in one call. A preset saves with the drafted option too. Return `null` to render nothing, as the example does while no rule is chosen. The example also passes a matching `icon` and a `description`. The description is the second line of the tooltip and part of the trigger's accessible description while a rule is set. ## Explain unavailable and read-only states | Need | Use | | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The rule cannot change now, and the person should learn why | `disabled` with `disabledReason`. The trigger stays focusable with `aria-disabled`, opens nothing, keeps the rule at full contrast, and adds the reason to its tooltip and description. | | The control is unavailable and needs no explanation | `disabled` alone. The trigger is natively disabled and leaves the tab order. | | The person may see the rule but never edit it | `readOnly`. The picker renders plain text with no button; screen readers hear the full sentence, and pointer users see it on hover. | A reason should say what would make the rule editable, such as reopening the item. A disabled control is not a permission check; enforce the rule where you save it. ## Fit a narrow column The trigger always stays on one line. When the compact rule does not fit, its tail — the weekdays or the day — drops out whole rather than being cut mid-word, and the head is truncated only if it cannot fit alone. In the states example, the narrow column shows **Every 2 weeks** without **Tue, Thu**. The accessible name, the tooltip, and the description still carry the full rule. Use `variant="default"` for a 36px outlined field in a form; the default `ghost` fits a 28px property row. ## Format a rule elsewhere Use the same words outside the picker, such as on a card or in an activity line: ```tsx import { useRecurrenceFormat } from '@tale/ui/use-recurrence-format'; const format = useRecurrenceFormat(); format.sentence(rule); // "Every 2 weeks on Tuesday and Thursday" format.compact(rule); // { head: 'Every 2 weeks', tail: 'Tue, Thu' } format.day({ year: 2026, month: 10, day: 6 }, 2026); // "Tue, Oct 6" format.never; // "Never" ``` The hook formats in the language the i18n instance renders. Outside React, `formatRecurrence(rule, t, locale)` and `formatRecurrenceCompact` from `@tale/ui/recurrence-format` take a `t` bound to the `recurrence` namespace. `@tale/ui/recurrence` holds the rule helpers: `normalizeRecurrence` returns a rule with only its own keys and sorted weekdays, `sameRecurrence` compares two rules and ignores extra keys, and `matchRecurrencePreset` names the preset a rule equals. A host may keep keys of its own on a rule, such as a time zone. The picker accepts them in `value`, ignores them when it compares, and always emits a normalized rule, so add them back in `onChange`. ## Embed the editor in a form `RecurrenceEditor` is the Custom view's form body without the popover. Use it when the rule is one field of a larger form: ```tsx import { recurrenceDraft, recurrenceFromDraft } from '@tale/ui/recurrence'; import { RecurrenceEditor } from '@tale/ui/recurrence-editor'; const [draft, setDraft] = useState(() => recurrenceDraft(savedRule, reference)); ; // On submit: save recurrenceFromDraft(draft). ``` The editor keeps each unit's own setting in the draft, so switching from **Month** to **Week** and back keeps the day of the month. It renders no `
` of its own; `onSubmit` is called on Enter in a number field. Name the editor with `aria-label`, or `aria-labelledby` pointing at a visible heading. ## Accessibility - The trigger's name is the control's label followed by the visible rule, such as "Repeat: Weekly, Tue", so a voice command that reads the visible text reaches it. The full sentence, your `description` and any lock reason form its accessible description whether or not the tooltip is open. - The popover is a dialog named by `label`. It is modal by default: focus stays inside, and it returns to the trigger on close. Keep `modal` inside a Dialog, Sheet or Drawer, whose scroll lock would otherwise block wheel scrolling in the popover. Escape closes only the top layer, such as the month list. - The presets are one radio group and one tab stop. The arrow keys move between rows without choosing; Space or Enter chooses and saves. **Custom** is a button whose description is the draft rule when it matches no preset. - In the Custom view, focus starts on the checked unit. **Back to presets** returns focus to **Custom**. Each number field's name reads as its sentence, such as "Every 2 weeks" or "On February 29". The weekday chips are one tab stop with the arrow keys between them, and each chip is named by the full day name. A status message announces the settled rule after you stop changing it. - Rows are 36px tall, weekday chips and the Back button 32px, and the number field's buttons 32px wide. The pressed chip is filled, so its state does not rely on colour alone. ## Props | Prop | Type or default | Purpose | | ----------------------------- | --------------------------------------- | ------------------------------------------------------------------------ | | `value` | `RecurrenceRule \| null` | The saved rule. Extra host keys are accepted and ignored. | | `onChange` | `(rule, extra?) => void` | Called once per popover session, only when something changed. | | `reference` | `RecurrenceReference` | The day the presets and a new custom rule are read from. | | `label` | `"Repeat"` | Name prefix of the trigger and name of the popover. | | `description` | Optional string | Second tooltip line and part of the description while a rule is set. | | `icon` | Lucide icon; default `Repeat` | The trigger's icon. | | `nextDates`, `nextDatesLabel` | Optional function; `"Next dates"` | The host's upcoming dates for the draft rule; the first three are shown. | | `extra`, `renderExtra` | Optional, together | A host option drafted and saved with the rule. | | `presets` | All five | One-click rows after **Never**. | | `frequencies` | All four | Units the Custom view offers. | | `maxInterval` | `99` | Largest interval. | | `disabled`, `disabledReason` | `false`; optional React content | Unavailable trigger, focusable when a reason is given. | | `readOnly` | `false` | Plain text instead of a control. | | `variant` | `ghost` or `default`; default `ghost` | 28px property row or 36px outlined field. | | `align` | `start`, `center`, `end`; default `end` | Popover alignment. | | `modal` | `true` | Focus trapping and scroll isolation. | | `id`, `className` | Optional | Trigger ID and classes. | # Tabs and navigation Source: https://ui.tale.dev/docs/components/tabs-and-navigation Use `Tabs` to switch panels within a screen, and `LocaleTabs` to edit one value per language. Use `TabNavigation` for destinations that should support a URL, reload, and browser history. Similar visual treatments do not make these controls interchangeable. ```tsx import { Tabs } from '@tale/ui/tabs'; import { LocaleTabs } from '@tale/ui/locale-tabs'; import { TabNavigation } from '@tale/ui/tab-navigation'; ``` ## Switch local panels Choose **Skills** or **Runs**. The visible panel changes without a URL change. Focus a tab and use the arrow keys to move through the strip. The Radix-based component supplies tab roles and panel associations. Each item has a stable `value`, a `label`, and optional `content`. Use `defaultValue` for local state, or `value` and `onValueChange` when the host controls the selection. Supply an initial selection explicitly; do not assume the first item becomes selected automatically. The `underline` variant suits a section strip. The default `pill` variant suits a smaller switch inside a panel: ## Preserve panel state intentionally Inactive panel content unmounts by default. Returning to a panel can therefore reset its local form or expanded state. Set `keepMounted` to retain the mounted panels and hide inactive ones instead. Mounted hidden content can still run hooks and subscriptions. Use this option when state should survive, and make loading or polling rules explicit in the panel. If the draft must survive route changes or reloads too, keep it in a host-owned store or persistence layer; `keepMounted` only addresses tab-panel mounting. | `Tabs` prop | Default and purpose | | --- | --- | | `items` | Required `TabItem[]`: `value`, `label`, optional `content`, `disabled`, `ariaLabel`. | | `variant` | `pill`; alternatively `underline`. | | `value`, `defaultValue`, `onValueChange` | Controlled state or initial local selection. | | `listAriaLabel` | Accessible name for the tab list. | | `equalWidth` | `false`; distributes items across the available list width. | | `actions`, `toolbar` | Trailing controls or a separate row beneath the strip. | | `overflowMenu` | `false`; folds tabs that no longer fit into a menu when enabled. | | `overflowMenuLabel` | Defaults to `More`; pass a localized label in translated screens. | | `keepMounted` | `false`; retains hidden panel content when enabled. | | `className`, `listClassName`, `triggerClassName` | Targeted layout customization. | ## Edit text per language Use `LocaleTabs` when a value exists once per language, such as a notice members read or an agent's description. It builds on the underline `Tabs`: the default locale's tab comes first and is marked **(default)**, and a translation tab without text of its own shows an **untranslated** pill. Choose **Français**. Its tab carries the pill, and the empty field shows the English text as a placeholder. Type a translation: the pill disappears, and your text is still there after you visit another tab, because every locale's editor renders in its own panel and the panels stay mounted. The component stores no text and applies no fallback. Your host keeps the values, answers `hasTranslation` for each translation locale, and decides what an empty language falls back to; the demo previews that choice as a placeholder. Tabs are named in each language's own words, so a reader finds their language whatever the interface language is. | `LocaleTabs` prop | Default and purpose | | --- | --- | | `defaultLocale` | Required. The source locale, shown first and marked default. | | `locales` | The shipped locales (`en`, `de`, `fr`); pass a list to choose and order them. | | `editingLocale`, `onEditingLocaleChange` | Required controlled selection. | | `hasTranslation` | Required. `(locale) => boolean`; `false` shows the untranslated pill. Never called for the default locale. | | `hasError` | Optional. `(locale) => boolean`; `true` puts an error mark on that tab, named for screen readers. | | `renderPanel` | Required. `(locale) => ReactNode`, rendered inside that locale's tab panel. | | `onAutoTranslate`, `isTranslating` | Optional action beside the strip on translation tabs; your host performs the translation. | | `subtitle` | Optional hint beneath the strip. | | `listAriaLabel` | Accessible name for the tab list. | Give each editor an accessible name that includes its language; a panel's association with its tab does not label the field inside it. A validation message in a hidden panel is invisible, and a disabled Save button cannot say which language to fix, so answer `hasError` from your form state: the tab of every language with a problem shows the mark while the reader works in another one. ## Navigate between routes `TabNavigation` requires TanStack Router context. Its items carry `label` and `href`, and the component reads the current location. Supply resolved URLs: unlike `LinkButton`, an item has no separate `params` prop to fill a `$id` placeholder. This composition excerpt assumes your application defines the listed routes and supplies `projectId`: ```tsx ``` The default `matchMode` is `startsWith`; choose `exact` for an overview route that would otherwise also match its children. `additionalActivePaths` keeps an item active for related sibling routes. `isActive` overrides path matching, useful when several views share a pathname and differ by a search parameter. | `TabNavigation` option | Behavior | | --- | --- | | `search` on an item | Search parameters passed with that destination. | | `disabled` on an item | Keeps the label visible without navigation. | | `trailing` on an item | Optional badge or other supporting content. | | `prefetch` | Defaults to `true`; preloads on intent. | | `standalone` | Defaults to `true`; set `false` inside an existing `StickyHeader`. | | `overflow` | Defaults to `scroll`; `menu` folds the trailing destinations into a menu. | | `dirtyKeys` | A `ReadonlySet` from the editor, intersected with each item's `dirtyKeys` array to show an unsaved-change dot. | ## Check navigation on a narrow screen Do not assume a route strip always becomes a dropdown on mobile: horizontal scrolling is the default. Select `overflow="menu"` when a growing list of destinations needs a menu. Trailing route-strip actions move to the mobile floating-action area below `md`; leave room for that area in the content layout. Name local tab lists with `listAriaLabel` and route landmarks with `ariaLabel`. Name icon-only local tabs with the item's `ariaLabel`. The active route uses `aria-current`, while a local selected tab uses `aria-selected`. Route tabs can use the host accent context; local `Tabs` uses its own theme classes. Use a [settings-page editor](/docs/patterns/settings-page) to protect drafts when leaving a route. A dirty dot communicates state but does not block navigation by itself. ## Place primary navigation on a phone `BottomTabBar` from `@tale/ui/bottom-tab-bar` places icons and labels inside a floating capsule. Supply `items` with stable `key`, `label`, `icon`, `active`, and `onSelect` values, and name the navigation landmark with `ariaLabel`. Use `badgeLabel` to describe a badge in the reader’s language. The component has no router or permission logic. Place the bar in a positioned, viewport-height `.mobile-nav-shell`. It is absolutely positioned at the bottom and hidden from `md` upward. Its 60px expanded height and external safe-area gap are shared with `BottomTabBarPlaceholder`; pass the placeholder the expected `tabs` count while loading. The platform keeps clearance on scrolling content rather than shrinking the whole page. `ContentArea` consumes `--mobile-nav-content-pad`; `.mobile-nav-clearance` resets that contribution for descendants so nested frames do not count it twice. Custom bounded panes use `.mobile-nav-clearance.mobile-nav-inset`, and scroll containers can use `.mobile-nav-scroll` for focus clearance. Keep page actions above the capsule through `MobileFloatingActions`. The platform uses `useMobileKeyboard` to hide navigation and release its clearance when editable focus accompanies a software-keyboard viewport contraction. The shared bar does not detect keyboards itself. Keyboard focus alone does not hide navigation. Reduced-transparency and increased-contrast preferences use an opaque surface. Pass `compact` to show a 52px-high, at most 280px-wide capsule, 4px lower than its expanded position. Icons stay 20px and targets remain at least 44px square. Expanded labels are 11px; compact labels remain available to assistive technology. Badges and every destination stay available. The platform uses `useScrollCompact` from `@tale/ui/use-scroll-compact` with the pathname as its reset key and keyboard visibility as its disabled flag. It observes nested scroll containers inside `.mobile-nav-shell`, minimizes after downward travel, and expands on upward travel or near the top. Wire keyboard focus and selection to the returned `expand` callback. Keep expanded content clearance in both states so resizing navigation does not move the page. Geometry transitions respect reduced motion. Use `trailing` for a compact navigation control that must remain at the right of the tab strip on every screen size. The `children` action slot moves into a floating dock on mobile; `trailing` stays in the strip. # Toast Source: https://ui.tale.dev/docs/components/toast Use a toast for brief feedback after an action, such as a successful save. Keep instructions, repairable errors, and progress that someone must revisit in the page itself. A transient message is not a history of completed operations. ## Trigger a notification Choose **Save settings**. This example shows a notification only; it does not save organization settings. The title states the result, and the optional description supplies one useful detail. ```tsx import { Button } from '@tale/ui/button'; import { useToast } from '@tale/ui/use-toast'; export function SaveNoticeDemo() { const { toast } = useToast(); return ( ); } ``` In an application, call `toast` after the operation succeeds, not merely when the button is pressed. For a failed save, retain the draft and show a persistent explanation near the affected fields. ## Mount one viewport ```tsx import { Toaster } from '@tale/ui/toaster'; // Inside your AppShell, beside the router or application content: ; ``` Mount one `Toaster` for the application. `AppShell` does not mount it automatically. The examples on this site share the root viewport; their source files only trigger notifications. The toast store is shared across callers and holds one current notification. A new toast replaces the previous one. Two mounted Toasters subscribe to that same store and render duplicate messages, rather than creating two isolated queues. ## Choose the message type | Variant | Presentation and use | | --- | --- | | `default` | Neutral text, without a leading status icon. | | `success` | A check icon for successful completion. | | `warning` | A warning icon for an operation that finished only in part, such as an import that brought in some files and not others; say what is missing. | | `destructive` | An error icon for a failed operation; keep actionable recovery available elsewhere. | `position` is `top-right` by default or `top-center`. The current toast determines the viewport position. Keep placement consistent within a workflow. ## Timing and programmatic control The default duration is five seconds. Radix pauses dismissal while the notification is hovered or focused and when the window loses focus. A toast can also be dismissed by a swipe. The component does not render a close button. A `duration` can be supplied per toast. A longer duration alone does not make time-sensitive information accessible to everyone; information needed to continue should remain available in a persistent surface. See W3C's [timing-adjustable guidance](https://www.w3.org/WAI/WCAG21/Understanding/timing-adjustable.html). `useToast()` returns `toast`, `dismiss`, and the current `toasts`. Creating a toast returns its `id`, a scoped `dismiss`, and `update`. Use these handles for an operation-specific change rather than relying on a notification's position in the store. An `action` can hold a React action element, but an essential action should also have a stable home in the application. ## Group multiple actions Use `toastActionGroupClassName` for two or more actions. It places them below the copy and lets buttons and their labels wrap when space is limited or text is enlarged. A single short action can remain beside the copy. A notice taller than the viewport scrolls so its actions stay reachable. This excerpt supplies the `action` of a toast. Import `Button` from `@tale/ui/button` and provide the host's `onUpdate` callback: ```tsx import * as ToastPrimitives from '@radix-ui/react-toast'; import { toastActionGroupClassName } from '@tale/ui/toast'; action: (
) ``` `Close` dismisses the notice. `Action` performs the callback and dismisses it; give `altText` the action's accessible description. ## Review the complete feedback path Trigger two notices in quick succession and confirm only the latest remains. Check that a notice does not cover the action needed next, that keyboard focus stays where the task expects, and that the same information has a persistent location when required. Use `Input.errorMessage` for a field issue, `Alert` for a persistent page condition, and [Dialog](/docs/components/dialog) for a decision. Use inline status for a long operation whose progress and outcome need to remain visible. # Accessibility Source: https://ui.tale.dev/docs/foundations/accessibility Tale targets WCAG 2.1 AA. Shared components provide useful semantics and interaction, but a complete screen still needs a human review: labels, focus order, contrast, and recovery depend on how you compose them. ## Start with names and structure Give each page one accessible `h1`, then organize its sections without skipping heading levels. The application header can supply that title; do not duplicate it in the body or omit it because the page appears in navigation. | Surface | What you supply | | --- | --- | | Text button | A meaningful visible action label. | | Icon button | `aria-label`; an icon-sized `Button` also accepts `title` as its name. | | Input | `label`, or a deliberate accessible name when a visible label is supplied elsewhere. | | Dialog | A required title, useful context, and named controls. | | Data table | A descriptive caption and understandable column headings. | | Tabs or navigation | A name for the list/landmark and clear destination labels. | A tooltip alone does not name a control. Decorative icons beside text should be hidden from assistive technology. Validation should include readable error text, not only a red border; [`Input`](/docs/components/input) associates that feedback with its control. ## Walk the keyboard path Use Tab and Shift+Tab to move through this example. Focus should be visible and its order should follow the task. Try Enter or Space on actions and arrow keys within composite controls such as tabs and menus. Add a skip link before repeated navigation: ```tsx import { SkipLink } from '@tale/ui/skip-link'; export function PageFrame() { return ( <> Skip to main content

Example page

); } ``` The target must accept programmatic focus. `tabIndex={-1}` makes the main region focusable without adding another ordinary Tab stop. Test the first Tab after a fresh deep-link load: initial scrolling or autofocus must not unexpectedly bypass the skip link. ## Check overlays and disabled controls A modal dialog traps focus while open and should return it to a useful opener on close. `Dialog`, `Sheet` and `ResponsiveDialog` all capture the opener themselves. When the opener is a menu item, which is gone once the dialog closes, they return focus to the button that opened the menu. If any other opener unmounts or moves, supply a stable `restoreFocusRef`. Test Escape, explicit cancellation, completion, and a failed request. Nonmodal tooltips and popovers do not all share modal focus behavior; do not assume every overlay traps focus. A popover that renders as `role="dialog"` still needs a name: point `aria-labelledby` at its visible heading, as `FilterPanel` does. `disabledReason` keeps supported disabled controls reachable so people can discover why an action is unavailable. Check both the tooltip and the blocked action. Keep essential instructions visible outside the tooltip as well. ## Measure contrast in the rendered state For ordinary text, check 4.5:1 contrast; large text and meaningful non-text UI have different criteria. Use semantic foreground/background pairs, then measure the actual pairing in light and dark themes. Muted text, placeholder text, status colors, and host accents deserve particular attention. The package's token names are not a guarantee that every combination passes. Opacity, an image underneath, a hover fill, or a disabled treatment can change the result. Do not carry a ratio measured against one surface over to another. [Colours](/docs/foundations/colors) explains the available pairings. ## Respect motion and timing preferences The shared stylesheet reduces CSS animation and transition durations and disables smooth scrolling under `prefers-reduced-motion: reduce`. JavaScript-driven motion needs its own handling: use the motion library's reduced-motion support, and request instant scrolling when the preference is active. A disappearing toast must not be the only location for information someone needs to continue. A five-second timeout alone does not establish compliance with [WCAG timing requirements](https://www.w3.org/WAI/WCAG21/Understanding/timing-adjustable.html). ## Combine automated and manual checks Type checks catch missing required icon labels; lint checks catch many invalid roles and associations. Component tests use `checkAccessibility()` with axe. That helper disables contrast checking in jsdom because it has no real layout or painted colors. Before shipping a page, complete its main task with the keyboard, inspect it at a narrow width and increased text size, test both themes and reduced motion, and verify focus after dialogs and navigation. Include error and empty states. An automated axe pass is evidence about the rules it can inspect, not proof that the entire experience is accessible. # Colours Source: https://ui.tale.dev/docs/foundations/colors Choose a color token for its role: page surface, secondary text, input edge, or status. This lets the shared stylesheet adjust the appearance centrally without changing each component. Avoid introducing raw palette colors in new UI code when an existing semantic token fits. ## Compare the two themes Use the header theme menu to switch between **Light** and **Dark**. The swatches retain their class names while their values change. Check text against the surface it actually sits on, rather than judging an isolated swatch. ## Canonical semantic tokens These tokens are declared in `@theme` in `packages/ui/src/globals.css`. The surface, text, border, and accent families have dark overrides. | Utility | Intended role | | --- | --- | | `bg-bg-base` | Main component surface. | | `bg-bg-elevated` | Raised or hovered surface. | | `bg-bg-muted` | Quiet inset area. | | `bg-bg-overlay` | Backdrop over other content. | | `text-fg-base` | Primary text. | | `text-fg-muted` | Supporting description. | | `text-fg-subtle` | Metadata or captions; check the actual background. | | `text-fg-inverse` | Text on a suitable inverted fill. | | `border-border-base` | Dividers and ordinary surface edges. | | `border-border-strong` | Stronger boundary treatment. | | `border-border-input` | Form-control outline. | | `bg-accent-base` with `text-accent-fg` | Primary neutral action fill and its foreground. | `border-border-input` resolves to the strong border in light mode and the base border in dark mode. Use the input primitive for its full outline/focus treatment rather than approximating the field with a plain divider border. The canonical status tints include `bg-success-bg`, `bg-warning-bg`, `bg-danger-bg`, `text-danger`, and `bg-info-bg`. These values do not all have dark overrides. Pair them deliberately, or use a shared status component that already applies the intended treatment. ## HSL-compatible aliases The stylesheet also exposes the familiar HSL token family used by many existing components: | Utilities | Intended pairing | | --- | --- | | `bg-background` and `text-foreground` | Page surface and primary text. | | `bg-card` and `text-card-foreground` | Card surface and text. | | `bg-popover` and `text-popover-foreground` | Menu or popover surface and text. | | `bg-primary` and `text-primary-foreground` | Primary fill and text. | | `bg-secondary` and `text-secondary-foreground` | Secondary fill and text. | | `bg-muted` and `text-muted-foreground` | Quiet fill and supporting text. | | `bg-accent` and `text-accent-foreground` | Highlighted surface and text. | | `text-destructive` | Error text or a destructive affordance. | | `bg-success`, `bg-warning`, `bg-info` | Status fills, each with a corresponding `-foreground` token. | | `border-border`, `bg-input`, `ring-ring` | Ordinary border, field fill, and focus-ring role. | | `bg-sidebar`, `bg-tab` | Navigation and selected-tab surfaces. | Follow the existing file's family rather than partially converting its colors while adding an unrelated feature. The canonical and HSL names are supported vocabularies, not interchangeable values you can mix without checking the result. ## Use chart-specific colors Charts have `chart-1` through `chart-5` plus `chart-success`, `chart-failure`, `chart-warning`, `chart-neutral`, and `chart-primary`. CSS consumers can read `var(--color-chart-1)` and its siblings; Tailwind exposes the corresponding color utilities. Use labels, shapes, or line patterns as well as color to distinguish meaningful series. Verify legends and tooltips in both themes, including small text and a single low-value data point. ## Add a color only when the meaning is missing First inspect the existing tokens and neighboring components. If a new semantic role is needed, add its token and intended theme treatment centrally. Record which foreground/background pairing it supports and check rest, hover, focus, selected, and error states. A host accent is a separate runtime input used by participating components; see [Theming](/docs/getting-started/theming). Neither an accent color nor a semantic token removes the need for the rendered [accessibility review](/docs/foundations/accessibility). # Icons Source: https://ui.tale.dev/docs/foundations/icons Use Lucide for interface glyphs and the package's existing custom marks for supported brands. Choose the icon from the action's meaning, then decide whether it supplements visible text or carries the whole label. ## Choose the icon size | Class | Typical use | | --- | --- | | `size-3` | Small inline metadata. | | `size-4` | Standard button, row, and navigation icons. | | `size-5` | A more prominent standalone control or status. | | `size-6` | A feature or empty-state glyph. | Lucide's normal stroke is the starting point. `Button.icon` uses `size-4`; `IconButton` accepts `iconSize={3 | 4 | 5 | 6}` and defaults to 4. Changing the glyph size does not change the button's pointer target. ## Hide decoration, name controls ```tsx import { Button } from '@tale/ui/button'; import { IconButton } from '@tale/ui/icon-button'; import { Download, Search } from 'lucide-react'; export function FileActions() { return (
); } ``` The Export icon is decorative because the visible text already names the action. Button hides that icon from assistive technology. The search control has no visible text, so its `aria-label` supplies the name and the shared IconButton uses that label for its tooltip. `IconButton` requires `aria-label` at the type level. An icon-sized `Button` accepts `aria-label` or `title`; in that specific case, `title` supplies both an accessible name and tooltip. A generic Tooltip only supplies a description. Do not rely on its text to name an otherwise unnamed button. For a stateful action, name the next action or communicate the current state clearly. A changed glyph alone may be ambiguous; use appropriate state attributes such as `aria-pressed` where the control is a toggle. ## Reuse custom brand marks ```tsx import { IconButton } from '@tale/ui/icon-button'; import { GithubIcon } from '@tale/ui/icons/github'; export function SourceLink() { return ( } /> ); } ``` The package exposes GitHub, Claude, Google, Microsoft, Gmail, Google Drive, OneDrive, Outlook, SharePoint, Shopify, website, enter-key, and locale-flag marks through its exported icon paths. Check `packages/ui/package.json` for the exact subpath; names such as `github` and `google-drive-icon` are not uniform enough to guess safely. Before drawing a new mark, search the package and Lucide. A new shared icon should match the existing prop/ref convention, carry no embedded screen-specific label, and be tested at its intended sizes. ## Keep the target larger than the glyph An IconButton is 36px square by default or 32px with `size="sm"`. Keep that hit area even when the glyph is only 12px. On touch-heavy layouts, favor additional space around important actions and inspect neighboring targets for accidental activation. WCAG 2.2 AA [Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) uses 24×24 CSS pixels with specified exceptions. WCAG 2.1's [Target Size criterion](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html) is the 44×44 AAA criterion. These are different requirements; do not cite the latter as a 24px rule. A control drawn smaller than 24px keeps a 24px target without growing: `Checkbox` (a 16px box) and `Switch` (an 18px track) carry an invisible ring, `relative after:absolute after:-inset-[5px] after:content-['']`, that takes the tap too. Give a small control of your own the same ring, and mind that an absolute ring is laid out from inside the border — on a bordered box add the border's width to the inset. Check focus visibility in both themes and verify that an icon inside an overlay does not make Escape or focus restoration confusing. [Accessibility](/docs/foundations/accessibility) covers the full page review. # Spacing and layout Source: https://ui.tale.dev/docs/foundations/spacing-and-layout Build layout from the shared spacing scale and containers. Consistent control heights and section gaps make related fields and actions easier to scan; responsive behavior still depends on how the full page is composed. ## Align ordinary controls The ordinary app control height is 36px (`h-9`). Buttons also offer a 32px (`h-8`) dense variant; their icon-sized counterparts are square. Input has no size axis. Match neighboring controls through these APIs rather than assigning individual pixel heights. The primary app header row is 52px (`h-13`). Other primitives, such as text-style link buttons and multiline fields, have different footprints. Do not force a multiline control into the single-line height. ## Use a deliberate gap scale ```tsx import { Grid, Row, Stack } from '@tale/ui/layout'; export function SectionLayout() { return ( {/* Related actions */} {/* Two responsive groups */} ); } ``` | Gap | Typical use | | --- | --- | | `2` | Tightly related items or field content. | | `4` | Items within a section; the layout primitives' default. | | `6` | A more open group. | | `8` | Separation between sections. | The full scale is `0`, `1`, `2`, `3`, `4`, `5`, `6`, `8`, `10`, `12`. Prefer the recommended steps for new app layouts; `5`, `10`, and `12` remain for existing compositions. `Stack`, `Row`, and `Grid` accept a semantic `as` element or `asChild` for one child. Choose one, rather than combining them. `Row` does not wrap by default. Enable `wrap` for action groups that should form another line; give flexible text children `min-w-0` where truncation or wrapping must work. `Grid` accepts `sm`, `md`, `lg`, and `xl` column overrides, which follow the viewport. Inside a page, choose columns by the grid's own width instead — wrap it in an `@container` element and use container variants such as `@2xl:grid-cols-2` — because a page column beside the rail and a section panel is far narrower than the window. ## Choose the content measure | Container | Use | | --- | --- | | `ContentArea` with `page` | Normal application content; shared padding and gaps. | | `ContentArea` with `narrow` | Centered configuration column, capped at `max-w-3xl`, with settings field layout. | | `ContentArea` with `panel` | Content inside a secondary panel. | | `NarrowContainer` | A centered form column capped at 544px. | | `Container` | Generic width-constrained content, with `md`, `lg`, `xl`, or `full` sizing. | `ContentArea` defaults to `page` and `gap={6}`. Its narrow variant also declares the `FieldShell` row layout: the element becomes a `field-layout` size container, and its fields stack until it is 36rem wide, then share one control column. It includes bottom clearance for mobile floating actions; avoid replacing that clearance with ad hoc padding. ## Use Card for a bordered object ```tsx import { Card, CardContent, CardHeader, CardTitle } from '@tale/ui/card'; export function MemberCard() { return ( Members Three members have access. ); } ``` This example assumes the card sits under an `h2` section because `CardTitle` is an `h3`. Card owns its padding; the header and content slots do not add their own padding. | Option | Values and default | | --- | --- | | `padding` | `none`, `sm` (12px), `md` (16px), `lg` (20px), `xl` (24px); default `xl`. | | `radius` | `lg` (normally 8px) or `xl` (16px); default `lg`. | | `shadow` | `none`, `sm`, `md`; default `none`. | | `interactive` | Hover/focus styling; default `false`. | | `asChild` | Merge the frame onto one child element; default `false`. | `interactive` does not turn a `div` into a keyboard-operable control. For a linked or clickable card, compose it onto a real anchor, router link, or button with `asChild` and an appropriate name. The shared radius tokens are `rounded-sm` 6px, `rounded-md` 8px, `rounded-lg` from the normal 8px radius variable, and `rounded-xl` 16px. Prefer component variants over rebuilding their edge and fill styles manually. ## Check the whole layout Test a long title, longer translated labels, an open side panel, and a phone width. Keep wide tables and code scrollable inside their containers rather than forcing the entire page sideways. Check that fixed actions leave the final field reachable. Use [App shell](/docs/components/app-shell) for page composition and [Settings page](/docs/patterns/settings-page) for draft/save behavior. # Typography Source: https://ui.tale.dev/docs/foundations/typography Use `Heading` for section headings and `Text` for common body, label, caption, and feedback styles. Select the HTML element from the content's role, then choose its visual size. A small heading remains a heading; large text is not automatically a page title. ## Compare the text scale Inspect the headings and supporting text in both themes. Use ordinary body text for instructions someone needs to read, and reserve captions for secondary metadata rather than shrinking important information to fit. ## Choose the heading level separately ```tsx import { Heading } from '@tale/ui/heading'; export function MemberSectionTitle() { return Members; } ``` | Prop | Values | Default | | --- | --- | --- | | `level` | `1` through `6`; selects the heading element. | `2` | | `size` | `xs`, `sm`, `base`, `lg`, `xl`, `2xl` | `base` | | `weight` | `medium`, `semibold`, `bold` | `semibold` | | `tracking` | `tighter`, `tight`, `normal` | Unset | | `truncate` | Adds an ellipsis and allows a flex child to shrink. | `false` | Use one accessible `h1` for the page. In an application layout, the header title or breadcrumb leaf usually supplies it, so body sections begin at `h2`. A settings rail label is navigation, not a replacement for the heading. `CardTitle` renders an `h3`. Use it under an `h2` section; if that is the wrong depth, choose an explicit `Heading` inside the card instead of accepting a skipped level. ## Pick a text variant ```tsx import { Text } from '@tale/ui/text'; export function DigestDescription() { return Send a weekly summary to the selected members.; } ``` | Variant | Treatment | | --- | --- | | `body` (default) | Primary text, `text-sm`. | | `body-sm` | Primary text, `text-xs`. | | `muted` | Supporting text, `text-sm`. | | `caption` | Supporting metadata, `text-xs`. | | `label`, `label-sm` | Medium-weight primary text, at small or extra-small size. | | `code` | Monospace, `text-xs`. | | `error`, `error-sm` | Destructive-color feedback, at small or extra-small size. | | `success` | Medium-weight success feedback, `text-sm`. | `Text` defaults to a paragraph. Its `as` prop accepts `p`, `span`, `div`, `label`, or `h3`; `align` accepts `left`, `center`, or `right`. Changing to `label` does not associate the text with a control by itself: use a form component's label API or provide a valid association. Use truncation for compact navigation or metadata only when the full content remains discoverable. Avoid truncating instructions, errors, or the only meaningful name of a record. ## Load the intended font `AppShell` imports Inter at weights 400, 500, 600, and 700 from the shared `fonts.ts` module. The font assets are self-hosted through the application build. Latin weights 400 and 500 are preloaded; a metric-adjusted Arial fallback reduces layout movement while the font loads. Preloading reduces delay but does not guarantee that a fallback is never visible. If text uses the fallback after loading, inspect the emitted font requests and confirm the app entry mounts `AppShell`. Do not add an unrelated remote font import to hide a broken asset path. Monospace uses the system stack: `ui-monospace`, SFMono-Regular, Menlo, Monaco, Consolas, then monospace. ## Use the marketing scale on public pages `@tale/marketing-ui/section-heading` uses the same typeface with larger, medium-weight display styles. Its display size defaults to `h1`; section and subsection sizes default to `h2`. Choose the semantic `as` level explicitly when nesting it. See [Marketing UI](/docs/marketing-ui/overview) for the surrounding layout. # Internationalization Source: https://ui.tale.dev/docs/getting-started/i18n A screen has two kinds of text. Your application supplies its own titles, field labels, and actions through props or children. Shared components translate the copy they own, such as a dialog's close control or an input's password toggle. Neither kind is translated automatically from an English string. An empty `Button` has no useful name. Pass visible text, or name an icon-only control explicitly. ## Initialize the catalogs The [first-control example](/docs/getting-started/installation) shows a complete minimal setup. A service with its own YAML catalogs uses this configuration, with paths relative to that service's i18n module: ```ts import { initServiceI18n } from '@tale/ui/i18n/init-service'; import { uiMessages } from '@tale/ui/i18n/messages'; import enMessages from '../../messages/en.yml'; import deMessages from '../../messages/de.yml'; import frMessages from '../../messages/fr.yml'; import globalMessages from '../../messages/global.yml'; export const i18n = initServiceI18n({ bundles: { en: enMessages, de: deMessages, fr: frMessages }, regional: import.meta.glob('../../messages/*-*.yml', { eager: true, import: 'default', }), global: globalMessages, packages: [uiMessages], }); ``` Register the YAML Vite plugin from Installation. The glob must remain a literal at the call site. Add `marketingUiMessages` from `@tale/marketing-ui/i18n/messages` to `packages` when rendering that package. | Input | Purpose | | --- | --- | | `bundles` | The service's complete base-locale namespace trees. | | `packages` | Shared catalogs, merged before the service's catalogs. Later package entries override earlier ones per key. | | `regional` | Sparse regional overrides, such as `de-CH`, discovered by the glob. | | `global` | Locale-neutral keys shared across locales, such as product names. | The merge is deep: overriding one service key preserves sibling keys from the package. A service override wins over the corresponding package key. Use that ability deliberately; changing a shared action label can affect multiple controls. ## Read a translated label ```tsx import { Button } from '@tale/ui/button'; import { useT } from '@tale/ui/i18n/client'; export function SaveButton() { const { t } = useT('common'); return ; } ``` `useT` wraps `react-i18next` for a namespace. Services can expose a typed wrapper narrowed to their own namespace catalog. Keep interpolation variables separate from translated prose; never assemble a sentence from separately translated fragments. ICU handles counts and interpolation: ```yaml members: count: "{count, plural, one {# member} other {# members}}" ``` Read it with `t('count', { count })` in the `members` namespace. Supply equivalent native plural forms in each locale and test zero, one, and multiple items. ## Choose one locale source For preference-driven apps, use `AppShell` with `locale={{ mode: 'client' }}`. Its locale provider detects the saved preference and browser language; the bridge synchronizes i18n. For URL-driven sites, omit that option and mount `LocaleSync` from the root route with the route's locale. Do not run two competing locale sources. This component documentation site deliberately pins `LocaleSync` to English: its English pages and examples do not have translated routes, even though its chrome catalogs contain EN, DE, and FR keys. ## Add or change copy 1. Find the component that owns the text. Put reusable control copy in the package catalog; put screen-specific copy in the service catalog. 2. Update `en`, `de`, and `fr` together. Use `de-CH` only where a regional override is needed, rather than copying the entire German catalog. 3. Write each locale from the intended meaning. Tale uses `du` in German and `tu` in French. Preserve values, interpolation keys, and conditions while making the sentence natural. 4. Check the actual state that renders the key: loading, disabled, success, error, or empty. Inspect long labels on a narrow screen. ## Check catalog wiring Services use `defineI18nTests` from `@tale/ui/i18n/tests`. Its `packageCatalogs` option lists the package message directories merged at runtime. These directories inform missing-key checks; package-owned tests remain responsible for orphan keys inside the package. If a component shows a dotted key, first check its namespace and spelling, then the runtime `packages` list and service override. If tests alone report a shared key missing, compare `packageCatalogs` with the runtime configuration. Run the service's tests after a catalog change; key parity cannot judge native grammar or whether the label fits its control. # Installation Source: https://ui.tale.dev/docs/getting-started/installation The packages ship React and TypeScript source. A working installation needs a compatible source-consuming build tool, the shared stylesheet, the package message catalog, and `AppShell`. The examples below use Tale's Bun, Vite, React 19, and Tailwind 4 setup. ## Add the package Inside this monorepo, add the workspace dependency to the service that uses it and run `bun install` from the repository root: ```json { "dependencies": { "@tale/ui": "workspace:*" } } ``` Add `"@tale/marketing-ui": "workspace:*"` only if that service renders marketing components. Existing services may already have these dependencies; check their `package.json` before changing it. ### Consume it from another repository The release workflow publishes each package as a root-level Git snapshot. For a trial installation: ```bash bun add 'github:tale-project/tale#dist/ui' react@19 react-dom@19 tailwindcss@4 bun add --dev vite @vitejs/plugin-react @tailwindcss/vite typescript @types/react @types/react-dom ``` For marketing components, also install `github:tale-project/tale#dist/marketing-ui`. That package requires `@tale/ui` as a peer. For a reproducible release, replace the moving branch with a published `ui-v` tag and, when used, its matching `marketing-ui-v` tag. `` is a placeholder, not a tag to install literally. Commit the resulting lockfile. To deliberately refresh a moving Git dependency, use `bun install --force` and review the lockfile changes. The packages' export maps point at TypeScript source. Run Vite through Bun (`bun --bun vite` and `bun --bun vite build`), as the Tale service scripts do. Use TypeScript's bundler module resolution and React JSX transform. Inside Tale-project repositories, select the appropriate shared `tsconfig` family rather than adding workspace-specific compiler options. ## Load Tailwind and YAML This Vite configuration handles React, Tailwind, and the YAML files imported by the translation catalogs: ```ts // vite.config.ts import { yamlImports } from '@tale/ui/vite/yaml'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [yamlImports(), react(), tailwindcss()], }); ``` Load the application stylesheet from your entry module: ```css /* src/globals.css */ @import '@tale/ui/globals.css'; ``` For a marketing site, use `@import '@tale/marketing-ui/globals.css';` instead. Each stylesheet declares the package source that Tailwind must scan. Keep your own application source within Tailwind's scan as well. ## Render a first control This complete client entry assumes `index.html` contains `
`. It uses the package's translations with empty service catalogs; replace those catalogs when you add application-specific copy. ```tsx // src/main.tsx import { AppShell } from '@tale/ui/app-shell'; import { Button } from '@tale/ui/button'; import { initServiceI18n } from '@tale/ui/i18n/init-service'; import { uiMessages } from '@tale/ui/i18n/messages'; import { Input } from '@tale/ui/input'; import { createRoot } from 'react-dom/client'; import './globals.css'; const i18n = initServiceI18n({ bundles: { en: {}, de: {}, fr: {} }, regional: {}, packages: [uiMessages], }); const root = document.getElementById('root'); if (!root) throw new Error('Missing root element'); createRoot(root).render(
, ); ``` `AppShell` mounts the translation and tooltip providers and imports Inter. `theme` enables light, dark, and system preferences. Client locale mode detects a saved or browser language. A URL-driven site should omit client locale mode and synchronize its route language instead; see [Internationalization](/docs/getting-started/i18n). Start the development server with `bun --bun vite`. The input should have a visible outline, the button should be 36px high, and its tooltip should appear on keyboard focus or hover. The button deliberately has no save callback. ## Troubleshoot setup | Symptom | Check | | --- | --- | | Components render without styling | Import the stylesheet from the entry that Vite actually loads, and enable the Tailwind plugin. | | YAML import or parse errors | Register `yamlImports()` before importing the service or package catalogs. | | A label renders as a dotted key | Include `uiMessages` in `packages`; add `marketingUiMessages` when rendering marketing components. | | Theme controls do nothing | Mount `AppShell` with `theme` and import the themed stylesheet. | | A package import fails | Check its public export path and the installed snapshot; avoid private filesystem imports. | | A router link fails outside routing context | Mount your router before using `LinkButton` or `TabNavigation`; the first-control example above needs no router. | PWA and Storybook support have additional optional peers. Install them only when using those exported integrations, following the package's `peerDependencies` and `peerDependenciesMeta`. # Introduction Source: https://ui.tale.dev/docs/getting-started/introduction These are the MIT-licensed React components behind [Tale](https://tale.dev), the open-source workspace for teams and AI agents. Use them to build interfaces in Tale or in your own application. You do not need a running Tale instance to use the packages. Use `@tale/ui` for application screens: forms, tables, dialogs, navigation, and the providers behind them. Use `@tale/marketing-ui` for public websites: page sections, calls to action, site navigation, and product illustrations. The marketing package builds on the application package, so you install both when building a marketing site. ## Start with your task | You want to… | Start here | | --- | --- | | Render your first Tale component | [Installation](/docs/getting-started/installation) | | Add an action or edit a value | [Button](/docs/components/button) and [Input](/docs/components/input) | | Build a searchable collection | [Data table](/docs/components/data-table), then [List page](/docs/patterns/list-page) | | Build configuration with Save and Discard | [Settings page](/docs/patterns/settings-page) | | Add dark mode or translate component labels | [Theming](/docs/getting-started/theming) and [Internationalization](/docs/getting-started/i18n) | | Build a public product page | [Marketing UI](/docs/marketing-ui/overview) | ## Choose the package by the screen's purpose Application screens favor compact controls, readable status, and predictable placement for repeated tasks. Marketing pages use larger headings, wider sections, and pill-shaped calls to action. This site's documentation uses the application components; its [home page](/) uses marketing components. | Package | Typical building blocks | Stylesheet | | --- | --- | --- | | `@tale/ui` | `Button`, `Input`, `DataTable`, `Dialog`, `PageLayout` | `@tale/ui/globals.css` | | `@tale/marketing-ui` | `MarketingButton`, `SectionHeading`, feature sections, `DemoShell` | `@tale/marketing-ui/globals.css` | The marketing stylesheet imports the application stylesheet. Load the stylesheet for your site once; you do not need both imports. Both packages ship TypeScript source. Import documented package subpaths, such as `@tale/ui/button`, rather than reaching into another workspace's `src` directory. The package's `exports` map defines which paths consumers can use. ## Use the examples Each **Live example** renders package components. Choose **Code** to inspect the source for that example, and **Hide code** to close it. You can change inputs, open dialogs, and switch tabs without connecting a Tale backend. Examples demonstrate UI behavior with local sample data. They do not send invitations, connect providers, or save organization settings. Page-layout and marketing-window examples are labelled illustrations: their contents are deliberately inert so a second application header and its controls do not enter the page's keyboard or screen-reader navigation. Use the linked component examples for interaction. The guides and examples are currently in English. The packages support translated interface text; [Internationalization](/docs/getting-started/i18n) explains how to supply it in your application. ## Keep business rules in the service The shared package owns reusable presentation and interaction. Your service owns authorization, network requests, validation rules specific to its domain, and persistence. For example, `DataTable` displays the rows you pass; it does not fetch organization members. `ConfirmDialog` asks for a decision; your callback decides whether and how to delete a record. Pass screen-specific titles and labels as props. Shared labels such as a dialog's close control come from the package catalog. Before introducing a new component, check the existing exports and [composition patterns](/docs/patterns/list-page). A service wrapper around a shared component is the appropriate place for a backend query or permission check. # Theming Source: https://ui.tale.dev/docs/getting-started/theming Semantic color tokens let a component keep the same classes in light and dark themes. Mount the shared theme provider, then choose colors by their purpose rather than their current appearance. ## Enable theme switching [Installation](/docs/getting-started/installation) mounts the provider with ``. Its default choice is `system`; `ThemeProvider` resolves the operating system preference and applies `.dark` to the document when needed. An explicit choice is saved under `tale-theme` in local storage. ```tsx import { ThemeSwitcher } from '@tale/ui/theme-switcher'; export function AppearanceControl() { return ; } ``` Try the theme control in this page's header. Choose **Dark**, then **Light**, and compare the swatches below. Choose **System** to follow the operating system again. `ThemeSwitcher` defaults to a menu. Its `segmented` variant presents the choices inline as one keyboard tab stop: use the left and right arrow keys to select **System**, **Light**, or **Dark**. Both variants provide 44px touch targets and must live inside the provider tree. The selected segment owns its background and border, so its highlight stays aligned if a host enlarges the controls. Use the component’s own sizing and colors rather than overriding its internal buttons or adding a separate selection marker. ## Read the choice or the displayed result ```tsx import { useTheme } from '@tale/ui/theme'; export function ThemeSummary() { const { theme, resolvedTheme, setTheme } = useTheme(); return ( ); } ``` `theme` is `light`, `dark`, or `system`. `resolvedTheme` is the resulting `light` or `dark`. Use the resolved value when selecting an image or chart palette. Querying `prefers-color-scheme` independently would ignore an explicit user choice. The Tailwind `dark:` variant follows the `.dark` class. The provider also updates CSS `color-scheme` and briefly suppresses transitions during a switch. This avoids animating every color on the page at once. ## Choose a token family consistently The stylesheet exposes two supported families: | Family | Example surface | Example secondary text | | --- | --- | --- | | Canonical semantic tokens | `bg-bg-base text-fg-base border-border-base` | `text-fg-muted` | | HSL-compatible aliases | `bg-background text-foreground border-border` | `text-muted-foreground` | Follow the surrounding component's vocabulary. Both resolve through the shared stylesheet; neither requires a second set of light and dark classes at every call site. [Colours](/docs/foundations/colors) maps common tokens to their uses. When extending the token set, define the light and dark values together. Check the actual foreground/background pairing, including hover, disabled, error, and focus states. A semantic name does not by itself prove sufficient contrast. ## Apply a host accent `AccentColorProvider` supplies a runtime accent to components that opt into it, including route-tab indicators and selected sub-panel rows. They set it as the open item's text and icon color over a tint of itself (about 15%), so pass a shade that reads as text there: 4.5:1 on the page and on that tint, in each theme. A raw brand pick often falls short. `#056CFF`, for example, reads 4.45:1 on the light page and 3.6:1 on its tint, while the same hue at `#0057d1` on light and `#3388ff` on dark reads on both: ```tsx import { AccentColorProvider } from '@tale/ui/accent-color'; import { useTheme } from '@tale/ui/theme'; import type { ReactNode } from 'react'; // The brand pick #056CFF, as a shade that reads as text in each theme. const ACCENT_TEXT = { light: '#0057d1', dark: '#3388ff' } as const; export function BrandAccent({ children }: { children: ReactNode }) { const { resolvedTheme } = useTheme(); return ( {children} ); } ``` The host supplies `children` and a validated shade for each theme; Tale's platform derives both from an organization's pick. Without the provider, participating components use their default treatment. The context does not recolor every component or replace all theme tokens; for example, `Tabs` uses its stylesheet classes directly. Keep organization lookup and branding policy in the service. Review an accent on both themes before using it for a meaningful indicator. ## Keep browser assets in sync `ThemeAssets`, mounted inside the theme tree, updates the favicon and theme-color metadata to match an explicit theme choice. Your HTML must provide the elements it updates: `favicon-light`, `favicon-dark`, `theme-color`, and `theme-color-dark`. If the page changes theme but its browser tab icon does not, inspect those IDs and the asset URLs. If only part of a page changes, look for hardcoded colors or an extra theme provider. Use the [accessibility checks](/docs/foundations/accessibility) to verify contrast and reduced-motion behavior in the rendered page. # Marketing UI overview Source: https://ui.tale.dev/docs/marketing-ui/overview `@tale/marketing-ui` supplies the public website's visual language on top of `@tale/ui`: site navigation, section headings, calls to action, content panels, and product-demo frames. Use it for discovery and explanation pages; use application components for repeated work inside the product. ## Compare the primitives The example combines `SectionHeading`, `MarketingButton`, `MarketingPanel`, and `MarketingCard`. The buttons demonstrate styling only. The cards have no `to` destination, so they are static content rather than links. Marketing surfaces use near-white paper in light mode and charcoal in dark mode, with fine dividers and distinct illustration palettes: mint projects, coral agents, violet chat, gold automations, and sky knowledge. Fluid Inter headings and rounded rectangular calls to action share one scale across the public sites. The application package supplies the underlying controls and utilities; use the marketing `surface-site` tokens for the surrounding page. Compose the page around the content: a split introduction can pair a large heading with a shorter description, while an asymmetric guide grid can give the starting point more space. Use `SiteContainer` to keep their edges aligned. Let columns stack on narrow screens, preserve readable controls, and simplify a decorative illustration before shrinking its text. ## Install the layer Install both packages and follow [Installation](/docs/getting-started/installation) for source-consuming Vite setup. Load one stylesheet: ```css @import '@tale/marketing-ui/globals.css'; ``` That file imports `@tale/ui/globals.css` and adds the marketing vocabulary. Include both `uiMessages` and `marketingUiMessages` in `initServiceI18n.packages` so shared controls and demo-window labels resolve. ## Choose a building block | Area | Exported subpaths | | --- | --- | | Site navigation and frame | `site-header`, `site-footer`, `site-container` | | Core page composition | `button`, `link`, `external-link`, `cta-group`, `card`, `panel`, `stack`, `page-section`, `section-heading`, `reveal` | | Feature sections | `feature-hero`, `feature-capability`, `feature-steps`, `feature-faq`, `feature-cta`, `related-pages`, `docs-links` | | Comparison and discovery | `marketing-section`, `tier-card`, `compare-table`, `segmented-radio`, `logo-cloud-section`, `progress-bar` | | Product illustrations | `demo-shell`, `demo-chrome`, `demo-stage`, `demo-tour-row`, `demo-tour-section`, `demo-typing-text`, `demo-stream-text`, `use-demo-timeline` | | Setup | `globals.css`, `routing`, `entrance`, `i18n/messages`, `tailwind-preset` | Import from the named package subpaths. Keep page-specific claims, translated titles, destinations, and scenarios in the host. A reusable feature section should not learn your service's pricing or permissions. ## Make headings and cards semantic `SectionHeading` takes a required `title` plus optional description and eyebrow. `size` chooses `display`, `section`, or `subsection`; `align` is `center` by default or `start`. Display defaults to `h1`, while section and subsection default to `h2`. Use `as` for the correct nested heading level; visual size does not determine the document outline. `MarketingButton` offers `tone="primary"` or `secondary` and `size="default"` or `lg`. Use `asChild` around an appropriate link when the action navigates. `MarketingCard` is static without `to` and becomes an internal link with it. Its surface is `plain` by default, with `raised` and `inset` alternatives. Do not put nested competing links inside a card that is itself a link. ## Connect host routing Internal marketing links use the link component supplied by `MarketingRouterProvider`. Without that provider, the fallback is TanStack Router's Link, which still requires router context. ```tsx import { MarketingRouterProvider, type MarketingLinkComponentProps, } from '@tale/marketing-ui/routing'; function SiteLink({ to, activeProps: _activeProps, ...props }: MarketingLinkComponentProps) { return ; } export function MarketingRoot({ children }: { children: React.ReactNode }) { return {children}; } ``` This plain-anchor adapter works without a client router and deliberately does not apply active-route styling. A localized router adapter should resolve locale prefixes and active styles in the host. Keep the package's `to` value a site path; do not duplicate locale routing inside each card or call to action. ## Present product windows as illustrations `DemoShell` places content inside the product window frame; `DemoStage` supplies the surrounding presentation. Its `domain` prop defaults to the depicted navigation and coordinates the illustration palette with the stage. Use an explicit override when the story differs from the navigation, such as an agent workspace inside chat or a governance approval inside Automations. The stage has no decorative accent line and stays still; the scene owns meaningful motion. The window is one labelled illustration: `role="img"` with an accessible description, and an `aria-hidden`, `inert` payload. Its demo copy is excluded from search snippets with `data-nosnippet`. That treatment lets a marketing page show real components without exposing a second application navigation or a misleading form to keyboard users. Supply a description of what the illustration demonstrates, and keep the explanation outside it complete. If the reader must interact, build an explicitly interactive example instead of placing required controls inside an inert frame. `Reveal` coordinates entrances and section reveals. Keep scroll reveals opacity-only so they do not move the page; reserve small vertical entrances for content appearing on the initial load. Hover and press feedback should reinforce an action without shifting neighboring content. `useDemoTimeline` coordinates a product illustration's beats, plays once, and pauses while the tab is hidden. Server rendering, reduced motion and navigation revisits show the completed state. Build a complete static composition first, then reveal its parts without changing the space the frame reserves. Inspect both themes at phone and desktop widths, and repeat with reduced motion enabled. To inspect the broader frame catalog locally, run `bun run --filter @tale/marketing-ui storybook`. For working application controls, continue with [Button](/docs/components/button), [Dialog](/docs/components/dialog), or [Data table](/docs/components/data-table). # List page Source: https://ui.tale.dev/docs/patterns/list-page A list page helps someone find an item, inspect it, or create another. Start with one page title and a `DataTable` that owns the collection toolbar. Keep data access, permissions, and filter state in your service. ## Inspect the composition This frame is an inert layout illustration. The header contains the title; the table toolbar contains search and the create action. For a working search interaction, use the [Data table example](/docs/components/data-table). The following excerpt assumes the host supplies `columns`, the loaded `rows` (`undefined` while the first request is pending), and `openCreate`: ```tsx const list = useListPage({ dataSource: { type: 'query', data: rows }, pageSize: DEFAULT_LIST_PAGE_SIZE, search: { fields: ['name'], placeholder: 'Search automations' }, getRowId: (row) => row.id, entityLabel: { one: 'automation', other: 'automations' }, }); return ( Automations } > ); ``` Import these components from their `@tale/ui` subpaths — `useListPage` and `DEFAULT_LIST_PAGE_SIZE` come from `@tale/ui/use-list-page` — and mount the [adaptive header context and mobile slot](/docs/components/app-shell) in the surrounding application. This fragment is the page body, not a complete app entry point. ## Scroll the rows, not the page `ContentArea variant="list"` and `DataTable stickyLayout` are one decision, not two options. The variant bounds the body against the page shell; the table then takes that bound and puts its own scrollport around the rows, so the toolbar, the header row and the count footer stay where the reader left them. Write both on every collection screen. Omit either and the table grows to its content and the page scroller moves instead: search and the create action scroll off the top, and two collection screens in the same product start behaving differently. A short list is unaffected — the frame hugs its rows rather than stretching to fill the viewport. A screen that is nothing but its table, with no content below it, can ask for the other behaviour with [`fillHeight`](/docs/components/data-table). A short viewport is the one exception, and it is built in: under 30rem of height (the `short-viewport:` variant — a phone held sideways, a laptop zoomed to 200 %) the chrome would leave the bounded frame a sliver, so the variant lets the frame grow with its rows, the page scrolls instead, and the page header scrolls away with it. An infinite list follows on its own: it watches the page scroll there rather than the table's. Nothing to write — but don't bound a collection screen any other way, or it loses this. Content the page stacks above the table — a folder breadcrumb, a load-failure alert — is a sibling inside the same `ContentArea`, so it keeps the page inset and the table keeps the remaining height. Tables embedded in a scrolling settings page are the exception: they are not collection screens and take neither the variant nor the flag. ## Connect the controls to one data source The search field and filters describe the rows beneath them. `useListPage` keeps them honest: it matches the query against the complete set — draining a paginated source first, so a match on a page that has not loaded yet is still found — and starts its window over when the query changes, so a new search never opens halfway through the old result. Search `fields` name the row's own keys, or pass an accessor for a value the row does not carry, such as a label your service translates. A facet that matches one field exactly can live in the hook through `filters.definitions`. Facets your service keeps itself — in the URL, say, or with several values at once — go to the hook as `filters.configs` with an `onClear`, and the rows you hand it are the ones those facets leave. Use `dateRange` and `filtersContent` on the table for the rest. Keep shareable filter state in the URL when reloads and copied links should preserve the view. `addAction` creates the primary toolbar affordance; it does not open a dialog by itself. Supply `onClick`, `href`, or menu items and derive availability from the host's permission state. Do not duplicate the same create action in the page header. ## Separate no data, no matches, and a failed request | State | What to communicate | | --- | --- | | Initial request pending | A loading skeleton, using `isLoading` and a meaningful `approxRowCount`. | | Collection has no items | `emptyState` explaining the collection and how to create the first item. | | Active search has no matches | The table's shared no-results state; preserve a way to clear the query. | | More cursor pages could contain matches | Continue loading; do not claim the entire collection has no results yet. | | Request failed | `error` plus `onRetry`, preserving the reader's query. `useListPage` derives both from the data source's `error` and `retry`: hand it the query's error once the retry policy gave up, and the table shows the error state instead of the collection's empty state. Rows already loaded stay on screen through a failed refetch; name that failure above the table with `CatalogLoadError` from `@tale/ui/catalog/catalog-view` and the same retry, marked `isRetrying` while it runs. Give it a new `failureKey` for each failure, so a repeated failure is announced again while **Try again** keeps its node and any focus on it, and an `onFocusLost` target for the focus it holds when a refresh that worked takes it away. Pass the same target as the table's `onErrorFocusLost`: with nothing loaded, a refresh the reader did not start swaps the error state for the loading state. A paginated source that failed on a later page stops loading until the retry, and the table says the rest could not be loaded. A host that lists rows of its own beside the source's, or narrows them itself, passes `loadFailed` to say the rows are partial, as a documents table does for folders shown beside a documents read that never answered. Pass `error` only when nothing at all is loaded. While `loadFailed` holds, the rows stay through a retry instead of giving way to a first-load skeleton. Every loaded row the search and filters keep also stays on screen, even after a search or its clearing resets the page window. | An unknown approximate count is `undefined`, not zero. Positive counts reserve skeleton rows up to the component cap. A create action moves into the initial empty state only when no search/filter toolbar needs to remain visible. ## Make each row usable Choose stable IDs with `getRowId`. Use a named link or action for the item's destination, even if `onRowClick` also makes pointer navigation convenient. Keep selection checkboxes, expansion, menus, and row navigation distinct, and check that activating one does not trigger another. `isRowClickable` excludes rows that should not navigate. `onRowMouseEnter` can preload a destination, but the click handler still needs to work when there was no hover, including keyboard and touch use. ## Choose paging and prove the states Every collection screen pages the same way, whatever its source: `useListPage` hands the table a window of rows, loads more as the reader nears the end, and closes the frame on the count footer — "Showing all 12 automations", or "3 of 12" while a search narrows the set. Pass `{ type: 'query', data }` for a set that arrives whole and `{ type: 'paginated', … }` for cursor pages with their status and `loadMore`. Provide singular and plural `entityLabel` values for the footer's copy. Rows that aggregate several entities, such as a folder, can report how many they stand for through `countRow`. Before shipping, try an initially empty collection, a nonmatching search, a rejected request followed by Retry, and a narrow viewport. Tab to the search, a row action, and the create control. Confirm the host prevents unauthorized writes even if its UI state is bypassed. # Settings page Source: https://ui.tale.dev/docs/patterns/settings-page A settings page needs a clear distinction between the saved configuration and the current draft. Align related fields with `ContentArea variant="narrow"`, then make Save, Discard, and navigation behavior reflect that distinction. ## Try the draft and saved states Change **Workspace name**, then choose **Save**. The example adopts that value as its new saved baseline and disables the actions. Edit again and choose **Discard** to return to that baseline. This example saves only in memory. Reloading restores the sample values. The region and digest controls demonstrate form layout; they do not change data residency or schedule emails. The bar stays visible while its status and button availability change. ## Align fields through the container ```tsx import { ContentArea } from '@tale/ui/content-area'; import { Input } from '@tale/ui/input'; export function SettingsLayoutExample() { return ( ); } ``` This layout-only example has uncontrolled fields; use an editor controller for a persisted form. The narrow container caps content at `max-w-3xl` and declares the shared field layout. `FieldShell` stacks labels and controls until the settings surface is 36rem wide and places them beside one another from there, with a consistent control column. The width is the surface's own, not the window's: beside the rail and a section panel a 768px window leaves the column about 400px, and its fields stack. If a field does not align, first check whether it uses `FieldShell`. Use `wideControl` deliberately for content that needs the available width. Group related settings with `FormSection`; reserve cards for distinct objects such as a connected account, rather than wrapping every input in a separate panel. The surrounding page header supplies the `h1`. Do not repeat it in the settings body, and do not assume a navigation label replaces a page heading. Begin settings section headings at the appropriate level beneath the page title. ## Connect a real editor `useFormEditor` adapts React Hook Form to the shared editor contract. It accepts server `data`, optional initial `defaultValues` and a validation schema, plus an asynchronous `save` callback. The returned controller exposes the form, status, `save`, `reset`, `submit`, and `dirtyKeys`. | Integration | Why it matters | | --- | --- | | Keep server data separate from the draft | A failed request must not erase the person's edit. | | Wire native form submission to `editor.submit` | This updates the saved baseline after success and reports a server failure with the same single toast `EditorActions` raises; calling the persistence callback directly bypasses both. | | Use `EditorActions` with the controller | Save/Discard availability follows dirty, valid, loading, and saving state. | | Keep the persistence call's own failure toast quiet | `EditorActions`, and `submit` for Enter or a `type="submit"` Save, raise the one toast for a rejected `save`, with the translated line it throws. A toast the write raises as well reports the same failure twice. | | Register related sections through `EditorGroup` | Their controllers contribute to one active-editor action area. The group does not draw a bar on its own. Save saves only the edited sections, so only those must be valid: an untouched section showing a field error does not block Save. | | Map server field errors where possible | Show repairable errors beside the relevant field rather than only in a disappearing toast. | | Pass `onReset` for state kept outside the form | A reveal toggle or local mode switch is not a form field, so `reset` cannot restore it. `onReset` runs after every reset — the section's own Discard and a group header's alike — so that state returns to the saved baseline with the fields. | For nested configuration objects that do not suit flat form paths, inspect `useJsonConfigEditor` before introducing another editor mechanism. Import controllers from `@tale/ui/editor/*`. ## Protect edits when leaving Mount `DirtyBlockerProvider` within the router tree and register the editor's dirty state. `useFormEditor` performs its dirty-source registration; the provider supplies the navigation decision and before-unload handling. Without the surrounding provider, a controller cannot provide a complete leave-page warning. For route-based settings tabs, use [`TabNavigation`](/docs/components/tabs-and-navigation). Pass the controller's `dirtyKeys` set and configure corresponding keys on the affected items. The dot indicates unsaved content; it is separate from the blocker that asks before leaving. The form editor reports `hasRemoteUpdate` when upstream data changes while a draft is dirty. Give the person a visible way to understand and resolve that situation. Do not silently overwrite a local draft with a fresh server response. ## Verify a real save workflow Test Save success, validation failure, server failure, Discard, and navigation away with a draft. After a successful save, reload and confirm the backend value persists. After a failed save, confirm the draft remains. The local example above demonstrates state transitions, but only your host integration can prove persistence, authorization, and unsaved-change protection.