Skip to content

feat(registry)!: adopt the new Remix APIs in the Fortal preset - #346

Draft
leoafarias wants to merge 17 commits into
stack/follow-ups-integrationfrom
feat/registry-fortal-adopt-remix-apis
Draft

leoafarias wants to merge 17 commits into
stack/follow-ups-integrationfrom
feat/registry-fortal-adopt-remix-apis

Conversation

@leoafarias

@leoafarias leoafarias commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Draft, stacked on #339. Do not merge until remix 1.0.0-beta.14 and naked_ui 1.3.0 are on pub.dev.

Fortal adopts the new Remix APIs.

  • Focus rings: the toggle, toggle group item, tab, accordion trigger, disclosure trigger, and sidebar destination draw their ring as an outline through containerEffects or triggerEffects.
    • Focusing no longer moves or resizes the part.
    • Tabs match Radix's outline: 2px solid var(--focus-8); outline-offset: -2px. The Fortal-only parts borrow that ring in focus-8 through one helper, fortalTabFocusOutline.
    • Every ring follows the focus ring tokens.
  • Sortable data-table header ring: a focused sortable header draws a focus-8 ring inside its own cell, like a tab.
    • Radix Themes 3.3.0 draws nothing on a table header and has no sortable header, so this is a Fortal extension: a focusable header needs a visible focus indicator. It is recorded in the parity manifest.
    • The ring is drawn by the header row, which already paints the divider at rest, so a stateful custom header keeps its state across focus. While focused, the ring replaces the divider on the cell's bottom edge.
    • A non-sortable header no longer lights up under a focused Pressable or disclosure trigger (fix(remix): keep outside widget state out of table headers, panel content, and sidebar slots #340).
    • At focusRingWidth: 0 nothing is drawn. A focusable control nested in a header keeps the ring lit while it holds focus.
  • Select panel: it is at least as wide as its trigger through remixAnchorWidth(), as Radix's min-width: var(--radix-select-trigger-width) does (select.css:45). A maxWidth below the trigger width must also set minWidth.
  • Menu triggers:
    • The data-table kebab and the top-bar account avatar are real icon buttons passed to RemixMenuTrigger.control. Each has one tab stop and expanded semantics on the button, and Escape returns focus to it. The visible change is focus and hover feedback.
    • The sidebar account row stays a visual trigger. It now draws a ring on keyboard focus through a RemixCard with containerEffects, outside the row content, so focus neither covers the avatar nor rebuilds the row.
  • Demos: the radio and switch demos pass label.

Validation

  • registry_source: 2330 tests pass. apps/dashboard: 84 pass. apps/demo: 64 pass.

  • New tests cover:

    • focus-ring size and paint;
    • the sidebar destination, expanded and collapsed, in a scrolling sidebar;
    • the sortable header ring;
    • the select panel width and its maxWidth override;
    • the focus ring on every dashboard menu trigger.

    The regression tests fail before their changes.

  • dart analyze, dart format, open-code:registry:build, build_registry --check, open-code:apps:check, melos run ci:checks (including fortal:parity:check and docs:check), melos run generate:check, and docs:catalog --check: pass.

  • check_open_code --preset fortal --source checkout: pass.

Merge note

Merge after #342. Both PRs add tests to registry_source/test/dashboard/dashboard_demo_test.dart at the same place, so rebasing this PR onto a main with #342 conflicts there: keep both test sets, and keep the unrestricted package:flutter/services.dart import. The full-stack check that merges #339, #342, #343, and this PR shows the result. Once rebased, point the Vanilla ring tests at this PR's shared test/helpers/pixels.dart instead of #342's private copy.

Related Issues

Stacked on #339. Builds on #320, #328, #331, and #340. Follows up the Fortal focus-ring finding tracked in the PR D plan.


Checklist

  • My PR includes unit or integration tests for all changed/updated/fixed behaviors.
  • I have updated or added relevant documentation (doc comments with ///).
  • I am prepared to follow up on review comments in a timely manner.

Breaking Change

  • Yes, this is a breaking change.
  • No, this is not a breaking change.

These apply to installed Fortal source and need remix 1.0.0-beta.14 and naked_ui 1.3.0. Overwrite theme, toggle, toggle_group, tabs, accordion, disclosure, data_table, select, and dashboard_demo together. sidebar needs no overwrite.

  • fortalFocusRing, FortalFocusRing, and FortalFocusRingFlexBox are removed. Put fortalTabFocusOutline(), or fortalFocusOutline(color, offset:), in containerEffects or triggerEffects.
  • Toggles, toggle groups, accordions, disclosures, and sidebar destinations draw their ring in focus-8 instead of focus-a8, and focusing no longer resizes or moves them.
  • FortalActionMenu takes a RemixMenuTrigger instead of semanticLabel and trigger: Widget. fortalDataTableActionsTrigger is now a function of the accessible label that returns a RemixMenuTrigger. The top-bar account menu is now an icon button.
  • The Fortal select panel is at least as wide as its trigger. A maxWidth below the trigger width must also set minWidth.

No package API changes. No registry floor or package version is bumped.

Toggle, toggle-group item, tab, disclosure and accordion triggers drew their
focus ring as an inside border, which resized the part and shifted its
neighbors by twice the ring width on focus. They now draw it through
containerEffects (triggerEffects on accordion and disclosure), which paints
over the box without taking layout space.

Tabs ring in focus-8 at outline-offset -2, as base-tab-list.css:126-129 does.
The Fortal-only parts keep their focus-a8 color and share the same inset -2
offset, so the clip of a toggle group or a panel never crops the ring. Every
ring still reads the focus ring width token. A sidebar destination keeps its
border ring, because RemixSidebar paints a destination's container but not its
containerEffects.

fortalFocusRing and its two extensions are replaced by fortalInsetFocusOutline.
The size and paint tests drive each part with the keyboard, in light and dark,
at the default and an overridden ring width.
…button

The data-table kebab passed a styled Box to RemixMenuTrigger.builder, which
fakes a button: it is not focusable on its own and has no button semantics.
It is now a FortalIconButton through RemixMenuTrigger.control, given the
handle's focusNode and toggle, so the menu keeps one tab stop, the expanded
state merges onto the button, and Escape returns focus to it.

FortalActionMenu takes the RemixMenuTrigger itself instead of a label and a
widget. The sidebar account row and the top-bar avatar stay visual-content
triggers.
The radio demo composed a Row with a Text beside each radio, and the switch
demo named itself with semanticLabel only. Both pass label now, so the text is
part of the control's tap, hover and focus target and its accessible name.
Radix gives the select content `min-width: var(--radix-select-trigger-width)`
(select.css:45), so a short option list never draws a panel narrower than its
trigger. Fortal set no width, so the panel was as wide as its widest option.
The content styler now reads remixAnchorWidth() as its minimum width. A longer
option still widens the panel past the trigger.

Fortal's content has no minimum of its own, so the token goes straight on the
content box.
Radix Themes 3.3.0 draws nothing on a focused table header: table.css has no
focus rule and its Table has no sortable header. A sortable header is a Fortal
extension and a focusable control, so it needs a visible focus indicator. It
rings in focus-8 at the focus ring width token, inside its cell like a tab.

The ring is a foreground border that is present and transparent at rest, and
focus changes only its color. A decoration that appeared on focus would swap
the cell's root widget and rebuild a stateful custom header on every focus
change. A non-sortable header resolves against its own empty widget-state
scope, so a focused Pressable or disclosure trigger around the table no longer
lights it.

The row divider paints over the ring's last pixel and tints it gray-a5; the
inside stroke does not avoid that, so it is recorded in the data_table
flutterExceptions of the parity manifest and the catalog.

The parity test that counted row dividers now selects them by their bottom-only
border, so the header ring is not counted.
RemixSidebar destinations now paint containerEffects, so a destination takes
the outline ring of the toggle it derives from. The inside border that
stood in for it is gone. Left in place it would have painted over the same
pixels as the outline, doubling the ring's alpha, and it inset the destination's
content by the ring width on focus.

The sidebar test now focuses a destination from the keyboard and checks that
its icon does not move, and that the ring is present on all four edges and
nothing paints outside it, in light and dark, expanded and collapsed. The
sidebar is shorter than its destinations and the focused one sits flush against
the bottom of the scroll region, so the inset ring is shown not to be clipped
there.
The ring was a foreground border on the header cell, transparent at rest. The
row divider, a foreground border on the outer row region, painted over its
bottom pixel and tinted it, and a zero focus ring width drew a hairline.

Each header cell resolves its own row region under its own widget-state scope,
so the ring now goes on that region: the focused cell rings on all four sides,
and its neighbours do not. The region already paints the divider at rest, so
focus only changes a decoration that is always there and a stateful custom
header still keeps its state. A width of zero leaves the divider alone.

While focused, the ring replaces the divider on the cell's bottom edge. A
focusable control nested in a header keeps the ring lit while it holds focus;
that is recorded in the parity manifest, which no longer mentions a divider
tint. The parity test that counted dividers is back to its original form.
…e helper

Every ring in the pinned Radix CSS is solid focus-8 except the soft variants,
and every other Fortal ring is focus-8 or the accent. The toggle, toggle-group
item, accordion and disclosure triggers, and the sidebar destination, which
derives from the toggle, rang in focus-a8. They now share the tab's ring:
fortalTabFocusOutline is focus-8 at outline-offset -2 and replaces both
fortalInsetFocusOutline and the tab's own copy of the same call. The name says
whose ring it is instead of how far inset it sits, which is not unique to it.

The control_styles and fortal.md descriptions called every offset except the
token one a Radix literal, and listed -1 for only some of its parts. They now
say that classic and solid buttons, checkboxes, radios, switches, and links
draw outward at the offset token, name every part at -1 (including the ghost,
outline, and surface buttons, the icon buttons, and the segmented control), and
say that the toggle-like parts borrow the tab ring rather than a Radix literal.
The sidebar account row and the top-bar avatar were menu triggers that took
keyboard focus and drew nothing, so a keyboard user could not see where they
were.

The account row stays a visual-content trigger, since it is a row and not a
button. Its content resolves under the menu's widget-state scope, so it now
draws the focus-8 ring as a foreground border on focus. The top-bar avatar
becomes a real icon button through RemixMenuTrigger.control, like its toolbar
neighbors, so it gets their hover and focus treatment and its own button
semantics. The apps' toolbar test now counts four square icon buttons.

fortalFocusRingDecoration builds the ring for boxes that take no box effects.
The data-table header row uses it too, so the zero-width case lives in one
place: a zero-width BorderSide is a hairline, so the ring is null there.

A pixel test for each trigger takes keyboard focus and checks the ring on all
four edges, and that the kebab, which already rings through the ghost icon
button, keeps doing so. The avatar test also pins one named, expandable button.
…th rule

Add an Unreleased entry to the Fortal changelog for the installed-source
changes in this stack: focus rings painted as outlines in focus-8, with
fortalFocusRing, FortalFocusRing, and FortalFocusRingFlexBox removed and
fortalTabFocusOutline as the replacement; FortalActionMenu taking a
RemixMenuTrigger and fortalDataTableActionsTrigger becoming a function; the
select panel's minimum width. It names the items to overwrite together and the
remix and naked_ui releases they need.

The select panel is now at least as wide as its trigger, which replaces a
minimum a caller sets on the content. The fortalSelectStyle doc comment and a
new Select section in the Fortal skill reference say so, and that a maxWidth
below the trigger must also set minWidth because a minimum above a maximum is
invalid. A test pins that the documented override caps the panel.
The generator copies fortalSelectStyle's doc comment onto FortalSelect. The
previous commit added the select width note to that comment but not to the
generated class, so generate:check reported select.g.dart as changed.
The sidebar account row drew its focus ring as a foreground border that existed
only while focused. Mix's Container inserts a DecoratedBox when a foreground
decoration appears, so focusing rebuilt the row's subtree and the avatar became
a new element. The ring was also a square, unpadded band painted inside the
row, over the avatar's left 2px, and it grew inward at larger ring widths.

The row content is now wrapped in a RemixCard with the row's radius, which has
an effects slot, and draws fortalFocusOutline in containerEffects on
focus-visible, as the Vanilla account row does. The ring sits at the focus ring
offset outside the row, so it covers none of the content, keeps the content's
state, follows the ring tokens, and draws nothing at a width of zero.

fortalFocusRingDecoration now has a single caller, the data-table header row,
so it becomes that recipe's private helper again. The top-bar avatar button
passes a null icon, since its iconBuilder draws the avatar and ignores it.

The row tests host the row in a sidebar footer and check, in light and dark,
that the ring paints around the row and leaves the avatar's pixels alone, that
the avatar element survives focus, and that a width of zero paints nothing.
…ix two stale notes

The changelog said FortalFocusRingFlexBox was removed but not what replaces it.
It rang a plain FlexBoxStyler, which has no effects slot, so there is no helper:
the entry now says to wrap the content in a RemixCard and use fortalFocusOutline
in its containerEffects, or to draw a foreground border.

The Fortal skill reference's ring colour sentence omitted the gray-8 ring of a
disabled field and the error-8 ring of an errored one. Eval 13 still expected
builder triggers without a ring for the avatar, profile row, and kebab; it now
expects real buttons through RemixMenuTrigger.control for the avatar and kebab,
and a builder that draws its own ring for the profile row.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant