Skip to content

feat: add naked link - #65

Merged
leoafarias merged 20 commits into
mainfrom
feat/naked-link
Aug 14, 2026
Merged

feat: add naked link#65
leoafarias merged 20 commits into
mainfrom
feat/naked-link

Conversation

@leoafarias

@leoafarias leoafarias commented Jul 13, 2026

Copy link
Copy Markdown
Member

Description

Adds a dependency-free NakedLink primitive for navigation surfaces that need Link—not Button—behavior. It gives downstream design systems a reusable Link role, Enter-only keyboard activation, focus handling, and observable interaction state without coupling the core package to a router or launcher.

This directly addresses the downstream FortalLink gaps documented in conceptadev/remix#137 and conceptadev/remix#143: the current Button-based workaround mirrors focus state, hand-builds Link semantics, and incorrectly activates on Space.

Behavior

  • Exposes immutable NakedLinkState through the builder and state scope, including hover, focus, press, disabled state, and optional destination metadata.
  • Treats the Link as interactive only when enabled && onPressed != null; inert Links have no Link role, URL, tap action, or focus stop.
  • Activates once for primary tap, semantic tap, Enter, and Numpad Enter. Space, held-key repeats, canceled gestures, and secondary click remain unclaimed.
  • Keeps routing, launching, destination validation, visited styling, and modified-click policy caller-owned.
  • Accepts optional linkUrl semantics metadata. Flutter web maps it to an anchor href, so callback-owned navigation examples intentionally omit it to avoid two navigation owners.
  • Reuses the package's existing focus/state infrastructure and adds no dependencies or unrelated framework changes.

Documentation

  • Adds root and package README usage, API documentation, a complete docs page, an executable example, registry entry, and changelog entry.
  • Documents when to use a Link instead of a Button, effective-disabled behavior, accessibility naming, activation keys, and the web href ownership caveat.

Visual Evidence

Desktop activation and hover feedback:

NakedLink desktop activation and hover state

Inert/disabled behavior at a 390 px viewport:

NakedLink disabled state at a narrow viewport

Preview Deployment

The repository's hosted example deploys from main. PR branches do not receive an isolated preview deployment, so the hosted example will include NakedLink only after this PR merges.

Validation

  • dart fix --apply — no fixes available.
  • dart format --output=none --set-exit-if-changed . — 154 files checked, 0 changed.
  • flutter analyze — no issues found.
  • Package suite — 703 passed, 3 skipped.
  • Example suite — 26 passed, 3 skipped.
  • Aggregate integration suite — 101 passed, 1 skipped.
  • Focused Link package/semantics tests — 19 passed.
  • Focused Link example tests — 3 passed.
  • Focused Link integration — passed on flutter-tester and macOS.
  • Chrome example walkthrough — verified Link semantics, click and Enter activation, Space non-activation, focus state, disable/reset transitions, and inert pointer behavior.
  • Hosted CI — all seven checks pass: minimum Flutter 3.41.0, full tests, flutter-tester, web, macOS, Android, and PR title validation.

Related Issues

Related to conceptadev/remix#137 and conceptadev/remix#143.


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

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.

@github-actions

Copy link
Copy Markdown
Contributor

LCOV of commit 2565a27 during Flutter CI #229

	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
Reading tracefile /tmp/__zgosalvez_github-actions-report-lcov/lcov.info.
Summary coverage rate:
  source files: 23
  lines.......: 90.5% (2094 of 2315 lines)
  functions...: no data found
  branches....: no data found
Message summary:
  1 warning message:
    deprecated: 1

Files changed coverage rate:
  	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
                                                                   |Lines       |Functions  |Branches    
  Filename                                                         |Rate     Num|Rate    Num|Rate     Num
  packages/naked_ui/lib/src/naked_link.dart                        |91.4%     70|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/intents.dart                 |95.6%    114|    -     0|    -      0

@github-actions

Copy link
Copy Markdown
Contributor

LCOV of commit 24460f0 during Flutter CI #230

	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
Reading tracefile /tmp/__zgosalvez_github-actions-report-lcov/lcov.info.
Summary coverage rate:
  source files: 23
  lines.......: 90.5% (2094 of 2315 lines)
  functions...: no data found
  branches....: no data found
Message summary:
  1 warning message:
    deprecated: 1

Files changed coverage rate:
  	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
                                                                   |Lines       |Functions  |Branches    
  Filename                                                         |Rate     Num|Rate    Num|Rate     Num
  packages/naked_ui/lib/src/naked_link.dart                        |91.4%     70|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/intents.dart                 |95.6%    114|    -     0|    -      0

@github-actions

Copy link
Copy Markdown
Contributor

LCOV of commit 2614555 during Flutter CI #231

	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
Reading tracefile /tmp/__zgosalvez_github-actions-report-lcov/lcov.info.
Summary coverage rate:
  source files: 23
  lines.......: 90.5% (2094 of 2315 lines)
  functions...: no data found
  branches....: no data found
Message summary:
  1 warning message:
    deprecated: 1

Files changed coverage rate:
  	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
                                                                   |Lines       |Functions  |Branches    
  Filename                                                         |Rate     Num|Rate    Num|Rate     Num
  packages/naked_ui/lib/src/naked_link.dart                        |91.4%     70|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/intents.dart                 |95.6%    114|    -     0|    -      0

@github-actions

Copy link
Copy Markdown
Contributor

LCOV of commit 8084ecf during Flutter CI #234

	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
Reading tracefile /tmp/__zgosalvez_github-actions-report-lcov/lcov.info.
Summary coverage rate:
  source files: 23
  lines.......: 90.4% (2139 of 2365 lines)
  functions...: no data found
  branches....: no data found
Message summary:
  1 warning message:
    deprecated: 1

Files changed coverage rate:
  	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
                                                                   |Lines       |Functions  |Branches    
  Filename                                                         |Rate     Num|Rate    Num|Rate     Num
  packages/naked_ui/lib/src/mixins/naked_mixins.dart               | 100%     78|    -     0|    -      0
  packages/naked_ui/lib/src/naked_link.dart                        |90.0%     90|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/intents.dart                 |95.6%    114|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart|97.4%     76|    -     0|    -      0

# Conflicts:
#	.github/workflows/ci.yml
#	.github/workflows/integration-tests.yml
#	packages/example/integration_test/screenshot_smoke.dart
#	packages/example/lib/registry.dart
#	packages/example/test/accessibility_guidelines_test.dart
#	packages/example/test/screenshot_evidence_test.dart
#	packages/naked_ui/CHANGELOG.md
#	tool/run_android_integration.sh
@github-actions

Copy link
Copy Markdown
Contributor

LCOV of commit 3bd5b38 during Flutter CI #236

	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
Reading tracefile /tmp/__zgosalvez_github-actions-report-lcov/lcov.info.
Summary coverage rate:
  source files: 23
  lines.......: 90.2% (2209 of 2449 lines)
  functions...: no data found
  branches....: no data found
Message summary:
  1 warning message:
    deprecated: 1

Files changed coverage rate:
  	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
                                                                   |Lines       |Functions  |Branches    
  Filename                                                         |Rate     Num|Rate    Num|Rate     Num
  packages/naked_ui/lib/src/mixins/naked_mixins.dart               | 100%     78|    -     0|    -      0
  packages/naked_ui/lib/src/naked_link.dart                        |84.1%    126|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/intents.dart                 |95.6%    114|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart|97.5%     81|    -     0|    -      0

@github-actions

Copy link
Copy Markdown
Contributor

LCOV of commit 52d9c97 during Flutter CI #237

	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
Reading tracefile /tmp/__zgosalvez_github-actions-report-lcov/lcov.info.
Summary coverage rate:
  source files: 23
  lines.......: 90.2% (2209 of 2449 lines)
  functions...: no data found
  branches....: no data found
Message summary:
  1 warning message:
    deprecated: 1

Files changed coverage rate:
  	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
                                                                   |Lines       |Functions  |Branches    
  Filename                                                         |Rate     Num|Rate    Num|Rate     Num
  packages/naked_ui/lib/src/mixins/naked_mixins.dart               | 100%     78|    -     0|    -      0
  packages/naked_ui/lib/src/naked_link.dart                        |84.1%    126|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/intents.dart                 |95.6%    114|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart|97.5%     81|    -     0|    -      0

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new headless NakedLink primitive to naked_ui, plus supporting intents, hover/focus utilities, documentation, examples, tests, and CI/integration evidence updates.

Changes:

  • Introduces NakedLink, NakedLinkState, and NakedLinkResolver with Link semantics and Enter-only keyboard activation.
  • Extends NakedFocusableDetector to optionally restore hover when re-enabled under a stationary pointer, and stabilizes Actions/Shortcuts wrappers across enable/disable changes.
  • Adds extensive unit/semantics/integration/golden evidence and updates CI workflows to validate Link behavior and artifacts.

Reviewed changes

