Skip to content

feat(remix)!: add RemixLink and rebuild FortalLink on it - #151

Merged
leoafarias merged 8 commits into
mainfrom
feat/remix-link
Aug 14, 2026
Merged

feat(remix)!: add RemixLink and rebuild FortalLink on it#151
leoafarias merged 8 commits into
mainfrom
feat/remix-link

Conversation

@leoafarias

@leoafarias leoafarias commented Aug 14, 2026

Copy link
Copy Markdown
Member

Description

Adds a first-class Link component to remixLinkSpec, the generated LinkStyler, and RemixLink built on naked_ui's NakedLink — and rebuilds FortalLink on top of it, replacing the hand-rolled NakedButton + outer Semantics(link:) workaround. A link publishes the Link role and activates on Enter and Numpad Enter, where a button publishes the Button role and also takes Space. onPressed: null and enabled: false mean the same thing, matching Flutter's universal convention that a null callback disables a control; for text that was never meant to navigate, use ordinary text rather than a link. fortalLinkStyle absorbs the old _fortalInteractiveLinkStyle and returns the complete style, so FortalLink sheds its mirrored focus field, its duplicated inert widget path, and its excludeSemantics workaround, and build no longer branches on actionability.

Requires NakedLink, which shipped in naked_ui 1.0.0-beta.11 (conceptadev/naked_ui#65). The temporary branch override used during development was reverted in 9435057; the naked_ui floor is raised to the released version in both package pubspecs and in the parity contract, which move together by design.

Related Issues

Closes #137
Closes #143


Checklist

Note: Updating the pubspec.yaml and CHANGELOG.md is not required. These are handled automatically during the release process.

  • 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

Does this PR require users of the package to manually update their code?

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

Four breaks, all in remix_fortal:

  1. FortalLink no longer activates on Space (FortalLink activates on Space; links must only activate on Enter #143). Use FortalButton where Space should activate.
  2. fortalLinkStyle returns LinkStyler instead of BadgeStyler, and its hovered and focused parameters are gone — they were the only raw widget-state parameters on any Fortal recipe and existed solely to build the variant snapshots the recipe now assembles itself. Pass actionable and let the returned style resolve its own states.
  3. A disabled FortalLink no longer publishes the Link role, because NakedLink gates the role on effective-enabled. This now covers onPressed: null as well as enabled: false — a callback-less link reports hasEnabledState and announces as unavailable, where it previously published a plain text node with no interactive metadata.
  4. A disabled FortalLink is no longer reachable under NavigationMode.directional. The old NakedButton-backed recipe left disabled controls traversable on directional platforms; NakedLink does not.

Verification

melos run ci passes end to end: toolchain:check, mix:consumer:check, material:check, generate:check, docs:check, fortal:parity:check (verifying hosted Naked 1.0.0-beta.11), and test:flutter — remix 2614, remix_fortal 378, dashboard 48, demo 1. flutter analyze and dart format clean.

RemixLink and FortalLink are built on NakedLink, which is unreleased and
lives on conceptadev/naked_ui#65. Isolated in its own commit so it reverts in one
step once that PR ships and the naked_ui floor can be raised instead.

The Fortal parity contract fails while this override is in place, by
design: tool/fortal_parity/check.dart guards that the workspace resolves a
hosted naked_ui. That guard is the merge blocker for this branch.
Adds a first-class Link component to `remix`: LinkSpec, the generated
LinkStyler, and RemixLink built on naked_ui's NakedLink. A link publishes
the Link role and activates on Enter, where a button publishes the Button
role and also takes Space.

FortalLink now delegates to RemixLink instead of wrapping NakedButton in a
hand-built Semantics node. That deletes the mirrored focus field, the
duplicated inert widget path, and the excludeSemantics workaround the old
recipe needed because NakedButton's ExcludeSemantics sits inside its focus
node. fortalLinkStyle absorbs _fortalInteractiveLinkStyle and now returns
the complete style, so the widget no longer branches on actionability.

A link with no onPressed renders as prose rather than through NakedLink:
the primitive folds `enabled` and `onPressed` into one effective-enabled
flag and publishes hasEnabledState for both, which would announce ordinary
body text as unavailable. Both paths resolve their style against their own
widget states, so one LinkStyler behaves the same whether or not the link
is actionable.

BREAKING CHANGE: FortalLink no longer activates on Space. Space belongs to
the Button role; a link takes Enter and Numpad Enter, matching an anchor on
the web. Use FortalButton where Space should activate.

BREAKING CHANGE: fortalLinkStyle returns LinkStyler instead of BadgeStyler,
and its `hovered` and `focused` parameters are gone. They were the only raw
widget-state parameters on any Fortal recipe and existed solely to build
the variant snapshots the recipe now assembles itself. Pass `actionable`
and let the returned style resolve its own states.

BREAKING CHANGE: a disabled FortalLink no longer publishes the Link role.
NakedLink gates the role on effective-enabled, so `enabled: false` with a
callback announces as unavailable text rather than an unavailable link.
NakedLink shipped in naked_ui 1.0.0-beta.11, so RemixLink and FortalLink
resolve it from pub.dev instead of the branch override reverted in the
previous commit.

The two package constraints and the parity contract's pinned version move
together on purpose: tool/fortal_parity/check.dart asserts that both
pubspecs name the same constraint and that the lockfile resolves that exact
version from a hosted source, so bumping fewer than all three keeps the
guard red.
RemixLink special-cased `onPressed: null` into its own render path so an
inert link would announce no enabled state, on the reasoning that HTML's
`<a>` without an href is not a link. That reasoning imported the wrong
model. Flutter's convention is the opposite and universal: a null callback
disables a control, on ElevatedButton, IconButton, NakedButton, and the
rest. NakedLink follows that convention, and choosing the link widget at
all is what declares intent, so a link with no callback is a disabled link.

Deleting the special case removes the second render path, the empty
WidgetStateProvider that path needed, and the fork that let one LinkStyler
resolve two different ways. That state fork was not hypothetical: it was
the source of the ancestor-hover leak this branch previously found and
patched. With one path there is nothing to leak, and the regression test
now passes for both spellings of disabled.

No consumer relied on the old behaviour. Every callback-less call site in
the workspace was a gallery tile demonstrating the inert state itself, and
those now name the state honestly. The playground's accent row was the one
real casualty: it used a callback-less link to show accent colour and would
have silently rendered the disabled treatment, so it gets a callback.

BREAKING CHANGE: a FortalLink or RemixLink with no `onPressed` now reports
`hasEnabledState` and announces as unavailable, where it previously
published a plain text node with no interactive metadata. Use ordinary text
such as `FortalText(accent: true)` for prose that was never meant to
navigate.
Adopts the `@MixableSpec(target:)` generation that landed in #152. The
hand-written `RemixLinkStylerRemixHelpers` extension is deleted; the
generator now emits an identical `LinkStyler.call` into link.g.dart, so the
constructor and its callable form can no longer drift apart.

link_style.dart held nothing but that extension, so it goes entirely, the
way badge's and button's did. The style files that survive #152 keep other
helpers besides `call`.

Adds LinkStyler to the generated-call test's non-generic group. It needs a
label because RemixLink asserts on having a name.
A link has no fill, border, or padding, so its container slot exists for
one reason: hosting the focus-visible ring. That made the box look like
dead weight, and nothing in the suite disagreed — deleting
RemixBoxWithEffects from RemixLink kept all 378 Fortal typography tests
green, including the two named for focus.

Those tests read the resolved LinkSpec out of its StyleSpecProvider, so
they prove the ring is computed, never that it is drawn. Without the box
the effects resolve exactly as before and paint nothing.

This asserts the resolved effects arrive at the widget that paints them,
and fails if the box is removed. It reaches past the barrel for the
internal painter type deliberately; that wiring is the thing under test.
@leoafarias
leoafarias merged commit 7505b66 into main Aug 14, 2026
3 checks passed
@leoafarias
leoafarias deleted the feat/remix-link branch August 14, 2026 19:34
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.

FortalLink activates on Space; links must only activate on Enter FortalLink: retire the mirrored focus state (NakedLink contract conflict)

1 participant