# Upgrading to v0.18
Source: /docs/migrations/v0-18/index.md
# Upgrading to v0.18
Frontile v0.18 is a major release with several breaking changes to theming, colors, DOM anatomy, and component APIs. This guide gives an overview of all of them and links to a detailed migration guide for each.
## What breaks, and how loudly
Two of these changes stop your app from working. The rest are cleanup, and one of
them is optional for the whole 0.18 line.
| Change | If you skip it | Fails how? |
| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Missing `@import "@frontile/theme"` | No Frontile styles at all | Loudly — the app is visibly unstyled |
| Numbered color classes (`bg-primary-500`) | Those elements render unstyled | **Silently** |
| `bg-background` → `bg-surface-canvas` | Those elements render unstyled | **Silently** |
| `--frontile-*` variable references | The declaration is dropped | **Silently** |
| Nested `LayoutTheme` config | Build or type error | Loudly, and only if you customize the theme |
| `@frontile/*` package imports | Nothing — they still work in 0.18.x | Deprecation warning only |
| `@frontile/forms-legacy` / `@frontile/changeset-form` | Nothing — they still work in 0.18.x, but are removed in 0.19.0 | Deprecation warning only |
| Derived border-radius scale | Slightly rounder corners on menus and small marks | Visual only — nothing to fix |
| Multi-select renders chips | Multi-selects look different — selections become removable chips | Visual only — nothing to fix |
| Filtered lists are ranked | `Autocomplete`/`Select` list the closest match first instead of source order | Visual only — nothing to fix |
| `text-body-pico`/`-nano`/`-micro` | Those elements render unstyled | **Silently** |
| Body text-scale font sizes corrected | Body text (`xs` through `xl`) renders larger than intended | Visual only — nothing to fix |
| `data-fr-*` / mismatched `data-component` / most `data-test-id` selectors | Those selectors stop matching | **Silently**, if you select Frontile-rendered elements yourself |
**The silent ones are the reason to take this in order.** A class Tailwind can't
resolve produces no error, no warning, and no CSS — the element just renders
without the style you asked for. We hit this in Frontile's own documentation
during the 0.18 work: thirteen demos were shipping with `bg-success-50` and
`bg-warning-50`, rendering with no background at all, and nobody noticed until a
linter went looking. Budget time for looking at the result, not just for the
find-and-replace.
## Migration order
### 1. Theme configuration — do this first
Nothing else is verifiable until the theme loads. Add the CSS import, and update
your config shape if you customize it.
**Impact:** required. **Time:** 15–30 minutes.
- Add `@import "@frontile/theme"` to your `app/styles/app.css`
- `LayoutTheme` moved from a flat to a nested structure (`hoverOpacity` becomes
`opacity: { hover }`)
- CSS variables lost the `--frontile-` prefix; colors gained `--color-`
**See:** [Theme Configuration](./theme-configuration.md)
### 2. Colors and surfaces — the bulk of the work
One pass over your classes and custom CSS. Everything in this step fails
silently, so verify visually as you go rather than at the end.
**Impact:** required, and touches every colored element. **Time:** an hour or two
for a small app, a day or more for a large one.
- Numbered scales (`50`, `100`, … `950`) become named levels (`subtle`, `muted`,
`soft`, `mild`, DEFAULT, `firm`, `strong`, `bolder`)
- `default-*` becomes `neutral-*`
- `bg-background` becomes `bg-surface-canvas`
- `{color}-foreground` and `contrast-1`/`contrast-2` become `on-{color}-{level}`
- `text-foreground` is gone
- `theme-inverse` flips every semantic token in a region, for panels that should read as the opposite theme
Colors also moved from HSL to OKLCH. That part is automatic — you may notice
small perceptual differences, but there is nothing to change.
**See:** [Semantic Colors](./semantic-colors.md)
### 3. Package consolidation — optional, any time before 0.19
Seven `@frontile/*` component packages became the single `frontile` package. The
old packages still re-export everything and only log a deprecation warning, so
**your imports keep working for all of 0.18.x.**
Leave this until the app builds and looks right. It rewrites every Frontile
import in your codebase, and doing that first buries the changes above in a diff
you can't read — which matters precisely because those changes fail silently.
**Impact:** none until 0.19. **Time:** 10–30 minutes, mostly automated.
**See:** [Package Consolidation](./package-consolidation.md)
### Forms Legacy & Changeset Form — optional, any time before 0.19
`@frontile/forms-legacy` and `@frontile/changeset-form` are deprecated and,
unlike the wrapper packages above, are being removed entirely in 0.19.0, not
just re-exported. If you depend on either, migrate to the modern `frontile`
forms (Form + Field pattern with Valibot, Zod, or a custom validator).
**Impact:** none until 0.19, unless you already depend on one of these
packages. **Time:** varies with form count and validation complexity — see
each guide's migration checklist.
**See:** [Forms Legacy Migration Guide](./forms-legacy.md), [Changeset Form Migration Guide](./changeset-form.md)
### Filtered lists are ranked by relevance — visual only
`Autocomplete` and filterable `Select` used to filter with a case-insensitive
"contains" check and render whatever survived **in the order you passed it**.
They now score each option and list the closest match first, and additionally
match acronyms (`nz` finds "New Zealand").
Nothing that matched before stops matching, so there is nothing to fix unless
you pass your own `@filter` — which now also accepts a score:
```ts
filter?: (itemValue: string, inputValue: string) => boolean | number;
```
**See:** [Filter Ranking](./filter-ranking.md)
### 4. Multi-select renders chips — visual only
`Select` with `@selectionMode="multiple"` used to show its selections as a
comma-joined string inside the trigger. It now renders each selection as a
removable [Chip](https://frontile.dev/docs/components/buttons/chip), so users
can drop one selection without reopening the dropdown. The control grows taller as chips
wrap, and a chips field is deliberately the same height as a same-size single
select (46px at `md`).
Nothing breaks, but the field is taller and looks different. If a layout
depends on the old fixed-height appearance, opt back out per-select:
```gts
```
Chips inherit the Select's `@intent` and default to the `faded` appearance;
`@chip={{hash appearance='outlined' size='md'}}` tunes appearance, intent,
size, radius and `withDot`. `@allowEmpty` defaults to `false`, so the final
selection's chip renders with no close button; `@isClearable` clears
everything and ignores `@allowEmpty`. Chip close buttons are deliberately not
in the tab order — the combobox is the single tab stop, and `Backspace` on
the field removes the last chip in both filterable and non-filterable modes.
See the [Select docs](https://frontile.dev/docs/components/forms/select#multiple-selection)
for the full section.
**Impact:** visual only. **Time:** none required; a few minutes if you want to
opt out.
### 5. DOM anatomy attributes — required only if you select internals
Every component now carries a stable `data-component`/`data-part` DOM
anatomy. The old `data-fr-*` attributes are gone, several mismatched
`data-component` values were corrected, and most `data-test-id` attributes
that existed only as anatomy selectors were replaced. This only affects you
if your own CSS, `querySelector` calls, or tests select Frontile-rendered
elements directly — the public component API (`@classes`, yielded blocks,
etc.) is unchanged.
**Impact:** required only if you select internals directly (silent — the
selector just stops matching); otherwise none. **Time:** a few minutes to a
couple hours, depending on how many selectors your app has.
**See:** [DOM Anatomy Attributes Migration](./anatomy-attributes.md),
[Customizing Component Styles](../../theming/component-styles.md) for the
ongoing contract.
### 6. Component API naming: `@appearance` → `@variant`, `@intent` → `@color`/`@status` — required only if you set them
Both styling axes are renamed. `@appearance` becomes `@variant` with a shared
value vocabulary (`solid`, `soft`, `subtle`, `outline`, `ghost`, `plain`), and
`@intent` becomes `@color` — or `@status` on `Alert`, `NotificationCard` and
`FormFeedback`, the three where the value also selects an icon, an ARIA role, or
whether a message is announced assertively. `default` becomes `neutral`
throughout; on Alert and NotificationCard, `info` folds into `primary`.
On the components whose API shipped in v0.17.1, the old props still work through
0.18.x and log a deprecation warning; they are removed in v0.19.0. Components
added during the 0.18 pre-release cycle are renamed outright with no warning —
if you tracked a `0.18.0-alpha.*`/`beta.*` build, read that guide's second
section.
**Impact:** required only if you pass `@appearance` or `@intent` today;
otherwise none. **Time:** a few minutes to an hour, depending on how many call
sites you have.
**See:** [Component API Naming Migration](./component-api-naming.md)
### 7. Body typography scale corrected
The `--text-body-*` tokens were mapped to the wrong steps of the modular scale,
so `xs` through `xl` rendered larger than the design spec (e.g. `md` shipped at
20.74px instead of 16px). These now match spec, and the scale gained `4xs`,
`5xs`, `2xl`, and `3xl` sizes to fill it out.
The non-standard `text-body-pico`, `text-body-nano`, and `text-body-micro`
tokens are gone — the body scale now uses the same `5xs`…`3xl` naming as every
other text-style category. Replace them with the equivalent standard size,
which renders at the same pixel value:
| Removed | Use instead |
| ----------------- | --------------- |
| `text-body-pico` | `text-body-4xs` |
| `text-body-nano` | `text-body-3xs` |
| `text-body-micro` | `text-body-2xs` |
**Impact:** required only if you use `text-body-pico`/`-nano`/`-micro`
directly (silent — the class stops resolving to any style); otherwise visual
only, from the corrected sizes. **Time:** a few minutes; search your codebase
for `text-body-pico`, `text-body-nano`, and `text-body-micro`.
**See:** [Typography](../../theming/design-tokens/typography.md)
## Checklist
- [ ] `@import "@frontile/theme"` added to `app.css`
- [ ] `LayoutTheme` config nested, if you customize it
- [ ] `--frontile-*` variable references renamed (colors take `--color-`)
- [ ] Numbered color classes replaced with named levels
- [ ] `default-*` renamed to `neutral-*`
- [ ] `bg-background` replaced with `bg-surface-canvas`
- [ ] `{color}-foreground` / `contrast-*` replaced with `on-{color}-{level}`
- [ ] **Looked at the running app**, not just the diff
- [ ] Imports moved to `frontile` (optional until 0.19)
- [ ] Migrated off `@frontile/forms-legacy` / `@frontile/changeset-form`, if used (required before 0.19)
- [ ] Looked at any multi-selects — they now render chips and are taller
- [ ] Replaced `text-body-pico`/`-nano`/`-micro` with `text-body-4xs`/`-3xs`/`-2xs`
- [ ] Replaced any `data-fr-*`, mismatched `data-component`, or retired
`data-test-id` selectors with the new `data-component`/`data-part`
attributes (see [DOM Anatomy Attributes Migration](./anatomy-attributes.md))
- [ ] `@appearance` replaced with `@variant`, and `@intent` with `@color` —
or `@status` on Alert, NotificationCard and FormFeedback — with
`default` replaced by `neutral` (see
[Component API Naming Migration](./component-api-naming.md))
## New projects
Starting fresh on v0.18 needs no migration. Follow
[Getting Started](../../get-started/index.md).
## Need help?
- The individual guides linked above
- The [documentation](../../get-started/index.md) for current usage examples
- Search or open an issue on [GitHub](https://github.com/josemarluedke/frontile)
---
# Semantic Colors Migration
Source: /docs/migrations/v0-18/semantic-colors.md
# Semantic Colors v2 Migration Guide
This guide helps you migrate from the old numbered color system to the new semantic color levels in Frontile v0.18+.
## Overview
The semantic color system has been redesigned to use named levels instead of numbered scales. Names describe emphasis rank (subtle, soft, firm, strong) rather than a numbered step, contrast colors are defined explicitly instead of inferred, and levels are chosen for how they read in both light and dark mode.
## Breaking Changes
### Color Categories Renamed
| Old Name | New Name | Notes |
| ----------- | ----------- | -------------------------------------------------------- |
| `default-*` | `neutral-*` | Renamed to better indicate non-semantic UI elements |
### Inverted Surfaces
There is no `inverse` color category. Content on an inverted surface is handled
one of two ways.
For a single element sitting on a filled background, use the automatic contrast
color for that background level:
```gts
Content with guaranteed contrast
```
For a whole region that should read as the opposite theme — a dark panel in light
mode, or a light one in dark mode — add the `theme-inverse` class. Every semantic
token inside it resolves to its other-theme value, so ordinary classes keep
working:
```gts
Reads as dark in light mode
And as light in dark mode.
```
`theme-inverse` is a selector the theme defines, not a color. It is unrelated to
the `inverse` category that appeared briefly during the 0.18 alpha cycle and was
removed before release.
### Surface Roles Simplified
The surface system (introduced during the v0.18 alpha cycle) has been simplified before its first stable release:
| Removed | Replacement | Notes |
| ------------------------------ | --------------------- | ---------------------------------------------------------------------------------------- |
| `surface-solid-0` … `surface-solid-11` | *(removed)* | The 12-step scale is gone. Use a surface role (`app`, `canvas`, `card`, `modal`, `input`) instead |
| `text-surface-solid-*` | `text-neutral-*` or `text-on-surface-modal` | Use the neutral text scale for body copy, or the auto-generated on-color for content on `surface-modal` |
| `surface-popover` | `surface-modal` | Popovers, dropdowns, modals, and drawers now share one "highest elevation" surface |
| `surface-overlay-content` | `surface-modal` | Same merge as above — this was already identical in value to `surface-popover` |
| `surface-panel` | *(removed)* | Had no real consumer; use `surface-card` for elevated sidebar/panel containers |
| `surface-inset` | *(removed)* | Had no real consumer; use `surface-overlay-subtle`/`soft` for recessed translucent wells |
| `surface-overlay-inverse-*` | `surface-overlay-strong` (for backdrops) | Had no real consumer. If you need a heavy backdrop tint, use `strong`; for a normal darken/lighten step, use `subtle`/`soft`/`mild`/`firm` |
| *(new)* `surface-input` | — | Dedicated surface for text inputs, checkboxes, and radios (previously `surface-solid-0`) |
`surface-card` also changed value: it's now translucent (`white @ 90%` in light mode) instead of fully opaque, and resolves to `base-800` in dark mode instead of `base-700`.
```gts
// Before
Menu
Modal
// After
Menu
Modal
```
See the [Surfaces](../../theming/design-tokens/surfaces.md) guide for the full, current token set.
### Surface Overlay Levels Renamed
The translucent overlay levels were renamed to line up with the named levels used
by every other color category (`subtle` → `soft` → `mild` → `firm` → `strong`).
The values did not change — only the names:
| Old | New | Notes |
| -------------------------- | ------------------------ | -------------------------------------------------------------- |
| `surface-overlay-medium` | `surface-overlay-mild` | Same value, renamed to match the shared level names |
| `surface-overlay-strong` | `surface-overlay-firm` | Same value, shifted down one name |
| `surface-overlay-scrim` | `surface-overlay-strong` | The heavy modal/drawer backdrop is now just the top level |
```gts
// Before
// After
```
### New: Surface Lift
`surface-lift-*` is a new translucent family alongside `surface-overlay-*`, with
the same five levels. Overlay darkens in light mode and lightens in dark mode,
pressing an element into the page; lift does the opposite, so the element floats
above what it covers.
```gts
// Recedes — hover states, section fills
// Floats — frosted panels, sticky headers, captions over media
```
Nothing is deprecated by this: existing `surface-overlay-*` usage is unaffected.
See the [Surfaces](../../theming/design-tokens/surfaces.md) guide for the level
table and usage guidance.
### Color Scale Changed
The numbered scale (50, 100, 200, ..., 950) has been replaced with named levels:
| Old Pattern | New Pattern | Description |
| -------------------- | --------------------- | ----------------------------------------- |
| `{color}-{number}` | `{color}-{level}` | Named emphasis levels |
| `{color}-foreground` | `on-{color}-{level}` | Automatic contrasting colors (black/white) |
## Color Level Mapping
### Understanding the New Levels
Each semantic color is organized into two bands that share one emphasis
vocabulary but are consumed by different CSS properties. Names describe emphasis
**rank**, never brightness — a level may be dark in light mode and light in dark
mode, since interaction direction inverts between the two schemes.
**Surface band** — fills for backgrounds and decorative borders (low → high emphasis):
- **`subtle`** — Faintest tint, for hairline backgrounds and tonal rests
- **`muted`** — Light tint, for hover on tonal surfaces
- **`soft`** — Soft fill, the hover step for solid fills
- **`DEFAULT`** — Resting fill, the bare `bg-{color}` token
- **`firm`** — Most emphatic fill, for pressed/active backgrounds
**Ink band** — legible foregrounds for text and outlined borders (low → high emphasis):
- **`strong`** — Default legible foreground, for body text and outlined-control text/borders
- **`bolder`** — Highest-emphasis foreground, for headings and hover/active text
**`on-{color}-{level}`** — Automatic contrasting color (black or white)
calculated for optimal WCAG contrast on the specified background level.
### Migration Mapping Table
#### Neutral (formerly Default)
| Old Class | New Class | Context |
| ------------------------- | ---------------------------------------------- | -------------------------- |
| `bg-default-50` | `bg-neutral-subtle` | Very light backgrounds |
| `bg-default-100` | `bg-neutral-subtle` | Light backgrounds, borders |
| `bg-default-200` | `bg-neutral-subtle` | Light surfaces |
| `bg-default-300` | `bg-neutral-soft` | Moderate backgrounds |
| `bg-default-400` | `bg-neutral-soft` | Borders, dividers |
| `bg-default-500` | `bg-neutral-soft` or `bg-neutral` | Medium emphasis |
| `bg-default-600` | `bg-neutral` | Standard emphasis |
| `bg-default-700` | `bg-neutral` or `bg-neutral-strong` | Strong emphasis |
| `bg-default-800` | `bg-neutral-strong` | Maximum emphasis, buttons |
| `bg-default-900` | `bg-neutral-strong` | Darkest backgrounds |
| `bg-default-950` | `bg-neutral-strong` | Maximum contrast |
| `text-default` | `text-neutral-strong` or `text-neutral-bolder` | Body text |
| `text-default-foreground` | `text-on-neutral` | Contrasting text on bg |
| `border-default` | `border-neutral-soft` | Standard borders |
#### Primary
| Old Class | New Class | Context |
| ------------------------- | ------------------------------------------ | ---------------------- |
| `bg-primary-500` | `bg-primary-soft` or `bg-primary` | Hover states |
| `bg-primary-600` | `bg-primary` | Default buttons |
| `bg-primary-700` | `bg-primary` or `bg-primary-strong` | Strong emphasis |
| `bg-primary-800` | `bg-primary-strong` | Active/pressed states |
| `text-primary-foreground` | `text-on-primary` | Contrasting text on bg |
| `ring-primary-500` | `ring-primary-soft` | Focus rings |
#### Success
| Old Class | New Class | Context |
| ------------------------- | ------------------------------------------ | ------------------------ |
| `bg-success-100` | `bg-success-subtle` | Alert backgrounds |
| `bg-success-500` | `bg-success-soft` or `bg-success` | Moderate emphasis |
| `bg-success-600` | `bg-success` or `bg-success-strong` | Buttons, hover |
| `bg-success-800` | `bg-success-strong` | Active states |
| `text-success-foreground` | `text-on-success` | Contrasting text on bg |
| `ring-success-500` | `ring-success-soft` | Focus rings |
#### Warning
| Old Class | New Class | Context |
| ------------------------- | ------------------------------------------ | ------------------------ |
| `bg-warning-100` | `bg-warning-subtle` | Alert backgrounds |
| `bg-warning-500` | `bg-warning-soft` or `bg-warning` | Moderate emphasis |
| `bg-warning-600` | `bg-warning` or `bg-warning-strong` | Buttons, hover |
| `bg-warning-800` | `bg-warning-strong` | Active states |
| `text-warning-foreground` | `text-on-warning` | Contrasting text on bg |
#### Danger
| Old Class | New Class | Context |
| ------------------------ | ---------------------------------------- | ----------------------- |
| `bg-danger-100` | `bg-danger-subtle` | Alert backgrounds |
| `bg-danger-500` | `bg-danger-soft` or `bg-danger` | Moderate emphasis |
| `bg-danger-600` | `bg-danger` or `bg-danger-strong` | Buttons, hover |
| `bg-danger-800` | `bg-danger-strong` | Active states |
| `text-danger-foreground` | `text-on-danger` | Contrasting text on bg |
## Common UI Pattern Migrations
### Buttons
#### Default Button
```gts
// Before
// After
```
#### Primary Button
```gts
// Before
// After
```
#### Outlined Button
```gts
// Before
// After
```
### Alerts and Notifications
#### Success Alert
```gts
// Before
Success message
// After
Success message
```
#### Error Alert
```gts
// Before
Error occurred
// After
Error occurred
```
### Form Inputs
```gts
// Before
// After
```
### Text Hierarchy
```gts
// Before
Heading
Body text
Caption
// After
Heading
Body text
Caption
```
### Badges
```gts
// Before
Active
// After
Active
```
## Dark Mode Considerations
The new semantic colors automatically adapt to dark mode. Key changes:
- In dark mode, `strong` becomes the **lightest** shade (inverted from light mode)
- `subtle` uses darker base colors with lower alpha
- `on-{color}-{level}` automatically maintains WCAG accessibility standards
**No changes needed** — your dark mode classes will work automatically:
```gts
// Both light and dark modes handled
Content
```
## Migration Strategy
### Step 1: Identify Old Patterns
Search your codebase for old color patterns:
```bash
# Find numbered color classes
grep -r "default-[0-9]" --include="*.{tsx,ts,gts,gjs,md}"
grep -r "primary-[0-9]" --include="*.{tsx,ts,gts,gjs,md}"
# Find foreground classes
grep -r "foreground" --include="*.{tsx,ts,gts,gjs,md}"
```
### Step 2: Update Theme Configuration
If you've customized Frontile's theme, update your color definitions:
```typescript
// Before
import { themeColors } from '@frontile/theme';
colors: {
default: themeColors.light.default,
primary: themeColors.light.primary,
}
// After
import semanticColors from '@frontile/theme/colors/semantic';
colors: {
neutral: semanticColors.light.neutral,
primary: semanticColors.light.primary,
}
```
### Step 3: Update Component Styles
Replace old color classes with new semantic levels:
1. Start with theme components (highest impact)
2. Move to your custom components
3. Update documentation and examples
4. Update tests
### Step 4: Test Thoroughly
- **Visual regression**: Check all components in both light and dark modes
- **Accessibility**: Verify contrast ratios still meet WCAG AA standards
- **Interactive states**: Test hover, focus, active, and disabled states
## Interactive states
The tables above map each old number to a resting level. They deliberately do not
try to guess hover, pressed, or disabled variants, because the old numbered scale
encoded those by stepping the number and the new one has dedicated levels:
| Old | New | Level |
| --- | --- | --- |
| `hover:bg-primary-500` on a `bg-primary-600` element | `hover:bg-primary-soft` | `soft` is the hover step for solid fills |
| `active:bg-primary-800` | `active:bg-primary-firm` | `firm` is the most emphatic fill |
| `hover:bg-default-200` on a tonal surface | `hover:bg-neutral-muted` | `muted` is the hover step for tonal surfaces |
| A fill between resting and firm | `bg-primary-mild` | `mild` |
If you had a three-state solid button — `bg-primary-600`, `hover:bg-primary-500`,
`active:bg-primary-700` — it becomes `bg-primary`, `hover:bg-primary-soft`,
`active:bg-primary-firm`.
## Automated migration
There is no supported codemod for this. A find-and-replace can do the
unambiguous half — the category rename and the `foreground` suffix — but it
cannot pick emphasis levels, and it cannot tell a resting fill from a hover one:
```bash
# The mechanical part only. Review everything it touches.
# GNU sed: drop the '' after -i
find . -type f \( -name "*.hbs" -o -name "*.gts" -o -name "*.gjs" -o -name "*.ts" \) \
-exec sed -i '' \
-e 's/\bbg-default-/bg-neutral-/g' \
-e 's/\btext-default-/text-neutral-/g' \
-e 's/\bborder-default-/border-neutral-/g' \
-e 's/\bbg-background\b/bg-surface-canvas/g' \
{} +
```
Then find what's left by hand — anything still carrying a number needs a level
chosen for it:
```bash
grep -rnE '(bg|text|border|ring|from|to|via)-(neutral|primary|secondary|tertiary|success|warning|danger)-[0-9]{2,3}' .
```
That grep is worth keeping in CI for a release or two. A leftover numbered class
produces no CSS and no error, so it will not show up any other way.
## Decision Guide
When choosing between levels, ask:
### Background Colors
- **Maximum emphasis button?** → `{color}-strong`
- **Standard button/element?** → `{color}` (DEFAULT)
- **Hover state?** → `{color}-soft`
- **Light alert background?** → `{color}-subtle`
### Text Colors
- **On colored background?** → `on-{color}-{level}` (automatically white or black based on WCAG contrast)
- **On neutral background?** → `neutral-bolder` (heading), `neutral-strong` (body), `neutral-firm` (caption)
### Borders
- **Colored semantic borders?** → `{color}` or `{color}-soft`
- **Neutral dividers?** → `neutral-soft` or `neutral-subtle`
---
# Theme Configuration Migration
Source: /docs/migrations/v0-18/theme-configuration.md
# Theme Configuration Migration Guide
This guide helps you migrate your Frontile theme configuration to v0.18, which adopts Tailwind v4's CSS-first configuration approach.
## Overview
Frontile v0.18 updates the theme system to align with Tailwind CSS v4's CSS-first configuration. Theme values are now CSS variables you can read and override directly in your stylesheets, instead of JavaScript config processed at build time, and the `--frontile-` prefix is gone from variable names.
## Breaking Changes
### 1. CSS Import Required
**You must add** `@import "@frontile/theme"` to your application's entry
stylesheet — `app/styles/app.css` on a classic Ember build, `app/app.css` under
Vite. It has to come after the `@plugin` line.
#### Before (v0.17)
```css
@import 'tailwindcss' source('../../');
@plugin "@frontile/theme/plugin/default";
```
#### After (v0.18)
```css
@import 'tailwindcss' source('../../');
@plugin "@frontile/theme/plugin/default";
@import "@frontile/theme";
```
**Why:** The `@import "@frontile/theme"` statement loads Frontile's base CSS styles, custom variants, and animations that are now CSS-based rather than plugin-generated.
### 2. Tailwind Content Detection
This one is a consequence of [package
consolidation](./package-consolidation.md) rather than of the theme itself, and
it is the easiest to miss.
Tailwind v4 skips `node_modules` when it scans for classes. Frontile's own
component templates live there, so they have to be pointed at explicitly or
their classes are purged and components render unstyled — with no error, since a
purged class is simply a class that no longer exists.
In v0.17 one `@source` covered everything, because every component shipped under
the `@frontile` scope:
```css
@source '../../node_modules/@frontile';
```
In v0.18 the components moved to the unscoped `frontile` package, so that line no
longer reaches them:
```css
@source '../../node_modules/frontile';
/* Keep the scoped line too if you use @frontile/forms-legacy or
@frontile/changeset-form, which remain separate packages. */
@source '../../node_modules/@frontile';
```
Paths are relative to the CSS file, so adjust the depth to match where yours
lives. This applies whether or not you have migrated your imports: the classes
come from `frontile` either way, because the old packages only re-export from it.
**If components look unstyled after upgrading and the theme import is present,
this is almost always why.**
### 3. CSS Variable Names
The `--frontile-` prefix is gone. What replaces it depends on the kind of token:
| Token kind | v0.17 | v0.18 |
| --- | --- | --- |
| Colors | `--frontile-primary-500` | `--color-primary-firm` (a `--color-` prefix, and a named level) |
| Everything else | `--frontile-hover-opacity` | `--opacity-hover` (no prefix) |
Colors are the case to watch. They did not simply lose a prefix — they gained
`--color-`, and the numbered scale they used is gone, so there is no
`--color-primary-500` either. Pick the level that matches the emphasis you
wanted; see the [semantic colors guide](./semantic-colors.md) for the mapping.
#### Before (v0.17)
```css
.my-component {
background: var(--frontile-primary-500);
opacity: var(--frontile-hover-opacity);
}
```
#### After (v0.18)
```css
.my-component {
background: var(--color-primary);
opacity: var(--opacity-hover);
}
```
**Migration:** search your codebase for `--frontile-`. Nothing errors if you
miss one — an undefined custom property silently resolves to nothing, so the
declaration is simply dropped:
```bash
grep -rn -- "--frontile-" --include="*.css" --include="*.scss" \
--include="*.gts" --include="*.gjs" --include="*.ts" --include="*.js" .
```
### 4. LayoutTheme Interface (Nested Structure)
If you're using TypeScript and customizing the theme configuration, the `LayoutTheme` interface has changed from flat to nested.
#### Before (v0.17)
```typescript
import { frontile } from '@frontile/theme/plugin';
module.exports = frontile({
hoverOpacity: 0.9,
disabledOpacity: 0.4
});
```
#### After (v0.18)
```typescript
import { frontile } from '@frontile/theme/plugin';
module.exports = frontile({
opacity: {
hover: 0.9,
disabled: 0.4
}
});
```
**Why:** The nested structure provides better organization for related theme properties and aligns with CSS custom property conventions.
### 5. Border Radius Is Now a Derived Scale
Every `rounded-*` step is now `calc(var(--radius) * n)` instead of a hardcoded
value, so `--radius` is a single knob for how round the whole library looks.
#### Before (v0.17)
```css
--radius-xs: 1px;
--radius-sm: 2px;
--radius-md: 4px;
--radius: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-default: 20px;
```
#### After (v0.18)
```css
--radius: 0.5rem; /* 8px — the base */
--radius-xs: calc(var(--radius) * 0.25); /* 2px */
--radius-sm: calc(var(--radius) * 0.5); /* 4px */
--radius-md: calc(var(--radius) * 0.75); /* 6px */
--radius-lg: calc(var(--radius) * 1); /* 8px */
--radius-xl: calc(var(--radius) * 1.5); /* 12px */
--radius-2xl: calc(var(--radius) * 2); /* 16px */
--radius-3xl: calc(var(--radius) * 3); /* 24px */
--radius-4xl: calc(var(--radius) * 4); /* 32px */
--radius-default: calc(var(--radius) * 2.5); /* 20px */
```
**What actually changes visually.** Three steps got slightly larger, bringing the
scale back in line with stock Tailwind v4:
| Utility | v0.17 | v0.18 |
| --- | --- | --- |
| `rounded-xs` | 1px | 2px |
| `rounded-sm` | 2px | 4px |
| `rounded-md` | 4px | 6px |
Everything from `rounded` upward keeps its value. If you relied on `rounded-sm`
being 2px, use `rounded-xs`, or pin the step explicitly:
```css
@theme {
--radius-sm: 2px; /* opt this one step out of the derived scale */
}
```
**Popover surfaces are rounder.** Dropdown and Popover panels moved from
`rounded-sm` (2px) to `rounded-xl` (12px), Listbox and menu items to
`rounded-lg` (8px), and NotificationCard to `rounded-xl`. Select and
Autocomplete inherit the change through Popover. This is a visual change only —
no API moved. To go back to square-ish menus, set `--radius: 0`, or override the
component classes via `@classes`.
**Why:** The old scale was smaller than stock Tailwind at every step, which made
`rounded-sm` a surprise for anyone reading the class name, and left popover
surfaces visibly sharper than the Modal and Drawer they sit alongside. Deriving
the scale from one value fixes the inconsistency and makes overall roundness a
one-line theme decision.
**Dialing it from the plugin config**, including per theme:
```typescript
module.exports = frontile({
layout: {
radius: { DEFAULT: '0.75rem' }
}
});
```
## Migration Steps
### Step 1: Update CSS Imports
Add the `@import "@frontile/theme"` statement to your entry stylesheet:
```css
@import 'tailwindcss' source('../../');
@plugin "@frontile/theme/plugin/default";
@import "@frontile/theme";
/* Your custom styles */
```
**For custom theme configurations**, update to reference your custom config file:
```css
@import 'tailwindcss' source('../../');
@plugin "./../../frontile.js";
@import "@frontile/theme";
```
### Step 2: Update CSS Variable References
If you reference Frontile's CSS variables directly in your own stylesheets or
components:
1. **Find all references:**
```bash
grep -rn -- "--frontile-" --include="*.css" --include="*.scss" \
--include="*.gts" --include="*.gjs" .
```
2. **Rename them.** Colors take a `--color-` prefix and a named level; other
tokens just drop the prefix:
```diff
- background: var(--frontile-primary);
+ background: var(--color-primary);
- border-color: var(--frontile-primary-700);
+ border-color: var(--color-primary-firm);
- opacity: var(--frontile-hover-opacity);
+ opacity: var(--opacity-hover);
```
3. **Update computed styles in JavaScript:**
```diff
- const color = getComputedStyle(el).getPropertyValue('--frontile-primary-500');
+ const color = getComputedStyle(el).getPropertyValue('--color-primary');
```
`getPropertyValue` returns an empty string for a variable that doesn't
exist, so a missed rename here reads as "no color" rather than throwing.
### Step 3: Update Theme Configuration (If Customized)
If you have a custom `frontile.js` configuration file, update the structure:
#### Before (v0.17)
```javascript
const { frontile } = require('@frontile/theme/plugin');
module.exports = frontile({
hoverOpacity: 0.9,
disabledOpacity: 0.4,
// other flat properties
});
```
#### After (v0.18)
```javascript
const { frontile } = require('@frontile/theme/plugin');
module.exports = frontile({
opacity: {
hover: 0.9,
disabled: 0.4
},
// other nested properties
});
```
**Common property mappings:**
| Old Property (v0.17) | New Property (v0.18) |
| --- | --- |
| `hoverOpacity` | `opacity.hover` |
| `disabledOpacity` | `opacity.disabled` |
### Step 4: Test Your Application
After making these changes:
1. **Rebuild your application** to ensure all changes are applied
2. **Test interactive states** (hover, disabled) to verify opacity values
3. **Check custom styled components** that reference CSS variables directly
4. **Verify in both light and dark modes** if your app supports theming
## Common Issues and Solutions
### Issue: Styles Not Applied
**Problem:** Components don't have expected Frontile styles
**Solution:** Ensure you've added `@import "@frontile/theme"` to your CSS file. This import is now required.
### Issue: CSS Variables Undefined
**Problem:** Browser console shows `undefined` for CSS variables
**Solution:**
1. Check that you removed the `--frontile-` prefix
2. Verify the import order in your CSS file (plugin first, then theme import)
3. Rebuild your application
### Issue: TypeScript Errors in Config
**Problem:** TypeScript errors in `frontile.js` configuration
**Solution:** Update to the nested structure. Ensure you're using `opacity.hover` instead of `hoverOpacity`.
## Migration Checklist
Use this checklist to track your theme configuration migration:
- [ ] Added `@import "@frontile/theme"` to app.css
- [ ] Removed `--frontile-` prefix from all CSS variable references
- [ ] Updated `frontile.js` config to use nested structure (if applicable)
- [ ] Rebuilt application and verified styles are applied
- [ ] Tested hover and disabled states
- [ ] Verified custom components using CSS variables
- [ ] Tested in both light and dark modes (if applicable)
## Need Help?
If you encounter issues:
- Review the [Theme documentation](../../theming/overview.md) for the latest configuration options
- Check the [Getting Started guide](../../get-started/index.md) for setup examples
- Search or create an issue on [GitHub](https://github.com/josemarluedke/frontile/issues)
---
# Package Consolidation
Source: /docs/migrations/v0-18/package-consolidation.md
# Package Consolidation Migration Guide
## Overview
Starting with version **0.18.0**, Frontile consolidates seven separate `@frontile/*` packages into a single `frontile` package:
- `@frontile/buttons`
- `@frontile/collections`
- `@frontile/forms`
- `@frontile/overlays`
- `@frontile/notifications`
- `@frontile/status`
- `@frontile/utilities`
> **This one is optional for all of 0.18.x.** The old packages re-export
> everything and only log a deprecation warning, so your imports keep working
> until 0.19. Do the [theme configuration](./theme-configuration.md) and
> [color](./semantic-colors.md) migrations first — those actually break things,
> and mostly do it silently. Rewriting every import before them produces a diff
> large enough to hide the changes you need to review.
**Why?** Modern Ember.js applications use explicit imports via `.gts`/`.gjs` template tag format. With explicit imports, bundlers can tree-shake unused code automatically, making the multi-package architecture unnecessary overhead. A single package simplifies installation, version management, and dependency resolution without sacrificing bundle size.
**Timeline:**
| Version | What happens |
|---------|--------------|
| **0.18.0** | The old `@frontile/*` packages become thin deprecation wrappers that re-export from `frontile`. All existing import paths continue to work, but emit deprecation warnings at build time. |
| **0.19.0** | The old `@frontile/*` wrapper packages are removed. You must update imports to use `frontile` directly. |
## Quick Start
Update your app in two steps:
### 1. Swap packages
```bash
# Remove old packages
npm uninstall @frontile/buttons @frontile/collections @frontile/forms \
@frontile/overlays @frontile/notifications @frontile/status @frontile/utilities
# Install the consolidated package (if not already installed)
npm install frontile @frontile/theme
```
If you use **pnpm** or **yarn**, substitute the appropriate commands:
```bash
# pnpm
pnpm remove @frontile/buttons @frontile/collections @frontile/forms \
@frontile/overlays @frontile/notifications @frontile/status @frontile/utilities
pnpm add frontile @frontile/theme
# yarn
yarn remove @frontile/buttons @frontile/collections @frontile/forms \
@frontile/overlays @frontile/notifications @frontile/status @frontile/utilities
yarn add frontile @frontile/theme
```
### 2. Update imports
Replace `@frontile/` with `frontile/` (or just `frontile`) in your source files. See the [Import Path Changes](#import-path-changes) section below for details.
## Import Path Changes
Three import styles are supported with the consolidated package:
### Flat barrel import
Import everything from one place:
```typescript
import { Button, Modal, Input, ProgressBar } from 'frontile';
```
### Scoped sub-exports (by category)
Import from a category-specific path. This mirrors the old package names without the `@frontile/` prefix:
```typescript
import { Button, ButtonGroup, Chip } from 'frontile/buttons';
import { Modal, Drawer, Overlay, Popover } from 'frontile/overlays';
import { Input, Select, Checkbox, Textarea } from 'frontile/forms';
import { Table, Listbox, Dropdown } from 'frontile/collections';
import { NotificationCard, NotificationsContainer } from 'frontile/notifications';
import { ProgressBar } from 'frontile/status';
import { Avatar, Collapsible, Divider, Spinner } from 'frontile/utilities';
```
### Direct component file imports
For maximum control and tree-shaking, import directly from the component file:
```typescript
import Button from 'frontile/components/buttons/button';
import Modal from 'frontile/components/overlays/modal';
import Header from 'frontile/components/overlays/modal/header';
import Input from 'frontile/components/forms/input';
```
## Import Mapping Table
The table below shows how old import paths map to the new paths.
### Barrel imports
| Old Import | New Import |
|---|---|
| `from '@frontile/buttons'` | `from 'frontile/buttons'` or `from 'frontile'` |
| `from '@frontile/forms'` | `from 'frontile/forms'` or `from 'frontile'` |
| `from '@frontile/collections'` | `from 'frontile/collections'` or `from 'frontile'` |
| `from '@frontile/overlays'` | `from 'frontile/overlays'` or `from 'frontile'` |
| `from '@frontile/notifications'` | `from 'frontile/notifications'` or `from 'frontile'` |
| `from '@frontile/status'` | `from 'frontile/status'` or `from 'frontile'` |
| `from '@frontile/utilities'` | `from 'frontile/utilities'` or `from 'frontile'` |
### Sub-path imports
| Old Import | New Import |
|---|---|
| `from '@frontile/overlays/components/modal'` | `from 'frontile/components/overlays/modal'` |
| `from '@frontile/overlays/components/modal/header'` | `from 'frontile/components/overlays/modal/header'` |
| `from '@frontile/overlays/components/drawer'` | `from 'frontile/components/overlays/drawer'` |
| `from '@frontile/overlays/components/overlay'` | `from 'frontile/components/overlays/overlay'` |
| `from '@frontile/overlays/components/popover'` | `from 'frontile/components/overlays/popover'` |
| `from '@frontile/forms/test-support'` | `from 'frontile/test-support'` |
| `from '@frontile/utilities/utils/safe-styles'` | `from 'frontile/utils/safe-styles'` |
| `from '@frontile/notifications/services/notifications'` | `from 'frontile/services/notifications'` |
## Automated Migration Script
Save the following script as `migrate-frontile.sh` and run it from your project root to update all import paths automatically.
```bash
#!/bin/bash
# Migrate Frontile imports from @frontile/* to frontile
# Run from your project root
#
# Usage:
# chmod +x migrate-frontile.sh
# ./migrate-frontile.sh
set -euo pipefail
echo "Migrating @frontile/* imports to frontile..."
# Sub-path imports (must run BEFORE barrel imports to avoid partial matches)
find app tests -name '*.ts' -o -name '*.gts' -o -name '*.js' -o -name '*.gjs' | \
xargs sed -i '' \
-e "s|from '@frontile/overlays/components/|from 'frontile/components/overlays/|g" \
-e "s|from '@frontile/notifications/services/|from 'frontile/services/|g" \
-e "s|from '@frontile/forms/test-support|from 'frontile/test-support|g" \
-e "s|from '@frontile/utilities/utils/|from 'frontile/utils/|g" \
-e "s|from '@frontile/collections/utils/|from 'frontile/utils/|g"
# Barrel imports
find app tests -name '*.ts' -o -name '*.gts' -o -name '*.js' -o -name '*.gjs' | \
xargs sed -i '' \
-e "s|from '@frontile/buttons'|from 'frontile/buttons'|g" \
-e "s|from '@frontile/collections'|from 'frontile/collections'|g" \
-e "s|from '@frontile/forms'|from 'frontile/forms'|g" \
-e "s|from '@frontile/overlays'|from 'frontile/overlays'|g" \
-e "s|from '@frontile/notifications'|from 'frontile/notifications'|g" \
-e "s|from '@frontile/status'|from 'frontile/status'|g" \
-e "s|from '@frontile/utilities'|from 'frontile/utilities'|g"
echo "Done! Review the changes with: git diff"
```
**Note:** The `sed -i ''` syntax is for macOS. On Linux, use `sed -i` (without the empty quotes) instead.
## Packages NOT Affected
The following packages are **not** part of this consolidation. Their imports remain unchanged:
- **`@frontile/theme`** -- The styling system. Stays as a separate package because it provides Tailwind CSS configuration and has a distinct role in the build pipeline.
- **`@frontile/forms-legacy`** -- Legacy form components. Deprecated, and removed in 0.19.0 alongside the wrapper packages above. See the [Forms Legacy migration guide](./forms-legacy.md).
- **`@frontile/changeset-form`** -- Changeset integration. Deprecated, and removed in 0.19.0 alongside the wrapper packages above. See the [Changeset Form migration guide](./changeset-form.md).
## Deprecation Timeline
| Version | Status |
|---------|--------|
| **0.18.0** | Old `@frontile/*` packages become thin wrappers that re-export from `frontile`. `@frontile/forms-legacy` and `@frontile/changeset-form` are also marked deprecated. All of them emit deprecation warnings at build time. All existing import paths continue to work. |
| **0.19.0** | All of the above are removed: the `@frontile/*` wrapper packages, `@frontile/forms-legacy`, and `@frontile/changeset-form`. You must update imports to use `frontile` directly, and migrate off legacy forms and changeset integration. |
During the **0.18.x** cycle, you can migrate at your own pace. Both old and new import paths work simultaneously. However, we recommend migrating sooner rather than later to avoid a last-minute rush before 0.19.0.
## For .hbs Template Users
If you use `.hbs` templates (not `.gts`/`.gjs`), components remain auto-importable through the deprecated wrapper packages during the 0.18.x cycle. However, you should plan to migrate to `.gts` explicit imports before 0.19.0, as the wrapper packages will be removed.
During 0.18.x, `.hbs` users should:
1. Keep the deprecated `@frontile/*` packages installed so that auto-imports continue to work.
2. Plan migration to `.gts`/`.gjs` template format for new code.
3. Use explicit imports in any new `.gts`/`.gjs` files.
Once you have migrated all templates to `.gts`/`.gjs`, you can remove the deprecated wrapper packages and use the new `frontile` imports exclusively.
---
# Filter Ranking
Source: /docs/migrations/v0-18/filter-ranking.md
# Filtered lists are now ranked by relevance
`Autocomplete` and filterable `Select` used to filter with a case-insensitive
"contains" check and render whatever survived **in the order you passed it**.
They now score each item and list the closest match first.
Nothing to change unless you pass your own `@filter`. The results are ordered
differently, which is the point.
## Why it changed
The old default could only answer "does this item match?", never "how well?":
```ts
// before
function defaultFilter(itemValue: string, filterValue: string): boolean {
return itemValue.toLowerCase().includes(filterValue.toLowerCase());
}
```
A predicate filters but cannot reorder, so an exact match sat wherever `@items`
happened to put it. Typing `button` into a list ordered alphabetically returned
`ButtonGroup` above `Button` — both "match", and source order decided the rest.
## What you get now
Typing `butt`:
| | Before | After |
| --- | --- | --- |
| 1 | ButtonGroup | **Button** |
| 2 | Button Group | ButtonGroup |
| 3 | Button | Button Group |
Acronyms also match now, which "contains" could never do:
- `bg` → `ButtonGroup`
- `nz` → `New Zealand`
- `prog` → `ProgressBar`
**Nothing that matched before stops matching.** The threshold is calibrated so
every result the old `includes()` filter returned is still returned; the change
is additive plus reordering.
## `@filter` accepts a score
```ts
filter?: (itemValue: string, inputValue: string) => boolean | number;
```
Return a **number** to rank — higher sorts first, `0` means no match. Return a
**boolean** to filter only, preserving the order of `@items`.
Existing boolean filters are unaffected:
```gts
{{! still works exactly as before, including source ordering }}
```
```ts
startsWith = (itemValue: string, inputValue: string) =>
itemValue.toLowerCase().startsWith(inputValue.toLowerCase());
```
### Keeping the old behavior
Pass the previous implementation explicitly:
```ts
const containsFilter = (itemValue: string, inputValue: string) =>
itemValue.toLowerCase().includes(inputValue.toLowerCase());
```
```gts
```
## Tuning how loose matching is
Fuzzy matching is looser than "contains", so a threshold keeps out noise. It
cannot distinguish a useful abbreviation from a coincidence — `btn` → `Button`
and `sa` → `Spain` are the same shape, both scattered mid-word subsequences —
so the default favors precision and matches neither.
To trade precision for recall, build your own:
```ts
import { createFuzzyFilter } from 'frontile/utils/filter';
// matches btn -> Button, at the cost of also matching sa -> Spain
const looseFilter = createFuzzyFilter({ threshold: 0 });
```
```gts
```
`DEFAULT_MATCH_THRESHOLD` is `0.4`. Raise it for stricter matching, lower it for
looser.
---
# Migrating from Forms Legacy
Source: /docs/migrations/v0-18/forms-legacy.md
# Migrating from Forms Legacy
This guide covers migrating from the legacy `@frontile/forms-legacy` package to the modern `frontile` forms: a smaller API surface, better ARIA/keyboard support, slot-based customization, and no dependency on `ember-power-select` or `ember-basic-dropdown`.
`@frontile/forms-legacy` is deprecated and will be removed in 0.19.0.
## Overview
### What's New in Frontile Forms
- Simpler component API with better TypeScript support
- Better ARIA support and keyboard navigation
- Slot-based content insertion and CSS class customization
- No dependency on `ember-power-select` or `ember-basic-dropdown`
- New components: a `Form` wrapper with automatic data extraction, and `Switch`
- Simplified error handling, and tighter integration with validation libraries
### Migration Effort
- **Low**: FormInput, FormTextarea, FormCheckbox, FormRadio (minor API changes)
- **Medium**: FormCheckboxGroup, FormRadioGroup (API restructuring)
- **High**: FormSelect (complete API redesign)
## Installation
```bash
# Remove the legacy package
npm uninstall @frontile/forms-legacy
# Install the new package
npm install frontile @frontile/theme
```
Update your imports:
```js
// Before (forms-legacy)
import FormInput from '@frontile/forms-legacy/components/form-input';
import FormCheckbox from '@frontile/forms-legacy/components/form-checkbox';
// After (forms)
import { Input, Checkbox } from 'frontile';
```
## Recommended Approach: Form + Field Pattern
The modern `frontile` package introduces a **Form + Field pattern** that handles data binding and validation for you. This is the recommended approach for new forms and migrations.
### Why Use Form + Field?
- No manual state management — `form.Field` binds value and errors automatically
- Built-in validation with Valibot, Zod, or a custom validator
- Nested data support via dot notation in field names
### Quick Example
```gts
import Component from '@glimmer/component';
import { Form } from 'frontile';
import type { FormResultData } from 'frontile';
import * as v from 'valibot';
const loginSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Email is required'),
v.email('Please enter a valid email')
),
password: v.pipe(
v.string(),
v.minLength(6, 'Password must be at least 6 characters')
)
});
type LoginSchema = v.InferOutput;
export default class LoginForm extends Component {
schema = loginSchema;
handleSubmit = (result: FormResultData) => {
if (result.isValid) {
// result.data is typed as LoginSchema
this.login(result.data);
}
};
}
```
Compared to manual binding:
- `form.Field` automatically binds the value and errors to the input
- Validation runs automatically based on the schema
- No need to manually manage `@tracked` properties for form data
- `result.data` contains all form values on submit
For complete documentation on the Form component, validation patterns, nested data, and advanced features, see the [Form Component Documentation](https://frontile.dev/docs/components/forms/form).
## Migrating Validation
If you're using manual validation with forms-legacy, you can migrate to schema-based validation with Valibot instead.
### Before (forms-legacy with manual validation)
```gts
import { tracked } from '@glimmer/tracking';
import { Input, Textarea } from '@frontile/forms-legacy';
export default class UserProfileForm extends Component {
@tracked email = '';
@tracked bio = '';
@tracked errors = {};
validateForm = () => {
const errors = {};
if (!this.email) {
errors.email = 'Email is required';
} else if (!this.email.includes('@')) {
errors.email = 'Invalid email format';
}
if (this.bio && this.bio.length < 10) {
errors.bio = 'Bio must be at least 10 characters';
}
return errors;
};
handleSubmit = (event) => {
event.preventDefault();
this.errors = this.validateForm();
if (Object.keys(this.errors).length === 0) {
this.saveProfile({ email: this.email, bio: this.bio });
}
};
}
```
### After (forms with Valibot validation)
```gts
import Component from '@glimmer/component';
import { Form } from 'frontile';
import type { FormResultData } from 'frontile';
import * as v from 'valibot';
const profileSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Email is required'),
v.email('Invalid email format')
),
bio: v.optional(
v.pipe(
v.string(),
v.minLength(10, 'Bio must be at least 10 characters')
)
)
});
type ProfileSchema = v.InferOutput;
export default class UserProfileForm extends Component {
schema = profileSchema;
handleSubmit = (result: FormResultData) => {
if (result.isValid) {
// result.data is typed as ProfileSchema
this.saveProfile(result.data);
}
};
}
```
Compared to the manual version:
- No need for `@tracked` properties or manual state management
- Validation logic is declarative and reusable
- Errors are automatically displayed by Field components
- Validation runs automatically on blur and submit (configurable with `@validateOn`)
For complex validation scenarios or custom validation functions, see the [Form Component Documentation](https://frontile.dev/docs/components/forms/form).
## Nested Data Support
The Form + Field pattern supports nested data structures using dot notation in field names. This makes it easy to work with complex data models without flattening your data structure.
```gts
import Component from '@glimmer/component';
import { Form } from 'frontile';
import type { FormResultData } from 'frontile';
import * as v from 'valibot';
const userSchema = v.object({
user: v.object({
profile: v.object({
email: v.pipe(v.string(), v.email()),
firstName: v.string(),
lastName: v.string()
}),
settings: v.object({
notifications: v.boolean()
})
})
});
type UserSchema = v.InferOutput;
export default class UserSettingsForm extends Component {
schema = userSchema;
handleSubmit = (result: FormResultData) => {
if (result.isValid) {
// result.data.user.profile.email is fully typed
this.saveUserSettings(result.data);
}
};
}
```
The Form component automatically handles data flattening and unflattening. On submit, `result.data` will contain the properly nested structure. See the [Form Component Documentation](https://frontile.dev/docs/components/forms/form) for more details.
## Breaking Changes
### 1. Component Names
All component names have dropped the `Form` prefix:
- `FormInput` → `Input`
- `FormTextarea` → `Textarea`
- `FormCheckbox` → `Checkbox`
- etc.
### 2. Import Strategy
Changed from default imports to named imports from the package index.
### 3. Error Handling
The error handling approach has been simplified:
- Removed `hasSubmitted`, `hasError`, `showError` props
- Use `errors` and `isInvalid` for error states
- Automatic error display based on `errors` presence
### 4. Validation Integration
Better integration with form validation libraries through the new `Form` component.
### 5. CSS Classes
Theme classes have been updated - check `@frontile/theme` for new class names.
## Component Migration
### FormInput → Input
The Input component now supports start/end content slots and clearable functionality.
#### Before (forms-legacy)
```hbs
```
#### After (forms)
```hbs
<:startContent>
<:endContent>
```
#### Key Changes
- `@hint` → `@description`
- `@containerClass` → `@classes={{hash base="..."}}`
- `@inputClass` → `@classes={{hash input="..."}}`
- Removed `@hasSubmitted`, `@hasError`, `@showError`
- Added `@isClearable` option
- Added `<:startContent>` and `<:endContent>` slots
- Added `@startContentPointerEvents` and `@endContentPointerEvents` for click handling
### FormTextarea → Textarea
Minimal changes required for textarea migration.
#### Before (forms-legacy)
```hbs
```
#### After (forms)
```hbs
```
#### Key Changes
- Move `@rows` to attributes (`rows="4"`)
- Removed error state props (`@hasSubmitted`, etc.)
### FormCheckbox → Checkbox
The Checkbox component now has better standalone usage and improved accessibility.
#### Before (forms-legacy)
```hbs
```
#### After (forms)
```hbs
```
#### Key Changes
- **No change** - Still uses `@checked`
- Removed error state props (`@hasSubmitted`, etc.)
### FormCheckboxGroup → CheckboxGroup
CheckboxGroup now uses a component-as-block pattern instead of an items-based API.
#### Before (forms-legacy)
```hbs
{{#each this.interestOptions as |option|}}
{{option.label}}
{{/each}}
```
#### After (forms)
```hbs
{{#each this.interestOptions as |option|}}
{{option.label}}
{{/each}}
```
#### Key Changes
- **No change** - Still uses `@onChange`
- Add `@name` prop for shared name attribute
- Still uses block params, not items-based API
- CheckboxGroup provides shared onChange to child checkboxes
- Manual tracking of selected values still required
#### Data Management (No Change)
```js
// Tracking selected values (same pattern as before)
@tracked selectedInterests = [];
isInterestSelected(value) {
return this.selectedInterests.includes(value);
}
setInterests = (value, isChecked) => {
if (isChecked) {
this.selectedInterests = [...this.selectedInterests, value];
} else {
this.selectedInterests = this.selectedInterests.filter(v => v !== value);
}
};
```
### FormRadio → Radio
Minimal changes required for radio migration.
#### Before (forms-legacy)
```hbs
Premium Plan
```
#### After (forms)
```hbs
Premium Plan
```
#### Key Changes
- `@checked` → `@checkedValue` (same concept, just renamed)
- Both expect the currently selected value, not a boolean
- Removed error state props (`@hasSubmitted`, etc.)
### FormRadioGroup → RadioGroup
RadioGroup now uses a component-as-block pattern instead of an items-based API.
#### Before (forms-legacy)
```hbs
{{#each this.planOptions as |option|}}
{{option.label}}
{{/each}}
```
#### After (forms)
```hbs
{{#each this.planOptions as |option|}}
{{option.label}}
{{/each}}
```
#### Key Changes
- **No change** - Still uses `@onChange`
- Uses `@value` for current selected value
- Add `@name` prop for shared name attribute
- Still uses block params, not items-based API
- RadioGroup automatically passes `@checkedValue` to child radios
### FormSelect → Select
This is the most significant change. The new Select component is completely rebuilt and no longer depends on ember-power-select.
#### Before (forms-legacy)
```hbs
{{country.name}}
```
#### After (forms)
```hbs
{{! Single selection mode (default) }}
```
#### Key Changes
- `@options` → `@items`
- `@selected` → `@selectedKey` (string | null for single selection)
- `@onChange` → `@onSelectionChange` (callback receives string | null for single selection)
- `@searchEnabled` → `@isFilterable`
- Removed `@searchField` (filtering works on label automatically)
- Use `<:item>` slot instead of block param
- Built-in filtering instead of external dependency
#### Data Format Migration
```js
// Before: Object-based selection
@tracked selectedCountry = null;
@tracked countries = [
{ id: 1, name: 'United States', code: 'US' },
{ id: 2, name: 'Canada', code: 'CA' }
];
setCountry = (country) => {
this.selectedCountry = country;
};
// After: Key-based selection (single mode)
@tracked selectedCountryKey = null;
@tracked countries = [
{ key: 'us', label: 'United States', code: 'US' },
{ key: 'ca', label: 'Canada', code: 'CA' }
];
setCountry = (key) => {
this.selectedCountryKey = key;
};
```
#### Multiple Selection
```hbs
{{! Multiple selection }}
```
```js
// Multiple selection data handling
@tracked selectedCountryKeys = [];
setCountries = (keys) => {
this.selectedCountryKeys = keys; // receives array of strings
};
```
#### Advanced Select Features
```hbs
{{! Single selection with advanced features }}
```
```js
// Handler for single selection
onChange = (key) => {
this.selectedKey = key; // receives string | null
};
```
## New Components
### Form Component
The new Form component provides automatic form data extraction, validation, and handling. See the [Recommended Approach section](#recommended-approach-form--field-pattern) above for a complete example with validation.
### Switch Component
A new toggle/switch component not available in forms-legacy.
```hbs
{{! Controlled mode }}
<:startContent>
{{! Uncontrolled mode }}
<:startContent>
```
### NativeSelect Component
For simple dropdown needs without the complexity of the full Select component.
```hbs
<:item as |item|>
{{item.label}}
```
## Common Patterns
### Error Handling
```hbs
{{! Before: Multiple error state props }}
{{! After: Simplified approach }}
```
### Custom Styling
```hbs
{{! Before: Individual class props }}
{{! After: Classes object }}
```
For form validation patterns, see the [Migrating Validation](#migrating-validation) section above.
## Migration Checklist
### 1. Package Setup
- [ ] Uninstall `@frontile/forms-legacy`
- [ ] Install `frontile` and `@frontile/theme`
- [ ] Update `@frontile/theme` to compatible version
- [ ] Update imports to use named imports
### 2. Choose Your Migration Strategy
**Option A: Full Migration to Form + Field (Recommended)**
- [ ] Install validation library (`valibot` or `zod`)
- [ ] Define validation schemas for your forms
- [ ] Wrap forms with `` component
- [ ] Remove manual state management (`@tracked` properties)
- [ ] Update submit handlers to use `result.data`
**Option B: Component-Level Migration**
- [ ] Rename all `Form*` components (drop `Form` prefix)
- [ ] Update error handling props (remove `@hasSubmitted`, `@hasError`)
- [ ] Migrate `@hint` to `@description`
- [ ] Update CSS class props to `@classes` object
- [ ] **Radio**: Rename `@checked` to `@checkedValue`
- [ ] **Select**: Convert to key-based selection (see [Select Migration](#formselect--select))
### 3. Testing
- [ ] Test all form interactions
- [ ] Verify error states display correctly
- [ ] Test validation behavior
- [ ] Verify keyboard navigation and accessibility
### 4. Optional Enhancements
- [ ] Add `@isClearable` to appropriate inputs
- [ ] Use start/end content slots for icons or buttons
- [ ] Consider using `Switch` component for toggles
- [ ] Implement nested data patterns where beneficial
---
For detailed Form component documentation, see [frontile.dev/docs/forms/form](https://frontile.dev/docs/components/forms/form).
---
# Migrating from Changeset Form
Source: /docs/migrations/v0-18/changeset-form.md
# Migrating from Changeset Form
This guide covers migrating from the deprecated `@frontile/changeset-form` package to the modern `frontile` forms. We recommend the Form + Field pattern with Valibot validation, and also cover paths for teams that need to keep `ember-changeset` validation.
## Overview
The `@frontile/changeset-form` package is deprecated and will be removed in 0.19.0. This guide presents three migration paths based on your project's constraints and goals.
## Key Architectural Patterns
**Form + Field pattern** (Approach 1): Automatic data binding, validation, and state management via `` components.
**Standalone components** (Approaches 2 & 3): Use form components directly with manual binding and validation integration.
See the [Form documentation](https://frontile.dev/docs/components/forms/form) for detailed architecture information.
## Migration Approach Overview
You have three main migration paths, **ordered by recommendation**:
| Approach | Validation | Components | Effort | Tech Debt | Best For |
| --------------------------------------- | ---------- | ------------------------------------ | ---------- | --------- | ---------------------------------------------- |
| **1. Modern Forms + Valibot** | Valibot | Form + Field pattern | Low-Medium | None | New features, best long-term choice |
| **2. Changeset + Modern Components** | Changeset | Standalone modern components | Medium | Medium | Keep validation, modernize UI |
| **3. Changeset + Forms-Legacy** | Changeset | Legacy components | Medium | High | Large codebases needing gradual migration only |
**Recommendation:** choose Approach 1 (Modern Forms + Valibot) unless you have a specific constraint that requires changeset validation.
## Before You Start
Build the package you'll use: `pnpm --filter frontile build` (Approaches 1 & 2) or `pnpm --filter forms-legacy build` (Approach 3)
---
## Approach 1: Modern Forms + Valibot (Recommended)
Uses the modern Form + Field pattern with Valibot validation.
### When to Choose This Approach
- Starting new features or components — a clean break from legacy patterns
- Modernizing existing forms
- Want type-safe, TypeScript-first validation schemas
- Want simpler validation logic without changeset dependencies
### Step 1: Install Dependencies
```bash
# Remove old packages
npm uninstall @frontile/changeset-form @frontile/forms-legacy
npm uninstall ember-changeset ember-changeset-validations
# Install new packages
npm install frontile @frontile/theme valibot
```
### Step 2: Migrate to Form + Field Pattern
#### Before (changeset-form)
```hbs
{{#let (changeset this.model this.validations) as |changeset|}}
{{option}}
{{/let}}
```
#### After (Modern Forms + Valibot)
Complete GTS component with template:
```gts title="app/components/user-form.gts" collapsible
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Form, type FormResultData } from 'frontile';
import { Button } from 'frontile';
import * as v from 'valibot';
// Define schema outside class (replaces changeset validations)
const UserFormSchema = v.object({
user: v.object({
firstName: v.pipe(v.string(), v.nonEmpty('Required')),
email: v.pipe(v.string(), v.nonEmpty('Required'), v.email('Invalid'))
}),
country: v.pipe(
v.fallback(v.string(), ''), // Required for Select fields
v.string(),
v.nonEmpty('Please select a country')
),
plan: v.pipe(
v.fallback(v.string(), ''),
v.string(),
v.nonEmpty('Please select a plan')
)
});
type UserFormSchema = v.InferOutput;
export default class UserFormComponent extends Component {
@tracked formData: UserFormSchema = {
user: { firstName: '', email: '' },
country: '',
plan: ''
};
countries = [
{ key: 'us', label: 'United States' },
{ key: 'ca', label: 'Canada' }
];
handleChange = (result: FormResultData) => {
this.formData = result.data;
};
handleSubmit = async (result: FormResultData) => {
// Data is already validated
await this.saveUser(result.data);
};
async saveUser(data: UserFormSchema) {
// Your API call
}
}
```
For advanced features like dirty tracking, loading states, and validation timing control, see the [Form documentation](https://frontile.dev/docs/components/forms/form).
---
## Approach 2: Keep Changeset, Use Modern Components
This approach keeps ember-changeset validation while using modern `frontile` form components in **standalone mode** (without Form/Field wrappers).
**Important:** This approach uses modern form components (Input, Select, Switch, etc.) directly, NOT the Form or Field components. You maintain manual control over data binding and validation.
### When to Choose This Approach
- Complex changeset validation you don't want to rewrite
- Need modern UI features — slots, clearable, filtering, better accessibility
- Modernizing components now and validation later
- Team is familiar with changeset and wants to minimize the learning curve
- Willing to accept the extra boilerplate that manual binding requires
### Step 1: Install Modern Forms
```bash
npm uninstall @frontile/changeset-form
npm install frontile @frontile/theme
# Keep: ember-changeset ember-changeset-validations
```
### Step 2: Standalone Components with Changeset
Use modern components directly without Form/Field wrappers:
#### Before (changeset-form)
```hbs
{{#let (changeset this.model this.validations) as |changeset|}}
{{option}}
{{/let}}
```
#### After (Changeset + Modern Components Standalone)
```hbs
{{#let (changeset this.model this.validations) as |changeset|}}
{{/let}}
```
### Step 3: Component Class
Key changes: manual binding and Select key-based selection.
```typescript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class ChangesetFormComponent extends Component {
@tracked hasSubmitted = false;
// Keep existing changeset validations
validations = {
firstName: validatePresence(true),
country: validatePresence(true),
plan: validatePresence(true)
};
countries = [
{ key: 'us', label: 'United States' },
{ key: 'ca', label: 'Canada' }
];
@action
updateField(changeset, fieldName, value) {
changeset.set(fieldName, value);
changeset.validate(fieldName);
}
// Select components use keys, need conversion for changeset
@action
updateCountry(changeset, selectedKey) {
changeset.set('country', selectedKey);
changeset.validate('country');
}
getSelectedCountry(changeset) {
return changeset.get('country') || null;
}
@action
async handleSubmit(changeset, event) {
event.preventDefault();
await changeset.validate();
this.hasSubmitted = true;
if (changeset.isValid) {
await changeset.save({});
}
}
getFieldErrors(changeset, fieldName) {
if (!this.hasSubmitted) return [];
return changeset.errors
.filter(e => e.key === fieldName)
.map(e => e.validation)
.flat();
}
}
```
---
## Approach 3: Keep Changeset, Use Forms-Legacy
This is the minimal-effort approach for large codebases that need gradual migration. It keeps changeset validation while using legacy form components.
**Warning:** this approach carries the most technical debt of the three, and should only be chosen when time or resource constraints rule out the other two.
### When to Choose This Approach
- Very large codebase — hundreds of forms to migrate
- Limited development resources, need the smallest possible effort now
- Extensive custom changeset validation logic
- A short-term solution you plan to revisit later, accepting the technical debt as a stopgap
### Step 1: Install Forms-Legacy
```bash
npm uninstall @frontile/changeset-form
npm install @frontile/forms-legacy
# Keep: ember-changeset ember-changeset-validations
# Build the package
pnpm --filter forms-legacy build
```
### Step 2: Key Changes
Replace `ChangesetForm` wrapper with manual changeset binding to `@frontile/forms-legacy` components:
```hbs
{{! Before }}
{{! After }}
```
See the [Forms Legacy Migration Guide](forms-legacy.md) for complete component mappings and detailed migration steps.
**Recommendation:** Plan to migrate to Approach 1 when resources allow.
---
## Common Gotchas
Key API changes when migrating to modern forms:
### 1. Radio: `@checked` → `@checkedValue`
```hbs
{{! Legacy }}
{{! Modern }}
```
In Approach 1, use `field.RadioGroup` which handles this automatically.
### 2. Select: Object-Based → Key-Based
```hbs
{{! Legacy (object selection) }}
{{! Modern (key selection) }}
{{! Just the key string }}
```
Modern Select components use string keys instead of full objects. Add `v.fallback(v.string(), '')` to validation schemas for Select fields.
### 3. Form + Field vs Standalone
**Approach 1** uses Form + Field: ``
**Approaches 2 & 3** use standalone components with manual binding: ``
Don't mix these patterns in the same form.
---
## Additional Resources
- [Form documentation](https://frontile.dev/docs/components/forms/form) - Complete Form + Field pattern guide
- [Forms Legacy Migration Guide](forms-legacy.md) - Component-specific migration details
- [Valibot documentation](https://valibot.dev/) - Validation schema reference
---
# DOM Anatomy Attributes Migration
Source: /docs/migrations/v0-18/anatomy-attributes.md
# DOM Anatomy Attributes Migration Guide
v0.18 gives every Frontile component a stable, documented DOM anatomy: two
attributes, `data-component` and `data-part`, replace three older and
inconsistent conventions — ad hoc `data-component` values that didn't match
any real hierarchy, one-off `data-fr-*` attributes on `Accordion`, and most of
the `data-test-id` attributes that existed purely as selectors.
**See also:** [Customizing Component Styles](../../theming/component-styles.md)
for the full contract — what the two attributes mean, how scoping works, and
its known limitation with nested components.
## Impact
**Required only if you select Frontile-rendered elements** — in your own
CSS, in `querySelector`/`closest` calls, or in tests — using any of the
retired attributes below. If you only pass `@classes`/`@class` or use
components through their public API, nothing changes for you.
## `data-component` renames
Several elements carried a `data-component` value that named something other
than the actual `tv()` config, or that belonged on an element that isn't a
real anatomy root. Each now carries the correct `data-component` (the
kebab-cased `tv()` config name, on the component's outermost element) plus a
`data-part` for the slot it renders, if any.
| Component | Old | New |
| --- | --- | --- |
| Alert | `data-component="alert"` (unchanged) + `data-test-id="alert"` | `data-component="alert"` `data-part="base"` |
| Autocomplete trigger `` | `data-component="autocomplete-trigger"` + `data-test-id="trigger"` | `data-part="input"` (Autocomplete's own `input` slot; the trigger is not a separate component) |
| Checkbox `` | `data-component="checkbox"` (on the ``) | `data-component="checkbox"` moves to Checkbox's root; the `` becomes `data-part="input"` |
| Command | `data-test-id="command"` alongside `data-component="command"` | `data-component="command"` `data-part="base"` |
| Command Dialog | `data-test-id="command-dialog"` | `data-component="command-dialog"` `data-part="base"` |
| Command Dialog panel | `data-test-id="command-dialog-panel"` | `data-part="panel"` |
| Command Footer | `data-component="command-footer"` + `data-test-id="command-footer"` | `data-part="footer"` (a part of `command`, not its own component) |
| Command Input | `data-component="command-input"` + `data-test-id="command-input"` | `data-part="input"` |
| FormControl live region | `data-component="form-feedback-live-region"` | `data-test-id="form-feedback-live-region"` (demoted — see [Kept as `data-test-id`](#kept-as-data-test-id) below) |
| Input `` | `data-component="input"` (on the ``) | `data-component="input"` moves to Input's root (the `FormControl` wrapper); the `` becomes `data-part="input"` |
| InputOtp container | `data-component="input-otp"` (on the container `
`) | `data-component="input-otp"` moves to the root; the container becomes `data-part="container"` |
| InputOtp `` | `data-component="input-otp-input"` | `data-part="input"` |
| NativeSelect `