Copilot reviewed 30 out of 32 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tool/run_android_integration.sh Verifies new Android Link screenshot evidence artifacts exist after the run.
README.md Documents the new NakedLink API and resolver usage at the workspace level.
pubspec.lock Adds url_launcher transitive resolution and updates recorded Dart SDK minimum.
packages/naked_ui/test/utilities/naked_focusable_detector_test.dart Adds coverage for hover restoration behavior across disable/enable transitions.
packages/naked_ui/test/src/naked_link_test.dart New widget tests for link activation, modifiers, lifecycle, focus, state, and platform-default navigation.
packages/naked_ui/test/semantics/naked_link_semantics_test.dart New semantics contract tests ensuring Link role/URL/actions match expectations and Button semantics are absent.
packages/naked_ui/test/hashcode_contract_test.dart Extends hashCode contract tests to include NakedLinkState.
packages/naked_ui/README.md Package-level documentation updates for NakedLink and resolver behavior.
packages/naked_ui/pubspec.yaml Adds url_launcher dependency needed for native/web Link coordination.
packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart Adds opt-in hover restoration and stabilizes Shortcuts/Actions wrappers when disabled.
packages/naked_ui/lib/src/utilities/intents.dart Introduces Link-specific intent/shortcut mapping for Enter/Numpad Enter without Space.
packages/naked_ui/lib/src/naked_widgets.dart Exports naked_link.dart from the public widgets barrel.
packages/naked_ui/lib/src/naked_link.dart New NakedLink implementation with semantics, resolver routing, and url_launcher-backed navigation.
packages/naked_ui/lib/src/mixins/naked_mixins.dart Adds optional rebuild control for state synchronization during framework lifecycle callbacks.
packages/naked_ui/CHANGELOG.md Changelog entry describing the new Link primitive and related behavior updates.
packages/example/test/screenshot_evidence_test.dart Ensures Link evidence artifact names remain stable/deterministic.
packages/example/test/naked_link_example_test.dart Tests the example fixture behavior/state determinism and semantics expectations.
packages/example/test/goldens/golden_test_harness.dart Updates golden font loading to include pinned Material Icons for deterministic rendering.
packages/example/test/goldens/components/naked_link_golden_test.dart Adds a Link golden test (keyboard focus baseline on pinned Linux host).
packages/example/test/accessibility_guidelines_test.dart Adds accessibility guideline checks for inline and standalone Link fixtures.
packages/example/test_driver/link_browser_driver.dart Adds a web-driver test to verify trusted-event browser ownership and navigation behavior.
packages/example/pubspec.yaml Adds dev deps for flutter_driver, webdriver, and web to support the new browser driver flow.
packages/example/lib/registry.dart Registers the new Link demo in the example demo registry.
packages/example/lib/api/naked_link.0.dart Adds the Link example fixture(s) used by tests, goldens, screenshots, and guidelines.
packages/example/integration_test/screenshot_smoke.dart Adds Link screenshot capture scenarios (inline, focus, disabled, long text) and surface checks.
packages/example/integration_test/link_browser_driver_app.dart Adds a minimal app target for flutter drive browser-ownership verification.
packages/example/integration_test/components/naked_link_integration.dart Adds integration tests for focus/activation, Space non-activation, semantics, disabled behavior, and RTL/long-text robustness.
packages/example/integration_test/all_tests.dart Registers Link integration tests into the suite runner.
.github/workflows/integration-web.yml Runs and verifies the new web flutter drive browser ownership log evidence.
.github/workflows/integration-tests.yml Verifies new macOS screenshot evidence artifacts exist in CI.
.github/workflows/ci.yml Improves golden failure diagnostics by generating/uploading candidate images only on the pinned-host failure path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +248 to +252
if (kIsWeb && widget.linkUrl.hasScheme) {
unawaited(_launchWebExternalLink());
return;
}
unawaited(followLink());
Comment on lines +283 to +291
void _handlePointerActivation(launcher.FollowLink followLink) {
final modified = _modifiedPointerActivation || _hasPointerModifier;
_modifiedPointerActivation = false;
if (modified) {
unawaited(followLink());
return;
}
_handleOrdinaryActivation(followLink);
}
# Conflicts:
#	packages/example/integration_test/screenshot_smoke.dart
#	packages/example/test/accessibility_guidelines_test.dart
@leoafarias

Copy link
Copy Markdown
Member Author

Downstream feedback from Remix, where we hit the exact gap this PR fills. Two parts: evidence that the primitive is worth having, and a boundary concern about how it is currently packaged.

The primitive is needed — here is the proof from downstream

FortalLink in remix_fortal is built on NakedButton with a hand-added Semantics(link: true, ...) wrapper. That inherits the button intent set:

// intents.dart
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),   // button-only

So our link activates on Space, which links must not do — Enter activates a link, Space scrolls. Measured:

ENTER activations: 1
SPACE activations: 1   <-- should be 0

We cannot fix this downstream by styling or by wrapping semantics; the keyboard contract lives in the primitive. Filed as conceptadev/remix#143.

This is the strongest argument for NakedLink: a link is not a button with different paint. It differs in activation keys, semantics role, and modifier-click behaviour. Worth noting that Radix Themes gets all of this free — its Link "is based on the a element", which is exactly why Radix's Link has no keyboard or navigation props at all. Flutter supplies no equivalent, so the primitive has to.

The concern: url_launcher in the core

This PR adds to packages/naked_ui/pubspec.yaml:

url_launcher: ^6.3.2
url_launcher_platform_interface: ^2.3.2

That is a platform-channel plugin in the dependency-light core, inherited by every consumer including those that never launch a URL. It cuts against the direction in #86.

It also makes linkUrl required and launching built-in, which conflicts with a deliberate downstream contract: Fortal documents that linkUrl is assistive metadata never launched, with navigation left to the caller, precisely to avoid taking a launcher dependency. Adopting as-is would reverse that.

Suggested split

The seam looks natural rather than like a workaround:

  • NakedLink — zero dependencies: link semantics role, Enter-not-Space activation, focus, disabled handling, observable hover/focus/press, modifier-key detection (report the modifiers, do not act on them)
  • Opt-in launcher layer: url_launcher integration and web <a> delegation, for consumers that want real URL navigation

Everything above the line is interaction contract, which is what this package is for. Everything below is platform integration.

With that split, a consumer like Remix can adopt the primitive for the part it actually needs — correct link keyboard and semantics — without inheriting a plugin, and it also retires a mirrored focus field we carry today (conceptadev/remix#137).

Not a blocking objection, and the work here is clearly sound — the modifier-click and web-anchor handling in particular is real work that belongs in a primitive. This is about where the dependency line sits.

@github-actions

Copy link
Copy Markdown
Contributor

LCOV of commit c5bf521 during Flutter CI #287

	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
Reading tracefile /tmp/__zgosalvez_github-actions-report-lcov/lcov.info.
Summary coverage rate:
  source files: 24
  lines.......: 90.1% (3193 of 3542 lines)
  functions...: no data found
  branches....: no data found
Message summary:
  1 warning message:
    deprecated: 1

Files changed coverage rate:
  	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
                                                                   |Lines       |Functions  |Branches    
  Filename                                                         |Rate     Num|Rate    Num|Rate     Num
  packages/naked_ui/lib/src/mixins/naked_mixins.dart               | 100%     78|    -     0|    -      0
  packages/naked_ui/lib/src/naked_link.dart                        |83.3%    144|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/intents.dart                 |66.7%    114|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/naked_focusable_detector.dart|97.9%     96|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/web_event_modifiers_stub.dart|50.0%      2|    -     0|    -      0

@github-actions

Copy link
Copy Markdown
Contributor

LCOV of commit 9bd2b3a during Flutter CI #288

	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
Reading tracefile /tmp/__zgosalvez_github-actions-report-lcov/lcov.info.
Summary coverage rate:
  source files: 23
  lines.......: 90.4% (3096 of 3424 lines)
  functions...: no data found
  branches....: no data found
Message summary:
  1 warning message:
    deprecated: 1

Files changed coverage rate:
  	(use "lcov --ignore-errors deprecated,deprecated ..." to suppress this warning)
                                                                   |Lines       |Functions  |Branches    
  Filename                                                         |Rate     Num|Rate    Num|Rate     Num
  packages/naked_ui/lib/src/naked_link.dart                        |92.3%     78|    -     0|    -      0
  packages/naked_ui/lib/src/utilities/intents.dart                 |66.7%    114|    -     0|    -      0

@leoafarias
leoafarias merged commit 52086b2 into main Aug 14, 2026
9 checks passed
@leoafarias
leoafarias deleted the feat/naked-link branch August 14, 2026 01:39
leoafarias added a commit to conceptadev/remix that referenced this pull request Aug 14, 2026
Adds a first-class Link component to `remix` — `LinkSpec`, the generated
`LinkStyler`, and `RemixLink` built on naked_ui's `NakedLink` — and rebuilds
`FortalLink` on top of it, replacing the hand-rolled `NakedButton` plus 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 convention that a null
callback disables a control; ordinary text is the right tool for prose that was
never meant to navigate.

`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 no longer branches
on actionability.

Requires `NakedLink`, released in naked_ui 1.0.0-beta.11 (conceptadev/naked_ui#65). The
floor is raised in both package pubspecs and in the Fortal parity contract, which
move together by design. The styler adopts the `@MixableSpec(target:)` generation
from #152, so `LinkStyler.call` is generated rather than hand-written.

Closes #137
Closes #143

BREAKING CHANGE: four changes to `remix_fortal` link behaviour.

1. `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.
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 covers `onPressed: null`
   as well as `enabled: false`: a callback-less link now 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.
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.

2 participants