diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3d3e8da9b..811f25dab 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,9 +32,8 @@ name: Publish to pub.dev # remix_cli, remix_ui_icons, and naked_ui are versioned independently from # Remix. # The CLI consumer check requires its bundled Remix version to exist before -# the CLI can publish. remix_fortal is not publishable; its -# source remains in the repository as the Fortal preset's analyzed authoring -# and parity surface. +# the CLI can publish. Nothing under `registry_source/` is publishable; it is +# the private authored catalog the bundled presets derive from. on: push: tags: diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 48fcd9c24..ee22ede4e 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -3,8 +3,8 @@ name: Prepare Version Bump # Pushes a branch that sets Remix to one explicit version and writes its # changelog from conventional commits. It never tags and never publishes: # `publish.yml` fires on a tag push, so the tag is pushed by hand after this PR -# is reviewed and merged. remix_fortal is an unpublished authoring package and -# is deliberately outside this workflow. +# is reviewed and merged. registry_source is the unpublished authoring source +# and is deliberately outside this workflow. # # It pushes a branch and prints its compare link rather than opening the pull # request itself. Opening one from a workflow needs "Allow GitHub Actions to @@ -89,7 +89,7 @@ jobs: # Disable dependent constraint and version propagation too: melos # otherwise rewrites and can version workspace dependents even when # the initial package filter selects only Remix, which would pull the - # unpublished remix_fortal authoring package back into releases. + # unpublished registry_source authoring package back into releases. dart run melos version \ --scope=remix \ --no-dependent-constraints \ @@ -108,7 +108,7 @@ jobs: # The Fortal preset copies its dependency floors from the default # registry. Rebuild it immediately after the default floor moves so # the two committed registry trees cannot disagree. - dart run tool/build_fortal_preset.dart + dart run tool/build_registry.dart # Verify the released Remix version and registry floor agree. This # also fails when the sync step above did not run. diff --git a/.gitignore b/.gitignore index 8eddbedbe..eac981d4b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,9 +27,6 @@ align_ui_flutter/ **/example/windows/ **/example/macos/ **/example/linux/ -# remix_agent catalog is a web host; keep its index/manifest in tree. -!/packages/remix_agent/example/web/ -!/packages/remix_agent/example/web/** # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line diff --git a/README.md b/README.md index a265d7393..501f132fa 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ Live examples: - [Component catalog](https://conceptadev.github.io/remix/catalog/) — the Widgetbook catalog for reviewing Remix and Fortal components, variants, and states. Check out `apps/dashboard`, `apps/demo`, and the per-package examples in -`packages/remix/example` and `packages/remix_fortal/example` for complete working examples demonstrating: +`packages/remix/example` and `registry_source/example` for complete working examples demonstrating: - Component usage patterns - Style composition techniques - Design system implementation diff --git a/apps/dashboard/analysis_options.yaml b/apps/dashboard/analysis_options.yaml index f9b303465..192c9f01b 100644 --- a/apps/dashboard/analysis_options.yaml +++ b/apps/dashboard/analysis_options.yaml @@ -1 +1,5 @@ include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - lib/ui/**/*.g.dart diff --git a/apps/dashboard/build.yaml b/apps/dashboard/build.yaml new file mode 100644 index 000000000..361497083 --- /dev/null +++ b/apps/dashboard/build.yaml @@ -0,0 +1,14 @@ +targets: + $default: + builders: + mix_generator:spec_styler_generator: + enabled: true + generate_for: + - lib/ui/components/activity.dart + - lib/ui/components/answer.dart + - lib/ui/components/composer.dart + - lib/ui/components/execution.dart + - lib/ui/components/message.dart + - lib/ui/components/permission.dart + - lib/ui/components/plan.dart + - lib/ui/components/transcript.dart diff --git a/apps/dashboard/lib/main.dart b/apps/dashboard/lib/main.dart index e864a463a..d42de5365 100644 --- a/apps/dashboard/lib/main.dart +++ b/apps/dashboard/lib/main.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'ui/ui.dart'; import 'shell/dashboard_shell.dart'; import 'theme/scroll_behavior.dart'; @@ -65,14 +65,14 @@ class _DashboardAppState extends State theme: ThemeData(brightness: .light, useMaterial3: true), darkTheme: ThemeData(brightness: .dark, useMaterial3: true), themeAnimationDuration: Duration.zero, - // FortalScope goes *below* MaterialApp and *above* the Navigator. + // UiScope goes *below* MaterialApp and *above* the Navigator. // // MaterialApp installs its fallback DefaultTextStyle below its widget // tree, so a scope placed above it would be overridden. `builder` wraps // the whole Navigator, so this placement reaches pushed routes and // dialogs. A nearer DefaultTextStyle retains its normal priority // through Flutter's inheritance. - builder: (context, child) => FortalScope( + builder: (context, child) => UiScope( key: const ValueKey('dashboard-fortal-scope'), accent: _settings.accentColor, gray: _settings.grayColor, @@ -83,9 +83,9 @@ class _DashboardAppState extends State // RemixToastScope sits above the Navigator, in its own Overlay, so // showRemixToast() works from every route, including dialogs and // the compact navigation sheet. It inherits the live Fortal tokens - // FortalScope publishes above. + // UiScope publishes above. child: Overlay.wrap( - child: RemixToastScope(style: fortalToastStyle(), child: child!), + child: RemixToastScope(style: uiToastStyle(), child: child!), ), ), home: const DashboardShell(), diff --git a/apps/dashboard/lib/pages/charts_page.dart b/apps/dashboard/lib/pages/charts_page.dart index 7bc8b796d..1f4a6d804 100644 --- a/apps/dashboard/lib/pages/charts_page.dart +++ b/apps/dashboard/lib/pages/charts_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mix_chart/mix_chart.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../widgets/chart_legend.dart'; import '../widgets/dashboard_chart_card.dart'; @@ -13,10 +13,10 @@ class ChartsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final palette = resolveFortalChartPalette(context); - final pageGap = MixScope.tokenOf(FortalTokens.space6, context); + final palette = resolveUiChartPalette(context); + final pageGap = MixScope.tokenOf(UiTokens.space6, context); final pagePadding = MediaQuery.sizeOf(context).width < 720 - ? MixScope.tokenOf(FortalTokens.space5, context) + ? MixScope.tokenOf(UiTokens.space5, context) : pageGap; return KeyedSubtree( @@ -85,8 +85,8 @@ class _ChartSection extends StatelessWidget { @override Widget build(BuildContext context) { - final gap = MixScope.tokenOf(FortalTokens.space4, context); - final titleGap = MixScope.tokenOf(FortalTokens.space2, context); + final gap = MixScope.tokenOf(UiTokens.space4, context); + final titleGap = MixScope.tokenOf(UiTokens.space2, context); // Omit autoRows: Mix 1031 defaults implicit rows to content height. final GridBoxStyler gridStyle = .equalColumns( 2, @@ -115,7 +115,7 @@ Widget _revenueMomentum(List palette) { return DashboardChartCard( title: 'Revenue momentum', description: 'Area fill and markers preserve exact point values.', - chart: FortalLineChart( + chart: UiLineChart( palette: palette, showMarkers: true, semanticsLabel: 'Weekly revenue momentum', @@ -153,7 +153,7 @@ Widget _revenueMomentum(List palette) { Widget _linePatterns(List palette) => DashboardChartCard( title: 'Per-series patterns', description: 'Solid circles and dashed squares reinforce color differences.', - chart: FortalLineChart( + chart: UiLineChart( key: const ValueKey('charts-line-patterns'), palette: palette, showMarkers: true, @@ -193,7 +193,7 @@ Widget _linePatterns(List palette) => DashboardChartCard( Widget _stepGaps(List palette) => DashboardChartCard( title: 'Steps and gaps', description: 'Missing values remain honest gaps instead of invented data.', - chart: FortalLineChart( + chart: UiLineChart( palette: palette, showMarkers: true, semanticsLabel: 'Inventory levels with missing observations', @@ -231,7 +231,7 @@ Widget _viewportLabels(List palette) => DashboardChartCard( description: 'Tokenized widget labels stay readable while panning and zooming.', chart: LayoutBuilder( - builder: (context, constraints) => FortalLineChart( + builder: (context, constraints) => UiLineChart( palette: palette, showMarkers: true, semanticsLabel: 'Revenue chart with scalable horizontal viewport', @@ -248,7 +248,7 @@ Widget _viewportLabels(List palette) => DashboardChartCard( max: 6, interval: constraints.maxWidth < 360 ? 3 : 1, labelFormatter: _weekdayLabel, - labelBuilder: (_, label) => FortalBadge.soft( + labelBuilder: (_, label) => UiBadge.soft( size: .size1, highContrast: true, label: label.formattedValue, @@ -272,7 +272,7 @@ Widget _viewportLabels(List palette) => DashboardChartCard( Widget _groupedBars(List palette) => DashboardChartCard( title: 'Actual versus plan', description: 'Solid and outlined bars remain distinct without color.', - chart: FortalBarChart( + chart: UiBarChart( key: const ValueKey('charts-bar-grouped'), palette: palette, semanticsLabel: 'Monthly actual and planned revenue', @@ -296,7 +296,7 @@ Widget _groupedBars(List palette) => DashboardChartCard( Widget _stackedBars(List palette) => DashboardChartCard( title: 'Revenue mix', description: 'Stacked segments expose composition and totals together.', - chart: FortalBarChart( + chart: UiBarChart( palette: palette, semanticsLabel: 'Monthly product and services revenue', groups: _stackedRevenue(palette), @@ -314,7 +314,7 @@ Widget _stackedBars(List palette) => DashboardChartCard( Widget _floatingBars(List palette) => DashboardChartCard( title: 'Floating changes', description: 'Range bars encode gains and declines from a real baseline.', - chart: FortalBarChart( + chart: UiBarChart( palette: palette, semanticsLabel: 'Monthly floating inventory changes', groups: _floatingChanges(palette), @@ -331,7 +331,7 @@ Widget _floatingBars(List palette) => DashboardChartCard( Widget _trackedBars(List palette) => DashboardChartCard( title: 'Tracks and labels', description: 'Visible tracks provide scale context before interaction.', - chart: FortalBarChart( + chart: UiBarChart( palette: palette, semanticsLabel: 'Monthly revenue against full-scale tracks', groups: _trackedRevenue(palette), @@ -352,7 +352,7 @@ Widget _trafficPie(List palette) { description: 'Legend-first labels keep the plot clean and easy to scan.', chartPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), chart: PieChart( - style: fortalPieChartStyle( + style: uiPieChartStyle( palette: palette, ).slice(PieSliceStyler().radius(72)), semanticsLabel: 'Traffic share by device', @@ -386,7 +386,7 @@ class _InteractiveProductMixState extends State<_InteractiveProductMix> { description: 'Selection expands one stable slice and preserves its ID.', chartPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), chart: PieChart( - style: fortalPieChartStyle( + style: uiPieChartStyle( palette: widget.palette, centerRadius: 40, ).slice(PieSliceStyler().radius(36)), @@ -409,9 +409,9 @@ class _InteractiveProductMixState extends State<_InteractiveProductMix> { Widget _badgePie(BuildContext context, List palette) { const icons = [Icons.phone_iphone, Icons.laptop_mac, Icons.tablet, Icons.tv]; - final panel = MixScope.tokenOf(FortalTokens.colorPanel, context); - final border = MixScope.tokenOf(FortalTokens.grayStroke6, context); - final iconColor = MixScope.tokenOf(FortalTokens.gray12, context); + final panel = MixScope.tokenOf(UiTokens.colorPanel, context); + final border = MixScope.tokenOf(UiTokens.grayStroke6, context); + final iconColor = MixScope.tokenOf(UiTokens.gray12, context); final base = _channelSlices(); final slices = [ for (var index = 0; index < base.length; index++) @@ -439,7 +439,7 @@ Widget _badgePie(BuildContext context, List palette) { description: 'Ordinary tokenized widgets can annotate individual slices.', chartPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), chart: PieChart( - style: fortalPieChartStyle( + style: uiPieChartStyle( palette: palette, centerRadius: 34, ).slice(PieSliceStyler().radius(48).badgePosition(0.72)), @@ -461,7 +461,7 @@ Widget _emptyPie(List palette) => DashboardChartCard( chart: Stack( alignment: .center, children: [ - FortalPieChart( + UiPieChart( palette: palette, centerRadius: 52, semanticsLabel: 'No channel data', @@ -473,7 +473,7 @@ Widget _emptyPie(List palette) => DashboardChartCard( children: [ StyledIcon( icon: Icons.inbox_outlined, - style: IconStyler().size(20).color(FortalTokens.gray11()), + style: IconStyler().size(20).color(UiTokens.gray11()), ), StyledText('No data yet', style: dashboardText(.size1, tone: .muted)), ], @@ -620,16 +620,16 @@ List _trackedRevenue(List palette) { .color(palette[0]) .label( TextStyler() - .style(FortalTokens.text1.mix()) + .style(UiTokens.text1.mix()) .fontWeight(.w700) - .color(FortalTokens.gray12()), + .color(UiTokens.gray12()), ) .background( BarBackgroundStyler() .show(true) .fromY(0) .toY(70) - .color(FortalTokens.grayA3()), + .color(UiTokens.grayA3()), ), ), ], diff --git a/apps/dashboard/lib/pages/chat_page.dart b/apps/dashboard/lib/pages/chat_page.dart new file mode 100644 index 000000000..170bcba87 --- /dev/null +++ b/apps/dashboard/lib/pages/chat_page.dart @@ -0,0 +1,489 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +import 'package:remix/remix.dart'; + +import '../ui/ui.dart'; + +enum _Scenario { success, permission, failure } + +enum _Stage { + idle, + preparing, + permission, + running, + complete, + failed, + stopped, + denied, +} + +/// A deterministic, local-only Agent demonstration. It contacts no model or +/// tool and intentionally keeps its state while DashboardShell's IndexedStack +/// switches pages. +class ChatPage extends StatefulWidget { + const ChatPage({ + super.key, + this.stepDelay = const Duration(milliseconds: 320), + }); + + final Duration stepDelay; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + static const _chunks = [ + 'I inspected the checkout flow. ', + 'The cart state is shared correctly, ', + 'and the focused checks pass. The flow is ready for review.', + ]; + + final _scroll = ScrollController(); + Timer? _timer; + var _runId = 0; + var _stage = _Stage.idle; + var _scenario = _Scenario.success; + var _prompt = ''; + var _answer = ''; + var _following = true; + var _toolStarted = false; + var _alwaysAllowTerminal = false; + final _history = <({String prompt, String answer})>[]; + + String get _visibleAnswer => _answer.isNotEmpty + ? _answer + : _stage == _Stage.denied + ? 'Permission denied. No tool ran.' + : !_toolStarted + ? 'Stopped before running the tool. No tool ran.' + : 'Run stopped; partial output was preserved.'; + + String get _executionOutput => + _answer.isEmpty ? r'$ flutter test' : '${r'$ flutter test'}\n$_answer'; + + bool get _active => const { + _Stage.preparing, + _Stage.permission, + _Stage.running, + }.contains(_stage); + + @override + void dispose() { + _cancelPending(); + _scroll.dispose(); + super.dispose(); + } + + void _cancelPending() { + _runId++; + _timer?.cancel(); + _timer = null; + } + + void _reset() { + _cancelPending(); + setState(() { + _stage = _Stage.idle; + _prompt = ''; + _answer = ''; + _following = true; + _alwaysAllowTerminal = false; + _toolStarted = false; + _history.clear(); + }); + } + + void _start( + String prompt, { + _Scenario scenario = _Scenario.success, + bool keepMessage = false, + }) { + if (_active) return; + _cancelPending(); + final id = _runId; + setState(() { + if (!keepMessage && _prompt.isNotEmpty) { + _history.add((prompt: _prompt, answer: _visibleAnswer)); + } + _scenario = scenario; + if (!keepMessage) _prompt = prompt; + _answer = ''; + _toolStarted = false; + _stage = _Stage.preparing; + _following = true; + }); + _timer = Timer(widget.stepDelay, () { + if (!mounted || id != _runId) return; + if (scenario == _Scenario.permission && !_alwaysAllowTerminal) { + setState(() => _stage = _Stage.permission); + } else { + _stream(id); + } + }); + } + + void _stream(int id) { + var chunk = 0; + setState(() { + _toolStarted = true; + _stage = _Stage.running; + }); + _timer = Timer.periodic(widget.stepDelay, (timer) { + if (!mounted || id != _runId) { + timer.cancel(); + return; + } + if (_scenario == _Scenario.failure && chunk == 1) { + timer.cancel(); + setState(() { + _stage = _Stage.failed; + _answer = + 'The simulated command failed. Retry to run the recovery path.'; + }); + return; + } + setState(() => _answer += _chunks[chunk++]); + if (chunk == _chunks.length) { + timer.cancel(); + setState(() => _stage = _Stage.complete); + } + }); + } + + void _allow(int requestId, {required bool always}) { + if (requestId != _runId || _stage != _Stage.permission) return; + if (always) _alwaysAllowTerminal = true; + _stream(_runId); + } + + void _stop() { + _cancelPending(); + setState(() => _stage = _Stage.stopped); + } + + void _retry() { + _start( + _prompt, + scenario: _scenario == .failure ? .success : _scenario, + keepMessage: true, + ); + } + + void _returnToLatest() { + setState(() => _following = true); + if (_scroll.hasClients) { + if (MediaQuery.disableAnimationsOf(context)) { + _scroll.jumpTo(_scroll.position.maxScrollExtent); + return; + } + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(MediaQuery.sizeOf(context).width < 600 ? 16 : 32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Agent chat', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + ), + ), + ), + RemixButton( + label: 'New chat', + onPressed: _reset, + style: uiButtonStyle(variant: .outline), + ), + ], + ), + const Text('Interactive demo'), + const SizedBox(height: 8), + const Text( + 'Simulated responses and tools — no backend or credentials.', + ), + const SizedBox(height: 16), + if (!_active) ...[_starters(), const SizedBox(height: 16)], + Expanded(child: _transcript()), + if (!_following) + Align( + alignment: Alignment.center, + child: RemixButton( + label: 'Return to latest', + onPressed: _returnToLatest, + style: uiButtonStyle(variant: .soft), + ), + ), + const SizedBox(height: 12), + _composer(), + ], + ), + ), + ), + ); + } + + Widget _starters() => Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _starter('Review checkout', _Scenario.success), + _starter('Run terminal checks', _Scenario.permission), + _starter('Recover a failed command', _Scenario.failure), + ], + ); + + Widget _starter(String label, _Scenario scenario) => RemixButton( + label: label, + onPressed: () => _start(label, scenario: scenario), + style: uiButtonStyle(variant: .surface), + ); + + Widget _transcript() { + final requestId = _runId; + final transcript = uiAgentTranscriptRecipe( + style: UiTranscriptStyler(viewport: BoxStyler().padding(.all(0))), + ); + final children = []; + final message = uiAgentMessageRecipe(); + final answer = uiAgentAnswerRecipe(); + for (final turn in _history) { + children.addAll([ + UiMessage( + role: .user, + style: message.style, + surfaceStyle: message.surfaceStyle, + child: Text(turn.prompt), + ), + UiAnswer( + status: .complete, + style: answer.style, + surfaceStyle: answer.surfaceStyle, + child: Text(turn.answer), + ), + ]); + } + if (_prompt.isNotEmpty) { + children.add( + UiMessage( + role: .user, + style: message.style, + surfaceStyle: message.surfaceStyle, + child: Text(_prompt), + ), + ); + final plan = uiAgentPlanRecipe(); + children.add( + UiPlan( + style: plan.style, + disclosureStyle: plan.disclosureStyle, + items: [ + const UiPlanItem( + id: 'inspect', + title: 'Inspect the request', + status: .completed, + ), + UiPlanItem( + id: 'tool', + title: 'Run focused work', + status: _active + ? .inProgress + : _stage == _Stage.complete + ? .completed + : .cancelled, + ), + ], + ), + ); + final activity = uiAgentActivityRecipe(); + children.add( + UiActivity( + style: activity.style, + disclosureStyle: activity.disclosureStyle, + status: _active ? .working : .complete, + items: [ + UiActivityItem( + id: 'run', + title: _activityLabel, + status: _active ? .active : .complete, + ), + ], + ), + ); + if (_stage == _Stage.permission || + (_scenario == _Scenario.permission && + _stage != _Stage.preparing && + (_stage != _Stage.stopped || _toolStarted))) { + final permission = uiAgentPermissionRecipe( + style: UiPermissionStyler( + actions: FlexBoxStyler() + .direction( + MediaQuery.sizeOf(context).width < 600 + ? Axis.vertical + : Axis.horizontal, + ) + .crossAxisAlignment( + MediaQuery.sizeOf(context).width < 600 + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.center, + ), + ), + ); + children.add( + UiPermission( + requestId: _runId, + tool: 'terminal.run', + description: 'Run deterministic focused checks in this demo.', + status: _stage == _Stage.permission + ? .pending + : _stage == _Stage.denied + ? .denied + : _stage == _Stage.stopped + ? .allowed + : _stage == _Stage.failed + ? .error + : _active + ? .running + : .complete, + parameters: const [ + RemixDataListItem(label: 'Command', value: 'flutter test'), + ], + style: permission.style, + surfaceStyle: permission.surfaceStyle, + detailsStyle: permission.detailsStyle, + parametersStyle: permission.parametersStyle, + allowOnceStyle: permission.allowOnceStyle, + alwaysAllowStyle: permission.alwaysAllowStyle, + denyStyle: permission.denyStyle, + onAllowOnce: () => _allow(requestId, always: false), + onAlwaysAllow: () => _allow(requestId, always: true), + onDeny: () { + if (requestId != _runId || _stage != _Stage.permission) return; + _cancelPending(); + setState(() => _stage = _Stage.denied); + }, + ), + ); + } + if (_toolStarted && + { + _Stage.running, + _Stage.complete, + _Stage.failed, + _Stage.stopped, + }.contains(_stage)) { + final execution = uiAgentExecutionRecipe(); + children.add( + UiExecution( + tool: 'terminal.run', + title: 'Focused checks', + status: _stage == _Stage.running + ? .running + : _stage == _Stage.complete + ? .success + : _stage == _Stage.failed + ? .error + : .cancelled, + style: execution.style, + surfaceStyle: execution.surfaceStyle, + disclosureStyle: execution.disclosureStyle, + copyStyle: execution.copyStyle, + retryStyle: execution.retryStyle, + onCopy: () => + Clipboard.setData(ClipboardData(text: _executionOutput)), + onRetry: _retry, + child: Text(_executionOutput), + ), + ); + } + if (_answer.isNotEmpty || + {_Stage.denied, _Stage.stopped}.contains(_stage)) { + children.add( + UiAnswer( + streamId: _runId, + status: _stage == _Stage.running + ? .streaming + : _stage == _Stage.failed + ? .error + : .complete, + style: answer.style, + surfaceStyle: answer.surfaceStyle, + sourcesStyle: answer.sourcesStyle, + copyStyle: answer.copyStyle, + retryStyle: answer.retryStyle, + onCopy: () => + Clipboard.setData(ClipboardData(text: _visibleAnswer)), + onRetry: _retry, + sourcesContent: const Text( + 'Deterministic local fixture · no network', + ), + child: Text(_visibleAnswer), + ), + ); + } + } + return UiTranscript( + controller: _scroll, + followOutput: _following, + busy: _active, + onFollowChanged: (value) { + if (_following != value) setState(() => _following = value); + }, + style: transcript.style, + children: children.isEmpty + ? [ + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text('Choose a starter or write a message.'), + ), + ), + ] + : children, + ); + } + + Widget _composer() { + final recipe = uiAgentComposerRecipe(); + return UiComposer( + running: _active, + onSubmit: _start, + onStop: _stop, + hintText: _active ? 'Run in progress…' : 'Ask the demo agent…', + style: recipe.style, + surfaceStyle: recipe.surfaceStyle, + fieldStyle: recipe.fieldStyle, + submitStyle: recipe.submitStyle, + stopStyle: recipe.stopStyle, + ); + } + + String get _activityLabel => switch (_stage) { + .preparing => 'Preparing the run', + .permission => 'Waiting for permission', + .running => 'Streaming simulated output', + .complete => 'Run complete', + .failed => 'Command failed', + .stopped => 'Run stopped', + .denied => 'Permission denied', + .idle => 'Ready', + }; +} diff --git a/apps/dashboard/lib/pages/customers_page.dart b/apps/dashboard/lib/pages/customers_page.dart index e386e1353..a9d31e571 100644 --- a/apps/dashboard/lib/pages/customers_page.dart +++ b/apps/dashboard/lib/pages/customers_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../data/customers.dart'; import '../data/models.dart'; @@ -55,7 +55,7 @@ class _CustomersPageState extends State { PageHeader( title: 'Customers', description: 'Manage customer access, plans, and account status.', - actions: FortalButton( + actions: UiButton( onPressed: () => showRemixToast( context, RemixToastData( @@ -71,7 +71,7 @@ class _CustomersPageState extends State { builder: (context, constraints) { final search = SizedBox( width: constraints.maxWidth < 320 ? constraints.maxWidth : 300, - child: FortalTextField( + child: UiTextField( key: const ValueKey('customer-search'), leading: const Icon(Icons.search, size: 18), hintText: 'Search customers…', @@ -88,12 +88,12 @@ class _CustomersPageState extends State { runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ - FortalBadge( + UiBadge( size: .size2, highContrast: true, label: '${_selectedIds.length} selected', ), - FortalButton.ghost( + UiButton.ghost( size: .size1, onPressed: () => showRemixToast( context, @@ -105,7 +105,7 @@ class _CustomersPageState extends State { ), label: 'Export', ), - FortalButton.ghost( + UiButton.ghost( size: .size1, onPressed: () => showRemixToast( context, @@ -133,7 +133,7 @@ class _CustomersPageState extends State { return Row(children: [search, const Spacer(), ?selection]); }, ), - FortalDataTable.surface( + UiDataTable.surface( key: const ValueKey('data-grid-customers'), rows: visible, columns: _columns, @@ -185,7 +185,7 @@ class _CustomersPageState extends State { mainAxisSize: .min, spacing: 9, children: [ - FortalAvatar(size: .size2, label: customer.initials), + UiAvatar(size: .size2, label: customer.initials), Flexible(child: DataTableCellText(customer.name, primary: true)), ], ), diff --git a/apps/dashboard/lib/pages/gallery/gallery_actions_page.dart b/apps/dashboard/lib/pages/gallery/gallery_actions_page.dart index acd3ef9df..c6ec80d55 100644 --- a/apps/dashboard/lib/pages/gallery/gallery_actions_page.dart +++ b/apps/dashboard/lib/pages/gallery/gallery_actions_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../../ui/ui.dart'; import '../../widgets/gallery_scaffold.dart'; @@ -25,9 +25,9 @@ class _GalleryActionsPageState extends State { description: 'Classic, solid, soft, surface, outline, and ghost actions.', child: GalleryEnumMatrix( - rows: FortalButtonVariant.values, - columns: FortalButtonSize.values, - cellBuilder: (context, variant, size) => FortalButton( + rows: UiButtonVariant.values, + columns: UiButtonSize.values, + cellBuilder: (context, variant, size) => UiButton( variant: variant, size: size, onPressed: () => showRemixToast( @@ -46,9 +46,9 @@ class _GalleryActionsPageState extends State { description: 'Compact icon-only controls with complete focus semantics.', child: GalleryEnumMatrix( - rows: FortalIconButtonVariant.values, - columns: FortalIconButtonSize.values, - cellBuilder: (context, variant, size) => FortalIconButton( + rows: UiIconButtonVariant.values, + columns: UiIconButtonSize.values, + cellBuilder: (context, variant, size) => UiIconButton( variant: variant, size: size, semanticLabel: 'Add item', @@ -67,9 +67,9 @@ class _GalleryActionsPageState extends State { label: 'Toggle', description: 'Ghost and outline toggles remain fully interactive.', child: GalleryEnumMatrix( - rows: FortalToggleVariant.values, - columns: FortalToggleSize.values, - cellBuilder: (_, variant, size) => FortalToggle( + rows: UiToggleVariant.values, + columns: UiToggleSize.values, + cellBuilder: (_, variant, size) => UiToggle( variant: variant, size: size, selected: _selected, @@ -87,13 +87,13 @@ class _GalleryActionsPageState extends State { spacing: 12, runSpacing: 12, children: [ - const FortalButton( + const UiButton( enabled: false, onPressed: null, label: 'Disabled', ), - FortalButton(loading: true, onPressed: () {}, label: 'Saving'), - FortalIconButton( + UiButton(loading: true, onPressed: () {}, label: 'Saving'), + UiIconButton( enabled: false, semanticLabel: 'Disabled favorite', onPressed: () {}, diff --git a/apps/dashboard/lib/pages/gallery/gallery_display_page.dart b/apps/dashboard/lib/pages/gallery/gallery_display_page.dart index cbd8d6d29..07a418f1b 100644 --- a/apps/dashboard/lib/pages/gallery/gallery_display_page.dart +++ b/apps/dashboard/lib/pages/gallery/gallery_display_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../../ui/ui.dart'; import '../../utils/text.dart'; import '../../widgets/gallery_scaffold.dart'; @@ -27,12 +27,12 @@ class _GalleryDisplayPageState extends State { description: 'Two visual variants across all nine Radix-compatible sizes.', child: GalleryEnumMatrix( - rows: FortalAvatarVariant.values, - columns: FortalAvatarSize.values, + rows: UiAvatarVariant.values, + columns: UiAvatarSize.values, // Size9 is 160px; preserve its 20px cell padding and divider. cellWidth: 181, cellBuilder: (_, variant, size) => - FortalAvatar(variant: variant, size: size, label: 'RF'), + UiAvatar(variant: variant, size: size, label: 'RF'), ), ), GallerySection( @@ -40,10 +40,10 @@ class _GalleryDisplayPageState extends State { description: 'Status labels in solid, soft, surface, and outline variants.', child: GalleryEnumMatrix( - rows: FortalBadgeVariant.values, - columns: FortalBadgeSize.values, + rows: UiBadgeVariant.values, + columns: UiBadgeSize.values, cellBuilder: (_, variant, size) => - FortalBadge(variant: variant, size: size, label: 'Active'), + UiBadge(variant: variant, size: size, label: 'Active'), ), ), GallerySection( @@ -51,15 +51,15 @@ class _GalleryDisplayPageState extends State { description: 'Surface, classic, and ghost containers across five spacing sizes.', child: GalleryEnumMatrix( - rows: FortalCardVariant.values, - columns: FortalCardSize.values, + rows: UiCardVariant.values, + columns: UiCardSize.values, cellWidth: 200, cellBuilder: (_, variant, size) => SizedBox( width: 160, - child: FortalCard( + child: UiCard( variant: variant, size: size, - child: const FortalText('Card content', size: .size2), + child: const UiText('Card content', size: .size2), ), ), ), @@ -68,12 +68,12 @@ class _GalleryDisplayPageState extends State { label: 'Callout', description: 'Contextual information in every variant and size.', child: GalleryEnumMatrix( - rows: FortalCalloutVariant.values, - columns: FortalCalloutSize.values, + rows: UiCalloutVariant.values, + columns: UiCalloutSize.values, cellWidth: 230, cellBuilder: (_, variant, size) => SizedBox( width: 200, - child: FortalCallout( + child: UiCallout( variant: variant, size: size, text: 'A helpful callout message.', @@ -87,15 +87,15 @@ class _GalleryDisplayPageState extends State { 'Label and value pairs at every size, horizontal and vertical.', child: GalleryEnumMatrix( rows: Axis.values, - columns: FortalDataListSize.values, + columns: UiDataListSize.values, cellWidth: 250, - cellBuilder: (_, orientation, size) => FortalDataList( + cellBuilder: (_, orientation, size) => UiDataList( size: size, orientation: orientation, items: const [ RemixDataListItem( label: 'Status', - child: FortalBadge(highContrast: true, label: 'Active'), + child: UiBadge(highContrast: true, label: 'Active'), ), RemixDataListItem(label: 'Plan', value: 'Enterprise'), RemixDataListItem(label: 'Seats', value: '48'), @@ -111,19 +111,19 @@ class _GalleryDisplayPageState extends State { crossAxisAlignment: .start, spacing: 14, children: [ - FortalButton.soft( + UiButton.soft( size: .size1, onPressed: () => setState(() => _skeletonLoading = !_skeletonLoading), label: _skeletonLoading ? 'Show content' : 'Show skeleton', ), - FortalSkeleton( + UiSkeleton( loading: _skeletonLoading, - child: const FortalAvatar(label: 'RF', size: .size5), + child: const UiAvatar(label: 'RF', size: .size5), ), - FortalSkeleton( + UiSkeleton( loading: _skeletonLoading, - child: const FortalText( + child: const UiText( 'Loaded content replaces the placeholder.', size: .size2, ), @@ -136,12 +136,12 @@ class _GalleryDisplayPageState extends State { description: 'Determinate progress with classic, surface, and soft treatments.', child: GalleryEnumMatrix( - rows: FortalProgressVariant.values, - columns: FortalProgressSize.values, + rows: UiProgressVariant.values, + columns: UiProgressSize.values, cellWidth: 210, cellBuilder: (_, variant, size) => SizedBox( width: 170, - child: FortalProgress( + child: UiProgress( variant: variant, size: size, value: 0.68, @@ -157,15 +157,15 @@ class _GalleryDisplayPageState extends State { child: Row( spacing: 18, children: [ - FortalButton.soft( + UiButton.soft( size: .size1, onPressed: () => setState(() => _spinnersRunning = !_spinnersRunning), label: _spinnersRunning ? 'Stop' : 'Start', ), - for (final size in FortalSpinnerSize.values) + for (final size in UiSpinnerSize.values) if (_spinnersRunning) - FortalSpinner(size: size, semanticsLabel: 'Loading example') + UiSpinner(size: size, semanticsLabel: 'Loading example') else const Icon(Icons.check, size: 16), ], @@ -177,15 +177,15 @@ class _GalleryDisplayPageState extends State { child: Column( spacing: 14, children: [ - for (final size in FortalDividerSize.values) + for (final size in UiDividerSize.values) Row( spacing: 12, children: [ SizedBox( width: 64, - child: FortalText(enumLabel(size), size: .size2), + child: UiText(enumLabel(size), size: .size2), ), - Expanded(child: FortalDivider(size: size)), + Expanded(child: UiDivider(size: size)), ], ), ], diff --git a/apps/dashboard/lib/pages/gallery/gallery_forms_page.dart b/apps/dashboard/lib/pages/gallery/gallery_forms_page.dart index b8472d160..bf1685c87 100644 --- a/apps/dashboard/lib/pages/gallery/gallery_forms_page.dart +++ b/apps/dashboard/lib/pages/gallery/gallery_forms_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../../ui/ui.dart'; import '../../widgets/gallery_scaffold.dart'; @@ -31,10 +31,10 @@ class _GalleryFormsPageState extends State { description: 'All field variants and sizes with a leading icon and placeholder.', child: GalleryEnumMatrix( - rows: FortalTextFieldVariant.values, - columns: FortalTextFieldSize.values, + rows: UiTextFieldVariant.values, + columns: UiTextFieldSize.values, cellWidth: 210, - cellBuilder: (_, variant, size) => FortalTextField( + cellBuilder: (_, variant, size) => UiTextField( variant: variant, size: size, hintText: 'Type something…', @@ -47,10 +47,10 @@ class _GalleryFormsPageState extends State { description: 'Multi-line input sharing the text field variants and sizes.', child: GalleryEnumMatrix( - rows: FortalTextAreaVariant.values, - columns: FortalTextAreaSize.values, + rows: UiTextAreaVariant.values, + columns: UiTextAreaSize.values, cellWidth: 230, - cellBuilder: (_, variant, size) => FortalTextArea( + cellBuilder: (_, variant, size) => UiTextArea( variant: variant, size: size, hintText: 'Add a note…', @@ -61,10 +61,10 @@ class _GalleryFormsPageState extends State { label: 'Segmented control', description: 'Exclusive selection in surface and classic treatments.', child: GalleryEnumMatrix( - rows: FortalSegmentedControlVariant.values, - columns: FortalSegmentedControlSize.values, + rows: UiSegmentedControlVariant.values, + columns: UiSegmentedControlSize.values, cellWidth: 250, - cellBuilder: (_, variant, size) => FortalSegmentedControl( + cellBuilder: (_, variant, size) => UiSegmentedControl( variant: variant, size: size, selectedValue: _density, @@ -81,9 +81,9 @@ class _GalleryFormsPageState extends State { label: 'Select', description: 'Every visual variant across the three sizes.', child: GalleryEnumMatrix( - rows: FortalSelectVariant.values, - columns: FortalSelectSize.values, - cellBuilder: (_, variant, size) => FortalSelect( + rows: UiSelectVariant.values, + columns: UiSelectSize.values, + cellBuilder: (_, variant, size) => UiSelect( variant: variant, size: size, trigger: const RemixSelectTrigger(placeholder: 'Fruit'), @@ -102,10 +102,10 @@ class _GalleryFormsPageState extends State { description: 'Single-selection groups in soft and surface treatments.', child: GalleryEnumMatrix( - rows: FortalToggleGroupVariant.values, - columns: FortalToggleGroupSize.values, + rows: UiToggleGroupVariant.values, + columns: UiToggleGroupSize.values, cellWidth: 230, - cellBuilder: (_, variant, size) => FortalToggleGroup( + cellBuilder: (_, variant, size) => UiToggleGroup( variant: variant, size: size, selectedValue: _alignment, @@ -137,9 +137,9 @@ class _GalleryFormsPageState extends State { label: 'Checkbox', description: 'Classic, surface, and soft checkbox recipes.', child: GalleryEnumMatrix( - rows: FortalCheckboxVariant.values, - columns: FortalCheckboxSize.values, - cellBuilder: (_, variant, size) => FortalCheckbox( + rows: UiCheckboxVariant.values, + columns: UiCheckboxSize.values, + cellBuilder: (_, variant, size) => UiCheckbox( variant: variant, size: size, selected: _checked, @@ -165,7 +165,7 @@ class _GalleryFormsPageState extends State { ('sms', 'SMS'), ('push', 'Push'), ]) - FortalCheckboxGroupItem(value: value, label: label), + UiCheckboxGroupItem(value: value, label: label), ], ), ), @@ -174,14 +174,14 @@ class _GalleryFormsPageState extends State { label: 'Radio', description: 'Radio selection shown across every variant and size.', child: GalleryEnumMatrix( - rows: FortalRadioVariant.values, - columns: FortalRadioSize.values, + rows: UiRadioVariant.values, + columns: UiRadioSize.values, cellBuilder: (_, variant, size) => RemixRadioGroup( groupValue: _radio, onChanged: (value) { if (value != null) setState(() => _radio = value); }, - child: FortalRadio( + child: UiRadio( variant: variant, size: size, value: 1, @@ -194,9 +194,9 @@ class _GalleryFormsPageState extends State { label: 'Switch', description: 'Binary settings controls with all visual treatments.', child: GalleryEnumMatrix( - rows: FortalSwitchVariant.values, - columns: FortalSwitchSize.values, - cellBuilder: (_, variant, size) => FortalSwitch( + rows: UiSwitchVariant.values, + columns: UiSwitchSize.values, + cellBuilder: (_, variant, size) => UiSwitch( variant: variant, size: size, selected: _switched, @@ -209,12 +209,12 @@ class _GalleryFormsPageState extends State { label: 'Slider', description: 'Discrete single-thumb sliders in every Fortal recipe.', child: GalleryEnumMatrix( - rows: FortalSliderVariant.values, - columns: FortalSliderSize.values, + rows: UiSliderVariant.values, + columns: UiSliderSize.values, cellWidth: 210, cellBuilder: (_, variant, size) => SizedBox( width: 170, - child: FortalSlider( + child: UiSlider( variant: variant, size: size, value: _slider, @@ -236,20 +236,20 @@ class _GalleryFormsPageState extends State { children: [ SizedBox( width: 240, - child: FortalTextField( + child: UiTextField( error: true, label: 'Workspace slug', hintText: 'remix', helperText: 'That slug is already in use.', ), ), - FortalCheckbox( + UiCheckbox( selected: null, tristate: true, semanticLabel: 'Indeterminate checkbox', ), - FortalCheckbox(selected: true, label: 'Labelled'), - FortalSwitch( + UiCheckbox(selected: true, label: 'Labelled'), + UiSwitch( selected: false, enabled: false, semanticLabel: 'Disabled switch', diff --git a/apps/dashboard/lib/pages/gallery/gallery_navigation_page.dart b/apps/dashboard/lib/pages/gallery/gallery_navigation_page.dart index f9bcc3a31..ca21b73f4 100644 --- a/apps/dashboard/lib/pages/gallery/gallery_navigation_page.dart +++ b/apps/dashboard/lib/pages/gallery/gallery_navigation_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../../ui/ui.dart'; import '../../utils/text.dart'; import '../../widgets/disclosure_trigger.dart'; @@ -24,9 +24,9 @@ class GalleryNavigationPage extends StatelessWidget { GallerySection( label: 'Tabs', description: 'Both tab sizes with live keyboard and pointer selection.', - child: GalleryMatrix( + child: GalleryMatrix( rows: const ['Tabs'], - columns: FortalTabsSize.values, + columns: UiTabsSize.values, rowLabelBuilder: (label) => label, columnLabelBuilder: enumLabel, cellWidth: 320, @@ -38,8 +38,8 @@ class GalleryNavigationPage extends StatelessWidget { description: 'Independent expandable panels in every Fortal variant and size.', child: GalleryEnumMatrix( - rows: FortalDisclosureVariant.values, - columns: FortalDisclosureSize.values, + rows: UiDisclosureVariant.values, + columns: UiDisclosureSize.values, cellWidth: 300, cellBuilder: (_, variant, size) => _DisclosureDemo(variant: variant, size: size), @@ -50,8 +50,8 @@ class GalleryNavigationPage extends StatelessWidget { description: 'Coordinated disclosure items where only one panel stays open.', child: GalleryEnumMatrix( - rows: FortalAccordionVariant.values, - columns: FortalAccordionSize.values, + rows: UiAccordionVariant.values, + columns: UiAccordionSize.values, cellWidth: 300, cellBuilder: (_, variant, size) => _AccordionDemo(variant: variant, size: size), @@ -108,7 +108,7 @@ class _SidebarDemoState extends State<_SidebarDemo> { child: SizedBox( width: 280, height: 320, - child: FortalSidebar( + child: UiSidebar( header: const _SidebarDemoHeader(), sections: _sections, selectedValue: _selected, @@ -126,7 +126,7 @@ class _SidebarDemoHeader extends StatelessWidget { @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: const FortalText('Acme', size: .size4, weight: .bold), + child: const UiText('Acme', size: .size4, weight: .bold), ); } @@ -139,8 +139,8 @@ class _SidebarDemoFooter extends StatelessWidget { child: Row( spacing: 10, children: [ - const FortalAvatar(label: 'AC', size: .size1), - const FortalText('Ada Chen', size: .size2, weight: .medium), + const UiAvatar(label: 'AC', size: .size1), + const UiText('Ada Chen', size: .size2, weight: .medium), ], ), ); @@ -149,13 +149,13 @@ class _SidebarDemoFooter extends StatelessWidget { class _DisclosureDemo extends StatelessWidget { const _DisclosureDemo({required this.variant, required this.size}); - final FortalDisclosureVariant variant; - final FortalDisclosureSize size; + final UiDisclosureVariant variant; + final UiDisclosureSize size; @override Widget build(BuildContext context) => SizedBox( width: 280, - child: FortalDisclosure( + child: UiDisclosure( key: ValueKey('disclosure-${variant.name}-${size.name}'), variant: variant, size: size, @@ -174,7 +174,7 @@ class _DisclosureDemo extends StatelessWidget { class _TabsDemo extends StatefulWidget { const _TabsDemo({required this.size}); - final FortalTabsSize size; + final UiTabsSize size; @override State<_TabsDemo> createState() => _TabsDemoState(); @@ -191,34 +191,26 @@ class _TabsDemoState extends State<_TabsDemo> { crossAxisAlignment: .stretch, spacing: 10, children: [ - FortalTabBar( + UiTabBar( child: Row( children: [ - FortalTab( - size: widget.size, - tabId: 'overview', - label: 'Overview', - ), - FortalTab( - size: widget.size, - tabId: 'activity', - label: 'Activity', - ), + UiTab(size: widget.size, tabId: 'overview', label: 'Overview'), + UiTab(size: widget.size, tabId: 'activity', label: 'Activity'), ], ), ), - FortalTabView( + UiTabView( tabId: 'overview', child: const Padding( padding: EdgeInsets.all(8), - child: FortalText('Overview content'), + child: UiText('Overview content'), ), ), - FortalTabView( + UiTabView( tabId: 'activity', child: const Padding( padding: EdgeInsets.all(8), - child: FortalText('Activity content'), + child: UiText('Activity content'), ), ), ], @@ -228,8 +220,8 @@ class _TabsDemoState extends State<_TabsDemo> { class _AccordionDemo extends StatefulWidget { const _AccordionDemo({required this.variant, required this.size}); - final FortalAccordionVariant variant; - final FortalAccordionSize size; + final UiAccordionVariant variant; + final UiAccordionSize size; @override State<_AccordionDemo> createState() => _AccordionDemoState(); @@ -251,21 +243,21 @@ class _AccordionDemoState extends State<_AccordionDemo> { child: Column( spacing: 8, children: [ - FortalAccordion( + UiAccordion( variant: widget.variant, size: widget.size, value: 'details', title: 'What is Fortal?', - child: const FortalText( + child: const UiText( 'A Radix-inspired theme and component system for Flutter.', ), ), - FortalAccordion( + UiAccordion( variant: widget.variant, size: widget.size, value: 'tokens', title: 'Does it support tokens?', - child: const FortalText( + child: const UiText( 'Every recipe resolves through the active Mix scope.', ), ), diff --git a/apps/dashboard/lib/pages/gallery/gallery_overlays_page.dart b/apps/dashboard/lib/pages/gallery/gallery_overlays_page.dart index cc674e32f..ddf18e026 100644 --- a/apps/dashboard/lib/pages/gallery/gallery_overlays_page.dart +++ b/apps/dashboard/lib/pages/gallery/gallery_overlays_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../../ui/ui.dart'; import '../../utils/text.dart'; import '../../widgets/gallery_scaffold.dart'; @@ -28,33 +28,33 @@ class _GalleryOverlaysPageState extends State { description: 'Both viewport alignments across the complete four-size scale.', child: GalleryEnumMatrix( - rows: FortalDialogAlign.values, - columns: FortalDialogSize.values, - cellBuilder: (context, align, size) => FortalButton.soft( + rows: UiDialogAlign.values, + columns: UiDialogSize.values, + cellBuilder: (context, align, size) => UiButton.soft( size: .size1, semanticLabel: 'Open ${enumLabel(align)} ${enumLabel(size)} dialog', onPressed: () => showRemixDialog( context: context, barrierLabel: 'Dismiss', - builder: (dialogContext) => FortalDialog( + builder: (dialogContext) => UiDialog( align: align, size: size, title: 'Invite teammates', description: 'Share this workspace with your collaborators.', actions: [ - FortalButton.soft( + UiButton.soft( onPressed: () => Navigator.of(dialogContext).pop(), label: 'Cancel', ), - FortalButton( + UiButton( onPressed: () => Navigator.of(dialogContext).pop(), label: 'Send invite', ), ], child: const Padding( padding: EdgeInsets.only(top: 12), - child: FortalTextField(hintText: 'teammate@example.com'), + child: UiTextField(hintText: 'teammate@example.com'), ), ), ), @@ -69,8 +69,8 @@ class _GalleryOverlaysPageState extends State { spacing: 14, runSpacing: 14, children: [ - for (final size in FortalPopoverSize.values) - FortalPopover( + for (final size in UiPopoverSize.values) + UiPopover( size: size, semanticLabel: 'Open ${enumLabel(size)} popover', popoverChild: const SizedBox( @@ -80,8 +80,8 @@ class _GalleryOverlaysPageState extends State { crossAxisAlignment: .start, spacing: 8, children: [ - FortalText('Quick note'), - FortalText( + UiText('Quick note'), + UiText( 'Popover content inherits the active Fortal scope.', ), ], @@ -96,7 +96,7 @@ class _GalleryOverlaysPageState extends State { label: 'Tooltip', description: 'Hover or long-press the trigger to reveal contextual help.', - child: FortalTooltip( + child: UiTooltip( tooltipSemantics: 'Keyboard shortcut Command K', tooltipChild: const Text('Search · ⌘K'), child: const _OverlayTrigger('Hover for shortcut'), @@ -106,9 +106,9 @@ class _GalleryOverlaysPageState extends State { label: 'Menu', description: 'Solid and soft menus at both supported density sizes.', child: GalleryEnumMatrix( - rows: FortalMenuVariant.values, - columns: FortalMenuSize.values, - cellBuilder: (context, variant, size) => FortalMenu( + rows: UiMenuVariant.values, + columns: UiMenuSize.values, + cellBuilder: (context, variant, size) => UiMenu( variant: variant, size: size, trigger: const RemixMenuTrigger( @@ -167,5 +167,5 @@ class _OverlayTrigger extends StatelessWidget { @override Widget build(BuildContext context) => - FortalBadge.surface(size: .size3, highContrast: true, label: label); + UiBadge.surface(size: .size3, highContrast: true, label: label); } diff --git a/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart b/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart index 136a96e5b..034f20862 100644 --- a/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart +++ b/apps/dashboard/lib/pages/gallery/gallery_typography_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../../ui/ui.dart'; import '../../utils/text.dart'; import '../../widgets/gallery_scaffold.dart'; @@ -27,7 +27,7 @@ class GalleryTypographyPage extends StatelessWidget { crossAxisAlignment: .start, spacing: 6, children: [ - for (final size in FortalTextSize.values) + for (final size in UiTextSize.values) Row( crossAxisAlignment: .baseline, textBaseline: TextBaseline.alphabetic, @@ -35,10 +35,10 @@ class GalleryTypographyPage extends StatelessWidget { children: [ SizedBox( width: 64, - child: FortalCode.ghost(enumLabel(size), size: .size1), + child: UiCode.ghost(enumLabel(size), size: .size1), ), Flexible( - child: FortalText( + child: UiText( 'The quick brown fox', size: size, truncate: true, @@ -55,19 +55,19 @@ class GalleryTypographyPage extends StatelessWidget { 'Text, heading, code, and link share all four weight presets.', child: GalleryEnumMatrix( rows: _TypographyFamily.values, - columns: FortalTextWeight.values, + columns: UiTextWeight.values, cellWidth: 130, cellBuilder: (context, family, weight) { return switch (family) { - .text => FortalText('Aa', size: .size4, weight: weight), - .heading => FortalHeading( + .text => UiText('Aa', size: .size4, weight: weight), + .heading => UiHeading( 'Aa', headingLevel: 3, size: .size4, weight: weight, ), - .code => FortalCode.soft('Aa', size: .size4, weight: weight), - .link => FortalLink( + .code => UiCode.soft('Aa', size: .size4, weight: weight), + .link => UiLink( 'Aa', size: .size4, weight: weight, @@ -93,11 +93,11 @@ class GalleryTypographyPage extends StatelessWidget { spacing: 8, children: [ for (final (level, size) in const [ - (1, FortalTextSize.size6), - (2, FortalTextSize.size4), - (3, FortalTextSize.size3), + (1, UiTextSize.size6), + (2, UiTextSize.size4), + (3, UiTextSize.size3), ]) - FortalHeading( + UiHeading( 'Level $level heading at size ${size.name.substring(4)}', headingLevel: level, size: size, @@ -109,14 +109,14 @@ class GalleryTypographyPage extends StatelessWidget { GallerySection( label: 'Code', description: 'Solid, soft, outline, and ghost inline code.', - child: GalleryMatrix( - rows: FortalCodeVariant.values, + child: GalleryMatrix( + rows: UiCodeVariant.values, columns: const [false, true], rowLabelBuilder: enumLabel, columnLabelBuilder: (highContrast) => highContrast ? 'High contrast' : 'Default', cellWidth: 170, - cellBuilder: (_, variant, highContrast) => FortalCode( + cellBuilder: (_, variant, highContrast) => UiCode( 'const x = 1;', variant: variant, size: .size2, @@ -130,10 +130,10 @@ class GalleryTypographyPage extends StatelessWidget { description: 'Classic key caps and the flat soft variant at all nine sizes.', child: GalleryEnumMatrix( - rows: FortalKbdVariant.values, - columns: FortalTextSize.values, + rows: UiKbdVariant.values, + columns: UiTextSize.values, cellWidth: 130, - cellBuilder: (_, variant, size) => FortalKbd( + cellBuilder: (_, variant, size) => UiKbd( '⌘K', variant: variant, size: size, @@ -151,8 +151,8 @@ class GalleryTypographyPage extends StatelessWidget { runSpacing: 14, crossAxisAlignment: WrapCrossAlignment.center, children: [ - for (final underline in FortalLinkUnderline.values) - FortalLink( + for (final underline in UiLinkUnderline.values) + UiLink( enumLabel(underline), underline: underline, onPressed: () => showRemixToast( @@ -163,7 +163,7 @@ class GalleryTypographyPage extends StatelessWidget { ), ), ), - FortalLink( + UiLink( 'High contrast', highContrast: true, onPressed: () => showRemixToast( @@ -176,9 +176,9 @@ class GalleryTypographyPage extends StatelessWidget { ), // Two spellings of the same state: a null callback disables the // link exactly as `enabled: false` does. - FortalLink('Disabled', enabled: false, onPressed: () {}), - const FortalLink('Disabled (no callback)'), - FortalLink( + UiLink('Disabled', enabled: false, onPressed: () {}), + const UiLink('Disabled (no callback)'), + UiLink( 'Documentation', linkUrl: Uri.parse('https://docs.page/btwld/remix/fortal'), semanticHint: 'Opens the Fortal documentation', @@ -203,20 +203,16 @@ class GalleryTypographyPage extends StatelessWidget { runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: const [ - FortalText('Neutral', size: .size3), - FortalText( - 'High contrast alone', - size: .size3, - highContrast: true, - ), - FortalText('Accent', size: .size3, accent: true), - FortalText( + UiText('Neutral', size: .size3), + UiText('High contrast alone', size: .size3, highContrast: true), + UiText('Accent', size: .size3, accent: true), + UiText( 'Accent high contrast', size: .size3, accent: true, highContrast: true, ), - FortalHeading( + UiHeading( 'Accent heading', headingLevel: 3, size: .size3, @@ -239,7 +235,7 @@ class GalleryTypographyPage extends StatelessWidget { children: [ SizedBox( width: 260, - child: FortalText( + child: UiText( 'Wrap keeps the complete sentence, across as many lines as ' 'it needs.', size: .size2, @@ -247,7 +243,7 @@ class GalleryTypographyPage extends StatelessWidget { ), SizedBox( width: 260, - child: FortalText( + child: UiText( 'Truncate keeps exactly one line, across as many lines as ' 'it needs.', size: .size2, diff --git a/apps/dashboard/lib/pages/orders_page.dart b/apps/dashboard/lib/pages/orders_page.dart index f6079b634..5bad89001 100644 --- a/apps/dashboard/lib/pages/orders_page.dart +++ b/apps/dashboard/lib/pages/orders_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../data/models.dart'; import '../data/orders.dart'; @@ -58,7 +58,7 @@ class _OrdersPageState extends State { PageHeader( title: 'Orders', description: 'Review transactions and fulfillment status.', - actions: FortalButton( + actions: UiButton( onPressed: () => showRemixToast( context, RemixToastData( @@ -74,7 +74,7 @@ class _OrdersPageState extends State { alignment: .centerLeft, child: SingleChildScrollView( scrollDirection: .horizontal, - child: FortalSegmentedControl<_OrderFilter>( + child: UiSegmentedControl<_OrderFilter>( semanticLabel: 'Filter orders by status', selectedValue: _filter, items: const [ @@ -97,7 +97,7 @@ class _OrdersPageState extends State { ), ), ), - FortalDataTable.surface( + UiDataTable.surface( key: const ValueKey('data-grid-orders'), rows: visible, columns: _columns, diff --git a/apps/dashboard/lib/pages/overview_page.dart b/apps/dashboard/lib/pages/overview_page.dart index 1dd617059..02b0a9bd4 100644 --- a/apps/dashboard/lib/pages/overview_page.dart +++ b/apps/dashboard/lib/pages/overview_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../data/activity.dart'; import '../data/models.dart'; @@ -20,10 +20,10 @@ class OverviewPage extends StatelessWidget { @override Widget build(BuildContext context) { - final pagePadding = MixScope.tokenOf(FortalTokens.space6, context); - final pageGap = MixScope.tokenOf(FortalTokens.space5, context); - final metricGap = MixScope.tokenOf(FortalTokens.space4, context); - final bandGap = MixScope.tokenOf(FortalTokens.space5, context); + final pagePadding = MixScope.tokenOf(UiTokens.space6, context); + final pageGap = MixScope.tokenOf(UiTokens.space5, context); + final metricGap = MixScope.tokenOf(UiTokens.space4, context); + final bandGap = MixScope.tokenOf(UiTokens.space5, context); // Omit autoRows: Mix 1031 defaults implicit rows to content height. final GridBoxStyler metricsStyle = .equalColumns(4) .gap(metricGap) @@ -97,7 +97,7 @@ class _ActivityCard extends StatelessWidget { @override Widget build(BuildContext context) { - return FortalCard( + return UiCard( size: .size2, child: Column( crossAxisAlignment: .stretch, @@ -107,7 +107,7 @@ class _ActivityCard extends StatelessWidget { for (final (index, event) in activityEvents.indexed) ...[ _ActivityRow(event), if (index != activityEvents.length - 1) - const FortalDivider(size: .size4), + const UiDivider(size: .size4), ], ], ), @@ -122,10 +122,10 @@ class _ActivityRow extends StatelessWidget { @override Widget build(BuildContext context) { final (icon, accent) = switch (event.kind) { - .customer => (Icons.person_add_alt, FortalAccentColor.blue), - .order => (Icons.local_shipping_outlined, FortalAccentColor.indigo), - .payment => (Icons.payments_outlined, FortalAccentColor.green), - .alert => (Icons.error_outline, FortalAccentColor.amber), + .customer => (Icons.person_add_alt, UiAccentColor.blue), + .order => (Icons.local_shipping_outlined, UiAccentColor.indigo), + .payment => (Icons.payments_outlined, UiAccentColor.green), + .alert => (Icons.error_outline, UiAccentColor.amber), }; return Padding( padding: const EdgeInsets.symmetric(vertical: 11), @@ -138,7 +138,7 @@ class _ActivityRow extends StatelessWidget { crossAxisAlignment: .start, spacing: 2, children: [ - FortalText(event.title, size: .size2, weight: .medium), + UiText(event.title, size: .size2, weight: .medium), StyledText( event.detail, style: dashboardText(.size1, tone: .muted), @@ -165,12 +165,12 @@ class _ActivityIcon extends StatelessWidget { const _ActivityIcon({required this.icon, required this.accent}); final IconData icon; - final FortalAccentColor accent; + final UiAccentColor accent; @override Widget build(BuildContext context) => AppAccentScope( accent: accent, - child: FortalAvatar.soft(icon: icon, size: .size2), + child: UiAvatar.soft(icon: icon, size: .size2), ); } @@ -180,7 +180,7 @@ class _RecentOrders extends StatelessWidget { @override Widget build(BuildContext context) { - return FortalCard( + return UiCard( size: .size2, child: Column( crossAxisAlignment: .stretch, @@ -189,7 +189,7 @@ class _RecentOrders extends StatelessWidget { Row( children: [ const Expanded(child: SectionLabel('Recent orders')), - FortalButton.ghost( + UiButton.ghost( key: const ValueKey('overview-view-orders'), size: .size1, onPressed: onViewOrders, @@ -198,7 +198,7 @@ class _RecentOrders extends StatelessWidget { ), ], ), - FortalDataTable.surface( + UiDataTable.surface( rows: orders.take(5).toList(), semanticLabel: 'Recent orders', minimumWidth: 560, diff --git a/apps/dashboard/lib/pages/settings_page.dart b/apps/dashboard/lib/pages/settings_page.dart index 50975b70c..ef2803b51 100644 --- a/apps/dashboard/lib/pages/settings_page.dart +++ b/apps/dashboard/lib/pages/settings_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../widgets/app_accent_scope.dart'; import '../widgets/disclosure_trigger.dart'; @@ -49,7 +49,7 @@ class _SettingsPageState extends State { title: 'Settings', description: 'Manage your profile, preferences, and workspace.', ), - FortalCard( + UiCard( size: .size3, child: Column( crossAxisAlignment: .stretch, @@ -63,12 +63,12 @@ class _SettingsPageState extends State { GridBox( style: profileFields, children: [ - FortalTextField( + UiTextField( controller: _nameController, label: 'Name', hintText: 'Your name', ), - FortalTextField( + UiTextField( controller: _emailController, label: 'Email', helperText: 'Domain verification is pending.', @@ -77,7 +77,7 @@ class _SettingsPageState extends State { ), ], ), - FortalSelect( + UiSelect( trigger: const RemixSelectTrigger( placeholder: 'Language', ), @@ -104,7 +104,7 @@ class _SettingsPageState extends State { _PreferenceRow( title: 'Product updates', description: 'News about features and improvements.', - trailing: FortalSwitch( + trailing: UiSwitch( selected: _productUpdates, semanticLabel: 'Receive product updates', onChanged: (value) => @@ -115,7 +115,7 @@ class _SettingsPageState extends State { title: 'Weekly digest', description: 'A summary of workspace activity each Monday.', - trailing: FortalCheckbox( + trailing: UiCheckbox( selected: _weeklyDigest, semanticLabel: 'Receive weekly digest', onChanged: (value) => @@ -124,7 +124,7 @@ class _SettingsPageState extends State { ), Align( alignment: .centerLeft, - child: FortalButton( + child: UiButton( onPressed: () => showRemixToast( context, RemixToastData( @@ -138,7 +138,7 @@ class _SettingsPageState extends State { ], ), ), - FortalCard( + UiCard( size: .size3, child: Column( crossAxisAlignment: .stretch, @@ -149,7 +149,7 @@ class _SettingsPageState extends State { description: 'Tune every Fortal theme parameter in real time.', ), - FortalCallout( + UiCallout( icon: Icons.auto_awesome_outlined, text: 'Changes apply live across the entire dashboard.', ), @@ -159,7 +159,7 @@ class _SettingsPageState extends State { ), AppAccentScope( accent: .red, - child: FortalDisclosure.soft( + child: UiDisclosure.soft( key: const ValueKey('settings-danger-zone'), size: .size3, animationStyle: dashboardDisclosureAnimationStyle, @@ -179,7 +179,7 @@ class _SettingsPageState extends State { ), Align( alignment: .centerLeft, - child: FortalButton.outline( + child: UiButton.outline( onPressed: _confirmDelete, label: 'Delete workspace', ), @@ -199,18 +199,18 @@ class _SettingsPageState extends State { final confirmed = await showRemixDialog( context: context, barrierLabel: 'Dismiss', - builder: (context) => FortalDialog( + builder: (context) => UiDialog( title: 'Delete workspace?', description: 'This demo keeps your data safe, but a real action would be permanent.', actions: [ - FortalButton.soft( + UiButton.soft( onPressed: () => Navigator.of(context).pop(false), label: 'Cancel', ), AppAccentScope( accent: .red, - child: FortalButton( + child: UiButton( onPressed: () => Navigator.of(context).pop(true), label: 'Delete', ), @@ -248,7 +248,7 @@ class _PreferenceRow extends StatelessWidget { crossAxisAlignment: .start, spacing: 2, children: [ - FortalText(title, size: .size2, weight: .medium), + UiText(title, size: .size2, weight: .medium), StyledText(description, style: dashboardText(.size1, tone: .muted)), ], ), diff --git a/apps/dashboard/lib/shell/dashboard_page.dart b/apps/dashboard/lib/shell/dashboard_page.dart index 3c16d899c..7c8d4fe8f 100644 --- a/apps/dashboard/lib/shell/dashboard_page.dart +++ b/apps/dashboard/lib/shell/dashboard_page.dart @@ -16,6 +16,7 @@ enum DashboardPage { 'Overview', Icons.space_dashboard_outlined, ), + chat(DashboardSection.overview, 'Chat', Icons.chat_bubble_outline), customers(DashboardSection.data, 'Customers', Icons.people_outline), orders(DashboardSection.data, 'Orders', Icons.receipt_long_outlined), settings(DashboardSection.settings, 'Settings', Icons.settings_outlined), diff --git a/apps/dashboard/lib/shell/dashboard_shell.dart b/apps/dashboard/lib/shell/dashboard_shell.dart index a0c055a75..0c4c10107 100644 --- a/apps/dashboard/lib/shell/dashboard_shell.dart +++ b/apps/dashboard/lib/shell/dashboard_shell.dart @@ -1,7 +1,8 @@ import 'package:flutter/widgets.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../pages/charts_page.dart'; +import '../pages/chat_page.dart'; import '../pages/customers_page.dart'; import '../pages/gallery/gallery_actions_page.dart'; import '../pages/gallery/gallery_display_page.dart'; @@ -28,6 +29,9 @@ class _DashboardShellState extends State { DashboardPage _selected = .overview; String _searchQuery = ''; bool _sidebarCollapsed = false; + // The sidebar layout reparents its body when crossing the compact breakpoint. + // Keep page state (including an active conversation) through that move. + final _pageStackKey = GlobalKey(); void _select(DashboardPage page) => setState(() => _selected = page); @@ -37,6 +41,7 @@ class _DashboardShellState extends State { // enum order. final pages = [ OverviewPage(onViewOrders: () => _select(.orders)), + const ChatPage(), CustomersPage(globalQuery: _searchQuery), OrdersPage(globalQuery: _searchQuery), const SettingsPage(), @@ -49,14 +54,14 @@ class _DashboardShellState extends State { const GalleryTypographyPage(), ]; - return FortalSidebarLayout( + return UiSidebarLayout( compactBreakpoint: dashboardCompactBreakpoint, sidebarWidth: dashboardSidebarWidth, collapsedWidth: dashboardSidebarCollapsedWidth, collapsed: _sidebarCollapsed, sidebar: Builder( builder: (context) { - final scope = FortalSidebarLayoutScope.of(context); + final scope = UiSidebarLayoutScope.of(context); return Sidebar( key: const ValueKey('dashboard-sidebar'), selected: _selected, @@ -77,7 +82,7 @@ class _DashboardShellState extends State { ), header: Builder( builder: (context) { - final scope = FortalSidebarLayoutScope.of(context); + final scope = UiSidebarLayoutScope.of(context); return TopBar( page: _selected, onMenuPressed: scope.isCompact ? scope.openCompact : null, @@ -86,7 +91,11 @@ class _DashboardShellState extends State { ); }, ), - body: IndexedStack(index: _selected.index, children: pages), + body: IndexedStack( + key: _pageStackKey, + index: _selected.index, + children: pages, + ), ); } } diff --git a/apps/dashboard/lib/shell/dashboard_shell_layout.dart b/apps/dashboard/lib/shell/dashboard_shell_layout.dart index 2cb90e801..fdb2ccdac 100644 --- a/apps/dashboard/lib/shell/dashboard_shell_layout.dart +++ b/apps/dashboard/lib/shell/dashboard_shell_layout.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; const dashboardCompactBreakpoint = 720.0; const dashboardSidebarWidth = 256.0; @@ -9,7 +9,7 @@ const dashboardShellHeaderHeight = 64.0; const dashboardToolbarButtonSize = 40.0; /// Square ghost target shared by top bar and sidebar header actions. -final dashboardToolbarButtonStyle = fortalIconButtonStyle(variant: .ghost) +final dashboardToolbarButtonStyle = uiIconButtonStyle(variant: .ghost) .width(dashboardToolbarButtonSize) .height(dashboardToolbarButtonSize) .padding(.all(0)) @@ -33,11 +33,9 @@ class DashboardShellHeader extends StatelessWidget { .height(dashboardShellHeaderHeight) .alignment(AlignmentDirectional.centerStart) .padding(.horizontal(horizontalPadding)) - .color(FortalTokens.colorPanelSolid()) + .color(UiTokens.colorPanelSolid()) .border( - .bottom( - .color(FortalTokens.grayA6()).width(FortalTokens.borderWidth1()), - ), + .bottom(.color(UiTokens.grayA6()).width(UiTokens.borderWidth1())), ), child: child, ); diff --git a/apps/dashboard/lib/shell/sidebar.dart b/apps/dashboard/lib/shell/sidebar.dart index f12a0b2af..11aea4bad 100644 --- a/apps/dashboard/lib/shell/sidebar.dart +++ b/apps/dashboard/lib/shell/sidebar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../utils/text.dart'; import '../widgets/action_menu.dart'; @@ -28,18 +28,18 @@ class Sidebar extends StatelessWidget { @override Widget build(BuildContext context) { - // Placement stays here: FortalSidebar owns no display edge. Passing the + // Placement stays here: UiSidebar owns no display edge. Passing the // device insets into the generated wrapper keeps them inside its painted // surface instead of putting a SafeArea around that surface. final insets = MediaQuery.paddingOf(context); // `Sidebar` stays self-sizing (rather than deferring width entirely to - // `FortalSidebarLayout`'s row) so it keeps working the way `sidebar_test` + // `UiSidebarLayout`'s row) so it keeps working the way `sidebar_test` // exercises it: standalone, in a bare `Row` with no imposed width. The // shell's own row wraps this same width in an `AnimatedContainer` using // the identical constants and the identical `collapsed` trigger, so the // two transitions move together. - return FortalSidebar( + return UiSidebar( collapsed: collapsed, expandedWidth: dashboardSidebarWidth, collapsedWidth: dashboardSidebarCollapsedWidth, @@ -63,11 +63,11 @@ class _Brand extends StatelessWidget { @override Widget build(BuildContext context) { final motion = RemixSidebar.animationOf(context); - final inset = FortalTokens.space3.resolve(context); + final inset = UiTokens.space3.resolve(context); final label = collapsed ? 'Expand navigation' : 'Collapse navigation'; return DashboardShellHeader( key: const ValueKey('dashboard-brand'), - horizontalPadding: FortalTokens.space3(), + horizontalPadding: UiTokens.space3(), child: RowBox( children: [ Expanded( @@ -77,21 +77,17 @@ class _Brand extends StatelessWidget { // The wordmark starts where expanded destination icons start. child: Padding( padding: EdgeInsetsDirectional.only( - start: FortalTokens.space4.resolve(context), + start: UiTokens.space4.resolve(context), ), child: _SidebarTextReveal( motion: motion, - child: const FortalText( - 'Dashboard', - size: .size5, - weight: .bold, - ), + child: const UiText('Dashboard', size: .size5, weight: .bold), ), ), ), ), if (onToggle case final onToggle?) ...[ - FortalTooltip( + UiTooltip( positioning: OverlayPositionConfig( side: Directionality.of(context) == TextDirection.ltr ? OverlaySide.right @@ -125,8 +121,7 @@ class _Brand extends StatelessWidget { /// Center line of the collapsed rail, where destination icons settle. double _railCenter(BuildContext context) => - (dashboardSidebarCollapsedWidth - - FortalTokens.borderWidth1.resolve(context)) / + (dashboardSidebarCollapsedWidth - UiTokens.borderWidth1.resolve(context)) / 2; class _Profile extends StatelessWidget { @@ -135,19 +130,14 @@ class _Profile extends StatelessWidget { @override Widget build(BuildContext context) { final motion = RemixSidebar.animationOf(context); - final inset = FortalTokens.space2.resolve(context); + final inset = UiTokens.space2.resolve(context); // The avatar stays on the rail's center line in both presentations. final lead = - (_railCenter(context) - - inset - - FortalTokens.space6.resolve(context) / 2) + (_railCenter(context) - inset - UiTokens.space6.resolve(context) / 2) .clamp(0.0, double.infinity); return Box( style: BoxStyler().padding( - .symmetric( - horizontal: FortalTokens.space2(), - vertical: FortalTokens.space3(), - ), + .symmetric(horizontal: UiTokens.space2(), vertical: UiTokens.space3()), ), child: DashboardActionMenu( key: const ValueKey('sidebar-account-trigger'), @@ -157,7 +147,7 @@ class _Profile extends StatelessWidget { child: RowBox( children: [ SizedBox(width: lead), - const FortalAvatar(label: 'LF', size: .size2), + const UiAvatar(label: 'LF', size: .size2), SizedBox(width: 10 * motion.expansion), Expanded( child: _SidebarTextReveal( @@ -167,11 +157,7 @@ class _Profile extends StatelessWidget { .mainAxisSize(.min) .crossAxisAlignment(.start), children: [ - const FortalText( - 'Leo Farias', - size: .size2, - weight: .medium, - ), + const UiText('Leo Farias', size: .size2, weight: .medium), StyledText( 'leo@remix.dev', style: dashboardText( @@ -188,7 +174,7 @@ class _Profile extends StatelessWidget { child: Icon( Icons.more_horiz, size: 18, - color: MixScope.tokenOf(FortalTokens.gray11, context), + color: MixScope.tokenOf(UiTokens.gray11, context), ), ), ], diff --git a/apps/dashboard/lib/shell/top_bar.dart b/apps/dashboard/lib/shell/top_bar.dart index 5f9600fd5..d9edd8564 100644 --- a/apps/dashboard/lib/shell/top_bar.dart +++ b/apps/dashboard/lib/shell/top_bar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../data/activity.dart'; import '../theme/theme_scope.dart'; @@ -36,11 +36,9 @@ class _TopBarState extends State { final compact = width < dashboardCompactBreakpoint; final toolbarButtonStyle = dashboardToolbarButtonStyle; return DashboardShellHeader( - horizontalPadding: compact - ? FortalTokens.space3() - : FortalTokens.space5(), + horizontalPadding: compact ? UiTokens.space3() : UiTokens.space5(), child: RowBox( - style: FlexBoxStyler().spacing(FortalTokens.space2()), + style: FlexBoxStyler().spacing(UiTokens.space2()), children: [ if (widget.onMenuPressed case final onMenuPressed?) RemixIconButton( @@ -52,7 +50,7 @@ class _TopBarState extends State { ), Expanded( child: RowBox( - style: FlexBoxStyler().spacing(FortalTokens.space3()), + style: FlexBoxStyler().spacing(UiTokens.space3()), children: [ if (width > 900) ...[ StyledText( @@ -63,7 +61,7 @@ class _TopBarState extends State { icon: Directionality.of(context) == TextDirection.ltr ? Icons.chevron_right : Icons.chevron_left, - style: IconStyler().size(14).color(FortalTokens.gray8()), + style: IconStyler().size(14).color(UiTokens.gray8()), ), ], Flexible( @@ -80,7 +78,7 @@ class _TopBarState extends State { if (width > 1000) Box( style: BoxStyler().width(260), - child: FortalTextField( + child: UiTextField( key: const ValueKey('global-search'), leading: const Icon(Icons.search, size: 18), hintText: 'Search…', @@ -93,16 +91,16 @@ class _TopBarState extends State { style: toolbarButtonStyle, onPressed: () { final theme = ThemeScope.of(context); - final isDark = FortalTheme.of(context).isDark; + final isDark = UiTheme.of(context).isDark; theme.onChanged( theme.settings.copyWith(appearance: isDark ? .light : .dark), ); }, - icon: FortalTheme.of(context).isDark + icon: UiTheme.of(context).isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined, ), - FortalPopover( + UiPopover( controller: _notificationsController, openOnTap: false, semanticLabel: 'Notifications', @@ -123,14 +121,14 @@ class _TopBarState extends State { RowBox( children: [ const Expanded( - child: FortalHeading( + child: UiHeading( 'Notifications', headingLevel: 2, size: .size3, weight: .medium, ), ), - FortalButton.ghost( + UiButton.ghost( size: .size1, onPressed: () { _notificationsController.close(); @@ -157,7 +155,7 @@ class _TopBarState extends State { .width(7) .height(7) .margin(.top(6)) - .color(FortalTokens.accent9()) + .color(UiTokens.accent9()) .borderRadius(.circular(4)), ), Expanded( @@ -166,7 +164,7 @@ class _TopBarState extends State { .crossAxisAlignment(.start) .spacing(2), children: [ - FortalText( + UiText( event.title, size: .size2, weight: .medium, @@ -202,7 +200,7 @@ class _TopBarState extends State { style: BoxStyler() .width(7) .height(7) - .color(FortalTokens.accent9()) + .color(UiTokens.accent9()) .borderRadius(.circular(4)), ), ), @@ -210,7 +208,7 @@ class _TopBarState extends State { ), ), ), - FortalPopover( + UiPopover( controller: _themeController, openOnTap: false, semanticLabel: 'Theme settings', @@ -239,7 +237,7 @@ class _TopBarState extends State { alignment: .end, sideOffset: 8, ), - trigger: const FortalAvatar(label: 'LF', size: .size2), + trigger: const UiAvatar(label: 'LF', size: .size2), actions: const [ DashboardAction(value: 'profile', label: 'Profile'), DashboardAction(value: 'preferences', label: 'Preferences'), diff --git a/apps/dashboard/lib/theme/theme_settings.dart b/apps/dashboard/lib/theme/theme_settings.dart index aa894a8e0..595dabc3a 100644 --- a/apps/dashboard/lib/theme/theme_settings.dart +++ b/apps/dashboard/lib/theme/theme_settings.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; @immutable class ThemeSettings { @@ -13,23 +13,23 @@ class ThemeSettings { }); final ThemeMode appearance; - final FortalAccentColor accentColor; - final FortalGrayColor grayColor; - final FortalPanelBackground panelBackground; - final FortalRadius radius; - final FortalScaling scaling; + final UiAccentColor accentColor; + final UiGrayColor grayColor; + final UiPanelBackground panelBackground; + final UiRadius radius; + final UiScaling scaling; ThemeMode get themeMode => appearance; - // ThemeMode.system is app state rather than a FortalThemeConfig value, so the + // ThemeMode.system is app state rather than a UiThemeConfig value, so the // dashboard keeps a concrete settings object that can be copied atomically. ThemeSettings copyWith({ ThemeMode? appearance, - FortalAccentColor? accentColor, - FortalGrayColor? grayColor, - FortalPanelBackground? panelBackground, - FortalRadius? radius, - FortalScaling? scaling, + UiAccentColor? accentColor, + UiGrayColor? grayColor, + UiPanelBackground? panelBackground, + UiRadius? radius, + UiScaling? scaling, }) { return ThemeSettings( appearance: appearance ?? this.appearance, diff --git a/apps/dashboard/lib/ui/components/accordion.dart b/apps/dashboard/lib/ui/components/accordion.dart new file mode 100644 index 000000000..647105b89 --- /dev/null +++ b/apps/dashboard/lib/ui/components/accordion.dart @@ -0,0 +1,158 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'accordion.g.dart'; + +/// Ui accordion size presets. +enum UiAccordionSize { size1, size2, size3 } + +/// Ui accordion color variants. +enum UiAccordionVariant { surface, soft } + +/// Ui-themed preset for [RemixAccordion]. +@MixWidget(target: RemixAccordion.new) +AccordionStyler uiAccordionStyle({ + UiAccordionVariant variant = .surface, + UiAccordionSize size = .size2, + AccordionStyler style = const AccordionStyler.create(), +}) { + return (switch (variant) { + .surface => _uiAccordionSurfaceStyler(size), + .soft => _uiAccordionSoftStyler(size), + }).merge(style); +} + +// Panel anatomy follows the mapped Table family (see data_table.dart): +// `container` alone owns radius, frame, fill, and clipping, while trigger and +// content stay flat rectangles that simply get cropped to its rounded shape. +// The frame and divider are foreground borders so edge-to-edge child fills +// cannot partially cover their antialiased edges. +AccordionStyler _uiAccordionBaseStyler(UiAccordionSize size) { + return AccordionStyler() + .trigger(.direction(.horizontal)) + .leadingIcon(.color(UiTokens.gray11())) + .title(.fontWeight(UiTokens.fontWeightMedium()).color(UiTokens.gray12())) + .trailingIcon(.color(UiTokens.gray11())) + .content(.width(.infinity)) + .merge(_uiAccordionSizeStyler(size)); +} + +AccordionStyler _uiAccordionFocusStyler() { + return AccordionStyler().trigger(FlexBoxStyler().uiFocusRing()); +} + +AccordionStyler _uiAccordionDisabledStyler() { + return AccordionStyler() + .trigger(.color(UiTokens.grayA3())) + .leadingIcon(.color(UiTokens.gray8())) + .title(.color(UiTokens.gray8())) + .trailingIcon(.color(UiTokens.gray8())); +} + +AccordionStyler _uiAccordionSurfaceStyler([UiAccordionSize size = .size2]) { + return _uiAccordionBaseStyler(size) + .container( + uiSurfaceFrame( + fillColor: UiTokens.gray2(), + borderColor: UiTokens.gray6(), + borderWidth: UiTokens.borderWidth1(), + radius: _uiAccordionRadius(size), + ), + ) + .trigger(.color(UiTokens.gray1())) + .content( + BoxStyler() + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.top( + _uiAccordionBorderSide(UiTokens.gray6()), + ), + ), + ) + .wrap(_uiAccordionContentTypography(UiTokens.gray12())), + ) + .onHovered(.trigger(.color(UiTokens.gray2()))) + .onPressed(.trigger(.color(UiTokens.gray3()))) + .onFocusVisible(_uiAccordionFocusStyler()) + .onDisabled(_uiAccordionDisabledStyler()); +} + +AccordionStyler _uiAccordionSoftStyler([UiAccordionSize size = .size2]) { + return _uiAccordionBaseStyler(size) + .container( + uiSurfaceFrame( + fillColor: UiTokens.accent2(), + borderColor: UiTokens.accent6(), + borderWidth: UiTokens.borderWidth1(), + radius: _uiAccordionRadius(size), + ), + ) + .trigger(.color(UiTokens.accent2())) + .title(.color(UiTokens.accent12())) + .trailingIcon(.color(UiTokens.accent11())) + .content( + BoxStyler() + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.top( + _uiAccordionBorderSide(UiTokens.accent6()), + ), + ), + ) + .wrap(_uiAccordionContentTypography(UiTokens.accent12())), + ) + .onHovered(.trigger(.color(UiTokens.accent3()))) + .onPressed(.trigger(.color(UiTokens.accent4()))) + .onFocusVisible(_uiAccordionFocusStyler()) + .onDisabled(_uiAccordionDisabledStyler()); +} + +/// The 1px edge shared by the panel's outer border and the trigger/content +/// divider, so the seam reads as a continuation of the frame rather than an +/// unrelated line. +BorderSideMix _uiAccordionBorderSide(Color color) => + BorderSideMix(color: color, width: UiTokens.borderWidth1()); + +/// Pins bare [Text] accordion content to the 14px type-scale step (`text2`) +/// regardless of accordion size, so content never renders larger than its own +/// trigger's title (measured 14/15/16px at size1/size2/size3). Ui text +/// children pin their own run. [color] supplies the variant's own content tint. +WidgetModifierConfig _uiAccordionContentTypography(Color color) => + WidgetModifierConfig.defaultTextStyle( + style: UiTokens.text2.mix(), + ).defaultTextStyle(style: TextStyleMix().color(color)); + +AccordionStyler _uiAccordionSizeStyler(UiAccordionSize size) { + return switch (size) { + .size1 => AccordionStyler( + trigger: FlexBoxStyler().padding(.all(UiTokens.space2())), + leadingIcon: .size(UiTokens.space4()), + title: .style(UiTokens.text2.mix()), + trailingIcon: .size(UiTokens.space4()), + content: .padding(.all(UiTokens.space2())), + ), + .size2 => AccordionStyler( + trigger: FlexBoxStyler().padding(.all(UiTokens.space3())), + leadingIcon: .size(UiTokens.spinnerSize3()), + title: .style(UiTokens.accordionText2.mix()), + trailingIcon: .size(UiTokens.spinnerSize3()), + content: .padding(.all(UiTokens.space3())), + ), + .size3 => AccordionStyler( + trigger: FlexBoxStyler().padding(.all(UiTokens.space4())), + leadingIcon: .size(UiTokens.space5()), + title: .style(UiTokens.text3.mix()), + trailingIcon: .size(UiTokens.space5()), + content: .padding(.all(UiTokens.space4())), + ), + }; +} + +Radius _uiAccordionRadius(UiAccordionSize size) => switch (size) { + .size1 => UiTokens.radius3(), + .size2 => UiTokens.radius4(), + .size3 => UiTokens.radius5(), +}; diff --git a/apps/dashboard/lib/ui/components/accordion.g.dart b/apps/dashboard/lib/ui/components/accordion.g.dart new file mode 100644 index 000000000..f615bb263 --- /dev/null +++ b/apps/dashboard/lib/ui/components/accordion.g.dart @@ -0,0 +1,143 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'accordion.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixAccordion]. +class UiAccordion extends StatelessWidget { + const UiAccordion({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const AccordionStyler.create(), + required this.value, + required this.child, + this.title, + this.leadingIcon, + this.trailingIcon, + this.builder, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.transitionBuilder, + }); + + const UiAccordion.surface({ + super.key, + this.size = .size2, + this.style = const AccordionStyler.create(), + required this.value, + required this.child, + this.title, + this.leadingIcon, + this.trailingIcon, + this.builder, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.transitionBuilder, + }) : variant = UiAccordionVariant.surface; + + const UiAccordion.soft({ + super.key, + this.size = .size2, + this.style = const AccordionStyler.create(), + required this.value, + required this.child, + this.title, + this.leadingIcon, + this.trailingIcon, + this.builder, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.transitionBuilder, + }) : variant = UiAccordionVariant.soft; + + final UiAccordionVariant variant; + + final UiAccordionSize size; + + final AccordionStyler style; + + final T value; + + final Widget child; + + final String? title; + + final IconData? leadingIcon; + + final IconData? trailingIcon; + + final NakedAccordionTriggerBuilder? builder; + + final bool enabled; + + final MouseCursor mouseCursor; + + final bool enableFeedback; + + final bool autofocus; + + final FocusNode? focusNode; + + final ValueChanged? onFocusChange; + + final ValueChanged? onHoverChange; + + final ValueChanged? onPressChange; + + final String? semanticLabel; + + final Widget Function(Widget, Animation)? transitionBuilder; + + @override + Widget build(BuildContext context) { + return RemixAccordion( + key: this.key, + style: uiAccordionStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + value: this.value, + child: this.child, + title: this.title, + leadingIcon: this.leadingIcon, + trailingIcon: this.trailingIcon, + builder: this.builder, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + enableFeedback: this.enableFeedback, + autofocus: this.autofocus, + focusNode: this.focusNode, + onFocusChange: this.onFocusChange, + onHoverChange: this.onHoverChange, + onPressChange: this.onPressChange, + semanticLabel: this.semanticLabel, + transitionBuilder: this.transitionBuilder, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/activity.dart b/apps/dashboard/lib/ui/components/activity.dart new file mode 100644 index 000000000..6702ad16d --- /dev/null +++ b/apps/dashboard/lib/ui/components/activity.dart @@ -0,0 +1,309 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/activity_item.dart'; +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'activity.g.dart'; + +typedef UiActivityStatusBuilder = + Widget Function(BuildContext context, UiActivityItem item); +typedef UiActivityStatusLabelBuilder = String Function(UiActivityItem item); +typedef UiActivityIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Activity ledger that is forced open and non-toggleable only while working. +class UiActivity extends StatefulWidget { + const UiActivity({ + super.key, + required this.items, + this.status = UiRunStatus.working, + this.title = 'Activity', + this.semanticLabel = 'Activity', + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.statusBuilder, + this.statusLabelBuilder, + this.indicatorBuilder, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + this.disclosureStyle = const DisclosureStyler.create(), + this.style = const UiActivityStyler.create(), + this.styleSpec, + }); + + final List items; + final UiRunStatus status; + final String title; + final String semanticLabel; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final UiActivityStatusBuilder? statusBuilder; + final UiActivityStatusLabelBuilder? statusLabelBuilder; + final UiActivityIndicatorBuilder? indicatorBuilder; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + final DisclosureStyler disclosureStyle; + final UiActivityStyler style; + final UiActivitySpec? styleSpec; + + bool get isWorking => status == UiRunStatus.working; + + /// Number of completed rows in the activity ledger. + int get settledCount => items + .where((item) => item.status == UiActivityItemStatus.complete) + .length; + + @override + State createState() => _UiActivityState(); +} + +class _UiActivityState extends State { + late final UiDisclosureEngine _disclosure; + + bool get _expanded => widget.isWorking ? true : (_disclosure.value); + + @override + void initState() { + super.initState(); + _disclosure = UiDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget(UiActivity oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + if (!oldWidget.isWorking && widget.isWorking) { + _request(true, lifecycle: true); + } else if (oldWidget.isWorking && + !widget.isWorking && + widget.collapseOnComplete) { + _request(false, lifecycle: true); + } + } + + void _request(bool next, {bool lifecycle = false}) { + if (widget.isWorking && !lifecycle) return; + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String _statusLabel(UiActivityItem item) => + widget.statusLabelBuilder?.call(item) ?? + switch (item.status) { + UiActivityItemStatus.pending => 'Pending', + UiActivityItemStatus.active => 'Active', + UiActivityItemStatus.complete => 'Complete', + }; + + UiFunctionalGlyphKind _statusGlyph(UiActivityItemStatus status) => + switch (status) { + UiActivityItemStatus.pending => .pending, + UiActivityItemStatus.active => .active, + UiActivityItemStatus.complete => .completed, + }; + + StyleSpec _statusContainer( + UiActivitySpec spec, + UiActivityItemStatus status, + ) => switch (status) { + UiActivityItemStatus.pending => spec.pendingItem, + UiActivityItemStatus.active => spec.activeItem, + UiActivityItemStatus.complete => spec.completedItem, + }; + + StyleSpec _statusStyle( + UiActivitySpec spec, + UiActivityItemStatus status, + ) => switch (status) { + UiActivityItemStatus.pending => spec.pendingStatus, + UiActivityItemStatus.active => spec.activeStatus, + UiActivityItemStatus.complete => spec.completedStatus, + }; + + Widget _defaultStatus( + BuildContext context, + UiActivitySpec spec, + UiActivityItem item, + ) => StyleSpecBuilder( + styleSpec: _statusStyle(spec, item.status), + builder: (context, iconSpec) => + UiFunctionalGlyph(kind: _statusGlyph(item.status), spec: iconSpec), + ); + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixDisclosure( + expanded: _expanded, + enabled: !widget.isWorking, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + // Preserve the count alignment and expansion cue while working. + // RemixDisclosure keeps the forced-open header non-toggleable. + UiDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: Row( + children: [ + Expanded( + child: StyledText(widget.title, styleSpec: spec.summaryTitle), + ), + StyledText( + '${widget.settledCount}/${widget.items.length}', + styleSpec: spec.count, + ), + ], + ), + content: Box( + styleSpec: spec.viewport, + child: UiLiveEdgeScrollView( + followOutput: widget.followOutput, + followThreshold: widget.followThreshold, + onFollowChanged: widget.onFollowChanged, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in widget.items) + Semantics( + container: true, + explicitChildNodes: true, + label: [ + item.title, + if (item.detail != null) item.detail!, + _statusLabel(item), + ].join(', '), + child: Box( + styleSpec: _statusContainer(spec, item.status), + child: RowBox( + key: ValueKey('ui-activity-item-${item.id}'), + styleSpec: spec.item, + children: [ + ExcludeSemantics( + child: + widget.statusBuilder?.call(context, item) ?? + _defaultStatus(context, spec, item), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: StyledText( + item.title, + styleSpec: spec.itemTitle, + ), + ), + if (item.detail != null) + ExcludeSemantics( + child: StyledText( + item.detail!, + styleSpec: spec.itemDetail, + ), + ), + if (item.child != null) item.child!, + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: UiActivity.new) +@immutable +final class UiActivitySpec with _$UiActivitySpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final StyleSpec summaryTitle; + @override + final StyleSpec itemTitle; + @override + final StyleSpec itemDetail; + @override + final StyleSpec count; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingItem; + @override + final StyleSpec activeItem; + @override + final StyleSpec completedItem; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec activeStatus; + @override + final StyleSpec completedStatus; + + const UiActivitySpec({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: FlexBoxSpec()), + summaryTitle = summaryTitle ?? const StyleSpec(spec: TextSpec()), + itemTitle = itemTitle ?? const StyleSpec(spec: TextSpec()), + itemDetail = itemDetail ?? const StyleSpec(spec: TextSpec()), + count = count ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingItem = pendingItem ?? const StyleSpec(spec: BoxSpec()), + activeItem = activeItem ?? const StyleSpec(spec: BoxSpec()), + completedItem = completedItem ?? const StyleSpec(spec: BoxSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: IconSpec()), + activeStatus = activeStatus ?? const StyleSpec(spec: IconSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: IconSpec()); +} diff --git a/apps/dashboard/lib/ui/components/activity.g.dart b/apps/dashboard/lib/ui/components/activity.g.dart new file mode 100644 index 000000000..a65b91eed --- /dev/null +++ b/apps/dashboard/lib/ui/components/activity.g.dart @@ -0,0 +1,505 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$UiActivitySpec implements Spec, Diagnosticable { + StyleSpec get viewport; + StyleSpec get item; + StyleSpec get summaryTitle; + StyleSpec get itemTitle; + StyleSpec get itemDetail; + StyleSpec get count; + StyleSpec get indicator; + StyleSpec get pendingItem; + StyleSpec get activeItem; + StyleSpec get completedItem; + StyleSpec get pendingStatus; + StyleSpec get activeStatus; + StyleSpec get completedStatus; + + @override + Type get type => UiActivitySpec; + + @override + UiActivitySpec copyWith({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + }) { + return UiActivitySpec( + viewport: viewport ?? this.viewport, + item: item ?? this.item, + summaryTitle: summaryTitle ?? this.summaryTitle, + itemTitle: itemTitle ?? this.itemTitle, + itemDetail: itemDetail ?? this.itemDetail, + count: count ?? this.count, + indicator: indicator ?? this.indicator, + pendingItem: pendingItem ?? this.pendingItem, + activeItem: activeItem ?? this.activeItem, + completedItem: completedItem ?? this.completedItem, + pendingStatus: pendingStatus ?? this.pendingStatus, + activeStatus: activeStatus ?? this.activeStatus, + completedStatus: completedStatus ?? this.completedStatus, + ); + } + + @override + UiActivitySpec lerp(UiActivitySpec? other, double t) { + return UiActivitySpec( + viewport: viewport.lerp(other?.viewport, t), + item: item.lerp(other?.item, t), + summaryTitle: summaryTitle.lerp(other?.summaryTitle, t), + itemTitle: itemTitle.lerp(other?.itemTitle, t), + itemDetail: itemDetail.lerp(other?.itemDetail, t), + count: count.lerp(other?.count, t), + indicator: indicator.lerp(other?.indicator, t), + pendingItem: pendingItem.lerp(other?.pendingItem, t), + activeItem: activeItem.lerp(other?.activeItem, t), + completedItem: completedItem.lerp(other?.completedItem, t), + pendingStatus: pendingStatus.lerp(other?.pendingStatus, t), + activeStatus: activeStatus.lerp(other?.activeStatus, t), + completedStatus: completedStatus.lerp(other?.completedStatus, t), + ); + } + + @override + List get props => [ + viewport, + item, + summaryTitle, + itemTitle, + itemDetail, + count, + indicator, + pendingItem, + activeItem, + completedItem, + pendingStatus, + activeStatus, + completedStatus, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is UiActivitySpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('viewport', viewport)) + ..add(DiagnosticsProperty('item', item)) + ..add(DiagnosticsProperty('summaryTitle', summaryTitle)) + ..add(DiagnosticsProperty('itemTitle', itemTitle)) + ..add(DiagnosticsProperty('itemDetail', itemDetail)) + ..add(DiagnosticsProperty('count', count)) + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('pendingItem', pendingItem)) + ..add(DiagnosticsProperty('activeItem', activeItem)) + ..add(DiagnosticsProperty('completedItem', completedItem)) + ..add(DiagnosticsProperty('pendingStatus', pendingStatus)) + ..add(DiagnosticsProperty('activeStatus', activeStatus)) + ..add(DiagnosticsProperty('completedStatus', completedStatus)); + } +} + +@Deprecated( + 'Rename to `_\$UiActivitySpec` and migrate the class declaration to `class UiActivitySpec with _\$UiActivitySpec`. The `_\$UiActivitySpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$UiActivitySpecMethods = _$UiActivitySpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class UiActivityStyler extends MixStyler + implements StylerFieldMetadata { + final Prop>? $viewport; + final Prop>? $item; + final Prop>? $summaryTitle; + final Prop>? $itemTitle; + final Prop>? $itemDetail; + final Prop>? $count; + final Prop>? $indicator; + final Prop>? $pendingItem; + final Prop>? $activeItem; + final Prop>? $completedItem; + final Prop>? $pendingStatus; + final Prop>? $activeStatus; + final Prop>? $completedStatus; + + const UiActivityStyler.create({ + Prop>? viewport, + Prop>? item, + Prop>? summaryTitle, + Prop>? itemTitle, + Prop>? itemDetail, + Prop>? count, + Prop>? indicator, + Prop>? pendingItem, + Prop>? activeItem, + Prop>? completedItem, + Prop>? pendingStatus, + Prop>? activeStatus, + Prop>? completedStatus, + super.variants, + super.modifier, + super.animation, + }) : $viewport = viewport, + $item = item, + $summaryTitle = summaryTitle, + $itemTitle = itemTitle, + $itemDetail = itemDetail, + $count = count, + $indicator = indicator, + $pendingItem = pendingItem, + $activeItem = activeItem, + $completedItem = completedItem, + $pendingStatus = pendingStatus, + $activeStatus = activeStatus, + $completedStatus = completedStatus; + + UiActivityStyler({ + BoxStyler? viewport, + FlexBoxStyler? item, + TextStyler? summaryTitle, + TextStyler? itemTitle, + TextStyler? itemDetail, + TextStyler? count, + IconStyler? indicator, + BoxStyler? pendingItem, + BoxStyler? activeItem, + BoxStyler? completedItem, + IconStyler? pendingStatus, + IconStyler? activeStatus, + IconStyler? completedStatus, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + viewport: Prop.maybeMix(viewport), + item: Prop.maybeMix(item), + summaryTitle: Prop.maybeMix(summaryTitle), + itemTitle: Prop.maybeMix(itemTitle), + itemDetail: Prop.maybeMix(itemDetail), + count: Prop.maybeMix(count), + indicator: Prop.maybeMix(indicator), + pendingItem: Prop.maybeMix(pendingItem), + activeItem: Prop.maybeMix(activeItem), + completedItem: Prop.maybeMix(completedItem), + pendingStatus: Prop.maybeMix(pendingStatus), + activeStatus: Prop.maybeMix(activeStatus), + completedStatus: Prop.maybeMix(completedStatus), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory UiActivityStyler.viewport(BoxStyler value) => + UiActivityStyler().viewport(value); + factory UiActivityStyler.item(FlexBoxStyler value) => + UiActivityStyler().item(value); + factory UiActivityStyler.summaryTitle(TextStyler value) => + UiActivityStyler().summaryTitle(value); + factory UiActivityStyler.itemTitle(TextStyler value) => + UiActivityStyler().itemTitle(value); + factory UiActivityStyler.itemDetail(TextStyler value) => + UiActivityStyler().itemDetail(value); + factory UiActivityStyler.count(TextStyler value) => + UiActivityStyler().count(value); + factory UiActivityStyler.indicator(IconStyler value) => + UiActivityStyler().indicator(value); + factory UiActivityStyler.pendingItem(BoxStyler value) => + UiActivityStyler().pendingItem(value); + factory UiActivityStyler.activeItem(BoxStyler value) => + UiActivityStyler().activeItem(value); + factory UiActivityStyler.completedItem(BoxStyler value) => + UiActivityStyler().completedItem(value); + factory UiActivityStyler.pendingStatus(IconStyler value) => + UiActivityStyler().pendingStatus(value); + factory UiActivityStyler.activeStatus(IconStyler value) => + UiActivityStyler().activeStatus(value); + factory UiActivityStyler.completedStatus(IconStyler value) => + UiActivityStyler().completedStatus(value); + + @override + Set get $stylerFieldNames => const { + 'viewport', + 'item', + 'summaryTitle', + 'itemTitle', + 'itemDetail', + 'count', + 'indicator', + 'pendingItem', + 'activeItem', + 'completedItem', + 'pendingStatus', + 'activeStatus', + 'completedStatus', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the viewport. + UiActivityStyler viewport(BoxStyler value) { + return merge(UiActivityStyler(viewport: value)); + } + + /// Sets the item. + UiActivityStyler item(FlexBoxStyler value) { + return merge(UiActivityStyler(item: value)); + } + + /// Sets the summaryTitle. + UiActivityStyler summaryTitle(TextStyler value) { + return merge(UiActivityStyler(summaryTitle: value)); + } + + /// Sets the itemTitle. + UiActivityStyler itemTitle(TextStyler value) { + return merge(UiActivityStyler(itemTitle: value)); + } + + /// Sets the itemDetail. + UiActivityStyler itemDetail(TextStyler value) { + return merge(UiActivityStyler(itemDetail: value)); + } + + /// Sets the count. + UiActivityStyler count(TextStyler value) { + return merge(UiActivityStyler(count: value)); + } + + /// Sets the indicator. + UiActivityStyler indicator(IconStyler value) { + return merge(UiActivityStyler(indicator: value)); + } + + /// Sets the pendingItem. + UiActivityStyler pendingItem(BoxStyler value) { + return merge(UiActivityStyler(pendingItem: value)); + } + + /// Sets the activeItem. + UiActivityStyler activeItem(BoxStyler value) { + return merge(UiActivityStyler(activeItem: value)); + } + + /// Sets the completedItem. + UiActivityStyler completedItem(BoxStyler value) { + return merge(UiActivityStyler(completedItem: value)); + } + + /// Sets the pendingStatus. + UiActivityStyler pendingStatus(IconStyler value) { + return merge(UiActivityStyler(pendingStatus: value)); + } + + /// Sets the activeStatus. + UiActivityStyler activeStatus(IconStyler value) { + return merge(UiActivityStyler(activeStatus: value)); + } + + /// Sets the completedStatus. + UiActivityStyler completedStatus(IconStyler value) { + return merge(UiActivityStyler(completedStatus: value)); + } + + /// Sets the animation configuration. + @override + UiActivityStyler animate(AnimationConfig value) { + return merge(UiActivityStyler(animation: value)); + } + + /// Sets the style variants. + @override + UiActivityStyler variants(List> value) { + return merge(UiActivityStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + UiActivityStyler wrap(WidgetModifierConfig value) { + return merge(UiActivityStyler(modifier: value)); + } + + /// Sets the widget modifier. + UiActivityStyler modifier(WidgetModifierConfig value) { + return merge(UiActivityStyler(modifier: value)); + } + + UiActivity call({ + Key? key, + required List items, + UiRunStatus status = UiRunStatus.working, + String title = 'Activity', + String semanticLabel = 'Activity', + bool collapseOnComplete = true, + bool? expanded, + bool defaultExpanded = true, + ValueChanged? onExpandedChanged, + UiActivityStatusBuilder? statusBuilder, + UiActivityStatusLabelBuilder? statusLabelBuilder, + UiActivityIndicatorBuilder? indicatorBuilder, + bool followOutput = true, + double followThreshold = 48, + ValueChanged? onFollowChanged, + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + }) { + return UiActivity( + key: key, + style: this, + items: items, + status: status, + title: title, + semanticLabel: semanticLabel, + collapseOnComplete: collapseOnComplete, + expanded: expanded, + defaultExpanded: defaultExpanded, + onExpandedChanged: onExpandedChanged, + statusBuilder: statusBuilder, + statusLabelBuilder: statusLabelBuilder, + indicatorBuilder: indicatorBuilder, + followOutput: followOutput, + followThreshold: followThreshold, + onFollowChanged: onFollowChanged, + disclosureStyle: disclosureStyle, + ); + } + + /// Merges with another [UiActivityStyler]. + @override + UiActivityStyler merge(UiActivityStyler? other) { + return UiActivityStyler.create( + viewport: MixOps.merge($viewport, other?.$viewport), + item: MixOps.merge($item, other?.$item), + summaryTitle: MixOps.merge($summaryTitle, other?.$summaryTitle), + itemTitle: MixOps.merge($itemTitle, other?.$itemTitle), + itemDetail: MixOps.merge($itemDetail, other?.$itemDetail), + count: MixOps.merge($count, other?.$count), + indicator: MixOps.merge($indicator, other?.$indicator), + pendingItem: MixOps.merge($pendingItem, other?.$pendingItem), + activeItem: MixOps.merge($activeItem, other?.$activeItem), + completedItem: MixOps.merge($completedItem, other?.$completedItem), + pendingStatus: MixOps.merge($pendingStatus, other?.$pendingStatus), + activeStatus: MixOps.merge($activeStatus, other?.$activeStatus), + completedStatus: MixOps.merge($completedStatus, other?.$completedStatus), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = UiActivitySpec( + viewport: MixOps.resolve(context, $viewport), + item: MixOps.resolve(context, $item), + summaryTitle: MixOps.resolve(context, $summaryTitle), + itemTitle: MixOps.resolve(context, $itemTitle), + itemDetail: MixOps.resolve(context, $itemDetail), + count: MixOps.resolve(context, $count), + indicator: MixOps.resolve(context, $indicator), + pendingItem: MixOps.resolve(context, $pendingItem), + activeItem: MixOps.resolve(context, $activeItem), + completedItem: MixOps.resolve(context, $completedItem), + pendingStatus: MixOps.resolve(context, $pendingStatus), + activeStatus: MixOps.resolve(context, $activeStatus), + completedStatus: MixOps.resolve(context, $completedStatus), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('viewport', $viewport)) + ..add(DiagnosticsProperty('item', $item)) + ..add(DiagnosticsProperty('summaryTitle', $summaryTitle)) + ..add(DiagnosticsProperty('itemTitle', $itemTitle)) + ..add(DiagnosticsProperty('itemDetail', $itemDetail)) + ..add(DiagnosticsProperty('count', $count)) + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('pendingItem', $pendingItem)) + ..add(DiagnosticsProperty('activeItem', $activeItem)) + ..add(DiagnosticsProperty('completedItem', $completedItem)) + ..add(DiagnosticsProperty('pendingStatus', $pendingStatus)) + ..add(DiagnosticsProperty('activeStatus', $activeStatus)) + ..add(DiagnosticsProperty('completedStatus', $completedStatus)); + } + + @override + List get props => [ + $viewport, + $item, + $summaryTitle, + $itemTitle, + $itemDetail, + $count, + $indicator, + $pendingItem, + $activeItem, + $completedItem, + $pendingStatus, + $activeStatus, + $completedStatus, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/dashboard/lib/ui/components/answer.dart b/apps/dashboard/lib/ui/components/answer.dart new file mode 100644 index 000000000..17f46e3d6 --- /dev/null +++ b/apps/dashboard/lib/ui/components/answer.dart @@ -0,0 +1,212 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; + +part 'answer.g.dart'; + +typedef UiAnswerSourcesIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Streaming answer surface with host-owned content and feedback. +class UiAnswer extends StatefulWidget { + const UiAnswer({ + super.key, + required this.child, + this.streamId, + this.status = UiAnswerStatus.streaming, + this.onCopy, + this.onRetry, + this.copyIconBuilder, + this.retryIconBuilder, + this.sourcesIndicatorBuilder, + this.copyLabel = 'Copy answer', + this.retryLabel = 'Retry answer', + this.showActions, + this.feedback, + this.sourcesContent, + this.sourcesExpanded, + this.defaultSourcesExpanded = false, + this.onSourcesExpandedChanged, + this.sourcesLabel = 'Sources', + this.semanticLabel = 'Answer', + this.surfaceStyle = const CardStyler.create(), + this.sourcesStyle = const DisclosureStyler.create(), + this.copyStyle = const IconButtonStyler.create(), + this.retryStyle = const IconButtonStyler.create(), + this.style = const UiAnswerStyler.create(), + this.styleSpec, + }); + + final Widget child; + final Object? streamId; + final UiAnswerStatus status; + final VoidCallback? onCopy; + final VoidCallback? onRetry; + final RemixIconButtonIconBuilder? copyIconBuilder; + final RemixIconButtonIconBuilder? retryIconBuilder; + final UiAnswerSourcesIndicatorBuilder? sourcesIndicatorBuilder; + final String copyLabel; + final String retryLabel; + final bool? showActions; + final Widget? feedback; + final Widget? sourcesContent; + final bool? sourcesExpanded; + final bool defaultSourcesExpanded; + final ValueChanged? onSourcesExpandedChanged; + final String sourcesLabel; + final String semanticLabel; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; + final UiAnswerStyler style; + final UiAnswerSpec? styleSpec; + + @override + State createState() => _UiAnswerState(); +} + +class _UiAnswerState extends State { + late final UiDisclosureEngine _disclosure; + + bool get _sourcesExpanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = UiDisclosureEngine( + value: widget.sourcesExpanded, + defaultValue: widget.defaultSourcesExpanded, + ); + } + + @override + void didUpdateWidget(UiAnswer oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.sourcesExpanded); + final beganStreaming = + !oldWidget.status.isStreaming && widget.status.isStreaming; + final newStreamingIdentity = + oldWidget.streamId != widget.streamId && widget.status.isStreaming; + if (beganStreaming || newStreamingIdentity) _requestSources(false); + } + + void _requestSources(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onSourcesExpandedChanged?.call(next); + } + + @override + Widget build(BuildContext context) { + final revealActions = + !widget.status.isStreaming && + (widget.showActions ?? widget.status.showsActions); + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixCard( + style: widget.surfaceStyle, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + liveRegion: widget.status.isStreaming, + child: Box(styleSpec: spec.body, child: widget.child), + ), + if (widget.sourcesContent != null) + RemixDisclosure( + expanded: _sourcesExpanded, + onExpandedChanged: _requestSources, + semanticLabel: widget.sourcesLabel, + style: widget.sourcesStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + UiDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.sourcesIndicatorBuilder, + ), + ], + ), + trigger: StyledText( + widget.sourcesLabel, + styleSpec: spec.sourcesLabel, + ), + content: widget.sourcesContent!, + ), + if (revealActions) + RowBox( + styleSpec: spec.actions, + children: [ + if (widget.onCopy != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.copyIconBuilder ?? + (context, iconSpec, icon) => + UiFunctionalGlyph(kind: .copy, spec: iconSpec), + semanticLabel: widget.copyLabel, + onPressed: widget.onCopy, + style: widget.copyStyle, + ), + if (widget.onRetry != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.retryIconBuilder ?? + (context, iconSpec, icon) => + UiFunctionalGlyph(kind: .retry, spec: iconSpec), + semanticLabel: widget.retryLabel, + onPressed: widget.onRetry, + style: widget.retryStyle, + ), + if (widget.status == UiAnswerStatus.complete && + widget.feedback != null) + Box(styleSpec: spec.feedback, child: widget.feedback), + ], + ), + ], + ), + ), + ), + ); + } +} + +@MixableSpec(target: UiAnswer.new) +@immutable +final class UiAnswerSpec with _$UiAnswerSpec { + @override + final StyleSpec body; + @override + final StyleSpec actions; + @override + final StyleSpec feedback; + @override + final StyleSpec sourcesLabel; + @override + final StyleSpec indicator; + + const UiAnswerSpec({ + StyleSpec? body, + StyleSpec? actions, + StyleSpec? feedback, + StyleSpec? sourcesLabel, + StyleSpec? indicator, + }) : body = body ?? const StyleSpec(spec: BoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + feedback = feedback ?? const StyleSpec(spec: BoxSpec()), + sourcesLabel = sourcesLabel ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()); +} diff --git a/apps/dashboard/lib/ui/components/answer.g.dart b/apps/dashboard/lib/ui/components/answer.g.dart new file mode 100644 index 000000000..f7de32074 --- /dev/null +++ b/apps/dashboard/lib/ui/components/answer.g.dart @@ -0,0 +1,328 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'answer.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$UiAnswerSpec implements Spec, Diagnosticable { + StyleSpec get body; + StyleSpec get actions; + StyleSpec get feedback; + StyleSpec get sourcesLabel; + StyleSpec get indicator; + + @override + Type get type => UiAnswerSpec; + + @override + UiAnswerSpec copyWith({ + StyleSpec? body, + StyleSpec? actions, + StyleSpec? feedback, + StyleSpec? sourcesLabel, + StyleSpec? indicator, + }) { + return UiAnswerSpec( + body: body ?? this.body, + actions: actions ?? this.actions, + feedback: feedback ?? this.feedback, + sourcesLabel: sourcesLabel ?? this.sourcesLabel, + indicator: indicator ?? this.indicator, + ); + } + + @override + UiAnswerSpec lerp(UiAnswerSpec? other, double t) { + return UiAnswerSpec( + body: body.lerp(other?.body, t), + actions: actions.lerp(other?.actions, t), + feedback: feedback.lerp(other?.feedback, t), + sourcesLabel: sourcesLabel.lerp(other?.sourcesLabel, t), + indicator: indicator.lerp(other?.indicator, t), + ); + } + + @override + List get props => [body, actions, feedback, sourcesLabel, indicator]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is UiAnswerSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('body', body)) + ..add(DiagnosticsProperty('actions', actions)) + ..add(DiagnosticsProperty('feedback', feedback)) + ..add(DiagnosticsProperty('sourcesLabel', sourcesLabel)) + ..add(DiagnosticsProperty('indicator', indicator)); + } +} + +@Deprecated( + 'Rename to `_\$UiAnswerSpec` and migrate the class declaration to `class UiAnswerSpec with _\$UiAnswerSpec`. The `_\$UiAnswerSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$UiAnswerSpecMethods = _$UiAnswerSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class UiAnswerStyler extends MixStyler + implements StylerFieldMetadata { + final Prop>? $body; + final Prop>? $actions; + final Prop>? $feedback; + final Prop>? $sourcesLabel; + final Prop>? $indicator; + + const UiAnswerStyler.create({ + Prop>? body, + Prop>? actions, + Prop>? feedback, + Prop>? sourcesLabel, + Prop>? indicator, + super.variants, + super.modifier, + super.animation, + }) : $body = body, + $actions = actions, + $feedback = feedback, + $sourcesLabel = sourcesLabel, + $indicator = indicator; + + UiAnswerStyler({ + BoxStyler? body, + FlexBoxStyler? actions, + BoxStyler? feedback, + TextStyler? sourcesLabel, + IconStyler? indicator, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + body: Prop.maybeMix(body), + actions: Prop.maybeMix(actions), + feedback: Prop.maybeMix(feedback), + sourcesLabel: Prop.maybeMix(sourcesLabel), + indicator: Prop.maybeMix(indicator), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory UiAnswerStyler.body(BoxStyler value) => UiAnswerStyler().body(value); + factory UiAnswerStyler.actions(FlexBoxStyler value) => + UiAnswerStyler().actions(value); + factory UiAnswerStyler.feedback(BoxStyler value) => + UiAnswerStyler().feedback(value); + factory UiAnswerStyler.sourcesLabel(TextStyler value) => + UiAnswerStyler().sourcesLabel(value); + factory UiAnswerStyler.indicator(IconStyler value) => + UiAnswerStyler().indicator(value); + + @override + Set get $stylerFieldNames => const { + 'body', + 'actions', + 'feedback', + 'sourcesLabel', + 'indicator', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the body. + UiAnswerStyler body(BoxStyler value) { + return merge(UiAnswerStyler(body: value)); + } + + /// Sets the actions. + UiAnswerStyler actions(FlexBoxStyler value) { + return merge(UiAnswerStyler(actions: value)); + } + + /// Sets the feedback. + UiAnswerStyler feedback(BoxStyler value) { + return merge(UiAnswerStyler(feedback: value)); + } + + /// Sets the sourcesLabel. + UiAnswerStyler sourcesLabel(TextStyler value) { + return merge(UiAnswerStyler(sourcesLabel: value)); + } + + /// Sets the indicator. + UiAnswerStyler indicator(IconStyler value) { + return merge(UiAnswerStyler(indicator: value)); + } + + /// Sets the animation configuration. + @override + UiAnswerStyler animate(AnimationConfig value) { + return merge(UiAnswerStyler(animation: value)); + } + + /// Sets the style variants. + @override + UiAnswerStyler variants(List> value) { + return merge(UiAnswerStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + UiAnswerStyler wrap(WidgetModifierConfig value) { + return merge(UiAnswerStyler(modifier: value)); + } + + /// Sets the widget modifier. + UiAnswerStyler modifier(WidgetModifierConfig value) { + return merge(UiAnswerStyler(modifier: value)); + } + + UiAnswer call({ + Key? key, + required Widget child, + Object? streamId, + UiAnswerStatus status = UiAnswerStatus.streaming, + VoidCallback? onCopy, + VoidCallback? onRetry, + RemixIconButtonIconBuilder? copyIconBuilder, + RemixIconButtonIconBuilder? retryIconBuilder, + UiAnswerSourcesIndicatorBuilder? sourcesIndicatorBuilder, + String copyLabel = 'Copy answer', + String retryLabel = 'Retry answer', + bool? showActions, + Widget? feedback, + Widget? sourcesContent, + bool? sourcesExpanded, + bool defaultSourcesExpanded = false, + ValueChanged? onSourcesExpandedChanged, + String sourcesLabel = 'Sources', + String semanticLabel = 'Answer', + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler sourcesStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), + }) { + return UiAnswer( + key: key, + style: this, + child: child, + streamId: streamId, + status: status, + onCopy: onCopy, + onRetry: onRetry, + copyIconBuilder: copyIconBuilder, + retryIconBuilder: retryIconBuilder, + sourcesIndicatorBuilder: sourcesIndicatorBuilder, + copyLabel: copyLabel, + retryLabel: retryLabel, + showActions: showActions, + feedback: feedback, + sourcesContent: sourcesContent, + sourcesExpanded: sourcesExpanded, + defaultSourcesExpanded: defaultSourcesExpanded, + onSourcesExpandedChanged: onSourcesExpandedChanged, + sourcesLabel: sourcesLabel, + semanticLabel: semanticLabel, + surfaceStyle: surfaceStyle, + sourcesStyle: sourcesStyle, + copyStyle: copyStyle, + retryStyle: retryStyle, + ); + } + + /// Merges with another [UiAnswerStyler]. + @override + UiAnswerStyler merge(UiAnswerStyler? other) { + return UiAnswerStyler.create( + body: MixOps.merge($body, other?.$body), + actions: MixOps.merge($actions, other?.$actions), + feedback: MixOps.merge($feedback, other?.$feedback), + sourcesLabel: MixOps.merge($sourcesLabel, other?.$sourcesLabel), + indicator: MixOps.merge($indicator, other?.$indicator), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = UiAnswerSpec( + body: MixOps.resolve(context, $body), + actions: MixOps.resolve(context, $actions), + feedback: MixOps.resolve(context, $feedback), + sourcesLabel: MixOps.resolve(context, $sourcesLabel), + indicator: MixOps.resolve(context, $indicator), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('body', $body)) + ..add(DiagnosticsProperty('actions', $actions)) + ..add(DiagnosticsProperty('feedback', $feedback)) + ..add(DiagnosticsProperty('sourcesLabel', $sourcesLabel)) + ..add(DiagnosticsProperty('indicator', $indicator)); + } + + @override + List get props => [ + $body, + $actions, + $feedback, + $sourcesLabel, + $indicator, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/dashboard/lib/ui/components/avatar.dart b/apps/dashboard/lib/ui/components/avatar.dart new file mode 100644 index 000000000..7f5027dd3 --- /dev/null +++ b/apps/dashboard/lib/ui/components/avatar.dart @@ -0,0 +1,121 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'avatar.g.dart'; + +/// Radix Themes Avatar size presets. +enum UiAvatarSize { + size1, + size2, + size3, + size4, + size5, + size6, + size7, + size8, + size9, +} + +/// Radix Themes Avatar variants. +enum UiAvatarVariant { soft, solid } + +/// Ui-themed Avatar with the Radix size, variant, and override contract. +/// +/// [fallbackLength] selects the pinned one- or two-character fallback +/// typography. Pass `2` when [RemixAvatar.label] contains two initials. +@MixWidget(target: RemixAvatar.new) +AvatarStyler uiAvatarStyle({ + UiAvatarVariant variant = .soft, + UiAvatarSize size = .size3, + bool highContrast = false, + int fallbackLength = 1, + AvatarStyler style = const AvatarStyler.create(), +}) { + final base = _uiAvatarBaseStyler(size, fallbackLength: fallbackLength); + final softContent = highContrast ? UiTokens.accent12() : UiTokens.accentA11(); + final solidContent = highContrast + ? UiTokens.accent1() + : UiTokens.accentContrast(); + return (switch (variant) { + .soft => + base + .color(UiTokens.accentA3()) + .labelColor(softContent) + .iconColor(softContent), + .solid => + base + .color(highContrast ? UiTokens.accent12() : UiTokens.accent9()) + .labelColor(solidContent) + .iconColor(solidContent), + }).merge(style); +} + +AvatarStyler _uiAvatarBaseStyler( + UiAvatarSize size, { + required int fallbackLength, +}) { + final fallbackText = _uiAvatarFallbackText(size, fallbackLength); + final dimension = _uiAvatarDimension(size); + return AvatarStyler() + .clipBehavior(.hardEdge) + .label( + TextStyler( + style: fallbackText.mix(), + ).fontWeight(UiTokens.fontWeightMedium()), + ) + .icon(.size(_uiAvatarIconSize(size)).color(UiTokens.accentA11())) + .size(dimension, dimension) + .borderRadius(.all(_uiAvatarRadius(size))); +} + +double _uiAvatarDimension(UiAvatarSize size) => switch (size) { + .size1 => UiTokens.space5(), + .size2 => UiTokens.space6(), + .size3 => UiTokens.space7(), + .size4 => UiTokens.space8(), + .size5 => UiTokens.space9(), + .size6 => UiTokens.avatarSize6(), + .size7 => UiTokens.avatarSize7(), + .size8 => UiTokens.avatarSize8(), + .size9 => UiTokens.avatarSize9(), +}; + +double _uiAvatarIconSize(UiAvatarSize size) => switch (size) { + .size1 => UiTokens.avatarIconSize1(), + .size2 => UiTokens.avatarIconSize2(), + .size3 => UiTokens.avatarIconSize3(), + .size4 => UiTokens.avatarIconSize4(), + .size5 => UiTokens.avatarIconSize5(), + .size6 => UiTokens.avatarIconSize6(), + .size7 => UiTokens.avatarIconSize7(), + .size8 => UiTokens.avatarIconSize8(), + .size9 => UiTokens.avatarIconSize9(), +}; + +Radius _uiAvatarRadius(UiAvatarSize size) => switch (size) { + .size1 || .size2 => UiTokens.radius2OrFull(), + .size3 || .size4 => UiTokens.radius3OrFull(), + .size5 => UiTokens.radius4OrFull(), + .size6 || .size7 => UiTokens.radius5OrFull(), + .size8 || .size9 => UiTokens.radius6OrFull(), +}; + +TextStyleToken _uiAvatarFallbackText(UiAvatarSize size, int fallbackLength) => + switch ((size, fallbackLength == 2)) { + (.size1, false) => UiTokens.avatarFallback1One, + (.size1, true) => UiTokens.avatarFallback1Two, + (.size2, false) => UiTokens.avatarFallback2One, + (.size2, true) => UiTokens.avatarFallback2Two, + (.size3, false) => UiTokens.avatarFallback3One, + (.size3, true) => UiTokens.avatarFallback3Two, + (.size4, false) => UiTokens.avatarFallback4One, + (.size4, true) => UiTokens.avatarFallback4Two, + (.size5, _) => UiTokens.avatarFallback5, + (.size6, _) => UiTokens.avatarFallback6, + (.size7, _) => UiTokens.avatarFallback7, + (.size8, _) => UiTokens.avatarFallback8, + (.size9, _) => UiTokens.avatarFallback9, + }; diff --git a/apps/dashboard/lib/ui/components/avatar.g.dart b/apps/dashboard/lib/ui/components/avatar.g.dart new file mode 100644 index 000000000..581cf6b4e --- /dev/null +++ b/apps/dashboard/lib/ui/components/avatar.g.dart @@ -0,0 +1,116 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'avatar.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed Avatar with the Radix size, variant, and override contract. +/// +/// [fallbackLength] selects the pinned one- or two-character fallback +/// typography. Pass `2` when [RemixAvatar.label] contains two initials. +class UiAvatar extends StatelessWidget { + const UiAvatar({ + super.key, + this.variant = .soft, + this.size = .size3, + this.highContrast = false, + this.fallbackLength = 1, + this.style = const AvatarStyler.create(), + this.backgroundImage, + this.foregroundImage, + this.onBackgroundImageError, + this.onForegroundImageError, + this.child, + this.label, + this.labelBuilder, + this.icon, + this.iconBuilder, + }); + + const UiAvatar.soft({ + super.key, + this.size = .size3, + this.highContrast = false, + this.fallbackLength = 1, + this.style = const AvatarStyler.create(), + this.backgroundImage, + this.foregroundImage, + this.onBackgroundImageError, + this.onForegroundImageError, + this.child, + this.label, + this.labelBuilder, + this.icon, + this.iconBuilder, + }) : variant = UiAvatarVariant.soft; + + const UiAvatar.solid({ + super.key, + this.size = .size3, + this.highContrast = false, + this.fallbackLength = 1, + this.style = const AvatarStyler.create(), + this.backgroundImage, + this.foregroundImage, + this.onBackgroundImageError, + this.onForegroundImageError, + this.child, + this.label, + this.labelBuilder, + this.icon, + this.iconBuilder, + }) : variant = UiAvatarVariant.solid; + + final UiAvatarVariant variant; + + final UiAvatarSize size; + + final bool highContrast; + + final int fallbackLength; + + final AvatarStyler style; + + final ImageProvider? backgroundImage; + + final ImageProvider? foregroundImage; + + final ImageErrorListener? onBackgroundImageError; + + final ImageErrorListener? onForegroundImageError; + + final Widget? child; + + final String? label; + + final RemixAvatarLabelBuilder? labelBuilder; + + final IconData? icon; + + final RemixAvatarIconBuilder? iconBuilder; + + @override + Widget build(BuildContext context) { + return RemixAvatar( + key: this.key, + style: uiAvatarStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + fallbackLength: this.fallbackLength, + style: this.style, + ), + backgroundImage: this.backgroundImage, + foregroundImage: this.foregroundImage, + onBackgroundImageError: this.onBackgroundImageError, + onForegroundImageError: this.onForegroundImageError, + child: this.child, + label: this.label, + labelBuilder: this.labelBuilder, + icon: this.icon, + iconBuilder: this.iconBuilder, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/badge.dart b/apps/dashboard/lib/ui/components/badge.dart new file mode 100644 index 000000000..693b85d36 --- /dev/null +++ b/apps/dashboard/lib/ui/components/badge.dart @@ -0,0 +1,101 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'badge.g.dart'; + +/// Radix Themes Badge size presets. +enum UiBadgeSize { size1, size2, size3 } + +/// Radix Themes Badge variants. +enum UiBadgeVariant { solid, soft, surface, outline } + +/// Ui-themed Badge with the Radix size, variant, and override contract. +@MixWidget(target: RemixBadge.new) +BadgeStyler uiBadgeStyle({ + UiBadgeVariant variant = .soft, + UiBadgeSize size = .size1, + bool highContrast = false, + BadgeStyler style = const BadgeStyler.create(), +}) { + final base = _uiBadgeBaseStyler(size); + return (switch (variant) { + .solid => + base + .color(highContrast ? UiTokens.accent12() : UiTokens.accent9()) + .labelColor( + highContrast ? UiTokens.accent1() : UiTokens.accentContrast(), + ), + .soft => + base + .color(UiTokens.accentA3()) + .labelColor( + // Step 11 is Radix low-contrast text, not WCAG AA 4.5:1 on + // accentA3 over colorPanelSolid. highContrast promotes accent12. + highContrast ? UiTokens.accent12() : UiTokens.accentA11(), + ), + .surface => + base + .color(UiTokens.accentSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.accentA6()]), + ), + ) + .labelColor( + highContrast ? UiTokens.accent12() : UiTokens.accentA11(), + ), + .outline => + base + .containerEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface( + strokes: [ + highContrast ? UiTokens.accentA7() : UiTokens.accentA8(), + if (highContrast) UiTokens.grayA11(), + ], + ), + ), + ) + .labelColor( + highContrast ? UiTokens.accent12() : UiTokens.accentA11(), + ), + }).merge(style); +} + +BadgeStyler _uiBadgeBaseStyler(UiBadgeSize size) { + final radius = _uiBadgeRadius(size); + return BadgeStyler( + container: .padding(_uiBadgePadding(size)), + label: .style( + _uiBadgeText(size).mix(), + ).fontWeight(UiTokens.fontWeightMedium()), + ).borderRadius(.all(radius)); +} + +TextStyleToken _uiBadgeText(UiBadgeSize size) => switch (size) { + .size1 || .size2 => UiTokens.text1, + .size3 => UiTokens.text2, +}; + +EdgeInsetsGeometryMix _uiBadgePadding(UiBadgeSize size) => switch (size) { + .size1 => EdgeInsetsGeometryMix.symmetric( + horizontal: UiTokens.badgePaddingX1(), + vertical: UiTokens.badgePaddingY1(), + ), + .size2 => EdgeInsetsGeometryMix.symmetric( + horizontal: UiTokens.space2(), + vertical: UiTokens.space1(), + ), + .size3 => EdgeInsetsGeometryMix.symmetric( + horizontal: UiTokens.badgePaddingX3(), + vertical: UiTokens.space1(), + ), +}; + +Radius _uiBadgeRadius(UiBadgeSize size) => switch (size) { + .size1 => UiTokens.radius1OrFull(), + .size2 || .size3 => UiTokens.radius2OrFull(), +}; diff --git a/apps/dashboard/lib/ui/components/badge.g.dart b/apps/dashboard/lib/ui/components/badge.g.dart new file mode 100644 index 000000000..8edf5c855 --- /dev/null +++ b/apps/dashboard/lib/ui/components/badge.g.dart @@ -0,0 +1,91 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'badge.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed Badge with the Radix size, variant, and override contract. +class UiBadge extends StatelessWidget { + const UiBadge({ + super.key, + this.variant = .soft, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }); + + const UiBadge.solid({ + super.key, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = UiBadgeVariant.solid; + + const UiBadge.soft({ + super.key, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = UiBadgeVariant.soft; + + const UiBadge.surface({ + super.key, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = UiBadgeVariant.surface; + + const UiBadge.outline({ + super.key, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = UiBadgeVariant.outline; + + final UiBadgeVariant variant; + + final UiBadgeSize size; + + final bool highContrast; + + final BadgeStyler style; + + final String? label; + + final Widget? child; + + final RemixBadgeLabelBuilder? labelBuilder; + + @override + Widget build(BuildContext context) { + return RemixBadge( + key: this.key, + style: uiBadgeStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + label: this.label, + child: this.child, + labelBuilder: this.labelBuilder, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/base_button.dart b/apps/dashboard/lib/ui/components/base_button.dart new file mode 100644 index 000000000..2f6534cdd --- /dev/null +++ b/apps/dashboard/lib/ui/components/base_button.dart @@ -0,0 +1,522 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +/// The Radix BaseButton size scale shared by Button and IconButton. +/// +/// Deliberate: this mirrors [UiBaseButtonVariant]. Button and IconButton +/// each own a size enum, so the shared metrics need a common type — passing the +/// raw `size.index + 1` instead would drop every switch below to a wildcard and +/// defer an unknown size to a runtime throw. +enum UiBaseButtonSize { size1, size2, size3, size4 } + +/// Shared Radix BaseButton metrics used by Button and IconButton recipes. +({ + double height, + double paddingX, + double gap, + Radius radius, + TextStyleToken text, + double spinnerSize, +}) +uiBaseButtonMetrics(UiBaseButtonSize size) => switch (size) { + .size1 => ( + height: UiTokens.space5(), + paddingX: UiTokens.space2(), + gap: UiTokens.space1(), + radius: UiTokens.radius1OrFull(), + text: UiTokens.text1, + spinnerSize: UiTokens.space3(), + ), + .size2 => ( + height: UiTokens.space6(), + paddingX: UiTokens.space3(), + gap: UiTokens.space2(), + radius: UiTokens.radius2OrFull(), + text: UiTokens.text2, + spinnerSize: UiTokens.space4(), + ), + .size3 => ( + height: UiTokens.space7(), + paddingX: UiTokens.space4(), + gap: UiTokens.space3(), + radius: UiTokens.radius3OrFull(), + text: UiTokens.text3, + spinnerSize: UiTokens.space4(), + ), + .size4 => ( + height: UiTokens.space8(), + paddingX: UiTokens.space5(), + gap: UiTokens.space3(), + radius: UiTokens.radius4OrFull(), + text: UiTokens.text4, + spinnerSize: UiTokens.spinnerSize3(), + ), +}; + +/// Default icon dimensions shared by text and icon-only button presets. +double uiBaseButtonIconSize(UiBaseButtonSize size) => switch (size) { + .size1 => UiTokens.space3(), + .size2 => UiTokens.space4(), + .size3 => UiTokens.spinnerSize3(), + .size4 => UiTokens.space5(), +}; + +/// Content-box metrics for the ghost BaseButton variant. +({double paddingX, double paddingY, double marginX, double marginY, double gap}) +uiBaseButtonGhostMetrics(UiBaseButtonSize size) => switch (size) { + .size1 => ( + paddingX: UiTokens.space2(), + paddingY: UiTokens.space1(), + marginX: UiTokens.baseButtonGhostMarginX12(), + marginY: UiTokens.baseButtonGhostMarginY12(), + gap: UiTokens.space1(), + ), + .size2 => ( + paddingX: UiTokens.space2(), + paddingY: UiTokens.space1(), + marginX: UiTokens.baseButtonGhostMarginX12(), + marginY: UiTokens.baseButtonGhostMarginY12(), + gap: UiTokens.space1(), + ), + .size3 => ( + paddingX: UiTokens.space3(), + paddingY: UiTokens.baseButtonGhostPaddingY3(), + marginX: UiTokens.baseButtonGhostMarginX3(), + marginY: UiTokens.baseButtonGhostMarginY3(), + gap: UiTokens.space2(), + ), + .size4 => ( + paddingX: UiTokens.space4(), + paddingY: UiTokens.space2(), + marginX: UiTokens.baseButtonGhostMarginX4(), + marginY: UiTokens.baseButtonGhostMarginY4(), + gap: UiTokens.space2(), + ), +}; + +/// Content-box metrics for the ghost IconButton variant. +({double padding, double margin}) uiIconButtonGhostMetrics( + UiBaseButtonSize size, +) => switch (size) { + .size1 => ( + padding: UiTokens.space1(), + margin: UiTokens.iconButtonGhostMargin1(), + ), + .size2 => ( + padding: UiTokens.iconButtonGhostPadding2(), + margin: UiTokens.iconButtonGhostMargin2(), + ), + .size3 => ( + padding: UiTokens.space2(), + margin: UiTokens.iconButtonGhostMargin3(), + ), + .size4 => ( + padding: UiTokens.space3(), + margin: UiTokens.iconButtonGhostMargin4(), + ), +}; + +/// Resolves a mode-aware ordered CSS filter at the component build context. +WidgetModifierConfig uiModeAwareFilter({ + required List light, + required List dark, +}) => WidgetModifierConfig.modifier( + _UiModeAwareFilterMix(light: light, dark: dark), +); + +/// Explicit identity filter used to clear a higher-priority state filter. +WidgetModifierConfig uiClearFilter() => + uiModeAwareFilter(light: const [], dark: const []); + +/// Exact classic BaseButton surface for a visual state. +RemixBoxEffectLayerMix uiClassicBaseButtonSurface({ + required bool highContrast, + bool hovered = false, + bool pressed = false, + bool disabled = false, +}) { + final inset = UiTokens.baseButtonClassicAfterInset(); + if (disabled) { + return RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [ + UiTokens.blackA1(), + const Color(0x00000000), + UiTokens.whiteA1(), + ], + stops: const [-0.2, 0.4, 1], + ), + RemixLinearGradientMix(colors: [UiTokens.grayA2(), UiTokens.grayA2()]), + ], + gradientInsets: [inset, inset], + shadowToken: UiTokens.baseButtonClassicDisabledShadows, + ); + } + + final baseColor = highContrast ? UiTokens.accent12() : UiTokens.accent9(); + final afterColor = hovered && !highContrast ? UiTokens.accent10() : baseColor; + final pseudoGradient = RemixLinearGradientMix( + colors: [ + highContrast + ? hovered || pressed + ? UiTokens.blackA5() + : UiTokens.blackA3() + : hovered + ? UiTokens.blackA2() + : pressed + ? UiTokens.blackA2() + : UiTokens.blackA1(), + const Color(0x00000000), + highContrast + ? pressed + ? UiTokens.whiteA3() + : UiTokens.whiteA2() + : hovered || pressed + ? UiTokens.whiteA3() + : UiTokens.whiteA2(), + ], + stops: hovered && !highContrast + ? const [-0.15, 0.425, 1] + : const [0, 0.5, 1], + ); + final gradients = [ + pseudoGradient, + RemixLinearGradientMix(colors: [afterColor, afterColor]), + if (pressed) + RemixLinearGradientMix( + colors: [UiTokens.blackA1(), const Color(0x00000000)], + ) + else ...[ + RemixLinearGradientMix( + colors: [ + const Color(0x00000000), + const Color(0x00000000), + UiTokens.grayA4(), + UiTokens.grayA4(), + ], + stops: const [0, 0.5, 0.5, 1], + ), + RemixLinearGradientMix( + colors: [ + const Color(0x00000000), + const Color(0x00000000), + baseColor, + baseColor, + ], + stops: const [0, 0.5, 0.8, 1], + ), + ], + ]; + return RemixBoxEffectLayerMix( + gradients: gradients, + gradientInsets: [inset, inset, ...List.filled(gradients.length - 2, 0)], + shadowToken: pressed + ? highContrast + ? UiTokens.baseButtonClassicActiveHighContrastShadows + : UiTokens.baseButtonClassicActiveShadows + : highContrast + ? UiTokens.baseButtonClassicHighContrastShadows + : UiTokens.baseButtonClassicShadows, + ); +} + +final class _UiModeAwareFilterMix + extends ModifierMix { + const _UiModeAwareFilterMix({required this.light, required this.dark}); + + final List light; + final List dark; + + @override + RemixOrderedColorFilterModifier resolve(BuildContext context) => + RemixOrderedColorFilterModifier( + UiTheme.of(context).isDark ? dark : light, + ); + + @override + _UiModeAwareFilterMix merge(_UiModeAwareFilterMix? other) => other ?? this; + + @override + List get props => [light, dark]; +} + +/// Shared Radix BaseButton variants implemented by Button and IconButton. +enum UiBaseButtonVariant { classic, solid, soft, surface, outline, ghost } + +/// One visual-state style fragment from the shared BaseButton recipe. +final class UiBaseButtonStateStyle { + const UiBaseButtonStateStyle({ + this.foreground, + this.background, + this.effects, + this.modifier, + this.spinnerOpacity, + }); + + final Color? foreground; + final Color? background; + final RemixBoxEffectsMix? effects; + final WidgetModifierConfig? modifier; + final double? spinnerOpacity; +} + +/// Visual state styles shared by the concrete Button and IconButton stylers. +final class UiBaseButtonStateStyles { + const UiBaseButtonStateStyles({ + required this.idle, + required this.hovered, + required this.pressed, + required this.disabled, + required this.focusVisible, + required this.disabledFocus, + }); + + final UiBaseButtonStateStyle idle; + final UiBaseButtonStateStyle hovered; + final UiBaseButtonStateStyle pressed; + final UiBaseButtonStateStyle disabled; + final UiBaseButtonStateStyle focusVisible; + final UiBaseButtonStateStyle disabledFocus; +} + +UiBaseButtonStateStyles uiBaseButtonStateStyles({ + required UiBaseButtonVariant variant, + required bool highContrast, +}) { + final states = switch (variant) { + .classic => _classicStateStyles(highContrast: highContrast), + .solid => _solidStateStyles(highContrast: highContrast), + .soft => _softStateStyles(highContrast: highContrast), + .surface => _surfaceStateStyles(highContrast: highContrast), + .outline => _outlineStateStyles(highContrast: highContrast), + .ghost => _ghostStateStyles(highContrast: highContrast), + }; + final focusColor = switch (variant) { + .soft => UiTokens.accent8(), + .classic || .solid || .surface || .outline || .ghost => UiTokens.focus8(), + }; + final focusOffset = switch (variant) { + .classic || .solid => 2.0, + .soft || .surface || .outline || .ghost => -1.0, + }; + + return UiBaseButtonStateStyles( + idle: states.idle, + hovered: states.hovered, + pressed: states.pressed, + disabled: states.disabled, + focusVisible: UiBaseButtonStateStyle( + effects: uiFocusOutline(focusColor, offset: focusOffset), + ), + disabledFocus: UiBaseButtonStateStyle( + effects: RemixBoxEffectsMix.outline( + BorderSideMix(style: BorderStyle.none), + ), + ), + ); +} + +typedef _InteractionStateStyles = ({ + UiBaseButtonStateStyle idle, + UiBaseButtonStateStyle hovered, + UiBaseButtonStateStyle pressed, + UiBaseButtonStateStyle disabled, +}); + +_InteractionStateStyles _classicStateStyles({required bool highContrast}) { + final foreground = highContrast + ? UiTokens.gray1() + : UiTokens.accentContrast(); + + return ( + idle: UiBaseButtonStateStyle( + foreground: foreground, + background: highContrast ? UiTokens.accent12() : UiTokens.accent9(), + effects: RemixBoxEffectsMix.behindContent( + uiClassicBaseButtonSurface(highContrast: highContrast), + ), + ), + hovered: UiBaseButtonStateStyle( + effects: RemixBoxEffectsMix.behindContent( + uiClassicBaseButtonSurface(highContrast: highContrast, hovered: true), + ), + modifier: _hoverFilter(highContrast, classic: true), + ), + pressed: UiBaseButtonStateStyle( + effects: RemixBoxEffectsMix.behindContent( + uiClassicBaseButtonSurface(highContrast: highContrast, pressed: true), + ), + modifier: _pressedFilter(highContrast), + ), + disabled: UiBaseButtonStateStyle( + foreground: UiTokens.grayA8(), + background: UiTokens.gray2(), + effects: RemixBoxEffectsMix.behindContent( + uiClassicBaseButtonSurface(highContrast: false, disabled: true), + ), + spinnerOpacity: 1, + modifier: uiClearFilter(), + ), + ); +} + +_InteractionStateStyles _solidStateStyles({required bool highContrast}) { + final foreground = highContrast + ? UiTokens.gray1() + : UiTokens.accentContrast(); + + return ( + idle: UiBaseButtonStateStyle( + foreground: foreground, + background: highContrast ? UiTokens.accent12() : UiTokens.accent9(), + ), + hovered: UiBaseButtonStateStyle( + background: highContrast ? UiTokens.accent12() : UiTokens.accent10(), + modifier: _hoverFilter(highContrast, classic: false), + ), + pressed: UiBaseButtonStateStyle( + background: highContrast ? UiTokens.accent12() : UiTokens.accent10(), + modifier: _pressedFilter(highContrast), + ), + disabled: UiBaseButtonStateStyle( + foreground: UiTokens.grayA8(), + background: UiTokens.grayA3(), + spinnerOpacity: 1, + modifier: uiClearFilter(), + ), + ); +} + +_InteractionStateStyles _softStateStyles({required bool highContrast}) => ( + idle: UiBaseButtonStateStyle( + foreground: highContrast ? UiTokens.accent12() : UiTokens.accentA11(), + background: UiTokens.accentA3(), + ), + hovered: UiBaseButtonStateStyle(background: UiTokens.accentA4()), + pressed: UiBaseButtonStateStyle(background: UiTokens.accentA5()), + disabled: UiBaseButtonStateStyle( + foreground: UiTokens.grayA8(), + background: UiTokens.grayA3(), + spinnerOpacity: 1, + ), +); + +_InteractionStateStyles _surfaceStateStyles({required bool highContrast}) => ( + idle: UiBaseButtonStateStyle( + foreground: highContrast ? UiTokens.accent12() : UiTokens.accentA11(), + background: UiTokens.accentSurface(), + effects: RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.accentA7()]), + ), + ), + hovered: UiBaseButtonStateStyle( + background: UiTokens.accentSurface(), + effects: RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.accentA8()]), + ), + ), + pressed: UiBaseButtonStateStyle( + background: UiTokens.accentA3(), + effects: RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.accentA8()]), + ), + ), + disabled: UiBaseButtonStateStyle( + foreground: UiTokens.grayA8(), + background: UiTokens.grayA2(), + effects: RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.grayA6()]), + ), + spinnerOpacity: 1, + ), +); + +_InteractionStateStyles _outlineStateStyles({required bool highContrast}) { + final strokes = highContrast + ? [UiTokens.accentA7(), UiTokens.grayA11()] + : [UiTokens.accentA8()]; + final effects = RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: strokes), + ); + + return ( + idle: UiBaseButtonStateStyle( + foreground: highContrast ? UiTokens.accent12() : UiTokens.accentA11(), + effects: effects, + ), + hovered: UiBaseButtonStateStyle( + background: UiTokens.accentA2(), + effects: effects, + ), + pressed: UiBaseButtonStateStyle( + background: UiTokens.accentA3(), + effects: effects, + ), + disabled: UiBaseButtonStateStyle( + foreground: UiTokens.grayA8(), + background: const Color(0x00000000), + effects: RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.grayA7()]), + ), + spinnerOpacity: 1, + ), + ); +} + +_InteractionStateStyles _ghostStateStyles({required bool highContrast}) => ( + idle: UiBaseButtonStateStyle( + foreground: highContrast ? UiTokens.accent12() : UiTokens.accentA11(), + background: const Color(0x00000000), + ), + hovered: UiBaseButtonStateStyle(background: UiTokens.accentA3()), + pressed: UiBaseButtonStateStyle(background: UiTokens.accentA4()), + disabled: UiBaseButtonStateStyle( + foreground: UiTokens.grayA8(), + background: const Color(0x00000000), + spinnerOpacity: 1, + ), +); + +WidgetModifierConfig _hoverFilter(bool highContrast, {required bool classic}) { + if (!highContrast) return uiClearFilter(); + + return uiModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.1), + RemixCssColorFilterOperation.brightness(1.1), + ], + dark: [ + const RemixCssColorFilterOperation.contrast(0.88), + const RemixCssColorFilterOperation.saturate(1.3), + RemixCssColorFilterOperation.brightness(classic ? 1.14 : 1.18), + ], + ); +} + +WidgetModifierConfig _pressedFilter(bool highContrast) { + if (highContrast) { + return uiModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.82), + RemixCssColorFilterOperation.saturate(1.2), + RemixCssColorFilterOperation.brightness(1.16), + ], + dark: const [ + RemixCssColorFilterOperation.brightness(0.95), + RemixCssColorFilterOperation.saturate(1.2), + ], + ); + } + + return uiModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.brightness(0.92), + RemixCssColorFilterOperation.saturate(1.1), + ], + dark: const [RemixCssColorFilterOperation.brightness(1.08)], + ); +} diff --git a/apps/dashboard/lib/ui/components/button.dart b/apps/dashboard/lib/ui/components/button.dart new file mode 100644 index 000000000..6483f56eb --- /dev/null +++ b/apps/dashboard/lib/ui/components/button.dart @@ -0,0 +1,140 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import 'base_button.dart'; +import '../theme/theme.dart'; + +part 'button.g.dart'; + +/// Radix Themes Button size presets. +enum UiButtonSize { size1, size2, size3, size4 } + +/// Radix Themes Button variants. +enum UiButtonVariant { classic, solid, soft, surface, outline, ghost } + +/// Ui-themed Button with the Radix size, variant, and override contract. +/// +/// Default icon slots use the preset's icon size, not the ambient IconTheme. +/// An explicit icon size in [style] overrides that default. +@MixWidget(target: RemixButton.new) +ButtonStyler uiButtonStyle({ + UiButtonVariant variant = .solid, + UiButtonSize size = .size2, + bool highContrast = false, + ButtonStyler style = const ButtonStyler.create(), +}) { + final base = _uiButtonBaseStyler(variant, _uiBaseButtonSize(size)); + final stateStyles = uiBaseButtonStateStyles( + variant: _uiBaseButtonVariant(variant), + highContrast: highContrast, + ); + + return _applyUiButtonStateStyles( + base, + stateStyles, + pressedPaddingTop: variant == .classic ? (size == .size1 ? 1 : 2) : null, + ).merge(style); +} + +ButtonStyler _uiButtonBaseStyler( + UiButtonVariant variant, + UiBaseButtonSize size, +) { + final metrics = uiBaseButtonMetrics(size); + var style = ButtonStyler( + icon: .size(uiBaseButtonIconSize(size)), + container: .direction(.horizontal).mainAxisSize(.min).spacing(metrics.gap), + label: .style(metrics.text.mix()).fontWeight( + variant == .ghost + ? UiTokens.fontWeightRegular() + : UiTokens.fontWeightMedium(), + ), + spinner: .size(metrics.spinnerSize) + .opacity(0.65) + .leafRadius(UiTokens.radius1()) + .duration(const Duration(milliseconds: 800)), + ).borderRadius(.all(metrics.radius)); + + if (variant == .ghost) { + final ghost = uiBaseButtonGhostMetrics(size); + style = style + .spacing(ghost.gap) + .padding( + .symmetric(horizontal: ghost.paddingX, vertical: ghost.paddingY), + ) + .margin(.symmetric(horizontal: ghost.marginX, vertical: ghost.marginY)); + } else { + style = style + .minHeight(metrics.height) + .padding(.horizontal(metrics.paddingX)) + .icon(.opacity(0.9)); + } + return style; +} + +UiBaseButtonVariant _uiBaseButtonVariant(UiButtonVariant variant) => + switch (variant) { + .classic => .classic, + .solid => .solid, + .soft => .soft, + .surface => .surface, + .outline => .outline, + .ghost => .ghost, + }; + +UiBaseButtonSize _uiBaseButtonSize(UiButtonSize size) => switch (size) { + .size1 => .size1, + .size2 => .size2, + .size3 => .size3, + .size4 => .size4, +}; + +ButtonStyler _applyUiButtonStateStyles( + ButtonStyler base, + UiBaseButtonStateStyles stateStyles, { + required double? pressedPaddingTop, +}) { + var pressed = _applyUiButtonState(ButtonStyler(), stateStyles.pressed); + if (pressedPaddingTop != null) { + pressed = pressed.padding(.top(pressedPaddingTop)); + } + + return _applyUiButtonState(base, stateStyles.idle) + .onHovered(_applyUiButtonState(ButtonStyler(), stateStyles.hovered)) + .onPressed(pressed) + .onDisabled(_applyUiButtonState(ButtonStyler(), stateStyles.disabled)) + .onFocusVisible( + _applyUiButtonState(ButtonStyler(), stateStyles.focusVisible), + ) + .onDisabled( + _applyUiButtonState(ButtonStyler(), stateStyles.disabledFocus), + ); +} + +ButtonStyler _applyUiButtonState( + ButtonStyler style, + UiBaseButtonStateStyle state, +) { + var result = style; + final foreground = state.foreground; + if (foreground != null) { + result = result + .label(.color(foreground)) + .icon(.color(foreground)) + .spinner(.color(foreground)); + } + if (state.background != null) { + result = result.color(state.background!); + } + if (state.effects != null) { + result = result.containerEffects(state.effects!); + } + if (state.spinnerOpacity != null) { + result = result.spinner(.opacity(state.spinnerOpacity!)); + } + if (state.modifier != null) { + result = result.wrap(state.modifier!); + } + return result; +} diff --git a/apps/dashboard/lib/ui/components/button.g.dart b/apps/dashboard/lib/ui/components/button.g.dart new file mode 100644 index 000000000..7a27f00f0 --- /dev/null +++ b/apps/dashboard/lib/ui/components/button.g.dart @@ -0,0 +1,264 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'button.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed Button with the Radix size, variant, and override contract. +/// +/// Default icon slots use the preset's icon size, not the ambient IconTheme. +/// An explicit icon size in [style] overrides that default. +class UiButton extends StatelessWidget { + const UiButton({ + super.key, + this.variant = .solid, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + const UiButton.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiButtonVariant.classic; + + const UiButton.solid({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiButtonVariant.solid; + + const UiButton.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiButtonVariant.soft; + + const UiButton.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiButtonVariant.surface; + + const UiButton.outline({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiButtonVariant.outline; + + const UiButton.ghost({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiButtonVariant.ghost; + + final UiButtonVariant variant; + + final UiButtonSize size; + + final bool highContrast; + + final ButtonStyler style; + + final String label; + + final IconData? leadingIcon; + + final IconData? trailingIcon; + + final RemixButtonTextBuilder? textBuilder; + + final RemixButtonIconBuilder? leadingIconBuilder; + + final RemixButtonIconBuilder? trailingIconBuilder; + + final RemixButtonLoadingBuilder? loadingBuilder; + + final bool loading; + + final bool enabled; + + final VoidCallback? onPressed; + + final VoidCallback? onLongPress; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool enableFeedback; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixButton( + key: this.key, + style: uiButtonStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + label: this.label, + leadingIcon: this.leadingIcon, + trailingIcon: this.trailingIcon, + textBuilder: this.textBuilder, + leadingIconBuilder: this.leadingIconBuilder, + trailingIconBuilder: this.trailingIconBuilder, + loadingBuilder: this.loadingBuilder, + loading: this.loading, + enabled: this.enabled, + onPressed: this.onPressed, + onLongPress: this.onLongPress, + focusNode: this.focusNode, + autofocus: this.autofocus, + enableFeedback: this.enableFeedback, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/callout.dart b/apps/dashboard/lib/ui/components/callout.dart new file mode 100644 index 000000000..1880f02eb --- /dev/null +++ b/apps/dashboard/lib/ui/components/callout.dart @@ -0,0 +1,86 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'callout.g.dart'; + +/// Radix Themes Callout size presets. +enum UiCalloutSize { size1, size2, size3 } + +/// Radix Themes Callout variants. +enum UiCalloutVariant { soft, surface, outline } + +/// Ui-themed Callout with the Radix size, variant, and override contract. +@MixWidget(target: RemixCallout.new) +CalloutStyler uiCalloutStyle({ + UiCalloutVariant variant = .soft, + UiCalloutSize size = .size2, + bool highContrast = false, + CalloutStyler style = const CalloutStyler.create(), +}) { + final contentColor = highContrast + ? UiTokens.accent12() + : UiTokens.accentA11(); + final base = _uiCalloutBaseStyler( + size, + ).iconColor(contentColor).textColor(contentColor); + return (switch (variant) { + .soft => base.color(UiTokens.accentA3()), + .surface => + base + .color(UiTokens.accentA2()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.accentA6()]), + ), + ), + .outline => base.containerEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.accentA7()]), + ), + ), + }).merge(style); +} + +CalloutStyler _uiCalloutBaseStyler(UiCalloutSize size) { + final radius = _uiCalloutRadius(size); + return CalloutStyler( + container: .direction(.horizontal) + .mainAxisSize(.min) + .crossAxisAlignment(.start) + .spacing(_uiCalloutGap(size)) + .padding(EdgeInsetsGeometryMix.all(_uiCalloutPadding(size))), + text: .style(_uiCalloutText(size).mix()), + icon: .size(_uiCalloutIconSize(size)), + ).borderRadius(.all(radius)); +} + +double _uiCalloutPadding(UiCalloutSize size) => switch (size) { + .size1 => UiTokens.space3(), + .size2 => UiTokens.space4(), + .size3 => UiTokens.space5(), +}; + +double _uiCalloutGap(UiCalloutSize size) => switch (size) { + .size1 => UiTokens.space2(), + .size2 => UiTokens.space3(), + .size3 => UiTokens.space4(), +}; + +TextStyleToken _uiCalloutText(UiCalloutSize size) => switch (size) { + .size1 || .size2 => UiTokens.text2, + .size3 => UiTokens.text3, +}; + +double _uiCalloutIconSize(UiCalloutSize size) => switch (size) { + .size1 || .size2 => UiTokens.space4(), + .size3 => UiTokens.spinnerSize3(), +}; + +Radius _uiCalloutRadius(UiCalloutSize size) => switch (size) { + .size1 => UiTokens.radius3(), + .size2 => UiTokens.radius4(), + .size3 => UiTokens.radius5(), +}; diff --git a/apps/dashboard/lib/ui/components/callout.g.dart b/apps/dashboard/lib/ui/components/callout.g.dart new file mode 100644 index 000000000..d701c7e22 --- /dev/null +++ b/apps/dashboard/lib/ui/components/callout.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'callout.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed Callout with the Radix size, variant, and override contract. +class UiCallout extends StatelessWidget { + const UiCallout({ + super.key, + this.variant = .soft, + this.size = .size2, + this.highContrast = false, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }); + + const UiCallout.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }) : variant = UiCalloutVariant.soft; + + const UiCallout.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }) : variant = UiCalloutVariant.surface; + + const UiCallout.outline({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }) : variant = UiCalloutVariant.outline; + + final UiCalloutVariant variant; + + final UiCalloutSize size; + + final bool highContrast; + + final CalloutStyler style; + + final String? text; + + final IconData? icon; + + final Widget? child; + + @override + Widget build(BuildContext context) { + return RemixCallout( + key: this.key, + style: uiCalloutStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + text: this.text, + icon: this.icon, + child: this.child, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/card.dart b/apps/dashboard/lib/ui/components/card.dart new file mode 100644 index 000000000..7ffab572d --- /dev/null +++ b/apps/dashboard/lib/ui/components/card.dart @@ -0,0 +1,212 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'card.g.dart'; + +/// Radix Themes Card size presets. +enum UiCardSize { size1, size2, size3, size4, size5 } + +/// Radix Themes Card variants. +enum UiCardVariant { surface, classic, ghost } + +/// Ui-themed Card with the Radix size and variant contract. +@MixWidget(target: RemixCard.new) +CardStyler uiCardStyle({ + UiCardVariant variant = .surface, + UiCardSize size = .size1, + CardStyler style = const CardStyler.create(), +}) { + final metrics = _uiCardMetrics(size); + final base = CardStyler() + .padding(.all(metrics.padding)) + .borderRadius(.all(metrics.radius)) + .clipBehavior(Clip.antiAlias) + .onFocusVisible( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: UiTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: -1, + ), + ), + ); + + return (switch (variant) { + .surface => _uiCardSurface(base), + .classic => _uiCardClassic(base), + .ghost => _uiCardGhost(base, metrics.ghostMargin), + }).merge(style); +} + +({double padding, double ghostMargin, Radius radius}) _uiCardMetrics( + UiCardSize size, +) => switch (size) { + .size1 => ( + padding: UiTokens.space3(), + ghostMargin: UiTokens.cardGhostMargin1(), + radius: UiTokens.radius4(), + ), + .size2 => ( + padding: UiTokens.space4(), + ghostMargin: UiTokens.cardGhostMargin2(), + radius: UiTokens.radius4(), + ), + .size3 => ( + padding: UiTokens.space5(), + ghostMargin: UiTokens.cardGhostMargin3(), + radius: UiTokens.radius5(), + ), + .size4 => ( + padding: UiTokens.space6(), + ghostMargin: UiTokens.cardGhostMargin4(), + radius: UiTokens.radius5(), + ), + .size5 => ( + padding: UiTokens.space8(), + ghostMargin: UiTokens.cardGhostMargin5(), + radius: UiTokens.radius6(), + ), +}; + +CardStyler _uiCardSurface(CardStyler base) { + base = base.containerEffects( + RemixBoxEffectsMix.backdropBlur(UiTokens.panelBlur()), + ); + final open = CardStyler() + .containerEffects(RemixBoxEffectsMix.behindContent(_uiCardPanel())) + .containerEffects( + RemixBoxEffectsMix.overContent( + _uiCardSurfaceStroke(UiTokens.grayStroke7()), + ), + ); + final activeFocus = CardStyler() + .containerEffects(RemixBoxEffectsMix.behindContent(_uiCardActiveFocus())) + .onSelected(open); + final pressed = CardStyler() + .containerEffects( + RemixBoxEffectsMix.overContent( + _uiCardSurfaceStroke(UiTokens.grayStroke6()), + ), + ) + .onFocusVisible(activeFocus) + .onSelected(open); + + return base + .containerEffects(RemixBoxEffectsMix.behindContent(_uiCardPanel())) + .containerEffects( + RemixBoxEffectsMix.overContent( + _uiCardSurfaceStroke(UiTokens.grayStroke5()), + ), + ) + .onHovered(open) + .onPressed(pressed) + .onSelected(open.onPressed(open)); +} + +CardStyler _uiCardClassic(CardStyler base) { + base = base.containerEffects( + RemixBoxEffectsMix.backdropBlur(UiTokens.panelBlur()), + ); + final open = CardStyler() + .animate(AnimationConfig.ease(const Duration(milliseconds: 40))) + .containerEffects( + RemixBoxEffectsMix.behindContent( + _uiCardPanel(shadowToken: UiTokens.cardClassicHoverOuterShadows), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadowToken: UiTokens.cardClassicHoverInnerShadows, + ), + ), + ); + final pressed = CardStyler() + .animate(AnimationConfig.ease(const Duration(milliseconds: 40))) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + shadowToken: UiTokens.cardClassicActiveOuterShadows, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadowToken: UiTokens.cardClassicActiveInnerShadows, + ), + ), + ) + .onFocusVisible( + .containerEffects( + RemixBoxEffectsMix.behindContent(_uiCardActiveFocus()), + ).onSelected(open), + ) + .onSelected(open); + + return base + .animate(AnimationConfig.ease(const Duration(milliseconds: 120))) + .containerEffects( + RemixBoxEffectsMix.behindContent( + _uiCardPanel(shadowToken: UiTokens.cardClassicOuterShadows), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadowToken: UiTokens.cardClassicInnerShadows), + ), + ) + .onHovered(open) + .onPressed(pressed) + .onSelected(open.onPressed(open)); +} + +CardStyler _uiCardGhost(CardStyler base, double ghostMargin) { + final focused = CardStyler().color(UiTokens.accentA2()); + final open = CardStyler().color(UiTokens.grayA3()).onFocusVisible(focused); + final pressed = CardStyler() + .color(UiTokens.grayA4()) + .onFocusVisible(focused) + .onSelected(open); + + return base + .margin(.all(ghostMargin)) + .color(const Color(0x00000000)) + .onHovered(open) + .onPressed(pressed) + .onSelected(open.onPressed(open)); +} + +RemixBoxEffectLayerMix _uiCardPanel({RemixBoxShadowListToken? shadowToken}) => + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [UiTokens.colorPanel(), UiTokens.colorPanel()], + ), + ], + gradientInsets: const [1], + shadowToken: shadowToken, + ); + +RemixBoxEffectLayerMix _uiCardActiveFocus() => RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix(colors: [UiTokens.accentA2(), UiTokens.accentA2()]), + RemixLinearGradientMix( + colors: [UiTokens.colorPanel(), UiTokens.colorPanel()], + ), + ], + gradientInsets: const [1, 1], +); + +RemixBoxEffectLayerMix _uiCardSurfaceStroke(Color color) => + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix(color: color, spreadRadius: 1, shapeInset: 1), + ], + ); diff --git a/apps/dashboard/lib/ui/components/card.g.dart b/apps/dashboard/lib/ui/components/card.g.dart new file mode 100644 index 000000000..1a30f3d7a --- /dev/null +++ b/apps/dashboard/lib/ui/components/card.g.dart @@ -0,0 +1,60 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'card.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed Card with the Radix size and variant contract. +class UiCard extends StatelessWidget { + const UiCard({ + super.key, + this.variant = .surface, + this.size = .size1, + this.style = const CardStyler.create(), + this.child, + }); + + const UiCard.surface({ + super.key, + this.size = .size1, + this.style = const CardStyler.create(), + this.child, + }) : variant = UiCardVariant.surface; + + const UiCard.classic({ + super.key, + this.size = .size1, + this.style = const CardStyler.create(), + this.child, + }) : variant = UiCardVariant.classic; + + const UiCard.ghost({ + super.key, + this.size = .size1, + this.style = const CardStyler.create(), + this.child, + }) : variant = UiCardVariant.ghost; + + final UiCardVariant variant; + + final UiCardSize size; + + final CardStyler style; + + final Widget? child; + + @override + Widget build(BuildContext context) { + return RemixCard( + key: this.key, + style: uiCardStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + child: this.child, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/chart.dart b/apps/dashboard/lib/ui/components/chart.dart new file mode 100644 index 000000000..f85f0269e --- /dev/null +++ b/apps/dashboard/lib/ui/components/chart.dart @@ -0,0 +1,253 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:mix_chart/mix_chart.dart'; +import 'package:remix/remix.dart'; + +import '../theme/radix_colors.dart' + show amber, blue, cyan, green, orange, ruby, violet; +import '../theme/theme.dart'; + +part 'chart.g.dart'; + +const _standardPaletteToken = ContextToken>( + _resolveStandardPalette, +); +const _highContrastPaletteToken = ContextToken>( + _resolveHighContrastPalette, +); +const _tooltipBorderToken = ContextToken(_resolveTooltipBorder); +const _tooltipRadiusToken = ContextToken(_resolveTooltipRadius); +const _tooltipPaddingToken = ContextToken(_resolveTooltipPadding); +const _barRadiusToken = ContextToken(_resolveBarRadius); +const _lineWidthToken = ContextToken(_resolveLineWidth); +const _highContrastLineWidthToken = ContextToken( + _resolveHighContrastLineWidth, +); + +/// Returns the categorical palette used by Ui charts in the current scope. +/// +/// The first entry follows the configured Ui accent. Remaining entries use +/// Radix color families selected for clear categorical separation. Set +/// [highContrast] to use step 12 instead of the standard solid-color step 9. +List resolveUiChartPalette( + BuildContext context, { + bool highContrast = false, +}) { + final theme = UiTheme.of(context); + final colors = resolveUiTokens(theme); + final step = highContrast ? 12 : 9; + final candidates = [ + colors.accent.scale.step(step), + for (final family in [cyan, orange, ruby, green, violet, amber, blue]) + (theme.isDark ? family.dark : family.light).scale.step(step), + ]; + + return List.unmodifiable(candidates.toSet()); +} + +/// Ui presentation for a Mix line or area chart. +/// +/// Generates [UiLineChart] through `mix_generator`. The plot remains +/// transparent so callers can compose it inside any Ui surface. +@MixWidget(target: LineChart.new) +LineChartStyler uiLineChartStyle({ + bool highContrast = false, + bool showMarkers = false, + List? palette, + LineChartStyler style = const LineChartStyler.create(), +}) { + final recipe = LineChartStyler() + .frame(_uiFrameStyle()) + .axis(_uiAxisStyle()) + .topAxis(_hiddenAxisStyle()) + .rightAxis(_hiddenAxisStyle()) + .grid(_uiGridStyle()) + .series( + LineSeriesStyler() + .curve(.curved) + .smoothness(0.18) + .preventCurveOvershooting(true) + .roundStrokeCap(true) + .roundStrokeJoin(true) + .stroke( + ChartStrokeStyler().width( + highContrast + ? _highContrastLineWidthToken() + : _lineWidthToken(), + ), + ) + .marker( + ChartMarkerStyler() + .show(showMarkers) + .radius(UiTokens.space1()) + .borderColor(UiTokens.colorPanel()) + .borderWidth(UiTokens.borderWidth2()), + ), + ) + .tooltip(_uiTooltipStyle()); + + return recipe + .merge( + LineChartStyler.create( + palette: _paletteProp(highContrast: highContrast, palette: palette), + ), + ) + .merge(style); +} + +/// Ui presentation for a Mix grouped, stacked, or floating bar chart. +/// +/// Generates [UiBarChart] through `mix_generator`. +@MixWidget(target: BarChart.new) +BarChartStyler uiBarChartStyle({ + bool highContrast = false, + List? palette, + BarChartStyler style = const BarChartStyler.create(), +}) { + final bar = BarStyler.create( + borderRadius: Prop.token(_barRadiusToken), + ).width(UiTokens.space4()); + final recipe = BarChartStyler() + .frame(_uiFrameStyle()) + .axis(_uiAxisStyle()) + .topAxis(_hiddenAxisStyle()) + .rightAxis(_hiddenAxisStyle()) + .grid(_uiGridStyle()) + .bar(bar) + .groupSpacing(UiTokens.space4()) + .barSpacing(UiTokens.space2()) + .tooltip(_uiTooltipStyle()); + + return recipe + .merge( + BarChartStyler.create( + palette: _paletteProp(highContrast: highContrast, palette: palette), + ), + ) + .merge(style); +} + +/// Ui presentation for a Mix pie or donut chart. +/// +/// A positive [centerRadius] renders a donut. Labels are hidden by default so +/// category names can be presented in a caller-owned legend without forcing +/// low-contrast text onto arbitrary categorical colors. Generates +/// [UiPieChart] through `mix_generator`. For advanced chart-level geometry, +/// pass this recipe directly to [PieChart.style] and merge a [PieSliceStyler]. +@MixWidget(target: PieChart.new) +PieChartStyler uiPieChartStyle({ + bool highContrast = false, + double centerRadius = 0, + bool showLabels = false, + List? palette, + PieChartStyler style = const PieChartStyler.create(), +}) { + final recipe = PieChartStyler() + .frame(_uiFrameStyle()) + .centerRadius(centerRadius) + .centerColor(UiTokens.colorPanel()) + .sliceSpacing(UiTokens.borderWidth2()) + .selectedSliceRadiusOffset(UiTokens.space2()) + .slice( + PieSliceStyler() + .showLabel(showLabels) + .cornerRadius(UiTokens.borderWidth2()) + .label( + TextStyler() + .style(UiTokens.text1.mix()) + .fontWeight(.w600) + .color(UiTokens.accentContrast()), + ), + ) + .tooltip(_uiTooltipStyle()); + + return recipe + .merge( + PieChartStyler.create( + palette: _paletteProp(highContrast: highContrast, palette: palette), + ), + ) + .merge(style); +} + +Prop> _paletteProp({ + required bool highContrast, + required List? palette, +}) { + if (palette != null) return Prop.value(List.unmodifiable(palette)); + + return Prop.token( + highContrast ? _highContrastPaletteToken : _standardPaletteToken, + ); +} + +ChartFrameStyler _uiFrameStyle() => ChartFrameStyler() + .backgroundColor(MixColors.transparent) + .showBorder(false) + .clip(true); + +ChartAxisStyler _uiAxisStyle() => ChartAxisStyler() + .showLabels(true) + .label(TextStyler().style(UiTokens.text1.mix()).color(UiTokens.gray11())) + .labelSpace(UiTokens.space2()) + .fitInside(true) + .fitInsideDistance(UiTokens.space1()) + .drawBelowEverything(true); + +ChartAxisStyler _hiddenAxisStyle() => ChartAxisStyler().showLabels(false); + +ChartGridStyler _uiGridStyle() => ChartGridStyler() + .show(true) + .showHorizontal(true) + .showVertical(false) + .stroke( + ChartStrokeStyler() + .color(UiTokens.grayA5()) + .width(UiTokens.borderWidth1()), + ); + +ChartTooltipStyler _uiTooltipStyle() => + ChartTooltipStyler.create( + border: Prop.token(_tooltipBorderToken), + borderRadius: Prop.token(_tooltipRadiusToken), + padding: Prop.token(_tooltipPaddingToken), + ) + .backgroundColor(UiTokens.colorPanel()) + .margin(UiTokens.space2()) + .maxWidth(280) + .fitHorizontally(true) + .fitVertically(true) + .text( + TextStyler() + .style(UiTokens.text1.mix()) + .fontWeight(.w500) + .color(UiTokens.gray12()), + ); + +List _resolveStandardPalette(BuildContext context) => + resolveUiChartPalette(context); + +List _resolveHighContrastPalette(BuildContext context) => + resolveUiChartPalette(context, highContrast: true); + +BorderSide _resolveTooltipBorder(BuildContext context) => BorderSide( + color: UiTokens.grayStroke6.resolve(context), + width: UiTokens.borderWidth1.resolve(context), +); + +BorderRadius _resolveTooltipRadius(BuildContext context) => + BorderRadius.all(UiTokens.radius3.resolve(context)); + +EdgeInsets _resolveTooltipPadding(BuildContext context) => EdgeInsets.symmetric( + horizontal: UiTokens.space3.resolve(context), + vertical: UiTokens.space2.resolve(context), +); + +BorderRadius _resolveBarRadius(BuildContext context) => + BorderRadius.all(UiTokens.radius2.resolve(context)); + +double _resolveLineWidth(BuildContext context) => + 2 * UiTheme.of(context).scaling.factor; + +double _resolveHighContrastLineWidth(BuildContext context) => + 3 * UiTheme.of(context).scaling.factor; diff --git a/apps/dashboard/lib/ui/components/chart.g.dart b/apps/dashboard/lib/ui/components/chart.g.dart new file mode 100644 index 000000000..1be94222f --- /dev/null +++ b/apps/dashboard/lib/ui/components/chart.g.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chart.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui presentation for a Mix line or area chart. +/// +/// Generates [UiLineChart] through `mix_generator`. The plot remains +/// transparent so callers can compose it inside any Ui surface. +class UiLineChart extends StatelessWidget { + const UiLineChart({ + super.key, + this.highContrast = false, + this.showMarkers = false, + this.palette, + this.style = const LineChartStyler.create(), + required this.series, + this.xAxis, + this.yAxis, + this.topAxis, + this.rightAxis, + this.viewport, + this.dataTransition = ChartDataTransition.none, + this.selectedPoints = const {}, + this.onPointHover, + this.onPointTap, + this.onPointLongPress, + this.tooltipBuilder, + this.hitTestRadius = 10, + this.mouseCursorResolver, + this.semanticsLabel, + this.semanticsValue, + this.excludeFromSemantics = false, + }); + + final bool highContrast; + + final bool showMarkers; + + final List? palette; + + final LineChartStyler style; + + final List series; + + final ChartAxis? xAxis; + + final ChartAxis? yAxis; + + final ChartAxis? topAxis; + + final ChartAxis? rightAxis; + + final ChartViewport? viewport; + + final ChartDataTransition dataTransition; + + final Set selectedPoints; + + final ValueChanged? onPointHover; + + final ValueChanged? onPointTap; + + final ValueChanged? onPointLongPress; + + final ChartTooltipBuilder? tooltipBuilder; + + final double hitTestRadius; + + final ChartMouseCursorResolver? mouseCursorResolver; + + final String? semanticsLabel; + + final String? semanticsValue; + + final bool excludeFromSemantics; + + @override + Widget build(BuildContext context) { + return LineChart( + key: this.key, + style: uiLineChartStyle( + highContrast: this.highContrast, + showMarkers: this.showMarkers, + palette: this.palette, + style: this.style, + ), + series: this.series, + xAxis: this.xAxis, + yAxis: this.yAxis, + topAxis: this.topAxis, + rightAxis: this.rightAxis, + viewport: this.viewport, + dataTransition: this.dataTransition, + selectedPoints: this.selectedPoints, + onPointHover: this.onPointHover, + onPointTap: this.onPointTap, + onPointLongPress: this.onPointLongPress, + tooltipBuilder: this.tooltipBuilder, + hitTestRadius: this.hitTestRadius, + mouseCursorResolver: this.mouseCursorResolver, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + excludeFromSemantics: this.excludeFromSemantics, + ); + } +} + +/// Ui presentation for a Mix grouped, stacked, or floating bar chart. +/// +/// Generates [UiBarChart] through `mix_generator`. +class UiBarChart extends StatelessWidget { + const UiBarChart({ + super.key, + this.highContrast = false, + this.palette, + this.style = const BarChartStyler.create(), + required this.groups, + this.xAxis, + this.yAxis, + this.topAxis, + this.rightAxis, + this.viewport, + this.dataTransition = ChartDataTransition.none, + this.selectedItems = const {}, + this.onBarHover, + this.onBarTap, + this.onBarLongPress, + this.tooltipBuilder, + this.hitTestPadding = const EdgeInsets.all(4), + this.mouseCursorResolver, + this.semanticsLabel, + this.semanticsValue, + this.excludeFromSemantics = false, + }); + + final bool highContrast; + + final List? palette; + + final BarChartStyler style; + + final List groups; + + final ChartAxis? xAxis; + + final ChartAxis? yAxis; + + final ChartAxis? topAxis; + + final ChartAxis? rightAxis; + + final ChartViewport? viewport; + + final ChartDataTransition dataTransition; + + final Set selectedItems; + + final ValueChanged? onBarHover; + + final ValueChanged? onBarTap; + + final ValueChanged? onBarLongPress; + + final ChartTooltipBuilder? tooltipBuilder; + + final EdgeInsets hitTestPadding; + + final ChartMouseCursorResolver? mouseCursorResolver; + + final String? semanticsLabel; + + final String? semanticsValue; + + final bool excludeFromSemantics; + + @override + Widget build(BuildContext context) { + return BarChart( + key: this.key, + style: uiBarChartStyle( + highContrast: this.highContrast, + palette: this.palette, + style: this.style, + ), + groups: this.groups, + xAxis: this.xAxis, + yAxis: this.yAxis, + topAxis: this.topAxis, + rightAxis: this.rightAxis, + viewport: this.viewport, + dataTransition: this.dataTransition, + selectedItems: this.selectedItems, + onBarHover: this.onBarHover, + onBarTap: this.onBarTap, + onBarLongPress: this.onBarLongPress, + tooltipBuilder: this.tooltipBuilder, + hitTestPadding: this.hitTestPadding, + mouseCursorResolver: this.mouseCursorResolver, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + excludeFromSemantics: this.excludeFromSemantics, + ); + } +} + +/// Ui presentation for a Mix pie or donut chart. +/// +/// A positive [centerRadius] renders a donut. Labels are hidden by default so +/// category names can be presented in a caller-owned legend without forcing +/// low-contrast text onto arbitrary categorical colors. Generates +/// [UiPieChart] through `mix_generator`. For advanced chart-level geometry, +/// pass this recipe directly to [PieChart.style] and merge a [PieSliceStyler]. +class UiPieChart extends StatelessWidget { + const UiPieChart({ + super.key, + this.highContrast = false, + this.centerRadius = 0, + this.showLabels = false, + this.palette, + this.style = const PieChartStyler.create(), + required this.slices, + this.dataTransition = ChartDataTransition.none, + this.selectedSliceIds = const {}, + this.onSliceHover, + this.onSliceTap, + this.onSliceLongPress, + this.tooltipBuilder, + this.mouseCursorResolver, + this.valueFormatter, + this.semanticsLabel, + this.semanticsValue, + this.excludeFromSemantics = false, + }); + + final bool highContrast; + + final double centerRadius; + + final bool showLabels; + + final List? palette; + + final PieChartStyler style; + + final List slices; + + final ChartDataTransition dataTransition; + + final Set selectedSliceIds; + + final ValueChanged? onSliceHover; + + final ValueChanged? onSliceTap; + + final ValueChanged? onSliceLongPress; + + final ChartTooltipBuilder? tooltipBuilder; + + final ChartMouseCursorResolver? mouseCursorResolver; + + final ChartAxisLabelFormatter? valueFormatter; + + final String? semanticsLabel; + + final String? semanticsValue; + + final bool excludeFromSemantics; + + @override + Widget build(BuildContext context) { + return PieChart( + key: this.key, + style: uiPieChartStyle( + highContrast: this.highContrast, + centerRadius: this.centerRadius, + showLabels: this.showLabels, + palette: this.palette, + style: this.style, + ), + slices: this.slices, + dataTransition: this.dataTransition, + selectedSliceIds: this.selectedSliceIds, + onSliceHover: this.onSliceHover, + onSliceTap: this.onSliceTap, + onSliceLongPress: this.onSliceLongPress, + tooltipBuilder: this.tooltipBuilder, + mouseCursorResolver: this.mouseCursorResolver, + valueFormatter: this.valueFormatter, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + excludeFromSemantics: this.excludeFromSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/checkbox.dart b/apps/dashboard/lib/ui/components/checkbox.dart new file mode 100644 index 000000000..e5d7ee0ac --- /dev/null +++ b/apps/dashboard/lib/ui/components/checkbox.dart @@ -0,0 +1,249 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'checkbox.g.dart'; + +/// Radix Themes Checkbox size presets. +enum UiCheckboxSize { size1, size2, size3 } + +/// Radix Themes Checkbox variants. +enum UiCheckboxVariant { classic, surface, soft } + +/// Ui recipe for [RemixCheckbox]. +@MixWidget(target: RemixCheckbox.new) +CheckboxStyler uiCheckboxStyle({ + UiCheckboxVariant variant = .surface, + UiCheckboxSize size = .size2, + bool highContrast = false, + CheckboxStyler style = const CheckboxStyler.create(), +}) { + final metrics = _uiCheckboxMetrics(size); + final base = + CheckboxStyler( + container: .size( + metrics.size, + metrics.size, + ).alignment(.center).borderRadius(.all(metrics.radius)), + indicator: .size(metrics.indicatorSize), + containerEffects: RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix(), + overContent: RemixBoxEffectLayerMix(), + ), + ).onFocusVisible( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: UiTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ); + + return (switch (variant) { + .classic => _uiCheckboxClassic(base, highContrast), + .surface => _uiCheckboxSurface(base, highContrast), + .soft => _uiCheckboxSoft(base, highContrast), + }).merge(style); +} + +/// Ui recipe for [RemixCheckboxGroupItem]. +/// +/// Combines the mapped checkbox recipe with Radix's size-linked item label +/// typography and `0.5em` label gap. The behavioral group remains layout +/// transparent, so callers continue to own root direction and spacing. +/// +/// It exists because `RemixCheckboxGroup` is behavioral and carries no styler, +/// so unlike every other Remix item (menu, select, segmented control, toggle +/// group) there is no parent recipe to push item styling down. Without this, +/// callers hand-attach a styler to each item and a missed one in a loop renders +/// unstyled beside its styled siblings. +@MixWidget(target: RemixCheckboxGroupItem.new) +CheckboxStyler uiCheckboxGroupItemStyle({ + UiCheckboxVariant variant = .surface, + UiCheckboxSize size = .size2, + bool highContrast = false, + CheckboxStyler style = const CheckboxStyler.create(), +}) { + final checkbox = uiCheckboxStyle( + variant: variant, + size: size, + highContrast: highContrast, + ); + + return (switch (size) { + .size1 => + checkbox + .label(.style(UiTokens.text1.mix())) + .labelSpacing(UiTokens.checkboxGroupItemGap1()), + .size2 => + checkbox + .label(.style(UiTokens.text2.mix())) + .labelSpacing(UiTokens.checkboxGroupItemGap2()), + .size3 => + checkbox + .label(.style(UiTokens.text3.mix())) + .labelSpacing(UiTokens.checkboxGroupItemGap3()), + }).merge(style); +} + +({double size, double indicatorSize, Radius radius}) _uiCheckboxMetrics( + UiCheckboxSize size, +) => switch (size) { + .size1 => ( + size: UiTokens.checkboxSize1(), + indicatorSize: UiTokens.checkboxIndicatorSize1(), + radius: UiTokens.checkboxRadius1(), + ), + .size2 => ( + size: UiTokens.space4(), + indicatorSize: UiTokens.checkboxIndicatorSize2(), + radius: UiTokens.radius1(), + ), + .size3 => ( + size: UiTokens.checkboxSize3(), + indicatorSize: UiTokens.checkboxIndicatorSize3(), + radius: UiTokens.checkboxRadius3(), + ), +}; + +CheckboxStyler _uiCheckboxSurface(CheckboxStyler base, bool highContrast) { + final selected = CheckboxStyler() + .color(highContrast ? UiTokens.accent12() : UiTokens.accentIndicator()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor( + highContrast ? UiTokens.accent1() : UiTokens.accentContrast(), + ); + + return base + .color(UiTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + uiInsetSurface(strokes: [UiTokens.grayA7()]), + ), + ) + .onSelected(selected) + .onIndeterminate(selected) + .onDisabled( + .color(UiTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + uiInsetSurface(strokes: [UiTokens.grayA6()]), + ), + ) + .indicatorColor(UiTokens.grayA8()), + ); +} + +CheckboxStyler _uiCheckboxClassic(CheckboxStyler base, bool highContrast) { + final selected = CheckboxStyler() + .color(highContrast ? UiTokens.accent12() : UiTokens.accentIndicator()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [ + UiTokens.whiteA3(), + const Color(0x00000000), + UiTokens.blackA1(), + ], + ), + ], + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: UiTokens.whiteA4(), + offset: const Offset(0, 0.5), + blurRadius: 0.5, + ), + RemixBoxShadowMix( + kind: .inset, + color: UiTokens.blackA4(), + offset: const Offset(0, -0.5), + blurRadius: 0.5, + ), + ], + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor( + highContrast ? UiTokens.accent1() : UiTokens.accentContrast(), + ); + + return base + .color(UiTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadowToken: UiTokens.shadow1Layers), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + uiInsetSurface(strokes: [UiTokens.grayA3()]), + ), + ) + .onSelected(selected) + .onIndeterminate(selected) + .onDisabled( + .color(UiTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: const [], + shadowToken: UiTokens.shadow1Layers, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor(UiTokens.grayA8()), + ); +} + +CheckboxStyler _uiCheckboxSoft(CheckboxStyler base, bool highContrast) { + final selected = CheckboxStyler().indicatorColor( + highContrast ? UiTokens.accent12() : UiTokens.accentA11(), + ); + + return base + .color(UiTokens.accentA5()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .onSelected(selected) + .onIndeterminate(selected) + .onDisabled( + .color(UiTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .indicatorColor(UiTokens.grayA8()), + ); +} diff --git a/apps/dashboard/lib/ui/components/checkbox.g.dart b/apps/dashboard/lib/ui/components/checkbox.g.dart new file mode 100644 index 000000000..b3b7a0e87 --- /dev/null +++ b/apps/dashboard/lib/ui/components/checkbox.g.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'checkbox.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui recipe for [RemixCheckbox]. +class UiCheckbox extends StatelessWidget { + const UiCheckbox({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon, + this.uncheckedIcon, + this.indeterminateIcon, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.label, + this.semanticLabel, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }); + + const UiCheckbox.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon, + this.uncheckedIcon, + this.indeterminateIcon, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.label, + this.semanticLabel, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiCheckboxVariant.classic; + + const UiCheckbox.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon, + this.uncheckedIcon, + this.indeterminateIcon, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.label, + this.semanticLabel, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiCheckboxVariant.surface; + + const UiCheckbox.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon, + this.uncheckedIcon, + this.indeterminateIcon, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.label, + this.semanticLabel, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiCheckboxVariant.soft; + + final UiCheckboxVariant variant; + + final UiCheckboxSize size; + + final bool highContrast; + + final CheckboxStyler style; + + final bool? selected; + + final ValueChanged? onChanged; + + final bool enabled; + + final bool tristate; + + final IconData? checkedIcon; + + final IconData? uncheckedIcon; + + final IconData? indeterminateIcon; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool enableFeedback; + + final String? label; + + final String? semanticLabel; + + final Size minimumTapTargetSize; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixCheckbox( + key: this.key, + style: uiCheckboxStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + selected: this.selected, + onChanged: this.onChanged, + enabled: this.enabled, + tristate: this.tristate, + checkedIcon: this.checkedIcon, + uncheckedIcon: this.uncheckedIcon, + indeterminateIcon: this.indeterminateIcon, + focusNode: this.focusNode, + autofocus: this.autofocus, + enableFeedback: this.enableFeedback, + label: this.label, + semanticLabel: this.semanticLabel, + minimumTapTargetSize: this.minimumTapTargetSize, + mouseCursor: this.mouseCursor, + ); + } +} + +/// Ui recipe for [RemixCheckboxGroupItem]. +/// +/// Combines the mapped checkbox recipe with Radix's size-linked item label +/// typography and `0.5em` label gap. The behavioral group remains layout +/// transparent, so callers continue to own root direction and spacing. +/// +/// It exists because `RemixCheckboxGroup` is behavioral and carries no styler, +/// so unlike every other Remix item (menu, select, segmented control, toggle +/// group) there is no parent recipe to push item styling down. Without this, +/// callers hand-attach a styler to each item and a missed one in a loop renders +/// unstyled beside its styled siblings. +class UiCheckboxGroupItem extends StatelessWidget { + const UiCheckboxGroupItem({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.value, + required this.label, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.checkedIcon, + this.uncheckedIcon, + this.enableFeedback = true, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }); + + const UiCheckboxGroupItem.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.value, + required this.label, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.checkedIcon, + this.uncheckedIcon, + this.enableFeedback = true, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiCheckboxVariant.classic; + + const UiCheckboxGroupItem.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.value, + required this.label, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.checkedIcon, + this.uncheckedIcon, + this.enableFeedback = true, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiCheckboxVariant.surface; + + const UiCheckboxGroupItem.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.value, + required this.label, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.checkedIcon, + this.uncheckedIcon, + this.enableFeedback = true, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiCheckboxVariant.soft; + + final UiCheckboxVariant variant; + + final UiCheckboxSize size; + + final bool highContrast; + + final CheckboxStyler style; + + final T value; + + final String label; + + final String? semanticLabel; + + final bool enabled; + + final FocusNode? focusNode; + + final bool autofocus; + + final IconData? checkedIcon; + + final IconData? uncheckedIcon; + + final bool enableFeedback; + + final Size minimumTapTargetSize; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixCheckboxGroupItem( + key: this.key, + style: uiCheckboxGroupItemStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + value: this.value, + label: this.label, + semanticLabel: this.semanticLabel, + enabled: this.enabled, + focusNode: this.focusNode, + autofocus: this.autofocus, + checkedIcon: this.checkedIcon, + uncheckedIcon: this.uncheckedIcon, + enableFeedback: this.enableFeedback, + minimumTapTargetSize: this.minimumTapTargetSize, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/code.dart b/apps/dashboard/lib/ui/components/code.dart new file mode 100644 index 000000000..8fb0a9d16 --- /dev/null +++ b/apps/dashboard/lib/ui/components/code.dart @@ -0,0 +1,297 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +/// Radix Themes Code variants. +enum UiCodeVariant { solid, soft, outline, ghost } + +/// Ui-themed inline code on the Radix nine-step scale. +/// +/// Geometry is em-relative to the resolved font size, so this recipe takes a +/// [context]. An omitted [size] anchors to the root `text3` token — not the +/// ambient `DefaultTextStyle` — while keeping upstream's separate unsized +/// factors, so a host text run cannot change Code's geometry. +BadgeStyler uiCodeStyle( + BuildContext context, { + UiTextSize? size, + UiCodeVariant variant = .soft, + UiTextWeight? weight, + bool softWrap = true, + bool truncate = false, + bool accent = false, + bool highContrast = false, + BadgeStyler style = const BadgeStyler.create(), +}) { + final base = uiResolveTextToken(context, size ?? UiTextSize.size3); + final baseFontSize = base.fontSize!; + + // Radix nests two adjustments: --code-font-size-adjust is 0.95, and + // --code-variant-font-size-adjust multiplies it by 0.95 again for every + // variant except ghost, which keeps the outer value. + final decorated = variant != .ghost; + final fontSize = baseFontSize * (decorated ? 0.95 * 0.95 : 0.95); + // An explicit size keeps its token's absolute line box; the unsized path + // uses the pinned unitless 1.25. + final lineHeight = size == null + ? 1.25 + : (baseFontSize * (base.height ?? 1)) / fontSize; + final letterSpacing = (base.letterSpacing ?? 0) - (0.007 * fontSize); + + var textStyle = TextStyler() + .fontFamily('Menlo') + .fontFamilyFallback(const [ + 'Consolas', + 'Bitstream Vera Sans Mono', + 'monospace', + 'Apple Color Emoji', + 'Segoe UI Emoji', + ]) + .fontSize(fontSize) + .height(lineHeight) + .letterSpacing(letterSpacing) + .inherit(false); + if (weight != null) { + textStyle = textStyle.fontWeight(uiTextWeightToken(weight)()); + } + textStyle = uiApplyTextFlow( + textStyle, + softWrap: softWrap, + truncate: truncate, + ); + + Color? fill; + Color? foreground; + final accent1 = uiResolveColor(context, UiTokens.accent1); + final accent12 = uiResolveColor(context, UiTokens.accent12); + final accentA3 = uiResolveColor(context, UiTokens.accentA3); + final accentA9 = uiResolveColor(context, UiTokens.accentA9); + final accentA11 = uiResolveColor(context, UiTokens.accentA11); + final accentContrast = uiResolveColor(context, UiTokens.accentContrast); + switch (variant) { + case .solid: + fill = highContrast ? accent12 : accentA9; + foreground = highContrast ? accent1 : accentContrast; + case .soft: + fill = accentA3; + foreground = highContrast ? accent12 : accentA11; + case .outline: + foreground = highContrast ? accent12 : accentA11; + case .ghost: + // Ghost is transparent and inherits the ambient colour unless the caller + // opts into the local accent. Apply only that intended ambient field + // after Mix composition so an explicit recipe colour or foreground can + // override it without creating an invalid Flutter TextStyle. + if (accent) { + foreground = highContrast ? accent12 : accentA11; + } else { + textStyle = textStyle.merge( + TextStyler.create( + style: Prop.directives([ + _AmbientCodeForegroundDirective( + DefaultTextStyle.of(context).style, + ), + ]), + ), + ); + } + } + if (foreground != null) textStyle = textStyle.color(foreground); + + var recipe = BadgeStyler() + .label(textStyle) + .borderRadius( + BorderRadiusGeometryMix.circular( + (0.5 + 0.2 * fontSize) * uiRadiusFactor(context), + ), + ); + if (decorated) { + recipe = recipe.padding( + EdgeInsetsGeometryMix.symmetric( + horizontal: 0.25 * fontSize, + vertical: 0.10 * fontSize, + ), + ); + } + if (fill != null) recipe = recipe.color(fill); + + if (variant == .outline) { + final ringWidth = math.max(1.0, 0.033 * fontSize); + recipe = recipe.containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: uiResolveColor( + context, + highContrast ? UiTokens.accentA7 : UiTokens.accentA8, + ), + spreadRadius: ringWidth, + ), + if (highContrast) + RemixBoxShadowMix( + kind: .inset, + color: uiResolveColor(context, UiTokens.grayA11), + spreadRadius: ringWidth, + ), + ], + ), + ), + ); + } + + return recipe.merge(style); +} + +final class _AmbientCodeForegroundDirective extends Directive { + _AmbientCodeForegroundDirective(TextStyle ambient) + : color = ambient.color, + foreground = ambient.foreground; + + final Color? color; + final Paint? foreground; + + @override + String get key => 'ui_code_ambient_foreground'; + + @override + TextStyle apply(TextStyle style) { + final hasAmbientFallback = _ambientCodeForegroundFallbacks[style] ?? false; + if (!hasAmbientFallback && + (style.color != null || style.foreground != null)) { + return style; + } + if (color == null && foreground == null) return style; + + late final TextStyle result; + if (foreground case final paint?) { + result = style.copyWith(foreground: paint); + } else if (style.foreground != null) { + // Mix concatenates directives when recipes merge. If an earlier fallback + // supplied a Paint, copyWith cannot clear it in favour of a Color. Keep + // the equivalent Paint representation so the later recipe still wins + // without producing an invalid TextStyle(color:, foreground:). + result = style.copyWith(foreground: Paint()..color = color!); + } else { + result = style.copyWith(color: color); + } + + // Expando keeps provenance out of TextStyle's visual and diagnostic + // fields, works with assertions disabled, and does not retain resolved + // styles after Mix finishes applying the directive list. + _ambientCodeForegroundFallbacks[result] = true; + return result; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _AmbientCodeForegroundDirective && + other.color == color && + other.foreground == foreground; + + @override + int get hashCode => Object.hash(color, foreground); +} + +final _ambientCodeForegroundFallbacks = Expando( + 'ui_code_ambient_foreground', +); + +/// Token-backed standalone code text with the Radix Code variants. +/// +/// Code carries no accessibility role: Flutter has no code semantics, and +/// inventing one would misreport the content. +class UiCode extends StatelessWidget { + const UiCode( + this.text, { + super.key, + this.size, + this.variant = UiCodeVariant.soft, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : assert(text != ''); + + const UiCode.solid( + this.text, { + super.key, + this.size, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : variant = UiCodeVariant.solid, + assert(text != ''); + + const UiCode.soft( + this.text, { + super.key, + this.size, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : variant = UiCodeVariant.soft, + assert(text != ''); + + const UiCode.outline( + this.text, { + super.key, + this.size, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : variant = UiCodeVariant.outline, + assert(text != ''); + + const UiCode.ghost( + this.text, { + super.key, + this.size, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : variant = UiCodeVariant.ghost, + assert(text != ''); + + final String text; + final UiTextSize? size; + final UiCodeVariant variant; + final UiTextWeight? weight; + final bool softWrap; + final bool truncate; + final bool accent; + final bool highContrast; + final BadgeStyler style; + + @override + Widget build(BuildContext context) => uiCodeStyle( + context, + size: size, + variant: variant, + weight: weight, + softWrap: softWrap, + truncate: truncate, + accent: accent, + highContrast: highContrast, + style: style, + )(label: text); +} diff --git a/apps/dashboard/lib/ui/components/composer.dart b/apps/dashboard/lib/ui/components/composer.dart new file mode 100644 index 000000000..6f4763fef --- /dev/null +++ b/apps/dashboard/lib/ui/components/composer.dart @@ -0,0 +1,276 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../support/functional_glyph.dart'; + +part 'composer.g.dart'; + +/// Growable prompt input composed from Remix text-area and icon-button controls. +class UiComposer extends StatefulWidget { + const UiComposer({ + super.key, + this.controller, + this.initialValue, + this.focusNode, + this.onChanged, + this.onSubmit, + this.onStop, + this.running = false, + this.enabled = true, + this.canSubmit, + this.clearOnSubmit = true, + this.autofocus = false, + this.hintText = 'Message', + this.semanticLabel = 'Message', + this.minLines = 2, + this.maxLines = 8, + this.leading, + this.trailing, + this.submitIconBuilder, + this.stopIconBuilder, + this.submitLabel = 'Send', + this.stopLabel = 'Stop', + this.surfaceStyle = const CardStyler.create(), + this.fieldStyle = const TextFieldStyler.create(), + this.submitStyle = const IconButtonStyler.create(), + this.stopStyle = const IconButtonStyler.create(), + this.style = const UiComposerStyler.create(), + this.styleSpec, + }) : assert( + controller == null || initialValue == null, + 'initialValue cannot be used with an external controller.', + ); + + final TextEditingController? controller; + final String? initialValue; + final FocusNode? focusNode; + final ValueChanged? onChanged; + final ValueChanged? onSubmit; + final VoidCallback? onStop; + final bool running; + final bool enabled; + final bool? canSubmit; + final bool clearOnSubmit; + final bool autofocus; + final String hintText; + final String semanticLabel; + final int minLines; + final int maxLines; + final Widget? leading; + final Widget? trailing; + final RemixIconButtonIconBuilder? submitIconBuilder; + final RemixIconButtonIconBuilder? stopIconBuilder; + final String submitLabel; + final String stopLabel; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; + final UiComposerStyler style; + final UiComposerSpec? styleSpec; + + @override + State createState() => _UiComposerState(); +} + +class _UiComposerState extends State { + TextEditingController? _ownedController; + FocusNode? _ownedFocusNode; + late TextEditingController _controller; + late String _text; + + FocusNode get _focusNode => + widget.focusNode ?? (_ownedFocusNode ??= FocusNode()); + + bool get _isComposing { + final composing = _controller.value.composing; + return composing.isValid && !composing.isCollapsed; + } + + bool get _canSubmit => + widget.enabled && + !widget.running && + _text.trim().isNotEmpty && + widget.onSubmit != null && + (widget.canSubmit ?? true); + + @override + void initState() { + super.initState(); + _controller = + widget.controller ?? + (_ownedController = TextEditingController(text: widget.initialValue)); + _text = _controller.text; + _controller.addListener(_handleControllerChanged); + } + + void _handleControllerChanged() { + final next = _controller.text; + if (next == _text) return; + setState(() => _text = next); + widget.onChanged?.call(next); + } + + @override + void didUpdateWidget(UiComposer oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + final seed = _controller.text; + _controller.removeListener(_handleControllerChanged); + final oldOwnedController = _ownedController; + _ownedController = null; + _controller = + widget.controller ?? + (_ownedController = TextEditingController(text: seed)); + _text = _controller.text; + _controller.addListener(_handleControllerChanged); + _disposeAfterFrame(oldOwnedController); + } + if (!identical(oldWidget.focusNode, widget.focusNode)) { + final oldOwnedFocusNode = _ownedFocusNode; + _ownedFocusNode = null; + _disposeAfterFrame(oldOwnedFocusNode); + } + } + + /// Releases a superseded owned object once the child has let go of it. + /// + /// The same deferral the transcript uses for its scroll controller: the child + /// RemixTextArea still holds the old controller and focus node until this + /// frame's rebuild detaches them, and detaching touches a disposed object. + void _disposeAfterFrame(ChangeNotifier? superseded) { + if (superseded == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) => superseded.dispose()); + } + + void _submit() { + if (!_canSubmit || _isComposing) return; + final prompt = _text.trim(); + widget.onSubmit?.call(prompt); + if (widget.clearOnSubmit) _controller.clear(); + _focusNode.requestFocus(); + } + + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final isEnter = + event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter; + if (!isEnter || HardwareKeyboard.instance.isShiftPressed || _isComposing) { + return KeyEventResult.ignored; + } + if (!_canSubmit) return KeyEventResult.ignored; + _submit(); + return KeyEventResult.handled; + } + + Widget _defaultSubmitIcon( + BuildContext context, + IconSpec spec, + IconData? icon, + ) => UiFunctionalGlyph(kind: .send, spec: spec); + + Widget _defaultStopIcon( + BuildContext context, + IconSpec spec, + IconData? icon, + ) => UiFunctionalGlyph(kind: .stop, spec: spec); + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + // Keep the field and action in separate accessibility nodes. + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _handleKey, + child: RemixCard( + style: widget.surfaceStyle, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ScrollConfiguration( + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false), + child: RemixTextArea( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + autofocus: widget.autofocus, + hintText: widget.hintText, + semanticLabel: widget.semanticLabel, + minLines: widget.minLines, + maxLines: widget.maxLines, + textInputAction: TextInputAction.newline, + style: widget.fieldStyle, + ), + ), + RowBox( + styleSpec: spec.toolbar, + children: [ + if (widget.leading != null) widget.leading!, + const Spacer(), + if (widget.trailing != null) widget.trailing!, + Semantics( + container: true, + child: RemixIconButton( + key: ValueKey( + widget.running + ? 'ui-composer-stop' + : 'ui-composer-send', + ), + icon: null, + iconBuilder: widget.running + ? (widget.stopIconBuilder ?? _defaultStopIcon) + : (widget.submitIconBuilder ?? _defaultSubmitIcon), + semanticLabel: widget.running + ? widget.stopLabel + : widget.submitLabel, + enabled: widget.running + ? widget.enabled && widget.onStop != null + : _canSubmit, + onPressed: widget.running ? widget.onStop : _submit, + style: widget.running + ? widget.stopStyle + : widget.submitStyle, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _controller.removeListener(_handleControllerChanged); + _ownedController?.dispose(); + _ownedFocusNode?.dispose(); + super.dispose(); + } +} + +@MixableSpec(target: UiComposer.new) +@immutable +final class UiComposerSpec with _$UiComposerSpec { + @override + final StyleSpec toolbar; + + const UiComposerSpec({StyleSpec? toolbar}) + : toolbar = toolbar ?? const StyleSpec(spec: FlexBoxSpec()); +} diff --git a/apps/dashboard/lib/ui/components/composer.g.dart b/apps/dashboard/lib/ui/components/composer.g.dart new file mode 100644 index 000000000..43d43cf35 --- /dev/null +++ b/apps/dashboard/lib/ui/components/composer.g.dart @@ -0,0 +1,232 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'composer.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$UiComposerSpec implements Spec, Diagnosticable { + StyleSpec get toolbar; + + @override + Type get type => UiComposerSpec; + + @override + UiComposerSpec copyWith({StyleSpec? toolbar}) { + return UiComposerSpec(toolbar: toolbar ?? this.toolbar); + } + + @override + UiComposerSpec lerp(UiComposerSpec? other, double t) { + return UiComposerSpec(toolbar: toolbar.lerp(other?.toolbar, t)); + } + + @override + List get props => [toolbar]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is UiComposerSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties.add(DiagnosticsProperty('toolbar', toolbar)); + } +} + +@Deprecated( + 'Rename to `_\$UiComposerSpec` and migrate the class declaration to `class UiComposerSpec with _\$UiComposerSpec`. The `_\$UiComposerSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$UiComposerSpecMethods = _$UiComposerSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class UiComposerStyler extends MixStyler + implements StylerFieldMetadata { + final Prop>? $toolbar; + + const UiComposerStyler.create({ + Prop>? toolbar, + super.variants, + super.modifier, + super.animation, + }) : $toolbar = toolbar; + + UiComposerStyler({ + FlexBoxStyler? toolbar, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + toolbar: Prop.maybeMix(toolbar), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory UiComposerStyler.toolbar(FlexBoxStyler value) => + UiComposerStyler().toolbar(value); + + @override + Set get $stylerFieldNames => const { + 'toolbar', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the toolbar. + UiComposerStyler toolbar(FlexBoxStyler value) { + return merge(UiComposerStyler(toolbar: value)); + } + + /// Sets the animation configuration. + @override + UiComposerStyler animate(AnimationConfig value) { + return merge(UiComposerStyler(animation: value)); + } + + /// Sets the style variants. + @override + UiComposerStyler variants(List> value) { + return merge(UiComposerStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + UiComposerStyler wrap(WidgetModifierConfig value) { + return merge(UiComposerStyler(modifier: value)); + } + + /// Sets the widget modifier. + UiComposerStyler modifier(WidgetModifierConfig value) { + return merge(UiComposerStyler(modifier: value)); + } + + UiComposer call({ + Key? key, + TextEditingController? controller, + String? initialValue, + FocusNode? focusNode, + ValueChanged? onChanged, + ValueChanged? onSubmit, + VoidCallback? onStop, + bool running = false, + bool enabled = true, + bool? canSubmit, + bool clearOnSubmit = true, + bool autofocus = false, + String hintText = 'Message', + String semanticLabel = 'Message', + int minLines = 2, + int maxLines = 8, + Widget? leading, + Widget? trailing, + RemixIconButtonIconBuilder? submitIconBuilder, + RemixIconButtonIconBuilder? stopIconBuilder, + String submitLabel = 'Send', + String stopLabel = 'Stop', + CardStyler surfaceStyle = const CardStyler.create(), + TextFieldStyler fieldStyle = const TextFieldStyler.create(), + IconButtonStyler submitStyle = const IconButtonStyler.create(), + IconButtonStyler stopStyle = const IconButtonStyler.create(), + }) { + return UiComposer( + key: key, + style: this, + controller: controller, + initialValue: initialValue, + focusNode: focusNode, + onChanged: onChanged, + onSubmit: onSubmit, + onStop: onStop, + running: running, + enabled: enabled, + canSubmit: canSubmit, + clearOnSubmit: clearOnSubmit, + autofocus: autofocus, + hintText: hintText, + semanticLabel: semanticLabel, + minLines: minLines, + maxLines: maxLines, + leading: leading, + trailing: trailing, + submitIconBuilder: submitIconBuilder, + stopIconBuilder: stopIconBuilder, + submitLabel: submitLabel, + stopLabel: stopLabel, + surfaceStyle: surfaceStyle, + fieldStyle: fieldStyle, + submitStyle: submitStyle, + stopStyle: stopStyle, + ); + } + + /// Merges with another [UiComposerStyler]. + @override + UiComposerStyler merge(UiComposerStyler? other) { + return UiComposerStyler.create( + toolbar: MixOps.merge($toolbar, other?.$toolbar), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = UiComposerSpec(toolbar: MixOps.resolve(context, $toolbar)); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('toolbar', $toolbar)); + } + + @override + List get props => [$toolbar, $animation, $modifier, $variants]; +} diff --git a/apps/dashboard/lib/ui/components/data_list.dart b/apps/dashboard/lib/ui/components/data_list.dart new file mode 100644 index 000000000..6f1e104b0 --- /dev/null +++ b/apps/dashboard/lib/ui/components/data_list.dart @@ -0,0 +1,47 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'data_list.g.dart'; + +/// Radix Themes DataList size presets. +enum UiDataListSize { size1, size2, size3 } + +/// Ui recipe for [RemixDataList]. +@MixWidget(target: RemixDataList.new) +DataListStyler uiDataListStyle({ + UiDataListSize size = .size2, + bool highContrast = false, + DataListStyler style = const DataListStyler.create(), +}) { + final metrics = _uiDataListMetrics(size); + + return DataListStyler() + .label( + TextStyler() + .style(metrics.text.mix()) + .fontWeight(UiTokens.fontWeightRegular()) + .color(highContrast ? UiTokens.gray12() : UiTokens.grayA11()), + ) + .value( + TextStyler() + .style(metrics.text.mix()) + .fontWeight(UiTokens.fontWeightRegular()) + .color(UiTokens.gray12()), + ) + .rowSpacing(metrics.rowSpacing) + .columnSpacing(metrics.rowSpacing) + .labelValueSpacing(UiTokens.space1()) + .minLabelWidth(UiTokens.dataListLabelMinWidth()) + .merge(style); +} + +({TextStyleToken text, double rowSpacing}) _uiDataListMetrics( + UiDataListSize size, +) => switch (size) { + .size1 => (text: UiTokens.text1, rowSpacing: UiTokens.space3()), + .size2 => (text: UiTokens.text2, rowSpacing: UiTokens.space4()), + .size3 => (text: UiTokens.text3, rowSpacing: UiTokens.dataListRowGap3()), +}; diff --git a/apps/dashboard/lib/ui/components/data_list.g.dart b/apps/dashboard/lib/ui/components/data_list.g.dart new file mode 100644 index 000000000..82745102d --- /dev/null +++ b/apps/dashboard/lib/ui/components/data_list.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_list.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui recipe for [RemixDataList]. +class UiDataList extends StatelessWidget { + const UiDataList({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const DataListStyler.create(), + required this.items, + this.orientation = Axis.horizontal, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final UiDataListSize size; + + final bool highContrast; + + final DataListStyler style; + + final List items; + + final Axis orientation; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixDataList( + key: this.key, + style: uiDataListStyle( + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + items: this.items, + orientation: this.orientation, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/data_table.dart b/apps/dashboard/lib/ui/components/data_table.dart new file mode 100644 index 000000000..29cbee381 --- /dev/null +++ b/apps/dashboard/lib/ui/components/data_table.dart @@ -0,0 +1,168 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'checkbox.dart'; +import 'icon_button.dart'; +import 'select.dart'; + +part 'data_table.g.dart'; + +/// Radix Themes Table size presets. +enum UiDataTableSize { size1, size2, size3 } + +/// Radix Themes Table variants. +enum UiDataTableVariant { surface, ghost } + +/// Resolved Radix `table.css` metrics for one size step. +typedef _UiDataTableMetrics = ({ + double paddingX, + double paddingY, + double minHeight, + double sortIconSize, + Radius radius, + TextStyleToken text, +}); + +/// Ui recipe for [RemixDataTable]. +/// +/// Sizes and variants map `@radix-ui/themes@3.3.0` `table.css` exactly: cell +/// padding, minimum cell height, typography, radius, the `gray-a5` row +/// divider, bold column headers, the surface panel/border, the `gray-a2` +/// header background, and the suppressed divider under a surface table's last +/// row. +/// +/// Sorting, selection, pagination, and row hover have no Radix counterpart — +/// Radix's Table is a passive layout. They are Ui extensions built from +/// existing accent/gray control tokens and are recorded as extensions in the +/// parity manifest. +@MixWidget(target: RemixDataTable.new) +DataTableStyler uiDataTableStyle({ + UiDataTableSize size = .size2, + UiDataTableVariant variant = .ghost, + DataTableStyler style = const DataTableStyler.create(), +}) { + final metrics = _uiDataTableMetrics(size); + final base = DataTableStyler() + .cellText(TextStyler(style: metrics.text.mix()).color(UiTokens.gray12())) + .headerLabel( + TextStyler( + style: metrics.text.mix(), + ).fontWeight(UiTokens.fontWeightBold()).color(UiTokens.gray12()), + ) + .footerLabel( + TextStyler( + style: UiTokens.text1.mix(), + ).fontWeight(UiTokens.fontWeightRegular()).color(UiTokens.gray11()), + ) + .headerCell(_uiDataTableCell(metrics)) + .bodyCell(_uiDataTableCell(metrics)) + // The selection column is a Ui extension with no Radix counterpart. + // It carries no padding of its own, so the composed checkbox's + // interaction target — sized to this cell — spans the whole column and + // the full row height instead of being inset from both. + .selectionCell(BoxStyler().alignment(Alignment.center)) + .headerMinHeight(metrics.minHeight) + .rowMinHeight(metrics.minHeight) + .selectionColumnWidth(UiTokens.space8()) + .sortIconSpacing(UiTokens.space1()) + .sortIcon( + IconStyler(color: UiTokens.gray11(), size: metrics.sortIconSize), + ) + .headerRow(_uiDataTableRowDivider()) + .bodyRow( + _uiDataTableRowDivider() + .color(const Color(0x00000000)) + // Hover and selection are Ui extensions. Both are pure color + // layers, so a row never changes geometry when either applies. + .onHovered(.color(UiTokens.grayA3())) + .onSelected( + .color( + UiTokens.accentA3(), + ).onHovered(.color(UiTokens.accentA4())), + ), + ) + .footer(_uiDataTableFooter()) + .selectionCheckbox(uiCheckboxStyle(size: .size1)) + .pageButton(uiIconButtonStyle(variant: .ghost, size: .size1)) + .pageSizeSelect(uiSelectStyle(variant: .ghost, size: .size1)); + + return (switch (variant) { + .surface => _uiDataTableSurface(base, metrics.radius), + .ghost => base.color(const Color(0x00000000)), + }).merge(style); +} + +_UiDataTableMetrics _uiDataTableMetrics(UiDataTableSize size) => switch (size) { + .size1 => ( + paddingX: UiTokens.space2(), + paddingY: UiTokens.space2(), + minHeight: UiTokens.dataTableRowHeight1(), + sortIconSize: 14.0, + radius: UiTokens.radius3(), + text: UiTokens.text2, + ), + .size2 => ( + paddingX: UiTokens.space3(), + paddingY: UiTokens.space3(), + minHeight: UiTokens.dataTableRowHeight2(), + sortIconSize: 16.0, + radius: UiTokens.radius4(), + text: UiTokens.text2, + ), + .size3 => ( + paddingX: UiTokens.space4(), + paddingY: UiTokens.space3(), + minHeight: UiTokens.space8(), + sortIconSize: 18.0, + radius: UiTokens.radius4(), + text: UiTokens.text3, + ), +}; + +BoxStyler _uiDataTableCell(_UiDataTableMetrics metrics) => BoxStyler() + .padding(.horizontal(metrics.paddingX)) + .padding(.vertical(metrics.paddingY)); + +/// Radix draws the row divider as `inset 0 -1px var(--gray-a5)`, which paints +/// over the cell without reserving layout space. A foreground border is the +/// Flutter equivalent; a regular border would inset the cell content by 1px. +BoxStyler _uiDataTableRowDivider() => BoxStyler().foregroundDecoration( + BoxDecorationMix(border: BoxBorderMix.bottom(_uiDataTableDividerSide())), +); + +/// The `gray-a5` 1px edge shared by the row divider and the footer's top +/// border, so the footer reads as a continuation of the last row's divider. +BorderSideMix _uiDataTableDividerSide() => + BorderSideMix(color: UiTokens.grayA5(), width: 1); + +FlexBoxStyler _uiDataTableFooter() => FlexBoxStyler() + .direction(.horizontal) + .spacing(UiTokens.space2()) + .padding(.horizontal(UiTokens.space4())) + .padding(.vertical(UiTokens.space2())) + .foregroundDecoration( + BoxDecorationMix(border: BoxBorderMix.top(_uiDataTableDividerSide())), + ); + +DataTableStyler _uiDataTableSurface(DataTableStyler base, Radius radius) { + return base + .container( + uiSurfaceFrame( + fillColor: UiTokens.colorPanel(), + borderColor: UiTokens.dataTableBorder(), + borderWidth: UiTokens.borderWidth1(), + radius: radius, + ), + ) + .containerEffects(RemixBoxEffectsMix.backdropBlur(UiTokens.panelBlur())) + .headerRow(.color(UiTokens.grayA2())) + // Radix clears `--table-row-box-shadow` on the surface variant's last + // row so its divider never doubles up with the panel border. + .lastBodyRow( + BoxStyler().foregroundDecoration( + BoxDecorationMix(border: BoxBorderMix.bottom(BorderSideMix.none)), + ), + ); +} diff --git a/apps/dashboard/lib/ui/components/data_table.g.dart b/apps/dashboard/lib/ui/components/data_table.g.dart new file mode 100644 index 000000000..9a710f702 --- /dev/null +++ b/apps/dashboard/lib/ui/components/data_table.g.dart @@ -0,0 +1,196 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_table.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui recipe for [RemixDataTable]. +/// +/// Sizes and variants map `@radix-ui/themes@3.3.0` `table.css` exactly: cell +/// padding, minimum cell height, typography, radius, the `gray-a5` row +/// divider, bold column headers, the surface panel/border, the `gray-a2` +/// header background, and the suppressed divider under a surface table's last +/// row. +/// +/// Sorting, selection, pagination, and row hover have no Radix counterpart — +/// Radix's Table is a passive layout. They are Ui extensions built from +/// existing accent/gray control tokens and are recorded as extensions in the +/// parity manifest. +class UiDataTable extends StatelessWidget { + const UiDataTable({ + super.key, + this.size = .size2, + this.variant = .ghost, + this.style = const DataTableStyler.create(), + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon, + this.sortAscendingIcon, + this.sortDescendingIcon, + this.previousPageIcon, + this.nextPageIcon, + }); + + const UiDataTable.surface({ + super.key, + this.size = .size2, + this.style = const DataTableStyler.create(), + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon, + this.sortAscendingIcon, + this.sortDescendingIcon, + this.previousPageIcon, + this.nextPageIcon, + }) : variant = UiDataTableVariant.surface; + + const UiDataTable.ghost({ + super.key, + this.size = .size2, + this.style = const DataTableStyler.create(), + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon, + this.sortAscendingIcon, + this.sortDescendingIcon, + this.previousPageIcon, + this.nextPageIcon, + }) : variant = UiDataTableVariant.ghost; + + final UiDataTableSize size; + + final UiDataTableVariant variant; + + final DataTableStyler style; + + final List rows; + + final List> columns; + + final String? semanticLabel; + + final RemixDataTableSort? sort; + + final ValueChanged? onSortChanged; + + final Object Function(T row)? rowId; + + final Set selectedRowIds; + + final ValueChanged>? onSelectionChanged; + + final int? totalRows; + + final int pageIndex; + + final int pageSize; + + final List pageSizeOptions; + + final ValueChanged? onPageChanged; + + final ValueChanged? onPageSizeChanged; + + final double minimumWidth; + + final WidgetBuilder? emptyBuilder; + + final RemixDataTableLabels labels; + + final RemixDataTablePageRangeFormatter pageRangeFormatter; + + final IconData? sortableIcon; + + final IconData? sortAscendingIcon; + + final IconData? sortDescendingIcon; + + final IconData? previousPageIcon; + + final IconData? nextPageIcon; + + @override + Widget build(BuildContext context) { + return RemixDataTable( + key: this.key, + style: uiDataTableStyle( + size: this.size, + variant: this.variant, + style: this.style, + ), + rows: this.rows, + columns: this.columns, + semanticLabel: this.semanticLabel, + sort: this.sort, + onSortChanged: this.onSortChanged, + rowId: this.rowId, + selectedRowIds: this.selectedRowIds, + onSelectionChanged: this.onSelectionChanged, + totalRows: this.totalRows, + pageIndex: this.pageIndex, + pageSize: this.pageSize, + pageSizeOptions: this.pageSizeOptions, + onPageChanged: this.onPageChanged, + onPageSizeChanged: this.onPageSizeChanged, + minimumWidth: this.minimumWidth, + emptyBuilder: this.emptyBuilder, + labels: this.labels, + pageRangeFormatter: this.pageRangeFormatter, + sortableIcon: this.sortableIcon, + sortAscendingIcon: this.sortAscendingIcon, + sortDescendingIcon: this.sortDescendingIcon, + previousPageIcon: this.previousPageIcon, + nextPageIcon: this.nextPageIcon, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/dialog.dart b/apps/dashboard/lib/ui/components/dialog.dart new file mode 100644 index 000000000..52c0df7e3 --- /dev/null +++ b/apps/dashboard/lib/ui/components/dialog.dart @@ -0,0 +1,88 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'dialog.g.dart'; + +/// Ui dialog size presets matching Radix Themes 3.3.0. +enum UiDialogSize { size1, size2, size3, size4 } + +/// Ui dialog vertical alignment matching Radix Themes 3.3.0. +enum UiDialogAlign { start, center } + +final _dialogViewportInsets = ContextToken((context) { + final safeArea = MediaQuery.paddingOf(context); + final viewportHeight = MediaQuery.sizeOf(context).height; + final horizontal = UiTokens.space4.resolve(context); + final vertical = UiTokens.space6.resolve(context); + + return EdgeInsets.fromLTRB( + math.max(safeArea.left, horizontal), + math.max(safeArea.top, vertical), + math.max(safeArea.right, horizontal), + math.max(safeArea.bottom, math.max(vertical, viewportHeight * 0.06)), + ); +}); + +/// Ui-themed preset for [RemixDialog]. +/// +/// The generated [UiDialog] defaults to [UiDialogSize.size3], +/// [UiDialogAlign.center], fills up to 600 logical pixels, preserves safe +/// viewport insets, and is modal. +@MixWidget(target: RemixDialog.new) +DialogStyler uiDialogStyle({ + UiDialogSize size = UiDialogSize.size3, + UiDialogAlign align = UiDialogAlign.center, + DialogStyler style = const DialogStyler.create(), +}) { + final radius = switch (size) { + UiDialogSize.size1 || UiDialogSize.size2 => UiTokens.radius4(), + UiDialogSize.size3 || UiDialogSize.size4 => UiTokens.radius5(), + }; + final padding = switch (size) { + UiDialogSize.size1 => UiTokens.space3(), + UiDialogSize.size2 => UiTokens.space4(), + UiDialogSize.size3 => UiTokens.space5(), + UiDialogSize.size4 => UiTokens.space6(), + }; + final alignment = switch (align) { + UiDialogAlign.start => Alignment.topCenter, + UiDialogAlign.center => Alignment.center, + }; + + return DialogStyler() + .wrap( + .modifier( + PaddingModifierMix.create(padding: Prop.token(_dialogViewportInsets)), + ).align(alignment: alignment).orderOfModifiers([ + PaddingModifier, + AlignModifier, + ]), + ) + .title( + .style(UiTokens.text5.mix()) + .fontWeight(UiTokens.fontWeightBold()) + .color(UiTokens.gray12()) + .wrap(.padding(EdgeInsetsMix.fromLTRB(0, 0, 0, UiTokens.space3()))), + ) + .description( + TextStyler(style: UiTokens.text3.mix()).color(UiTokens.gray12()), + ) + .actions( + FlexBoxStyler() + .mainAxisAlignment(.end) + .spacing(UiTokens.space3()) + .margin(.top(UiTokens.space5())), + ) + .width(600) + .padding(.all(padding)) + .borderRadius(.all(radius)) + .color(UiTokens.colorPanel()) + .decoration(BoxDecorationMix.create(boxShadow: UiTokens.shadow6.mix())) + .containerEffects(RemixBoxEffectsMix.backdropBlur(UiTokens.panelBlur())) + .merge(style); +} diff --git a/apps/dashboard/lib/ui/components/dialog.g.dart b/apps/dashboard/lib/ui/components/dialog.g.dart new file mode 100644 index 000000000..2e471b704 --- /dev/null +++ b/apps/dashboard/lib/ui/components/dialog.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'dialog.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixDialog]. +/// +/// The generated [UiDialog] defaults to [UiDialogSize.size3], +/// [UiDialogAlign.center], fills up to 600 logical pixels, preserves safe +/// viewport insets, and is modal. +class UiDialog extends StatelessWidget { + const UiDialog({ + super.key, + this.size = UiDialogSize.size3, + this.align = UiDialogAlign.center, + this.style = const DialogStyler.create(), + this.child, + this.title, + this.description, + this.actions, + this.scrollable = false, + this.modal = true, + this.semanticLabel, + }); + + final UiDialogSize size; + + final UiDialogAlign align; + + final DialogStyler style; + + final Widget? child; + + final String? title; + + final String? description; + + final List? actions; + + final bool scrollable; + + final bool modal; + + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + return RemixDialog( + key: this.key, + style: uiDialogStyle( + size: this.size, + align: this.align, + style: this.style, + ), + child: this.child, + title: this.title, + description: this.description, + actions: this.actions, + scrollable: this.scrollable, + modal: this.modal, + semanticLabel: this.semanticLabel, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/disclosure.dart b/apps/dashboard/lib/ui/components/disclosure.dart new file mode 100644 index 000000000..ee1d77f6d --- /dev/null +++ b/apps/dashboard/lib/ui/components/disclosure.dart @@ -0,0 +1,192 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'disclosure.g.dart'; + +/// Ui disclosure size presets. +enum UiDisclosureSize { size1, size2, size3 } + +/// Ui disclosure color variants. +enum UiDisclosureVariant { surface, soft } + +/// Ui-themed preset for [RemixDisclosure]. +@MixWidget(target: RemixDisclosure.new) +DisclosureStyler uiDisclosureStyle({ + UiDisclosureVariant variant = .surface, + UiDisclosureSize size = .size2, + DisclosureStyler style = const DisclosureStyler.create(), +}) { + return (switch (variant) { + .surface => _uiDisclosureSurfaceStyler(size), + .soft => _uiDisclosureSoftStyler(size), + }).merge(style); +} + +// Panel anatomy follows the mapped Table family (see data_table.dart): +// `container` alone owns radius, frame, fill, and clipping, while trigger and +// content stay flat rectangles that simply get cropped to its rounded shape. +// The frame and divider are foreground borders so edge-to-edge child fills +// cannot partially cover their antialiased edges. +DisclosureStyler _uiDisclosureBaseStyler(UiDisclosureSize size) { + final metrics = _uiDisclosureMetrics(size); + + return DisclosureStyler() + .trigger( + BoxStyler() + .width(.infinity) + .alignment(.centerLeft) + .padding(.all(metrics.padding)) + .wrap( + _uiDisclosureTypography( + style: metrics.triggerText, + color: UiTokens.gray12(), + iconColor: UiTokens.gray11(), + iconSize: metrics.iconSize, + ), + ), + ) + .content( + BoxStyler() + .width(.infinity) + .padding(.all(metrics.padding)) + .wrap( + _uiDisclosureTypography( + style: UiTokens.text2, + color: UiTokens.gray12(), + iconColor: UiTokens.gray11(), + iconSize: metrics.iconSize, + ), + ), + ); +} + +DisclosureStyler _uiDisclosureSurfaceStyler(UiDisclosureSize size) { + final metrics = _uiDisclosureMetrics(size); + return _uiDisclosureBaseStyler(size) + .container( + uiSurfaceFrame( + fillColor: UiTokens.gray2(), + borderColor: UiTokens.gray6(), + borderWidth: UiTokens.borderWidth1(), + radius: metrics.radius, + ), + ) + .trigger(.color(UiTokens.gray1())) + .content( + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.top(_uiDisclosureBorderSide(UiTokens.gray6())), + ), + ), + ) + .onHovered(.trigger(.color(UiTokens.gray2()))) + .onPressed(.trigger(.color(UiTokens.gray3()))) + .onFocusVisible(DisclosureStyler().uiFocusRing()) + .onDisabled(_uiDisclosureDisabledStyler()); +} + +DisclosureStyler _uiDisclosureSoftStyler(UiDisclosureSize size) { + final metrics = _uiDisclosureMetrics(size); + return _uiDisclosureBaseStyler(size) + .container( + uiSurfaceFrame( + fillColor: UiTokens.accent2(), + borderColor: UiTokens.accent6(), + borderWidth: UiTokens.borderWidth1(), + radius: metrics.radius, + ), + ) + .trigger( + BoxStyler() + .color(UiTokens.accent2()) + .wrap( + _uiDisclosureForeground( + color: UiTokens.accent12(), + iconColor: UiTokens.accent11(), + ), + ), + ) + .content( + BoxStyler() + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.top( + _uiDisclosureBorderSide(UiTokens.accent6()), + ), + ), + ) + .wrap( + _uiDisclosureForeground( + color: UiTokens.accent12(), + iconColor: UiTokens.accent11(), + ), + ), + ) + .onHovered(.trigger(.color(UiTokens.accent3()))) + .onPressed(.trigger(.color(UiTokens.accent4()))) + .onFocusVisible(DisclosureStyler().uiFocusRing()) + .onDisabled(_uiDisclosureDisabledStyler()); +} + +DisclosureStyler _uiDisclosureDisabledStyler() { + return DisclosureStyler().trigger( + BoxStyler() + .color(UiTokens.grayA3()) + .wrap( + _uiDisclosureForeground( + color: UiTokens.gray8(), + iconColor: UiTokens.gray8(), + ), + ), + ); +} + +BorderSideMix _uiDisclosureBorderSide(Color color) => + BorderSideMix(color: color, width: UiTokens.borderWidth1()); + +WidgetModifierConfig _uiDisclosureTypography({ + required TextStyleToken style, + required Color color, + required Color iconColor, + required double iconSize, +}) { + return WidgetModifierConfig.defaultTextStyle(style: style.mix()) + .defaultTextStyle(style: TextStyleMix().color(color)) + .merge(WidgetModifierConfig.iconTheme(color: iconColor, size: iconSize)); +} + +WidgetModifierConfig _uiDisclosureForeground({ + required Color color, + required Color iconColor, +}) { + return WidgetModifierConfig.defaultTextStyle( + style: TextStyleMix().color(color), + ).merge(WidgetModifierConfig.iconTheme(color: iconColor)); +} + +({double padding, Radius radius, TextStyleToken triggerText, double iconSize}) +_uiDisclosureMetrics(UiDisclosureSize size) { + return switch (size) { + .size1 => ( + padding: UiTokens.space2(), + radius: UiTokens.radius3(), + triggerText: UiTokens.text2, + iconSize: UiTokens.space4(), + ), + .size2 => ( + padding: UiTokens.space3(), + radius: UiTokens.radius4(), + triggerText: UiTokens.accordionText2, + iconSize: UiTokens.spinnerSize3(), + ), + .size3 => ( + padding: UiTokens.space4(), + radius: UiTokens.radius5(), + triggerText: UiTokens.text3, + iconSize: UiTokens.space5(), + ), + }; +} diff --git a/apps/dashboard/lib/ui/components/disclosure.g.dart b/apps/dashboard/lib/ui/components/disclosure.g.dart new file mode 100644 index 000000000..b8b04fb9b --- /dev/null +++ b/apps/dashboard/lib/ui/components/disclosure.g.dart @@ -0,0 +1,173 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'disclosure.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixDisclosure]. +class UiDisclosure extends StatelessWidget { + const UiDisclosure({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const DisclosureStyler.create(), + required this.trigger, + required this.content, + this.triggerBuilder, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.transitionBuilder, + this.animationStyle = const AnimationStyle( + curve: Curves.ease, + duration: Duration(milliseconds: 200), + reverseDuration: Duration(milliseconds: 200), + ), + }); + + const UiDisclosure.surface({ + super.key, + this.size = .size2, + this.style = const DisclosureStyler.create(), + required this.trigger, + required this.content, + this.triggerBuilder, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.transitionBuilder, + this.animationStyle = const AnimationStyle( + curve: Curves.ease, + duration: Duration(milliseconds: 200), + reverseDuration: Duration(milliseconds: 200), + ), + }) : variant = UiDisclosureVariant.surface; + + const UiDisclosure.soft({ + super.key, + this.size = .size2, + this.style = const DisclosureStyler.create(), + required this.trigger, + required this.content, + this.triggerBuilder, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.transitionBuilder, + this.animationStyle = const AnimationStyle( + curve: Curves.ease, + duration: Duration(milliseconds: 200), + reverseDuration: Duration(milliseconds: 200), + ), + }) : variant = UiDisclosureVariant.soft; + + final UiDisclosureVariant variant; + + final UiDisclosureSize size; + + final DisclosureStyler style; + + final Widget trigger; + + final Widget content; + + final ValueWidgetBuilder? triggerBuilder; + + final bool? expanded; + + final bool defaultExpanded; + + final ValueChanged? onExpandedChanged; + + final bool enabled; + + final MouseCursor mouseCursor; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final ValueChanged? onFocusChange; + + final ValueChanged? onHoverChange; + + final ValueChanged? onPressChange; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + final NakedDisclosureTransitionBuilder? transitionBuilder; + + final AnimationStyle animationStyle; + + @override + Widget build(BuildContext context) { + return RemixDisclosure( + key: this.key, + style: uiDisclosureStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + trigger: this.trigger, + content: this.content, + triggerBuilder: this.triggerBuilder, + expanded: this.expanded, + defaultExpanded: this.defaultExpanded, + onExpandedChanged: this.onExpandedChanged, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + onFocusChange: this.onFocusChange, + onHoverChange: this.onHoverChange, + onPressChange: this.onPressChange, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + transitionBuilder: this.transitionBuilder, + animationStyle: this.animationStyle, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/divider.dart b/apps/dashboard/lib/ui/components/divider.dart new file mode 100644 index 000000000..3603e090b --- /dev/null +++ b/apps/dashboard/lib/ui/components/divider.dart @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'divider.g.dart'; + +/// Ui divider length presets: 16, 32, 64, or the available axis extent. +enum UiDividerSize { size1, size2, size3, size4 } + +/// Ui-themed preset for [RemixDivider]. +@MixWidget(target: RemixDivider.new) +DividerStyler uiDividerStyle({ + UiDividerSize size = .size1, + Axis orientation = Axis.horizontal, + DividerStyler style = const DividerStyler.create(), +}) { + return DividerStyler() + .color(UiTokens.gray6()) + .merge(_uiDividerSizeStyler(size, orientation)) + .merge(style); +} + +DividerStyler _uiDividerSizeStyler(UiDividerSize size, Axis orientation) { + final length = switch (size) { + .size1 => UiTokens.space4(), + .size2 => UiTokens.space6(), + .size3 => UiTokens.space9(), + .size4 => null, + }; + if (orientation == Axis.horizontal) { + final style = DividerStyler().height(UiTokens.borderWidth1()); + return length == null + ? style.wrap( + WidgetModifierConfig.fractionallySizedBox(widthFactor: 1).align(), + ) + : style.width(length); + } + final style = DividerStyler().width(UiTokens.borderWidth1()); + return length == null + ? style.wrap( + WidgetModifierConfig.fractionallySizedBox(heightFactor: 1).align(), + ) + : style.height(length); +} diff --git a/apps/dashboard/lib/ui/components/divider.g.dart b/apps/dashboard/lib/ui/components/divider.g.dart new file mode 100644 index 000000000..cef0a0717 --- /dev/null +++ b/apps/dashboard/lib/ui/components/divider.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'divider.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixDivider]. +class UiDivider extends StatelessWidget { + const UiDivider({ + super.key, + this.size = .size1, + this.orientation = Axis.horizontal, + this.style = const DividerStyler.create(), + }); + + final UiDividerSize size; + + final Axis orientation; + + final DividerStyler style; + + @override + Widget build(BuildContext context) { + return RemixDivider( + key: this.key, + style: uiDividerStyle( + size: this.size, + orientation: this.orientation, + style: this.style, + ), + ); + } +} diff --git a/apps/dashboard/lib/ui/components/execution.dart b/apps/dashboard/lib/ui/components/execution.dart new file mode 100644 index 000000000..e35789e60 --- /dev/null +++ b/apps/dashboard/lib/ui/components/execution.dart @@ -0,0 +1,339 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'execution.g.dart'; + +typedef UiExecutionStatusLabelBuilder = + String Function(UiExecutionStatus status); +typedef UiExecutionStatusBuilder = + Widget Function(BuildContext context, UiExecutionStatus status); +typedef UiExecutionIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Toggleable tool execution output with lifecycle-driven open requests. +class UiExecution extends StatefulWidget { + const UiExecution({ + super.key, + required this.tool, + required this.title, + required this.child, + this.status = UiExecutionStatus.running, + this.meta, + this.icon, + this.onCopy, + this.onRetry, + this.copyIconBuilder, + this.retryIconBuilder, + this.indicatorBuilder, + this.statusBuilder, + this.statusLabelBuilder, + this.copyLabel = 'Copy output', + this.retryLabel = 'Retry execution', + this.outputLabel = 'Tool output', + this.showActions = true, + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.semanticLabel = 'Tool execution', + this.surfaceStyle = const CardStyler.create(), + this.disclosureStyle = const DisclosureStyler.create(), + this.copyStyle = const IconButtonStyler.create(), + this.retryStyle = const IconButtonStyler.create(), + this.style = const UiExecutionStyler.create(), + this.styleSpec, + }); + + final String tool; + final String title; + final Widget child; + final UiExecutionStatus status; + final String? meta; + final Widget? icon; + final VoidCallback? onCopy; + final VoidCallback? onRetry; + final RemixIconButtonIconBuilder? copyIconBuilder; + final RemixIconButtonIconBuilder? retryIconBuilder; + final UiExecutionIndicatorBuilder? indicatorBuilder; + final UiExecutionStatusBuilder? statusBuilder; + final UiExecutionStatusLabelBuilder? statusLabelBuilder; + final String copyLabel; + final String retryLabel; + final String outputLabel; + final bool showActions; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final String semanticLabel; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; + final UiExecutionStyler style; + final UiExecutionSpec? styleSpec; + + @override + State createState() => _UiExecutionState(); +} + +class _UiExecutionState extends State { + late final UiDisclosureEngine _disclosure; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = UiDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget(UiExecution oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + if (!oldWidget.status.isWorking && widget.status.isWorking) { + _request(true); + } else if (oldWidget.status.isWorking && + !widget.status.isWorking && + widget.collapseOnComplete) { + _request(false); + } + } + + void _request(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String get _statusLabel => + widget.statusLabelBuilder?.call(widget.status) ?? + switch (widget.status) { + UiExecutionStatus.running => 'Running', + UiExecutionStatus.success => 'Completed', + UiExecutionStatus.error => 'Failed', + UiExecutionStatus.cancelled => 'Cancelled', + }; + + StyleSpec _statusContainer(UiExecutionSpec spec) => + switch (widget.status) { + UiExecutionStatus.running => spec.runningStatus, + UiExecutionStatus.success => spec.successStatus, + UiExecutionStatus.error => spec.errorStatus, + UiExecutionStatus.cancelled => spec.cancelledStatus, + }; + + UiFunctionalGlyphKind get _statusGlyph => switch (widget.status) { + UiExecutionStatus.running => .loading, + UiExecutionStatus.success => .completedCircle, + UiExecutionStatus.error => .errorCircle, + UiExecutionStatus.cancelled => .cancelledCircle, + }; + + Widget _toolIcon(UiExecutionSpec spec) { + final icon = widget.icon; + if (icon != null) return icon; + return StyleSpecBuilder( + styleSpec: spec.toolIcon, + builder: (context, iconSpec) => + UiFunctionalGlyph(kind: .tool, spec: iconSpec), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + value: '${widget.tool}, $_statusLabel', + child: RemixCard( + style: widget.surfaceStyle, + child: RemixDisclosure( + expanded: _expanded, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + UiDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: RowBox( + styleSpec: spec.header, + children: [ + _toolIcon(spec), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StyledText(widget.title, styleSpec: spec.title), + StyledText(widget.tool, styleSpec: spec.tool), + ], + ), + ), + if (widget.meta != null) + StyledText(widget.meta!, styleSpec: spec.meta), + Box( + styleSpec: _statusContainer(spec), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.statusBuilder?.call(context, widget.status) ?? + StyleSpecBuilder( + styleSpec: spec.statusIcon, + builder: (context, iconSpec) => UiFunctionalGlyph( + kind: _statusGlyph, + spec: iconSpec, + ), + ), + StyledText(_statusLabel, styleSpec: spec.status), + ], + ), + ), + ], + ), + content: Box( + styleSpec: spec.output, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Deliberately not an UiTranscript. That installs + // Arrow/Page/Home/End shortcuts and its own Semantics + // container, and an execution card is normally nested inside + // a host transcript: the inner list shrink-wraps to a zero + // scroll extent but its action still consumes those intents, + // so focus landing here stopped the outer transcript from + // scrolling, and its `busy` value announced the status a + // second time. This is the primitive plan and activity use. + Semantics( + label: widget.outputLabel, + child: UiLiveEdgeScrollView( + followOutput: widget.status.isWorking, + child: widget.child, + ), + ), + if (widget.showActions && widget.status.isSettled) + RowBox( + styleSpec: spec.actions, + children: [ + if (widget.onCopy != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.copyIconBuilder ?? + (context, iconSpec, icon) => UiFunctionalGlyph( + kind: .copy, + spec: iconSpec, + ), + semanticLabel: widget.copyLabel, + onPressed: widget.onCopy, + style: widget.copyStyle, + ), + if (widget.onRetry != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.retryIconBuilder ?? + (context, iconSpec, icon) => UiFunctionalGlyph( + kind: .retry, + spec: iconSpec, + ), + semanticLabel: widget.retryLabel, + onPressed: widget.onRetry, + style: widget.retryStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: UiExecution.new) +@immutable +final class UiExecutionSpec with _$UiExecutionSpec { + @override + final StyleSpec header; + @override + final StyleSpec output; + @override + final StyleSpec actions; + @override + final StyleSpec tool; + @override + final StyleSpec title; + @override + final StyleSpec meta; + @override + final StyleSpec status; + @override + final StyleSpec toolIcon; + @override + final StyleSpec statusIcon; + @override + final StyleSpec indicator; + @override + final StyleSpec runningStatus; + @override + final StyleSpec successStatus; + @override + final StyleSpec errorStatus; + @override + final StyleSpec cancelledStatus; + + const UiExecutionSpec({ + StyleSpec? header, + StyleSpec? output, + StyleSpec? actions, + StyleSpec? tool, + StyleSpec? title, + StyleSpec? meta, + StyleSpec? status, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? runningStatus, + StyleSpec? successStatus, + StyleSpec? errorStatus, + StyleSpec? cancelledStatus, + }) : header = header ?? const StyleSpec(spec: FlexBoxSpec()), + output = output ?? const StyleSpec(spec: BoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + tool = tool ?? const StyleSpec(spec: TextSpec()), + title = title ?? const StyleSpec(spec: TextSpec()), + meta = meta ?? const StyleSpec(spec: TextSpec()), + status = status ?? const StyleSpec(spec: TextSpec()), + toolIcon = toolIcon ?? const StyleSpec(spec: IconSpec()), + statusIcon = statusIcon ?? const StyleSpec(spec: IconSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + runningStatus = runningStatus ?? const StyleSpec(spec: BoxSpec()), + successStatus = successStatus ?? const StyleSpec(spec: BoxSpec()), + errorStatus = errorStatus ?? const StyleSpec(spec: BoxSpec()), + cancelledStatus = cancelledStatus ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/apps/dashboard/lib/ui/components/execution.g.dart b/apps/dashboard/lib/ui/components/execution.g.dart new file mode 100644 index 000000000..36fcf431c --- /dev/null +++ b/apps/dashboard/lib/ui/components/execution.g.dart @@ -0,0 +1,550 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'execution.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$UiExecutionSpec implements Spec, Diagnosticable { + StyleSpec get header; + StyleSpec get output; + StyleSpec get actions; + StyleSpec get tool; + StyleSpec get title; + StyleSpec get meta; + StyleSpec get status; + StyleSpec get toolIcon; + StyleSpec get statusIcon; + StyleSpec get indicator; + StyleSpec get runningStatus; + StyleSpec get successStatus; + StyleSpec get errorStatus; + StyleSpec get cancelledStatus; + + @override + Type get type => UiExecutionSpec; + + @override + UiExecutionSpec copyWith({ + StyleSpec? header, + StyleSpec? output, + StyleSpec? actions, + StyleSpec? tool, + StyleSpec? title, + StyleSpec? meta, + StyleSpec? status, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? runningStatus, + StyleSpec? successStatus, + StyleSpec? errorStatus, + StyleSpec? cancelledStatus, + }) { + return UiExecutionSpec( + header: header ?? this.header, + output: output ?? this.output, + actions: actions ?? this.actions, + tool: tool ?? this.tool, + title: title ?? this.title, + meta: meta ?? this.meta, + status: status ?? this.status, + toolIcon: toolIcon ?? this.toolIcon, + statusIcon: statusIcon ?? this.statusIcon, + indicator: indicator ?? this.indicator, + runningStatus: runningStatus ?? this.runningStatus, + successStatus: successStatus ?? this.successStatus, + errorStatus: errorStatus ?? this.errorStatus, + cancelledStatus: cancelledStatus ?? this.cancelledStatus, + ); + } + + @override + UiExecutionSpec lerp(UiExecutionSpec? other, double t) { + return UiExecutionSpec( + header: header.lerp(other?.header, t), + output: output.lerp(other?.output, t), + actions: actions.lerp(other?.actions, t), + tool: tool.lerp(other?.tool, t), + title: title.lerp(other?.title, t), + meta: meta.lerp(other?.meta, t), + status: status.lerp(other?.status, t), + toolIcon: toolIcon.lerp(other?.toolIcon, t), + statusIcon: statusIcon.lerp(other?.statusIcon, t), + indicator: indicator.lerp(other?.indicator, t), + runningStatus: runningStatus.lerp(other?.runningStatus, t), + successStatus: successStatus.lerp(other?.successStatus, t), + errorStatus: errorStatus.lerp(other?.errorStatus, t), + cancelledStatus: cancelledStatus.lerp(other?.cancelledStatus, t), + ); + } + + @override + List get props => [ + header, + output, + actions, + tool, + title, + meta, + status, + toolIcon, + statusIcon, + indicator, + runningStatus, + successStatus, + errorStatus, + cancelledStatus, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is UiExecutionSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('header', header)) + ..add(DiagnosticsProperty('output', output)) + ..add(DiagnosticsProperty('actions', actions)) + ..add(DiagnosticsProperty('tool', tool)) + ..add(DiagnosticsProperty('title', title)) + ..add(DiagnosticsProperty('meta', meta)) + ..add(DiagnosticsProperty('status', status)) + ..add(DiagnosticsProperty('toolIcon', toolIcon)) + ..add(DiagnosticsProperty('statusIcon', statusIcon)) + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('runningStatus', runningStatus)) + ..add(DiagnosticsProperty('successStatus', successStatus)) + ..add(DiagnosticsProperty('errorStatus', errorStatus)) + ..add(DiagnosticsProperty('cancelledStatus', cancelledStatus)); + } +} + +@Deprecated( + 'Rename to `_\$UiExecutionSpec` and migrate the class declaration to `class UiExecutionSpec with _\$UiExecutionSpec`. The `_\$UiExecutionSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$UiExecutionSpecMethods = _$UiExecutionSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class UiExecutionStyler extends MixStyler + implements StylerFieldMetadata { + final Prop>? $header; + final Prop>? $output; + final Prop>? $actions; + final Prop>? $tool; + final Prop>? $title; + final Prop>? $meta; + final Prop>? $status; + final Prop>? $toolIcon; + final Prop>? $statusIcon; + final Prop>? $indicator; + final Prop>? $runningStatus; + final Prop>? $successStatus; + final Prop>? $errorStatus; + final Prop>? $cancelledStatus; + + const UiExecutionStyler.create({ + Prop>? header, + Prop>? output, + Prop>? actions, + Prop>? tool, + Prop>? title, + Prop>? meta, + Prop>? status, + Prop>? toolIcon, + Prop>? statusIcon, + Prop>? indicator, + Prop>? runningStatus, + Prop>? successStatus, + Prop>? errorStatus, + Prop>? cancelledStatus, + super.variants, + super.modifier, + super.animation, + }) : $header = header, + $output = output, + $actions = actions, + $tool = tool, + $title = title, + $meta = meta, + $status = status, + $toolIcon = toolIcon, + $statusIcon = statusIcon, + $indicator = indicator, + $runningStatus = runningStatus, + $successStatus = successStatus, + $errorStatus = errorStatus, + $cancelledStatus = cancelledStatus; + + UiExecutionStyler({ + FlexBoxStyler? header, + BoxStyler? output, + FlexBoxStyler? actions, + TextStyler? tool, + TextStyler? title, + TextStyler? meta, + TextStyler? status, + IconStyler? toolIcon, + IconStyler? statusIcon, + IconStyler? indicator, + BoxStyler? runningStatus, + BoxStyler? successStatus, + BoxStyler? errorStatus, + BoxStyler? cancelledStatus, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + header: Prop.maybeMix(header), + output: Prop.maybeMix(output), + actions: Prop.maybeMix(actions), + tool: Prop.maybeMix(tool), + title: Prop.maybeMix(title), + meta: Prop.maybeMix(meta), + status: Prop.maybeMix(status), + toolIcon: Prop.maybeMix(toolIcon), + statusIcon: Prop.maybeMix(statusIcon), + indicator: Prop.maybeMix(indicator), + runningStatus: Prop.maybeMix(runningStatus), + successStatus: Prop.maybeMix(successStatus), + errorStatus: Prop.maybeMix(errorStatus), + cancelledStatus: Prop.maybeMix(cancelledStatus), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory UiExecutionStyler.header(FlexBoxStyler value) => + UiExecutionStyler().header(value); + factory UiExecutionStyler.output(BoxStyler value) => + UiExecutionStyler().output(value); + factory UiExecutionStyler.actions(FlexBoxStyler value) => + UiExecutionStyler().actions(value); + factory UiExecutionStyler.tool(TextStyler value) => + UiExecutionStyler().tool(value); + factory UiExecutionStyler.title(TextStyler value) => + UiExecutionStyler().title(value); + factory UiExecutionStyler.meta(TextStyler value) => + UiExecutionStyler().meta(value); + factory UiExecutionStyler.status(TextStyler value) => + UiExecutionStyler().status(value); + factory UiExecutionStyler.toolIcon(IconStyler value) => + UiExecutionStyler().toolIcon(value); + factory UiExecutionStyler.statusIcon(IconStyler value) => + UiExecutionStyler().statusIcon(value); + factory UiExecutionStyler.indicator(IconStyler value) => + UiExecutionStyler().indicator(value); + factory UiExecutionStyler.runningStatus(BoxStyler value) => + UiExecutionStyler().runningStatus(value); + factory UiExecutionStyler.successStatus(BoxStyler value) => + UiExecutionStyler().successStatus(value); + factory UiExecutionStyler.errorStatus(BoxStyler value) => + UiExecutionStyler().errorStatus(value); + factory UiExecutionStyler.cancelledStatus(BoxStyler value) => + UiExecutionStyler().cancelledStatus(value); + + @override + Set get $stylerFieldNames => const { + 'header', + 'output', + 'actions', + 'tool', + 'title', + 'meta', + 'status', + 'toolIcon', + 'statusIcon', + 'indicator', + 'runningStatus', + 'successStatus', + 'errorStatus', + 'cancelledStatus', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the header. + UiExecutionStyler header(FlexBoxStyler value) { + return merge(UiExecutionStyler(header: value)); + } + + /// Sets the output. + UiExecutionStyler output(BoxStyler value) { + return merge(UiExecutionStyler(output: value)); + } + + /// Sets the actions. + UiExecutionStyler actions(FlexBoxStyler value) { + return merge(UiExecutionStyler(actions: value)); + } + + /// Sets the tool. + UiExecutionStyler tool(TextStyler value) { + return merge(UiExecutionStyler(tool: value)); + } + + /// Sets the title. + UiExecutionStyler title(TextStyler value) { + return merge(UiExecutionStyler(title: value)); + } + + /// Sets the meta. + UiExecutionStyler meta(TextStyler value) { + return merge(UiExecutionStyler(meta: value)); + } + + /// Sets the status. + UiExecutionStyler status(TextStyler value) { + return merge(UiExecutionStyler(status: value)); + } + + /// Sets the toolIcon. + UiExecutionStyler toolIcon(IconStyler value) { + return merge(UiExecutionStyler(toolIcon: value)); + } + + /// Sets the statusIcon. + UiExecutionStyler statusIcon(IconStyler value) { + return merge(UiExecutionStyler(statusIcon: value)); + } + + /// Sets the indicator. + UiExecutionStyler indicator(IconStyler value) { + return merge(UiExecutionStyler(indicator: value)); + } + + /// Sets the runningStatus. + UiExecutionStyler runningStatus(BoxStyler value) { + return merge(UiExecutionStyler(runningStatus: value)); + } + + /// Sets the successStatus. + UiExecutionStyler successStatus(BoxStyler value) { + return merge(UiExecutionStyler(successStatus: value)); + } + + /// Sets the errorStatus. + UiExecutionStyler errorStatus(BoxStyler value) { + return merge(UiExecutionStyler(errorStatus: value)); + } + + /// Sets the cancelledStatus. + UiExecutionStyler cancelledStatus(BoxStyler value) { + return merge(UiExecutionStyler(cancelledStatus: value)); + } + + /// Sets the animation configuration. + @override + UiExecutionStyler animate(AnimationConfig value) { + return merge(UiExecutionStyler(animation: value)); + } + + /// Sets the style variants. + @override + UiExecutionStyler variants(List> value) { + return merge(UiExecutionStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + UiExecutionStyler wrap(WidgetModifierConfig value) { + return merge(UiExecutionStyler(modifier: value)); + } + + /// Sets the widget modifier. + UiExecutionStyler modifier(WidgetModifierConfig value) { + return merge(UiExecutionStyler(modifier: value)); + } + + UiExecution call({ + Key? key, + required String tool, + required String title, + required Widget child, + UiExecutionStatus status = UiExecutionStatus.running, + String? meta, + Widget? icon, + VoidCallback? onCopy, + VoidCallback? onRetry, + RemixIconButtonIconBuilder? copyIconBuilder, + RemixIconButtonIconBuilder? retryIconBuilder, + UiExecutionIndicatorBuilder? indicatorBuilder, + UiExecutionStatusBuilder? statusBuilder, + UiExecutionStatusLabelBuilder? statusLabelBuilder, + String copyLabel = 'Copy output', + String retryLabel = 'Retry execution', + String outputLabel = 'Tool output', + bool showActions = true, + bool collapseOnComplete = true, + bool? expanded, + bool defaultExpanded = true, + ValueChanged? onExpandedChanged, + String semanticLabel = 'Tool execution', + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), + }) { + return UiExecution( + key: key, + style: this, + tool: tool, + title: title, + child: child, + status: status, + meta: meta, + icon: icon, + onCopy: onCopy, + onRetry: onRetry, + copyIconBuilder: copyIconBuilder, + retryIconBuilder: retryIconBuilder, + indicatorBuilder: indicatorBuilder, + statusBuilder: statusBuilder, + statusLabelBuilder: statusLabelBuilder, + copyLabel: copyLabel, + retryLabel: retryLabel, + outputLabel: outputLabel, + showActions: showActions, + collapseOnComplete: collapseOnComplete, + expanded: expanded, + defaultExpanded: defaultExpanded, + onExpandedChanged: onExpandedChanged, + semanticLabel: semanticLabel, + surfaceStyle: surfaceStyle, + disclosureStyle: disclosureStyle, + copyStyle: copyStyle, + retryStyle: retryStyle, + ); + } + + /// Merges with another [UiExecutionStyler]. + @override + UiExecutionStyler merge(UiExecutionStyler? other) { + return UiExecutionStyler.create( + header: MixOps.merge($header, other?.$header), + output: MixOps.merge($output, other?.$output), + actions: MixOps.merge($actions, other?.$actions), + tool: MixOps.merge($tool, other?.$tool), + title: MixOps.merge($title, other?.$title), + meta: MixOps.merge($meta, other?.$meta), + status: MixOps.merge($status, other?.$status), + toolIcon: MixOps.merge($toolIcon, other?.$toolIcon), + statusIcon: MixOps.merge($statusIcon, other?.$statusIcon), + indicator: MixOps.merge($indicator, other?.$indicator), + runningStatus: MixOps.merge($runningStatus, other?.$runningStatus), + successStatus: MixOps.merge($successStatus, other?.$successStatus), + errorStatus: MixOps.merge($errorStatus, other?.$errorStatus), + cancelledStatus: MixOps.merge($cancelledStatus, other?.$cancelledStatus), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = UiExecutionSpec( + header: MixOps.resolve(context, $header), + output: MixOps.resolve(context, $output), + actions: MixOps.resolve(context, $actions), + tool: MixOps.resolve(context, $tool), + title: MixOps.resolve(context, $title), + meta: MixOps.resolve(context, $meta), + status: MixOps.resolve(context, $status), + toolIcon: MixOps.resolve(context, $toolIcon), + statusIcon: MixOps.resolve(context, $statusIcon), + indicator: MixOps.resolve(context, $indicator), + runningStatus: MixOps.resolve(context, $runningStatus), + successStatus: MixOps.resolve(context, $successStatus), + errorStatus: MixOps.resolve(context, $errorStatus), + cancelledStatus: MixOps.resolve(context, $cancelledStatus), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('header', $header)) + ..add(DiagnosticsProperty('output', $output)) + ..add(DiagnosticsProperty('actions', $actions)) + ..add(DiagnosticsProperty('tool', $tool)) + ..add(DiagnosticsProperty('title', $title)) + ..add(DiagnosticsProperty('meta', $meta)) + ..add(DiagnosticsProperty('status', $status)) + ..add(DiagnosticsProperty('toolIcon', $toolIcon)) + ..add(DiagnosticsProperty('statusIcon', $statusIcon)) + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('runningStatus', $runningStatus)) + ..add(DiagnosticsProperty('successStatus', $successStatus)) + ..add(DiagnosticsProperty('errorStatus', $errorStatus)) + ..add(DiagnosticsProperty('cancelledStatus', $cancelledStatus)); + } + + @override + List get props => [ + $header, + $output, + $actions, + $tool, + $title, + $meta, + $status, + $toolIcon, + $statusIcon, + $indicator, + $runningStatus, + $successStatus, + $errorStatus, + $cancelledStatus, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/dashboard/lib/ui/components/heading.dart b/apps/dashboard/lib/ui/components/heading.dart new file mode 100644 index 000000000..02e289975 --- /dev/null +++ b/apps/dashboard/lib/ui/components/heading.dart @@ -0,0 +1,116 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +/// Ui-themed heading style on the Radix nine-step scale. +/// +/// Radix's `--heading-font-size-adjust` is `1`, so headings use the raw token +/// size; only the line box differs from body text. Each ratio below is the +/// pinned Radix heading line height over its font size, so both scale together +/// and the ratio stays constant across theme scaling. +/// +/// This is a plain recipe rather than a `@MixWidget`: a generated widget only +/// renders the styler, and [UiHeading] must additionally publish a native +/// heading node that generation cannot supply. +TextStyler uiHeadingStyle({ + UiTextSize size = .size6, + UiTextWeight weight = .bold, + TextAlign? align, + bool softWrap = true, + bool truncate = false, + bool accent = false, + bool highContrast = false, + TextStyler style = const TextStyler.create(), +}) { + final lineHeight = switch (size) { + .size1 => 16.0 / 12.0, + .size2 => 18.0 / 14.0, + .size3 => 22.0 / 16.0, + .size4 => 24.0 / 18.0, + .size5 => 26.0 / 20.0, + .size6 => 30.0 / 24.0, + .size7 => 36.0 / 28.0, + .size8 => 40.0 / 35.0, + .size9 => 1.0, + }; + + var recipe = TextStyler( + style: uiTextSizeToken(size).mix(), + ).height(lineHeight).fontWeight(uiTextWeightToken(weight)()); + // Neutral headings pin `gray12` from the tokens rather than inheriting the + // ambient foreground, matching uiTextStyle's token-default contract. + recipe = accent + ? uiAccentForeground(recipe, highContrast: highContrast) + : recipe.color(UiTokens.gray12()); + recipe = recipe.inherit(false); + + return uiApplyTextFlow( + recipe, + align: align, + softWrap: softWrap, + truncate: truncate, + ).merge(style); +} + +/// Token-backed visual heading with an independent native heading level. +/// +/// [headingLevel] drives the accessibility level only; changing it never +/// changes the visual [size], matching Radix. +class UiHeading extends StatelessWidget { + const UiHeading( + this.text, { + super.key, + this.headingLevel = 1, + this.size = UiTextSize.size6, + this.weight = UiTextWeight.bold, + this.align, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const TextStyler.create(), + }) : assert(text != ''), + assert(headingLevel >= 1 && headingLevel <= 6), + assert(semanticLabel == null || semanticLabel != ''); + + final String text; + final int headingLevel; + final UiTextSize size; + final UiTextWeight weight; + final TextAlign? align; + final bool softWrap; + final bool truncate; + final bool accent; + final bool highContrast; + final String? semanticLabel; + final bool excludeSemantics; + final TextStyler style; + + @override + Widget build(BuildContext context) { + final content = uiHeadingStyle( + size: size, + weight: weight, + align: align, + softWrap: softWrap, + truncate: truncate, + accent: accent, + highContrast: highContrast, + style: style, + )(text); + + if (excludeSemantics) return ExcludeSemantics(child: content); + + return Semantics( + header: true, + headingLevel: headingLevel, + label: semanticLabel ?? text, + excludeSemantics: true, + child: content, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/icon_button.dart b/apps/dashboard/lib/ui/components/icon_button.dart new file mode 100644 index 000000000..bb5bed21f --- /dev/null +++ b/apps/dashboard/lib/ui/components/icon_button.dart @@ -0,0 +1,130 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import 'base_button.dart'; +import '../theme/theme.dart'; + +part 'icon_button.g.dart'; + +/// Radix Themes IconButton size presets. +enum UiIconButtonSize { size1, size2, size3, size4 } + +/// Radix Themes IconButton variants. +enum UiIconButtonVariant { classic, solid, soft, surface, outline, ghost } + +/// Ui-themed IconButton with the Radix size, variant, and override contract. +@MixWidget(target: RemixIconButton.new) +IconButtonStyler uiIconButtonStyle({ + UiIconButtonVariant variant = .solid, + UiIconButtonSize size = .size2, + bool highContrast = false, + IconButtonStyler style = const IconButtonStyler.create(), +}) { + final base = _uiIconButtonBaseStyler(variant, _uiBaseButtonSize(size)); + final stateStyles = uiBaseButtonStateStyles( + variant: _uiBaseButtonVariant(variant), + highContrast: highContrast, + ); + + return _applyUiIconButtonStateStyles( + base, + stateStyles, + pressedPaddingTop: variant == .classic ? (size == .size1 ? 1 : 2) : null, + ).merge(style); +} + +IconButtonStyler _uiIconButtonBaseStyler( + UiIconButtonVariant variant, + UiBaseButtonSize size, +) { + final metrics = uiBaseButtonMetrics(size); + var style = IconButtonStyler( + icon: .size(uiBaseButtonIconSize(size)), + spinner: .size(metrics.spinnerSize) + .opacity(0.65) + .leafRadius(UiTokens.radius1()) + .duration(const Duration(milliseconds: 800)), + ).borderRadius(.all(metrics.radius)); + + if (variant == .ghost) { + final ghost = uiIconButtonGhostMetrics(size); + style = style.padding(.all(ghost.padding)).margin(.all(ghost.margin)); + } else { + style = style + .container(.alignment(.center)) + .width(metrics.height) + .height(metrics.height); + } + return style; +} + +UiBaseButtonVariant _uiBaseButtonVariant(UiIconButtonVariant variant) => + switch (variant) { + .classic => .classic, + .solid => .solid, + .soft => .soft, + .surface => .surface, + .outline => .outline, + .ghost => .ghost, + }; + +UiBaseButtonSize _uiBaseButtonSize(UiIconButtonSize size) => switch (size) { + .size1 => .size1, + .size2 => .size2, + .size3 => .size3, + .size4 => .size4, +}; + +IconButtonStyler _applyUiIconButtonStateStyles( + IconButtonStyler base, + UiBaseButtonStateStyles stateStyles, { + required double? pressedPaddingTop, +}) { + var pressed = _applyUiIconButtonState( + IconButtonStyler(), + stateStyles.pressed, + ); + if (pressedPaddingTop != null) { + pressed = pressed.padding(.top(pressedPaddingTop)); + } + + return _applyUiIconButtonState(base, stateStyles.idle) + .onHovered( + _applyUiIconButtonState(IconButtonStyler(), stateStyles.hovered), + ) + .onPressed(pressed) + .onDisabled( + _applyUiIconButtonState(IconButtonStyler(), stateStyles.disabled), + ) + .onFocusVisible( + _applyUiIconButtonState(IconButtonStyler(), stateStyles.focusVisible), + ) + .onDisabled( + _applyUiIconButtonState(IconButtonStyler(), stateStyles.disabledFocus), + ); +} + +IconButtonStyler _applyUiIconButtonState( + IconButtonStyler style, + UiBaseButtonStateStyle state, +) { + var result = style; + final foreground = state.foreground; + if (foreground != null) { + result = result.icon(.color(foreground)).spinner(.color(foreground)); + } + if (state.background != null) { + result = result.color(state.background!); + } + if (state.effects != null) { + result = result.containerEffects(state.effects!); + } + if (state.spinnerOpacity != null) { + result = result.spinner(.opacity(state.spinnerOpacity!)); + } + if (state.modifier != null) { + result = result.wrap(state.modifier!); + } + return result; +} diff --git a/apps/dashboard/lib/ui/components/icon_button.g.dart b/apps/dashboard/lib/ui/components/icon_button.g.dart new file mode 100644 index 000000000..62fe3926a --- /dev/null +++ b/apps/dashboard/lib/ui/components/icon_button.g.dart @@ -0,0 +1,221 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'icon_button.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed IconButton with the Radix size, variant, and override contract. +class UiIconButton extends StatelessWidget { + const UiIconButton({ + super.key, + this.variant = .solid, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + const UiIconButton.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiIconButtonVariant.classic; + + const UiIconButton.solid({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiIconButtonVariant.solid; + + const UiIconButton.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiIconButtonVariant.soft; + + const UiIconButton.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiIconButtonVariant.surface; + + const UiIconButton.outline({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiIconButtonVariant.outline; + + const UiIconButton.ghost({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiIconButtonVariant.ghost; + + final UiIconButtonVariant variant; + + final UiIconButtonSize size; + + final bool highContrast; + + final IconButtonStyler style; + + final IconData? icon; + + final String semanticLabel; + + final RemixIconButtonIconBuilder? iconBuilder; + + final RemixIconButtonLoadingBuilder? loadingBuilder; + + final bool loading; + + final bool enabled; + + final bool enableFeedback; + + final VoidCallback? onPressed; + + final VoidCallback? onLongPress; + + final FocusNode? focusNode; + + final bool autofocus; + + final String? semanticHint; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixIconButton( + key: this.key, + style: uiIconButtonStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + icon: this.icon, + semanticLabel: this.semanticLabel, + iconBuilder: this.iconBuilder, + loadingBuilder: this.loadingBuilder, + loading: this.loading, + enabled: this.enabled, + enableFeedback: this.enableFeedback, + onPressed: this.onPressed, + onLongPress: this.onLongPress, + focusNode: this.focusNode, + autofocus: this.autofocus, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/kbd.dart b/apps/dashboard/lib/ui/components/kbd.dart new file mode 100644 index 000000000..449151671 --- /dev/null +++ b/apps/dashboard/lib/ui/components/kbd.dart @@ -0,0 +1,207 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +/// Radix Themes Kbd variants. +enum UiKbdVariant { classic, soft } + +/// Ui-themed keyboard key. +/// +/// Like Code, the geometry is em-relative to the resolved font size, so this +/// recipe takes a [context]. Radix uses two different type-scale factors: an +/// explicit size multiplies its token by `0.8`, while an omitted one keeps +/// upstream's unsized `0.75em` — anchored to the root `text3` token rather +/// than the ambient `DefaultTextStyle`, so a host text run cannot resize the +/// key cap. +/// The resolved token supplies the font family and fallback families; Kbd +/// retains its own weight, spacing, and line box. +BadgeStyler uiKbdStyle( + BuildContext context, { + UiTextSize? size, + UiKbdVariant variant = .classic, + BadgeStyler style = const BadgeStyler.create(), +}) { + final base = uiResolveTextToken(context, size ?? UiTextSize.size3); + final fontSize = base.fontSize! * (size == null ? 0.75 : 0.8); + // Upstream `--letter-spacing-N` is em-relative, so an explicit size resolves + // it against Kbd's own `0.8em` rather than the token's own font size. The + // unsized path keeps the token's letter spacing unscaled, matching the + // resolved value upstream's `0.75em` run would carry. + final letterSpacing = (base.letterSpacing ?? 0) * (size == null ? 1 : 0.8); + + // Kbd pins its own weight and line box regardless of the surrounding style, + // so it stays a key cap rather than following surrounding copy. + final textStyle = TextStyler() + .style( + TextStyleMix( + fontFamily: base.fontFamily, + fontFamilyFallback: base.fontFamilyFallback, + ), + ) + .fontSize(fontSize) + .fontWeight(UiTokens.fontWeightRegular()) + .height(1.7) + .letterSpacing(letterSpacing) + .wordSpacing(-0.1 * fontSize) + .textAlign(TextAlign.center) + .softWrap(false) + .maxLines(1) + .color(UiTokens.gray12()) + .inherit(false); + + var recipe = BadgeStyler() + .label(textStyle) + .minWidth(1.75 * fontSize) + .padding( + EdgeInsetsGeometryMix.only( + left: 0.5 * fontSize, + right: 0.5 * fontSize, + bottom: 0.05 * fontSize, + ), + ) + .borderRadius( + BorderRadiusGeometryMix.circular( + 0.35 * fontSize * uiRadiusFactor(context), + ), + ) + .color(switch (variant) { + .classic => uiResolveColor(context, UiTokens.gray1), + .soft => uiResolveColor(context, UiTokens.grayA3), + }); + + if (variant == .classic) { + recipe = recipe.containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadows: _uiKbdShadows(context, fontSize)), + ), + ); + } + + return recipe.merge(style); +} + +/// The pinned six-layer classic key-cap stack, in upstream paint order. +/// +/// Radix's `-0.03em` visual top nudge is deliberately skipped; a transform +/// wrapper for a sub-pixel baseline tweak is recorded as a measured visual +/// approximation instead. +List _uiKbdShadows(BuildContext context, double em) { + final isDark = UiTheme.of(context).isDark; + + return [ + RemixBoxShadowMix( + kind: .inset, + color: uiResolveColor( + context, + isDark ? UiTokens.grayA3 : UiTokens.grayA2, + ), + offset: Offset(0, -0.05 * em), + blurRadius: 0.5 * em, + ), + RemixBoxShadowMix( + kind: .inset, + color: uiResolveColor( + context, + isDark ? UiTokens.grayA11 : UiTokens.whiteA12, + ), + offset: Offset(0, 0.05 * em), + ), + RemixBoxShadowMix( + kind: .inset, + color: uiResolveColor(context, UiTokens.grayA2), + offset: Offset(0, 0.25 * em), + blurRadius: 0.5 * em, + ), + RemixBoxShadowMix( + kind: .inset, + color: uiResolveColor( + context, + isDark ? UiTokens.blackA11 : UiTokens.grayA6, + ), + offset: Offset(0, (isDark ? -0.1 : -0.05) * em), + ), + RemixBoxShadowMix( + color: uiResolveColor( + context, + isDark ? UiTokens.grayA7 : UiTokens.grayA5, + ), + spreadRadius: (isDark ? 0.075 : 0.05) * em, + ), + RemixBoxShadowMix( + color: uiResolveColor( + context, + isDark ? UiTokens.blackA12 : UiTokens.grayA7, + ), + offset: Offset(0, 0.08 * em), + blurRadius: 0.17 * em, + ), + ]; +} + +/// Token-backed representation of one keyboard key or shortcut. +/// +/// Publishes a single native `keyboardKey` node and no tap action; Kbd is inert +/// upstream, so the hover/pressed selectors that apply only when it is nested +/// in an actionable element are deliberately absent. +class UiKbd extends StatelessWidget { + const UiKbd( + this.text, { + super.key, + this.size, + this.variant = UiKbdVariant.classic, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const BadgeStyler.create(), + }) : assert(text != ''), + assert(semanticLabel == null || semanticLabel != ''); + + const UiKbd.classic( + this.text, { + super.key, + this.size, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const BadgeStyler.create(), + }) : variant = UiKbdVariant.classic, + assert(text != ''), + assert(semanticLabel == null || semanticLabel != ''); + + const UiKbd.soft( + this.text, { + super.key, + this.size, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const BadgeStyler.create(), + }) : variant = UiKbdVariant.soft, + assert(text != ''), + assert(semanticLabel == null || semanticLabel != ''); + + final String text; + final UiTextSize? size; + final UiKbdVariant variant; + final String? semanticLabel; + final bool excludeSemantics; + final BadgeStyler style; + + @override + Widget build(BuildContext context) { + final content = uiKbdStyle( + context, + size: size, + variant: variant, + style: style, + )(label: text); + + if (excludeSemantics) return ExcludeSemantics(child: content); + + return Semantics( + keyboardKey: true, + label: semanticLabel ?? text, + excludeSemantics: true, + child: content, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/link.dart b/apps/dashboard/lib/ui/components/link.dart new file mode 100644 index 000000000..0c53c75e2 --- /dev/null +++ b/apps/dashboard/lib/ui/components/link.dart @@ -0,0 +1,233 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +/// Underline visibility for [UiLink]. +enum UiLinkUnderline { auto, always, hover, none } + +/// Ui-themed link style. +/// +/// Takes a [context] because the focus outline's radius is em-relative to the +/// resolved font size. +/// +/// [actionable] gates every state-dependent rule, matching upstream's +/// `:where(:any-link, button)`. A non-actionable link carries no hover or +/// focus-visible variant at all, so it stays plain accent text no matter what +/// widget states are resolved around it. +LinkStyler uiLinkStyle( + BuildContext context, { + UiTextSize? size, + UiTextWeight? weight, + UiLinkUnderline underline = .auto, + bool softWrap = true, + bool truncate = false, + bool highContrast = false, + required bool actionable, + LinkStyler style = const LinkStyler.create(), +}) { + LinkStyler styleFor({bool hovered = false, bool focused = false}) => + _uiLinkStateStyle( + context, + size: size, + weight: weight, + underline: underline, + softWrap: softWrap, + truncate: truncate, + highContrast: highContrast, + actionable: actionable, + hovered: hovered, + focused: focused, + ); + + if (!actionable) return styleFor().merge(style); + + // The focus-visible snapshot already drops the underline via `focused`; the + // explicit `none` also clears any decoration inherited through the merge. + final focusVisible = styleFor( + focused: true, + ).label(.decoration(TextDecoration.none)); + + return styleFor() + .onHovered(styleFor(hovered: true)) + .onFocusVisible(focusVisible) + .merge(style); +} + +/// Resolves one point in the link's state space. +/// +/// Separate from [uiLinkStyle] because the public recipe returns a style +/// carrying Mix variants, and building those variants needs the flat snapshots +/// they are built from. +LinkStyler _uiLinkStateStyle( + BuildContext context, { + required UiTextSize? size, + required UiTextWeight? weight, + required UiLinkUnderline underline, + required bool softWrap, + required bool truncate, + required bool highContrast, + required bool actionable, + required bool hovered, + required bool focused, +}) { + var textStyle = uiAccentForeground(TextStyler(), highContrast: highContrast); + // An omitted size anchors to the root `text3` token rather than the ambient + // `DefaultTextStyle`, so a host text run cannot change the link's metrics or + // its em-relative underline geometry. + textStyle = textStyle.style(uiTextSizeToken(size ?? UiTextSize.size3).mix()); + if (weight != null) { + textStyle = textStyle.fontWeight(uiTextWeightToken(weight)()); + } + textStyle = textStyle.inherit(false); + + final effectiveText = uiResolveTextToken(context, size ?? UiTextSize.size3); + final fontSize = effectiveText.fontSize!; + + // Every upstream underline rule is gated behind `:where(:any-link, button)`, + // so a link with no callback stays plain accent-coloured text. A focus-visible + // outline replaces the underline rather than stacking both. + final underlined = + actionable && + !focused && + switch (underline) { + .always => true, + .hover => hovered, + .auto => highContrast || hovered, + .none => false, + }; + if (underlined) { + // Radix declares the decoration colour twice for this selector and the + // later rule wins: + // text-decoration-color: color-mix(in oklab, var(--accent-aN), var(--gray-a6)) + // Using the accent alpha alone leaves the underline noticeably fainter, so + // blend it. Color.lerp is an sRGB approximation of the oklab mix. + final accentStep = underline == UiLinkUnderline.auto && highContrast + ? UiTokens.accentA6 + : UiTokens.accentA5; + final decorationColor = Color.lerp( + uiResolveColor(context, accentStep), + uiResolveColor(context, UiTokens.grayA6), + 0.5, + )!; + textStyle = textStyle + .decoration(TextDecoration.underline) + .decorationStyle(TextDecorationStyle.solid) + .decorationColor(decorationColor) + // Upstream is `min(2px, max(1px, 0.05em))`. Flutter reads + // decorationThickness as a multiple of the font's own underline + // thickness rather than a length, so the pinned 1–2 range lands as a + // 1×–2× stroke instead of exact pixels; the em breakpoints still fall + // where Radix puts them. + .decorationThickness(math.min(2, math.max(1, 0.05 * fontSize))); + } + textStyle = uiApplyTextFlow( + textStyle, + softWrap: softWrap, + truncate: truncate, + ); + + var style = LinkStyler() + .label(textStyle) + .borderRadius( + BorderRadiusGeometryMix.circular( + 0.07 * fontSize * uiRadiusFactor(context), + ), + ); + if (focused) { + style = style.containerEffects( + uiFocusOutline(uiResolveColor(context, UiTokens.focus8), offset: 2), + ); + } + + return style; +} + +/// Token-backed text that becomes an accessible link only when actionable. +/// +/// A null [onPressed] disables the link just as [enabled] `false` does: accent +/// text with no focus stop, link role, or activation, and never underlined. +/// Reach for `UiText(accent: true)` when the text was never meant to +/// navigate. +/// +/// `linkUrl` is assistive metadata only and is never launched; navigation stays +/// the caller's responsibility in [onPressed]. +/// +/// An actionable link activates on pointer and Enter. Space belongs to the +/// Button role and is deliberately left unclaimed. +class UiLink extends StatelessWidget { + const UiLink( + this.text, { + super.key, + this.size, + this.weight, + this.underline = UiLinkUnderline.auto, + this.softWrap = true, + this.truncate = false, + this.highContrast = false, + this.onPressed, + this.enabled = true, + this.linkUrl, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.style = const LinkStyler.create(), + }) : assert(text != ''), + assert(semanticLabel == null || semanticLabel != ''), + assert(semanticHint == null || semanticHint != ''), + assert(linkUrl == null || onPressed != null); + + final String text; + final UiTextSize? size; + final UiTextWeight? weight; + final UiLinkUnderline underline; + final bool softWrap; + final bool truncate; + final bool highContrast; + final VoidCallback? onPressed; + final bool enabled; + final Uri? linkUrl; + final FocusNode? focusNode; + final bool autofocus; + final bool enableFeedback; + final MouseCursor mouseCursor; + final String? semanticLabel; + final String? semanticHint; + final bool excludeSemantics; + final LinkStyler style; + + @override + Widget build(BuildContext context) { + return RemixLink( + label: text, + onPressed: onPressed, + enabled: enabled, + linkUrl: linkUrl, + focusNode: focusNode, + autofocus: autofocus, + enableFeedback: enableFeedback, + mouseCursor: mouseCursor, + semanticLabel: semanticLabel, + semanticHint: semanticHint, + excludeSemantics: excludeSemantics, + style: uiLinkStyle( + context, + size: size, + weight: weight, + underline: underline, + softWrap: softWrap, + truncate: truncate, + highContrast: highContrast, + actionable: enabled && onPressed != null, + style: style, + ), + ); + } +} diff --git a/apps/dashboard/lib/ui/components/menu.dart b/apps/dashboard/lib/ui/components/menu.dart new file mode 100644 index 000000000..8edb0ccb9 --- /dev/null +++ b/apps/dashboard/lib/ui/components/menu.dart @@ -0,0 +1,215 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'menu.g.dart'; + +/// Radix Themes menu content sizes. +enum UiMenuSize { size1, size2 } + +/// Radix Themes menu content variants. +enum UiMenuVariant { solid, soft } + +/// Ui menu content with Radix-owned size, variant, and contrast behavior. +@MixWidget(target: RemixMenu.new) +MenuStyler uiMenuStyle({ + UiMenuVariant variant = .solid, + UiMenuSize size = .size2, + bool highContrast = false, + MenuStyler style = const MenuStyler.create(), +}) { + final metrics = _uiMenuMetrics(size); + final base = MenuStyler() + .trigger(_uiMenuTriggerStyler(metrics)) + .overlay( + FlexBoxStyler() + .padding(.all(metrics.contentPadding)) + .borderRadius(.all(metrics.contentRadius)) + // Radix pins menus to the solid panel with no backdrop blur, + // even when the theme panel background is translucent. + .color(UiTokens.colorPanelSolid()) + .decoration( + BoxDecorationMix.create(boxShadow: UiTokens.shadow5.mix()), + ) + .clipBehavior(Clip.antiAlias), + ) + .item(_uiMenuItemStyler(variant, metrics, highContrast: highContrast)) + .submenuItem( + _uiMenuSubmenuItemStyler(variant, metrics, highContrast: highContrast), + ) + .divider(_uiMenuDividerStyler(metrics)); + + return base.merge(style); +} + +/// Ui item recipe for per-item style overrides. +MenuItemStyler uiMenuItemStyle({ + UiMenuVariant variant = .solid, + UiMenuSize size = .size2, + bool highContrast = false, +}) => _uiMenuItemStyler( + variant, + _uiMenuMetrics(size), + highContrast: highContrast, +); + +/// Radix has no menu-owned trigger; this mirrors the base Radix button +/// content treatment (gap, text token, icon) without button chrome. +MenuTriggerStyler _uiMenuTriggerStyler(_UiMenuMetrics metrics) => + MenuTriggerStyler() + .spacing(metrics.triggerGap) + .label(.style(metrics.text.mix()).color(UiTokens.gray12())) + .icon(.color(UiTokens.gray12()).size(metrics.contentIconSize)); + +MenuItemStyler _uiMenuItemStyler( + UiMenuVariant variant, + _UiMenuMetrics metrics, { + required bool highContrast, +}) { + final base = MenuItemStyler() + .direction(.horizontal) + .spacing(UiTokens.space2()) + .height(metrics.itemHeight) + .padding(.horizontal(metrics.leadingInset)) + .borderRadius(.all(metrics.itemRadius)) + .label(.style(metrics.text.mix()).color(UiTokens.gray12())) + // Radix pins only indicator/subtrigger icons (8/10px); content icons + // follow the repo-wide text-matched sizes used by tabs and toggles. + .leadingIcon(.color(UiTokens.gray12()).size(metrics.contentIconSize)) + .trailingIcon(.color(UiTokens.grayA11()).size(metrics.contentIconSize)) + .indicator(.color(UiTokens.gray12()).size(metrics.indicatorSize)); + final highlighted = _uiMenuHighlightedItemStyler( + variant, + highContrast: highContrast, + ); + final disabled = MenuItemStyler() + .color(const Color(0x00000000)) + .label(.color(UiTokens.grayA8())) + .leadingIcon(.color(UiTokens.grayA8())) + .trailingIcon(.color(UiTokens.grayA8())) + .indicator(.color(UiTokens.grayA8())); + + // Naked's focused item is Radix's roving `data-highlighted` item, not a + // CSS focus ring, so this intentionally follows raw focus. + return base + .onHovered(highlighted) + .onFocused(highlighted) + .onPressed(highlighted) + .onDisabled(disabled); +} + +MenuItemStyler _uiMenuHighlightedItemStyler( + UiMenuVariant variant, { + required bool highContrast, +}) { + final solidForeground = highContrast + ? UiTokens.accent1() + : UiTokens.accentContrast(); + + return switch (variant) { + .solid => + MenuItemStyler() + .color(highContrast ? UiTokens.accent12() : UiTokens.accent9()) + .label(.color(solidForeground)) + .leadingIcon(.color(solidForeground)) + .trailingIcon(.color(solidForeground)) + .indicator(.color(solidForeground)), + .soft => + MenuItemStyler() + .color(UiTokens.accentA4()) + .trailingIcon(.color(UiTokens.gray12())), + }; +} + +MenuItemStyler _uiMenuSubmenuItemStyler( + UiMenuVariant variant, + _UiMenuMetrics metrics, { + required bool highContrast, +}) { + final highlighted = _uiMenuHighlightedItemStyler( + variant, + highContrast: highContrast, + ); + final submenuOpen = MenuItemStyler() + .color(switch (variant) { + .solid => UiTokens.grayA3(), + .soft => UiTokens.accentA3(), + }) + .onHovered(highlighted) + // Roving `data-highlighted` state, not a focus-visible ring. + .onFocused(highlighted) + .onPressed(highlighted); + + // The chevron keeps the exact Radix subtrigger icon size even though + // content trailing icons are text-matched. + return MenuItemStyler() + .trailingIcon(.color(UiTokens.gray12()).size(metrics.indicatorSize)) + .onSelected(submenuOpen); +} + +DividerStyler _uiMenuDividerStyler(_UiMenuMetrics metrics) => DividerStyler() + .height(1) + .margin( + .only( + left: metrics.leadingInset, + right: metrics.trailingInset, + top: UiTokens.space2(), + bottom: UiTokens.space2(), + ), + ) + .color(UiTokens.grayA6()); + +class _UiMenuMetrics { + const _UiMenuMetrics({ + required this.contentPadding, + required this.contentRadius, + required this.itemHeight, + required this.itemRadius, + required this.leadingInset, + required this.trailingInset, + required this.indicatorSize, + required this.contentIconSize, + required this.triggerGap, + required this.text, + }); + + final double contentPadding; + final Radius contentRadius; + final double itemHeight; + final Radius itemRadius; + final double leadingInset; + final double trailingInset; + final double indicatorSize; + final double contentIconSize; + final double triggerGap; + final TextStyleToken text; +} + +_UiMenuMetrics _uiMenuMetrics(UiMenuSize size) => switch (size) { + .size1 => _UiMenuMetrics( + contentPadding: UiTokens.space1(), + contentRadius: UiTokens.radius3(), + itemHeight: UiTokens.space5(), + itemRadius: UiTokens.radius1(), + leadingInset: UiTokens.space2(), + trailingInset: UiTokens.space2(), + indicatorSize: UiTokens.selectIndicatorSize1(), + contentIconSize: UiTokens.space3(), + triggerGap: UiTokens.space1(), + text: UiTokens.text1, + ), + .size2 => _UiMenuMetrics( + contentPadding: UiTokens.space2(), + contentRadius: UiTokens.radius4(), + itemHeight: UiTokens.space6(), + itemRadius: UiTokens.radius2(), + leadingInset: UiTokens.space3(), + trailingInset: UiTokens.space3(), + indicatorSize: UiTokens.selectIndicatorSize2(), + contentIconSize: UiTokens.space4(), + triggerGap: UiTokens.space2(), + text: UiTokens.text2, + ), +}; diff --git a/apps/dashboard/lib/ui/components/menu.g.dart b/apps/dashboard/lib/ui/components/menu.g.dart new file mode 100644 index 000000000..d2c2f4b94 --- /dev/null +++ b/apps/dashboard/lib/ui/components/menu.g.dart @@ -0,0 +1,149 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'menu.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui menu content with Radix-owned size, variant, and contrast behavior. +class UiMenu extends StatelessWidget { + const UiMenu({ + super.key, + this.variant = .solid, + this.size = .size2, + this.highContrast = false, + this.style = const MenuStyler.create(), + required this.trigger, + required this.items, + this.controller, + this.onSelected, + this.onOpen, + this.onClose, + this.onCanceled, + this.onOpenRequested, + this.onCloseRequested, + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.closeOnClickOutside = true, + this.triggerFocusNode, + this.positioning = const OverlayPositionConfig(), + this.semanticLabel, + this.excludeSemantics = false, + }); + + const UiMenu.solid({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const MenuStyler.create(), + required this.trigger, + required this.items, + this.controller, + this.onSelected, + this.onOpen, + this.onClose, + this.onCanceled, + this.onOpenRequested, + this.onCloseRequested, + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.closeOnClickOutside = true, + this.triggerFocusNode, + this.positioning = const OverlayPositionConfig(), + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = UiMenuVariant.solid; + + const UiMenu.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const MenuStyler.create(), + required this.trigger, + required this.items, + this.controller, + this.onSelected, + this.onOpen, + this.onClose, + this.onCanceled, + this.onOpenRequested, + this.onCloseRequested, + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.closeOnClickOutside = true, + this.triggerFocusNode, + this.positioning = const OverlayPositionConfig(), + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = UiMenuVariant.soft; + + final UiMenuVariant variant; + + final UiMenuSize size; + + final bool highContrast; + + final MenuStyler style; + + final RemixMenuTrigger trigger; + + final List> items; + + final MenuController? controller; + + final ValueChanged? onSelected; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final VoidCallback? onCanceled; + + final RawMenuAnchorOpenRequestedCallback? onOpenRequested; + + final RawMenuAnchorCloseRequestedCallback? onCloseRequested; + + final bool consumeOutsideTaps; + + final bool useRootOverlay; + + final bool closeOnClickOutside; + + final FocusNode? triggerFocusNode; + + final OverlayPositionConfig positioning; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixMenu( + key: this.key, + style: uiMenuStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + trigger: this.trigger, + items: this.items, + controller: this.controller, + onSelected: this.onSelected, + onOpen: this.onOpen, + onClose: this.onClose, + onCanceled: this.onCanceled, + onOpenRequested: this.onOpenRequested, + onCloseRequested: this.onCloseRequested, + consumeOutsideTaps: this.consumeOutsideTaps, + useRootOverlay: this.useRootOverlay, + closeOnClickOutside: this.closeOnClickOutside, + triggerFocusNode: this.triggerFocusNode, + positioning: this.positioning, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/message.dart b/apps/dashboard/lib/ui/components/message.dart new file mode 100644 index 000000000..6f4facdb6 --- /dev/null +++ b/apps/dashboard/lib/ui/components/message.dart @@ -0,0 +1,382 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; + +part 'message.g.dart'; + +enum UiMessageAlign { start, end } + +/// Groups chronological message rows without imposing visual chrome. +class UiMessageGroup extends StatelessWidget { + const UiMessageGroup({super.key, required this.children, this.spacing = 0}); + + final List children; + final double spacing; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + spacing: spacing, + children: children, + ); +} + +/// Sender-aware message row. Message bodies are never clamped automatically. +class UiMessage extends StatelessWidget { + const UiMessage({ + super.key, + required this.role, + required this.child, + this.align, + this.avatar, + this.showAvatar = false, + this.placeholderAvatar = false, + this.maxWidth, + this.header, + this.footer, + this.semanticLabel, + this.surfaceStyle = const CardStyler.create(), + this.style = const UiMessageStyler.create(), + this.styleSpec, + }); + + final UiRole role; + final Widget child; + final UiMessageAlign? align; + final Widget? avatar; + final bool showAvatar; + final bool placeholderAvatar; + final double? maxWidth; + final Widget? header; + final Widget? footer; + final String? semanticLabel; + final CardStyler surfaceStyle; + final UiMessageStyler style; + final UiMessageSpec? styleSpec; + + bool get _alignEnd => + (align ?? + (role == UiRole.user ? UiMessageAlign.end : UiMessageAlign.start)) == + UiMessageAlign.end; + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: style, + styleSpec: styleSpec, + builder: (context, spec) { + final body = RemixCard( + style: surfaceStyle, + child: Box(styleSpec: spec.body, child: child), + ); + final cap = maxWidth ?? spec.maxWidth; + final constrained = cap == null + ? body + : ConstrainedBox( + constraints: BoxConstraints(maxWidth: cap), + child: body, + ); + final stack = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _alignEnd + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (header != null) Box(styleSpec: spec.header, child: header), + constrained, + if (footer != null) Box(styleSpec: spec.footer, child: footer), + ], + ); + final avatarSlot = _avatarSlot(spec); + final row = RowBox( + styleSpec: spec.row, + children: [ + if (!_alignEnd && avatarSlot != null) avatarSlot, + Expanded( + child: Align( + alignment: _alignEnd + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: stack, + ), + ), + if (_alignEnd && avatarSlot != null) avatarSlot, + ], + ); + return Semantics( + container: true, + explicitChildNodes: true, + label: + semanticLabel ?? + (role == UiRole.user ? 'User message' : 'Assistant message'), + child: row, + ); + }, + ); + } + + Widget? _avatarSlot(UiMessageSpec spec) { + if (placeholderAvatar) return Box(styleSpec: spec.avatar); + if (!showAvatar || avatar == null) return null; + return Box(styleSpec: spec.avatar, child: avatar); + } +} + +/// Explicit, opt-in clipping for noninteractive message copy. +/// +/// Do not place buttons, links, or other interactive descendants in [child]. +/// While collapsed, the whole child remains readable to assistive technology +/// but is removed from pointer input, focus, and traversal. +class UiMessageCollapsible extends StatefulWidget { + const UiMessageCollapsible({ + super.key, + required this.child, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.showMoreLabel = 'Show more', + this.showLessLabel = 'Show less', + this.toggleStyle = const ButtonStyler.create(), + this.style = const UiMessageCollapsibleStyler.create(), + this.styleSpec, + }); + + final Widget child; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final String showMoreLabel; + final String showLessLabel; + final ButtonStyler toggleStyle; + final UiMessageCollapsibleStyler style; + final UiMessageCollapsibleSpec? styleSpec; + + @override + State createState() => _UiMessageCollapsibleState(); +} + +class _UiMessageCollapsibleState extends State { + late final UiDisclosureEngine _disclosure; + bool _overflows = false; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = UiDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget(UiMessageCollapsible oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + } + + void _toggle() { + final next = !_expanded; + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + void _handleOverflow(bool value) { + if (value == _overflows) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && value != _overflows) setState(() => _overflows = value); + }); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) { + final height = spec.collapsedHeight; + final collapsed = !_expanded && height != null; + Widget content = _OverflowClip( + maxHeight: height, + clip: collapsed, + onOverflowChanged: _handleOverflow, + child: Box(styleSpec: spec.clipped, child: widget.child), + ); + if (collapsed) { + content = IgnorePointer( + child: Focus( + canRequestFocus: false, + skipTraversal: true, + descendantsAreFocusable: false, + descendantsAreTraversable: false, + child: content, + ), + ); + } + return Box( + styleSpec: spec.container, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + if (_overflows) + RemixButton( + label: _expanded + ? widget.showLessLabel + : widget.showMoreLabel, + semanticLabel: _expanded + ? widget.showLessLabel + : widget.showMoreLabel, + onPressed: _toggle, + style: widget.toggleStyle, + ), + ], + ), + ); + }, + ); + } +} + +class _OverflowClip extends SingleChildRenderObjectWidget { + const _OverflowClip({ + required this.maxHeight, + required this.clip, + required this.onOverflowChanged, + required super.child, + }); + + final double? maxHeight; + final bool clip; + final ValueChanged onOverflowChanged; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderOverflowClip(maxHeight, clip, onOverflowChanged); + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderOverflowClip renderObject, + ) { + renderObject + ..maxHeight = maxHeight + ..clip = clip + ..onOverflowChanged = onOverflowChanged; + } +} + +class _RenderOverflowClip extends RenderProxyBox { + _RenderOverflowClip(this._maxHeight, this._clip, this.onOverflowChanged); + + double? _maxHeight; + bool _clip; + ValueChanged onOverflowChanged; + bool _reportedOverflow = false; + + set maxHeight(double? value) { + if (value == _maxHeight) return; + _maxHeight = value; + markNeedsLayout(); + } + + set clip(bool value) { + if (value == _clip) return; + _clip = value; + markNeedsLayout(); + } + + @override + void performLayout() { + final current = child; + if (current == null) { + size = constraints.smallest; + return; + } + current.layout( + constraints.copyWith(minHeight: 0, maxHeight: double.infinity), + parentUsesSize: true, + ); + final limit = _maxHeight; + final overflow = limit != null && current.size.height > limit; + size = constraints.constrain( + Size(current.size.width, _clip && overflow ? limit : current.size.height), + ); + if (overflow != _reportedOverflow) { + _reportedOverflow = overflow; + onOverflowChanged(overflow); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) return; + if (!_clip) { + super.paint(context, offset); + return; + } + // pushClipRect applies the paint offset to this local rectangle. + context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + super.paint, + ); + } +} + +@MixableSpec(target: UiMessage.new) +@immutable +final class UiMessageSpec with _$UiMessageSpec { + @override + final double? maxWidth; + @override + final StyleSpec row; + @override + final StyleSpec avatar; + @override + final StyleSpec header; + @override + final StyleSpec body; + @override + final StyleSpec footer; + + const UiMessageSpec({ + this.maxWidth, + StyleSpec? row, + StyleSpec? avatar, + StyleSpec? header, + StyleSpec? body, + StyleSpec? footer, + }) : row = row ?? const StyleSpec(spec: FlexBoxSpec()), + avatar = avatar ?? const StyleSpec(spec: BoxSpec()), + header = header ?? const StyleSpec(spec: BoxSpec()), + body = body ?? const StyleSpec(spec: BoxSpec()), + footer = footer ?? const StyleSpec(spec: BoxSpec()); +} + +@MixableSpec(target: UiMessageCollapsible.new) +@immutable +final class UiMessageCollapsibleSpec with _$UiMessageCollapsibleSpec { + @override + final double? collapsedHeight; + @override + final StyleSpec container; + @override + final StyleSpec clipped; + + const UiMessageCollapsibleSpec({ + this.collapsedHeight, + StyleSpec? container, + StyleSpec? clipped, + }) : container = container ?? const StyleSpec(spec: BoxSpec()), + clipped = clipped ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/apps/dashboard/lib/ui/components/message.g.dart b/apps/dashboard/lib/ui/components/message.g.dart new file mode 100644 index 000000000..7068a6299 --- /dev/null +++ b/apps/dashboard/lib/ui/components/message.g.dart @@ -0,0 +1,577 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$UiMessageSpec implements Spec, Diagnosticable { + double? get maxWidth; + StyleSpec get row; + StyleSpec get avatar; + StyleSpec get header; + StyleSpec get body; + StyleSpec get footer; + + @override + Type get type => UiMessageSpec; + + @override + UiMessageSpec copyWith({ + double? maxWidth, + StyleSpec? row, + StyleSpec? avatar, + StyleSpec? header, + StyleSpec? body, + StyleSpec? footer, + }) { + return UiMessageSpec( + maxWidth: maxWidth ?? this.maxWidth, + row: row ?? this.row, + avatar: avatar ?? this.avatar, + header: header ?? this.header, + body: body ?? this.body, + footer: footer ?? this.footer, + ); + } + + @override + UiMessageSpec lerp(UiMessageSpec? other, double t) { + return UiMessageSpec( + maxWidth: MixOps.lerp(maxWidth, other?.maxWidth, t), + row: row.lerp(other?.row, t), + avatar: avatar.lerp(other?.avatar, t), + header: header.lerp(other?.header, t), + body: body.lerp(other?.body, t), + footer: footer.lerp(other?.footer, t), + ); + } + + @override + List get props => [maxWidth, row, avatar, header, body, footer]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is UiMessageSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DoubleProperty('maxWidth', maxWidth)) + ..add(DiagnosticsProperty('row', row)) + ..add(DiagnosticsProperty('avatar', avatar)) + ..add(DiagnosticsProperty('header', header)) + ..add(DiagnosticsProperty('body', body)) + ..add(DiagnosticsProperty('footer', footer)); + } +} + +@Deprecated( + 'Rename to `_\$UiMessageSpec` and migrate the class declaration to `class UiMessageSpec with _\$UiMessageSpec`. The `_\$UiMessageSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$UiMessageSpecMethods = _$UiMessageSpec; // ignore: unused_element + +mixin _$UiMessageCollapsibleSpec + implements Spec, Diagnosticable { + double? get collapsedHeight; + StyleSpec get container; + StyleSpec get clipped; + + @override + Type get type => UiMessageCollapsibleSpec; + + @override + UiMessageCollapsibleSpec copyWith({ + double? collapsedHeight, + StyleSpec? container, + StyleSpec? clipped, + }) { + return UiMessageCollapsibleSpec( + collapsedHeight: collapsedHeight ?? this.collapsedHeight, + container: container ?? this.container, + clipped: clipped ?? this.clipped, + ); + } + + @override + UiMessageCollapsibleSpec lerp(UiMessageCollapsibleSpec? other, double t) { + return UiMessageCollapsibleSpec( + collapsedHeight: MixOps.lerp(collapsedHeight, other?.collapsedHeight, t), + container: container.lerp(other?.container, t), + clipped: clipped.lerp(other?.clipped, t), + ); + } + + @override + List get props => [collapsedHeight, container, clipped]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is UiMessageCollapsibleSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DoubleProperty('collapsedHeight', collapsedHeight)) + ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('clipped', clipped)); + } +} + +@Deprecated( + 'Rename to `_\$UiMessageCollapsibleSpec` and migrate the class declaration to `class UiMessageCollapsibleSpec with _\$UiMessageCollapsibleSpec`. The `_\$UiMessageCollapsibleSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$UiMessageCollapsibleSpecMethods = _$UiMessageCollapsibleSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class UiMessageStyler extends MixStyler + implements StylerFieldMetadata { + final Prop? $maxWidth; + final Prop>? $row; + final Prop>? $avatar; + final Prop>? $header; + final Prop>? $body; + final Prop>? $footer; + + const UiMessageStyler.create({ + Prop? maxWidth, + Prop>? row, + Prop>? avatar, + Prop>? header, + Prop>? body, + Prop>? footer, + super.variants, + super.modifier, + super.animation, + }) : $maxWidth = maxWidth, + $row = row, + $avatar = avatar, + $header = header, + $body = body, + $footer = footer; + + UiMessageStyler({ + double? maxWidth, + FlexBoxStyler? row, + BoxStyler? avatar, + BoxStyler? header, + BoxStyler? body, + BoxStyler? footer, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + maxWidth: Prop.maybe(maxWidth), + row: Prop.maybeMix(row), + avatar: Prop.maybeMix(avatar), + header: Prop.maybeMix(header), + body: Prop.maybeMix(body), + footer: Prop.maybeMix(footer), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory UiMessageStyler.maxWidth(double value) => + UiMessageStyler().maxWidth(value); + factory UiMessageStyler.row(FlexBoxStyler value) => + UiMessageStyler().row(value); + factory UiMessageStyler.avatar(BoxStyler value) => + UiMessageStyler().avatar(value); + factory UiMessageStyler.header(BoxStyler value) => + UiMessageStyler().header(value); + factory UiMessageStyler.body(BoxStyler value) => + UiMessageStyler().body(value); + factory UiMessageStyler.footer(BoxStyler value) => + UiMessageStyler().footer(value); + + @override + Set get $stylerFieldNames => const { + 'maxWidth', + 'row', + 'avatar', + 'header', + 'body', + 'footer', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the maxWidth. + UiMessageStyler maxWidth(double value) { + return merge(UiMessageStyler(maxWidth: value)); + } + + /// Sets the row. + UiMessageStyler row(FlexBoxStyler value) { + return merge(UiMessageStyler(row: value)); + } + + /// Sets the avatar. + UiMessageStyler avatar(BoxStyler value) { + return merge(UiMessageStyler(avatar: value)); + } + + /// Sets the header. + UiMessageStyler header(BoxStyler value) { + return merge(UiMessageStyler(header: value)); + } + + /// Sets the body. + UiMessageStyler body(BoxStyler value) { + return merge(UiMessageStyler(body: value)); + } + + /// Sets the footer. + UiMessageStyler footer(BoxStyler value) { + return merge(UiMessageStyler(footer: value)); + } + + /// Sets the animation configuration. + @override + UiMessageStyler animate(AnimationConfig value) { + return merge(UiMessageStyler(animation: value)); + } + + /// Sets the style variants. + @override + UiMessageStyler variants(List> value) { + return merge(UiMessageStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + UiMessageStyler wrap(WidgetModifierConfig value) { + return merge(UiMessageStyler(modifier: value)); + } + + /// Sets the widget modifier. + UiMessageStyler modifier(WidgetModifierConfig value) { + return merge(UiMessageStyler(modifier: value)); + } + + UiMessage call({ + Key? key, + required UiRole role, + required Widget child, + UiMessageAlign? align, + Widget? avatar, + bool showAvatar = false, + bool placeholderAvatar = false, + double? maxWidth, + Widget? header, + Widget? footer, + String? semanticLabel, + CardStyler surfaceStyle = const CardStyler.create(), + }) { + return UiMessage( + key: key, + style: this, + role: role, + child: child, + align: align, + avatar: avatar, + showAvatar: showAvatar, + placeholderAvatar: placeholderAvatar, + maxWidth: maxWidth, + header: header, + footer: footer, + semanticLabel: semanticLabel, + surfaceStyle: surfaceStyle, + ); + } + + /// Merges with another [UiMessageStyler]. + @override + UiMessageStyler merge(UiMessageStyler? other) { + return UiMessageStyler.create( + maxWidth: MixOps.merge($maxWidth, other?.$maxWidth), + row: MixOps.merge($row, other?.$row), + avatar: MixOps.merge($avatar, other?.$avatar), + header: MixOps.merge($header, other?.$header), + body: MixOps.merge($body, other?.$body), + footer: MixOps.merge($footer, other?.$footer), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = UiMessageSpec( + maxWidth: MixOps.resolve(context, $maxWidth), + row: MixOps.resolve(context, $row), + avatar: MixOps.resolve(context, $avatar), + header: MixOps.resolve(context, $header), + body: MixOps.resolve(context, $body), + footer: MixOps.resolve(context, $footer), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('maxWidth', $maxWidth)) + ..add(DiagnosticsProperty('row', $row)) + ..add(DiagnosticsProperty('avatar', $avatar)) + ..add(DiagnosticsProperty('header', $header)) + ..add(DiagnosticsProperty('body', $body)) + ..add(DiagnosticsProperty('footer', $footer)); + } + + @override + List get props => [ + $maxWidth, + $row, + $avatar, + $header, + $body, + $footer, + $animation, + $modifier, + $variants, + ]; +} + +class UiMessageCollapsibleStyler + extends MixStyler + implements StylerFieldMetadata { + final Prop? $collapsedHeight; + final Prop>? $container; + final Prop>? $clipped; + + const UiMessageCollapsibleStyler.create({ + Prop? collapsedHeight, + Prop>? container, + Prop>? clipped, + super.variants, + super.modifier, + super.animation, + }) : $collapsedHeight = collapsedHeight, + $container = container, + $clipped = clipped; + + UiMessageCollapsibleStyler({ + double? collapsedHeight, + BoxStyler? container, + BoxStyler? clipped, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + collapsedHeight: Prop.maybe(collapsedHeight), + container: Prop.maybeMix(container), + clipped: Prop.maybeMix(clipped), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory UiMessageCollapsibleStyler.collapsedHeight(double value) => + UiMessageCollapsibleStyler().collapsedHeight(value); + factory UiMessageCollapsibleStyler.container(BoxStyler value) => + UiMessageCollapsibleStyler().container(value); + factory UiMessageCollapsibleStyler.clipped(BoxStyler value) => + UiMessageCollapsibleStyler().clipped(value); + + @override + Set get $stylerFieldNames => const { + 'collapsedHeight', + 'container', + 'clipped', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the collapsedHeight. + UiMessageCollapsibleStyler collapsedHeight(double value) { + return merge(UiMessageCollapsibleStyler(collapsedHeight: value)); + } + + /// Sets the container. + UiMessageCollapsibleStyler container(BoxStyler value) { + return merge(UiMessageCollapsibleStyler(container: value)); + } + + /// Sets the clipped. + UiMessageCollapsibleStyler clipped(BoxStyler value) { + return merge(UiMessageCollapsibleStyler(clipped: value)); + } + + /// Sets the animation configuration. + @override + UiMessageCollapsibleStyler animate(AnimationConfig value) { + return merge(UiMessageCollapsibleStyler(animation: value)); + } + + /// Sets the style variants. + @override + UiMessageCollapsibleStyler variants( + List> value, + ) { + return merge(UiMessageCollapsibleStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + UiMessageCollapsibleStyler wrap(WidgetModifierConfig value) { + return merge(UiMessageCollapsibleStyler(modifier: value)); + } + + /// Sets the widget modifier. + UiMessageCollapsibleStyler modifier(WidgetModifierConfig value) { + return merge(UiMessageCollapsibleStyler(modifier: value)); + } + + UiMessageCollapsible call({ + Key? key, + required Widget child, + bool? expanded, + bool defaultExpanded = false, + ValueChanged? onExpandedChanged, + String showMoreLabel = 'Show more', + String showLessLabel = 'Show less', + ButtonStyler toggleStyle = const ButtonStyler.create(), + }) { + return UiMessageCollapsible( + key: key, + style: this, + child: child, + expanded: expanded, + defaultExpanded: defaultExpanded, + onExpandedChanged: onExpandedChanged, + showMoreLabel: showMoreLabel, + showLessLabel: showLessLabel, + toggleStyle: toggleStyle, + ); + } + + /// Merges with another [UiMessageCollapsibleStyler]. + @override + UiMessageCollapsibleStyler merge(UiMessageCollapsibleStyler? other) { + return UiMessageCollapsibleStyler.create( + collapsedHeight: MixOps.merge($collapsedHeight, other?.$collapsedHeight), + container: MixOps.merge($container, other?.$container), + clipped: MixOps.merge($clipped, other?.$clipped), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = UiMessageCollapsibleSpec( + collapsedHeight: MixOps.resolve(context, $collapsedHeight), + container: MixOps.resolve(context, $container), + clipped: MixOps.resolve(context, $clipped), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('collapsedHeight', $collapsedHeight)) + ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('clipped', $clipped)); + } + + @override + List get props => [ + $collapsedHeight, + $container, + $clipped, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/dashboard/lib/ui/components/permission.dart b/apps/dashboard/lib/ui/components/permission.dart new file mode 100644 index 000000000..82e00532d --- /dev/null +++ b/apps/dashboard/lib/ui/components/permission.dart @@ -0,0 +1,385 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; + +part 'permission.g.dart'; + +typedef UiPermissionStatusLabelBuilder = + String Function(UiPermissionStatus status); +typedef UiPermissionStatusBuilder = + Widget Function(BuildContext context, UiPermissionStatus status); +typedef UiPermissionIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// In-transcript permission request composed from Remix controls. +class UiPermission extends StatefulWidget { + const UiPermission({ + super.key, + required this.tool, + this.requestId, + this.title = 'Allow this tool to run?', + this.description, + this.status = UiPermissionStatus.pending, + this.parameters = const [], + this.showParameters = true, + this.detailsExpanded, + this.defaultDetailsExpanded = false, + this.onDetailsExpandedChanged, + this.onAllowOnce, + this.onAlwaysAllow, + this.onDeny, + this.statusLabelBuilder, + this.statusBuilder, + this.indicatorBuilder, + this.allowOnceLabel = 'Allow once', + this.alwaysAllowLabel = 'Always allow', + this.denyLabel = 'Deny', + this.detailsLabel = 'View details', + this.semanticLabel = 'Tool permission', + this.parameterOrientation = Axis.horizontal, + this.surfaceStyle = const CardStyler.create(), + this.detailsStyle = const DisclosureStyler.create(), + this.parametersStyle = const DataListStyler.create(), + this.allowOnceStyle = const ButtonStyler.create(), + this.alwaysAllowStyle = const ButtonStyler.create(), + this.denyStyle = const ButtonStyler.create(), + this.style = const UiPermissionStyler.create(), + this.styleSpec, + }); + + final Object? requestId; + final String tool; + final String title; + final String? description; + final UiPermissionStatus status; + final List parameters; + final bool showParameters; + final bool? detailsExpanded; + final bool defaultDetailsExpanded; + final ValueChanged? onDetailsExpandedChanged; + final VoidCallback? onAllowOnce; + final VoidCallback? onAlwaysAllow; + final VoidCallback? onDeny; + final UiPermissionStatusLabelBuilder? statusLabelBuilder; + final UiPermissionStatusBuilder? statusBuilder; + final UiPermissionIndicatorBuilder? indicatorBuilder; + final String allowOnceLabel; + final String alwaysAllowLabel; + final String denyLabel; + final String detailsLabel; + final String semanticLabel; + final Axis parameterOrientation; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; + final UiPermissionStyler style; + final UiPermissionSpec? styleSpec; + + @override + State createState() => _UiPermissionState(); +} + +class _UiPermissionState extends State { + late final UiDisclosureEngine _disclosure; + bool _decisionSubmitted = false; + + bool get _detailsExpanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = UiDisclosureEngine( + value: widget.detailsExpanded, + defaultValue: + widget.status.keepsDetailsOpen || widget.defaultDetailsExpanded, + ); + } + + @override + void didUpdateWidget(UiPermission oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.detailsExpanded); + final returnedToPending = + oldWidget.status != UiPermissionStatus.pending && + widget.status == UiPermissionStatus.pending; + final newPendingRequest = + oldWidget.requestId != widget.requestId && + widget.status == UiPermissionStatus.pending; + if (returnedToPending || newPendingRequest) _decisionSubmitted = false; + + if (!oldWidget.status.keepsDetailsOpen && widget.status.keepsDetailsOpen) { + _requestDetails(true); + } else if (!oldWidget.status.isSettled && widget.status.isSettled) { + _requestDetails(false); + } + } + + void _requestDetails(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onDetailsExpandedChanged?.call(next); + } + + void _submit(VoidCallback? callback) { + if (_decisionSubmitted || + widget.status != UiPermissionStatus.pending || + callback == null) { + return; + } + setState(() => _decisionSubmitted = true); + callback(); + } + + String get _statusLabel => + widget.statusLabelBuilder?.call(widget.status) ?? + switch (widget.status) { + UiPermissionStatus.pending => 'Permission required', + UiPermissionStatus.deciding => 'Recording', + UiPermissionStatus.allowed => 'Allowed', + UiPermissionStatus.running => 'Running', + UiPermissionStatus.complete => 'Complete', + UiPermissionStatus.denied => 'Denied', + UiPermissionStatus.error => 'Error', + }; + + UiFunctionalGlyphKind get _statusGlyph => switch (widget.status) { + UiPermissionStatus.pending => .permission, + UiPermissionStatus.deciding => .loading, + UiPermissionStatus.allowed => .completed, + UiPermissionStatus.running => .loading, + UiPermissionStatus.complete => .completed, + UiPermissionStatus.denied => .cancelled, + UiPermissionStatus.error => .error, + }; + + StyleSpec _statusContainer(UiPermissionSpec spec) => + switch (widget.status) { + UiPermissionStatus.pending => spec.pendingStatus, + UiPermissionStatus.deciding => spec.decidingStatus, + UiPermissionStatus.allowed => spec.allowedStatus, + UiPermissionStatus.running => spec.runningStatus, + UiPermissionStatus.complete => spec.completedStatus, + UiPermissionStatus.denied => spec.deniedStatus, + UiPermissionStatus.error => spec.errorStatus, + }; + + // Horizontal by default; callers may stack actions without losing the + // action slot's box, modifiers, or nested style resolution. + StyleSpec _actionsStyle(UiPermissionSpec spec) { + final actions = spec.actions.spec; + final flex = actions.flex ?? const StyleSpec(spec: FlexSpec()); + return spec.actions.copyWith( + spec: actions.copyWith( + flex: flex.copyWith( + spec: flex.spec.copyWith( + direction: flex.spec.direction ?? Axis.horizontal, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixCard( + style: widget.surfaceStyle, + child: Box( + styleSpec: spec.content, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RowBox( + styleSpec: spec.header, + children: [ + StyleSpecBuilder( + styleSpec: spec.toolIcon, + builder: (context, iconSpec) => + UiFunctionalGlyph(kind: .tool, spec: iconSpec), + ), + Expanded( + child: StyledText(widget.title, styleSpec: spec.title), + ), + ], + ), + StyledText(widget.tool, styleSpec: spec.tool), + if (widget.description != null) + StyledText(widget.description!, styleSpec: spec.description), + Box( + styleSpec: _statusContainer(spec), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.statusBuilder?.call(context, widget.status) ?? + StyleSpecBuilder( + styleSpec: spec.statusIcon, + builder: (context, iconSpec) => UiFunctionalGlyph( + kind: _statusGlyph, + spec: iconSpec, + ), + ), + Flexible( + child: StyledText(_statusLabel, styleSpec: spec.status), + ), + ], + ), + ), + if (widget.showParameters && widget.parameters.isNotEmpty) + RemixDisclosure( + expanded: _detailsExpanded, + onExpandedChanged: _requestDetails, + semanticLabel: widget.detailsLabel, + style: widget.detailsStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + UiDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: StyledText( + widget.detailsLabel, + styleSpec: spec.detailsLabel, + ), + content: RemixDataList( + items: widget.parameters, + orientation: widget.parameterOrientation, + style: widget.parametersStyle, + ), + ), + if (widget.status == UiPermissionStatus.pending) + FlexBox( + styleSpec: _actionsStyle(spec), + children: [ + RemixButton( + key: const ValueKey('ui-permission-allow-once'), + label: widget.allowOnceLabel, + enabled: !_decisionSubmitted, + onPressed: widget.onAllowOnce == null + ? null + : () => _submit(widget.onAllowOnce), + style: widget.allowOnceStyle, + ), + if (widget.onAlwaysAllow != null) + RemixButton( + key: const ValueKey('ui-permission-always-allow'), + label: widget.alwaysAllowLabel, + enabled: !_decisionSubmitted, + onPressed: () => _submit(widget.onAlwaysAllow), + style: widget.alwaysAllowStyle, + ), + RemixButton( + key: const ValueKey('ui-permission-deny'), + label: widget.denyLabel, + enabled: !_decisionSubmitted, + onPressed: widget.onDeny == null + ? null + : () => _submit(widget.onDeny), + style: widget.denyStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: UiPermission.new) +@immutable +final class UiPermissionSpec with _$UiPermissionSpec { + @override + final StyleSpec content; + @override + final StyleSpec header; + @override + final StyleSpec actions; + @override + final StyleSpec title; + @override + final StyleSpec tool; + @override + final StyleSpec description; + @override + final StyleSpec status; + @override + final StyleSpec detailsLabel; + @override + final StyleSpec toolIcon; + @override + final StyleSpec statusIcon; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec decidingStatus; + @override + final StyleSpec allowedStatus; + @override + final StyleSpec runningStatus; + @override + final StyleSpec completedStatus; + @override + final StyleSpec deniedStatus; + @override + final StyleSpec errorStatus; + + const UiPermissionSpec({ + StyleSpec? content, + StyleSpec? header, + StyleSpec? actions, + StyleSpec? title, + StyleSpec? tool, + StyleSpec? description, + StyleSpec? status, + StyleSpec? detailsLabel, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? pendingStatus, + StyleSpec? decidingStatus, + StyleSpec? allowedStatus, + StyleSpec? runningStatus, + StyleSpec? completedStatus, + StyleSpec? deniedStatus, + StyleSpec? errorStatus, + }) : content = content ?? const StyleSpec(spec: BoxSpec()), + header = header ?? const StyleSpec(spec: FlexBoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + title = title ?? const StyleSpec(spec: TextSpec()), + tool = tool ?? const StyleSpec(spec: TextSpec()), + description = description ?? const StyleSpec(spec: TextSpec()), + status = status ?? const StyleSpec(spec: TextSpec()), + detailsLabel = detailsLabel ?? const StyleSpec(spec: TextSpec()), + toolIcon = toolIcon ?? const StyleSpec(spec: IconSpec()), + statusIcon = statusIcon ?? const StyleSpec(spec: IconSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: BoxSpec()), + decidingStatus = decidingStatus ?? const StyleSpec(spec: BoxSpec()), + allowedStatus = allowedStatus ?? const StyleSpec(spec: BoxSpec()), + runningStatus = runningStatus ?? const StyleSpec(spec: BoxSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: BoxSpec()), + deniedStatus = deniedStatus ?? const StyleSpec(spec: BoxSpec()), + errorStatus = errorStatus ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/apps/dashboard/lib/ui/components/permission.g.dart b/apps/dashboard/lib/ui/components/permission.g.dart new file mode 100644 index 000000000..c7a16be44 --- /dev/null +++ b/apps/dashboard/lib/ui/components/permission.g.dart @@ -0,0 +1,646 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'permission.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$UiPermissionSpec implements Spec, Diagnosticable { + StyleSpec get content; + StyleSpec get header; + StyleSpec get actions; + StyleSpec get title; + StyleSpec get tool; + StyleSpec get description; + StyleSpec get status; + StyleSpec get detailsLabel; + StyleSpec get toolIcon; + StyleSpec get statusIcon; + StyleSpec get indicator; + StyleSpec get pendingStatus; + StyleSpec get decidingStatus; + StyleSpec get allowedStatus; + StyleSpec get runningStatus; + StyleSpec get completedStatus; + StyleSpec get deniedStatus; + StyleSpec get errorStatus; + + @override + Type get type => UiPermissionSpec; + + @override + UiPermissionSpec copyWith({ + StyleSpec? content, + StyleSpec? header, + StyleSpec? actions, + StyleSpec? title, + StyleSpec? tool, + StyleSpec? description, + StyleSpec? status, + StyleSpec? detailsLabel, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? pendingStatus, + StyleSpec? decidingStatus, + StyleSpec? allowedStatus, + StyleSpec? runningStatus, + StyleSpec? completedStatus, + StyleSpec? deniedStatus, + StyleSpec? errorStatus, + }) { + return UiPermissionSpec( + content: content ?? this.content, + header: header ?? this.header, + actions: actions ?? this.actions, + title: title ?? this.title, + tool: tool ?? this.tool, + description: description ?? this.description, + status: status ?? this.status, + detailsLabel: detailsLabel ?? this.detailsLabel, + toolIcon: toolIcon ?? this.toolIcon, + statusIcon: statusIcon ?? this.statusIcon, + indicator: indicator ?? this.indicator, + pendingStatus: pendingStatus ?? this.pendingStatus, + decidingStatus: decidingStatus ?? this.decidingStatus, + allowedStatus: allowedStatus ?? this.allowedStatus, + runningStatus: runningStatus ?? this.runningStatus, + completedStatus: completedStatus ?? this.completedStatus, + deniedStatus: deniedStatus ?? this.deniedStatus, + errorStatus: errorStatus ?? this.errorStatus, + ); + } + + @override + UiPermissionSpec lerp(UiPermissionSpec? other, double t) { + return UiPermissionSpec( + content: content.lerp(other?.content, t), + header: header.lerp(other?.header, t), + actions: actions.lerp(other?.actions, t), + title: title.lerp(other?.title, t), + tool: tool.lerp(other?.tool, t), + description: description.lerp(other?.description, t), + status: status.lerp(other?.status, t), + detailsLabel: detailsLabel.lerp(other?.detailsLabel, t), + toolIcon: toolIcon.lerp(other?.toolIcon, t), + statusIcon: statusIcon.lerp(other?.statusIcon, t), + indicator: indicator.lerp(other?.indicator, t), + pendingStatus: pendingStatus.lerp(other?.pendingStatus, t), + decidingStatus: decidingStatus.lerp(other?.decidingStatus, t), + allowedStatus: allowedStatus.lerp(other?.allowedStatus, t), + runningStatus: runningStatus.lerp(other?.runningStatus, t), + completedStatus: completedStatus.lerp(other?.completedStatus, t), + deniedStatus: deniedStatus.lerp(other?.deniedStatus, t), + errorStatus: errorStatus.lerp(other?.errorStatus, t), + ); + } + + @override + List get props => [ + content, + header, + actions, + title, + tool, + description, + status, + detailsLabel, + toolIcon, + statusIcon, + indicator, + pendingStatus, + decidingStatus, + allowedStatus, + runningStatus, + completedStatus, + deniedStatus, + errorStatus, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is UiPermissionSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('content', content)) + ..add(DiagnosticsProperty('header', header)) + ..add(DiagnosticsProperty('actions', actions)) + ..add(DiagnosticsProperty('title', title)) + ..add(DiagnosticsProperty('tool', tool)) + ..add(DiagnosticsProperty('description', description)) + ..add(DiagnosticsProperty('status', status)) + ..add(DiagnosticsProperty('detailsLabel', detailsLabel)) + ..add(DiagnosticsProperty('toolIcon', toolIcon)) + ..add(DiagnosticsProperty('statusIcon', statusIcon)) + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('pendingStatus', pendingStatus)) + ..add(DiagnosticsProperty('decidingStatus', decidingStatus)) + ..add(DiagnosticsProperty('allowedStatus', allowedStatus)) + ..add(DiagnosticsProperty('runningStatus', runningStatus)) + ..add(DiagnosticsProperty('completedStatus', completedStatus)) + ..add(DiagnosticsProperty('deniedStatus', deniedStatus)) + ..add(DiagnosticsProperty('errorStatus', errorStatus)); + } +} + +@Deprecated( + 'Rename to `_\$UiPermissionSpec` and migrate the class declaration to `class UiPermissionSpec with _\$UiPermissionSpec`. The `_\$UiPermissionSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$UiPermissionSpecMethods = _$UiPermissionSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class UiPermissionStyler extends MixStyler + implements StylerFieldMetadata { + final Prop>? $content; + final Prop>? $header; + final Prop>? $actions; + final Prop>? $title; + final Prop>? $tool; + final Prop>? $description; + final Prop>? $status; + final Prop>? $detailsLabel; + final Prop>? $toolIcon; + final Prop>? $statusIcon; + final Prop>? $indicator; + final Prop>? $pendingStatus; + final Prop>? $decidingStatus; + final Prop>? $allowedStatus; + final Prop>? $runningStatus; + final Prop>? $completedStatus; + final Prop>? $deniedStatus; + final Prop>? $errorStatus; + + const UiPermissionStyler.create({ + Prop>? content, + Prop>? header, + Prop>? actions, + Prop>? title, + Prop>? tool, + Prop>? description, + Prop>? status, + Prop>? detailsLabel, + Prop>? toolIcon, + Prop>? statusIcon, + Prop>? indicator, + Prop>? pendingStatus, + Prop>? decidingStatus, + Prop>? allowedStatus, + Prop>? runningStatus, + Prop>? completedStatus, + Prop>? deniedStatus, + Prop>? errorStatus, + super.variants, + super.modifier, + super.animation, + }) : $content = content, + $header = header, + $actions = actions, + $title = title, + $tool = tool, + $description = description, + $status = status, + $detailsLabel = detailsLabel, + $toolIcon = toolIcon, + $statusIcon = statusIcon, + $indicator = indicator, + $pendingStatus = pendingStatus, + $decidingStatus = decidingStatus, + $allowedStatus = allowedStatus, + $runningStatus = runningStatus, + $completedStatus = completedStatus, + $deniedStatus = deniedStatus, + $errorStatus = errorStatus; + + UiPermissionStyler({ + BoxStyler? content, + FlexBoxStyler? header, + FlexBoxStyler? actions, + TextStyler? title, + TextStyler? tool, + TextStyler? description, + TextStyler? status, + TextStyler? detailsLabel, + IconStyler? toolIcon, + IconStyler? statusIcon, + IconStyler? indicator, + BoxStyler? pendingStatus, + BoxStyler? decidingStatus, + BoxStyler? allowedStatus, + BoxStyler? runningStatus, + BoxStyler? completedStatus, + BoxStyler? deniedStatus, + BoxStyler? errorStatus, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + content: Prop.maybeMix(content), + header: Prop.maybeMix(header), + actions: Prop.maybeMix(actions), + title: Prop.maybeMix(title), + tool: Prop.maybeMix(tool), + description: Prop.maybeMix(description), + status: Prop.maybeMix(status), + detailsLabel: Prop.maybeMix(detailsLabel), + toolIcon: Prop.maybeMix(toolIcon), + statusIcon: Prop.maybeMix(statusIcon), + indicator: Prop.maybeMix(indicator), + pendingStatus: Prop.maybeMix(pendingStatus), + decidingStatus: Prop.maybeMix(decidingStatus), + allowedStatus: Prop.maybeMix(allowedStatus), + runningStatus: Prop.maybeMix(runningStatus), + completedStatus: Prop.maybeMix(completedStatus), + deniedStatus: Prop.maybeMix(deniedStatus), + errorStatus: Prop.maybeMix(errorStatus), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory UiPermissionStyler.content(BoxStyler value) => + UiPermissionStyler().content(value); + factory UiPermissionStyler.header(FlexBoxStyler value) => + UiPermissionStyler().header(value); + factory UiPermissionStyler.actions(FlexBoxStyler value) => + UiPermissionStyler().actions(value); + factory UiPermissionStyler.title(TextStyler value) => + UiPermissionStyler().title(value); + factory UiPermissionStyler.tool(TextStyler value) => + UiPermissionStyler().tool(value); + factory UiPermissionStyler.description(TextStyler value) => + UiPermissionStyler().description(value); + factory UiPermissionStyler.status(TextStyler value) => + UiPermissionStyler().status(value); + factory UiPermissionStyler.detailsLabel(TextStyler value) => + UiPermissionStyler().detailsLabel(value); + factory UiPermissionStyler.toolIcon(IconStyler value) => + UiPermissionStyler().toolIcon(value); + factory UiPermissionStyler.statusIcon(IconStyler value) => + UiPermissionStyler().statusIcon(value); + factory UiPermissionStyler.indicator(IconStyler value) => + UiPermissionStyler().indicator(value); + factory UiPermissionStyler.pendingStatus(BoxStyler value) => + UiPermissionStyler().pendingStatus(value); + factory UiPermissionStyler.decidingStatus(BoxStyler value) => + UiPermissionStyler().decidingStatus(value); + factory UiPermissionStyler.allowedStatus(BoxStyler value) => + UiPermissionStyler().allowedStatus(value); + factory UiPermissionStyler.runningStatus(BoxStyler value) => + UiPermissionStyler().runningStatus(value); + factory UiPermissionStyler.completedStatus(BoxStyler value) => + UiPermissionStyler().completedStatus(value); + factory UiPermissionStyler.deniedStatus(BoxStyler value) => + UiPermissionStyler().deniedStatus(value); + factory UiPermissionStyler.errorStatus(BoxStyler value) => + UiPermissionStyler().errorStatus(value); + + @override + Set get $stylerFieldNames => const { + 'content', + 'header', + 'actions', + 'title', + 'tool', + 'description', + 'status', + 'detailsLabel', + 'toolIcon', + 'statusIcon', + 'indicator', + 'pendingStatus', + 'decidingStatus', + 'allowedStatus', + 'runningStatus', + 'completedStatus', + 'deniedStatus', + 'errorStatus', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the content. + UiPermissionStyler content(BoxStyler value) { + return merge(UiPermissionStyler(content: value)); + } + + /// Sets the header. + UiPermissionStyler header(FlexBoxStyler value) { + return merge(UiPermissionStyler(header: value)); + } + + /// Sets the actions. + UiPermissionStyler actions(FlexBoxStyler value) { + return merge(UiPermissionStyler(actions: value)); + } + + /// Sets the title. + UiPermissionStyler title(TextStyler value) { + return merge(UiPermissionStyler(title: value)); + } + + /// Sets the tool. + UiPermissionStyler tool(TextStyler value) { + return merge(UiPermissionStyler(tool: value)); + } + + /// Sets the description. + UiPermissionStyler description(TextStyler value) { + return merge(UiPermissionStyler(description: value)); + } + + /// Sets the status. + UiPermissionStyler status(TextStyler value) { + return merge(UiPermissionStyler(status: value)); + } + + /// Sets the detailsLabel. + UiPermissionStyler detailsLabel(TextStyler value) { + return merge(UiPermissionStyler(detailsLabel: value)); + } + + /// Sets the toolIcon. + UiPermissionStyler toolIcon(IconStyler value) { + return merge(UiPermissionStyler(toolIcon: value)); + } + + /// Sets the statusIcon. + UiPermissionStyler statusIcon(IconStyler value) { + return merge(UiPermissionStyler(statusIcon: value)); + } + + /// Sets the indicator. + UiPermissionStyler indicator(IconStyler value) { + return merge(UiPermissionStyler(indicator: value)); + } + + /// Sets the pendingStatus. + UiPermissionStyler pendingStatus(BoxStyler value) { + return merge(UiPermissionStyler(pendingStatus: value)); + } + + /// Sets the decidingStatus. + UiPermissionStyler decidingStatus(BoxStyler value) { + return merge(UiPermissionStyler(decidingStatus: value)); + } + + /// Sets the allowedStatus. + UiPermissionStyler allowedStatus(BoxStyler value) { + return merge(UiPermissionStyler(allowedStatus: value)); + } + + /// Sets the runningStatus. + UiPermissionStyler runningStatus(BoxStyler value) { + return merge(UiPermissionStyler(runningStatus: value)); + } + + /// Sets the completedStatus. + UiPermissionStyler completedStatus(BoxStyler value) { + return merge(UiPermissionStyler(completedStatus: value)); + } + + /// Sets the deniedStatus. + UiPermissionStyler deniedStatus(BoxStyler value) { + return merge(UiPermissionStyler(deniedStatus: value)); + } + + /// Sets the errorStatus. + UiPermissionStyler errorStatus(BoxStyler value) { + return merge(UiPermissionStyler(errorStatus: value)); + } + + /// Sets the animation configuration. + @override + UiPermissionStyler animate(AnimationConfig value) { + return merge(UiPermissionStyler(animation: value)); + } + + /// Sets the style variants. + @override + UiPermissionStyler variants(List> value) { + return merge(UiPermissionStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + UiPermissionStyler wrap(WidgetModifierConfig value) { + return merge(UiPermissionStyler(modifier: value)); + } + + /// Sets the widget modifier. + UiPermissionStyler modifier(WidgetModifierConfig value) { + return merge(UiPermissionStyler(modifier: value)); + } + + UiPermission call({ + Key? key, + required String tool, + Object? requestId, + String title = 'Allow this tool to run?', + String? description, + UiPermissionStatus status = UiPermissionStatus.pending, + List parameters = const [], + bool showParameters = true, + bool? detailsExpanded, + bool defaultDetailsExpanded = false, + ValueChanged? onDetailsExpandedChanged, + VoidCallback? onAllowOnce, + VoidCallback? onAlwaysAllow, + VoidCallback? onDeny, + UiPermissionStatusLabelBuilder? statusLabelBuilder, + UiPermissionStatusBuilder? statusBuilder, + UiPermissionIndicatorBuilder? indicatorBuilder, + String allowOnceLabel = 'Allow once', + String alwaysAllowLabel = 'Always allow', + String denyLabel = 'Deny', + String detailsLabel = 'View details', + String semanticLabel = 'Tool permission', + Axis parameterOrientation = Axis.horizontal, + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler detailsStyle = const DisclosureStyler.create(), + DataListStyler parametersStyle = const DataListStyler.create(), + ButtonStyler allowOnceStyle = const ButtonStyler.create(), + ButtonStyler alwaysAllowStyle = const ButtonStyler.create(), + ButtonStyler denyStyle = const ButtonStyler.create(), + }) { + return UiPermission( + key: key, + style: this, + tool: tool, + requestId: requestId, + title: title, + description: description, + status: status, + parameters: parameters, + showParameters: showParameters, + detailsExpanded: detailsExpanded, + defaultDetailsExpanded: defaultDetailsExpanded, + onDetailsExpandedChanged: onDetailsExpandedChanged, + onAllowOnce: onAllowOnce, + onAlwaysAllow: onAlwaysAllow, + onDeny: onDeny, + statusLabelBuilder: statusLabelBuilder, + statusBuilder: statusBuilder, + indicatorBuilder: indicatorBuilder, + allowOnceLabel: allowOnceLabel, + alwaysAllowLabel: alwaysAllowLabel, + denyLabel: denyLabel, + detailsLabel: detailsLabel, + semanticLabel: semanticLabel, + parameterOrientation: parameterOrientation, + surfaceStyle: surfaceStyle, + detailsStyle: detailsStyle, + parametersStyle: parametersStyle, + allowOnceStyle: allowOnceStyle, + alwaysAllowStyle: alwaysAllowStyle, + denyStyle: denyStyle, + ); + } + + /// Merges with another [UiPermissionStyler]. + @override + UiPermissionStyler merge(UiPermissionStyler? other) { + return UiPermissionStyler.create( + content: MixOps.merge($content, other?.$content), + header: MixOps.merge($header, other?.$header), + actions: MixOps.merge($actions, other?.$actions), + title: MixOps.merge($title, other?.$title), + tool: MixOps.merge($tool, other?.$tool), + description: MixOps.merge($description, other?.$description), + status: MixOps.merge($status, other?.$status), + detailsLabel: MixOps.merge($detailsLabel, other?.$detailsLabel), + toolIcon: MixOps.merge($toolIcon, other?.$toolIcon), + statusIcon: MixOps.merge($statusIcon, other?.$statusIcon), + indicator: MixOps.merge($indicator, other?.$indicator), + pendingStatus: MixOps.merge($pendingStatus, other?.$pendingStatus), + decidingStatus: MixOps.merge($decidingStatus, other?.$decidingStatus), + allowedStatus: MixOps.merge($allowedStatus, other?.$allowedStatus), + runningStatus: MixOps.merge($runningStatus, other?.$runningStatus), + completedStatus: MixOps.merge($completedStatus, other?.$completedStatus), + deniedStatus: MixOps.merge($deniedStatus, other?.$deniedStatus), + errorStatus: MixOps.merge($errorStatus, other?.$errorStatus), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = UiPermissionSpec( + content: MixOps.resolve(context, $content), + header: MixOps.resolve(context, $header), + actions: MixOps.resolve(context, $actions), + title: MixOps.resolve(context, $title), + tool: MixOps.resolve(context, $tool), + description: MixOps.resolve(context, $description), + status: MixOps.resolve(context, $status), + detailsLabel: MixOps.resolve(context, $detailsLabel), + toolIcon: MixOps.resolve(context, $toolIcon), + statusIcon: MixOps.resolve(context, $statusIcon), + indicator: MixOps.resolve(context, $indicator), + pendingStatus: MixOps.resolve(context, $pendingStatus), + decidingStatus: MixOps.resolve(context, $decidingStatus), + allowedStatus: MixOps.resolve(context, $allowedStatus), + runningStatus: MixOps.resolve(context, $runningStatus), + completedStatus: MixOps.resolve(context, $completedStatus), + deniedStatus: MixOps.resolve(context, $deniedStatus), + errorStatus: MixOps.resolve(context, $errorStatus), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('content', $content)) + ..add(DiagnosticsProperty('header', $header)) + ..add(DiagnosticsProperty('actions', $actions)) + ..add(DiagnosticsProperty('title', $title)) + ..add(DiagnosticsProperty('tool', $tool)) + ..add(DiagnosticsProperty('description', $description)) + ..add(DiagnosticsProperty('status', $status)) + ..add(DiagnosticsProperty('detailsLabel', $detailsLabel)) + ..add(DiagnosticsProperty('toolIcon', $toolIcon)) + ..add(DiagnosticsProperty('statusIcon', $statusIcon)) + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('pendingStatus', $pendingStatus)) + ..add(DiagnosticsProperty('decidingStatus', $decidingStatus)) + ..add(DiagnosticsProperty('allowedStatus', $allowedStatus)) + ..add(DiagnosticsProperty('runningStatus', $runningStatus)) + ..add(DiagnosticsProperty('completedStatus', $completedStatus)) + ..add(DiagnosticsProperty('deniedStatus', $deniedStatus)) + ..add(DiagnosticsProperty('errorStatus', $errorStatus)); + } + + @override + List get props => [ + $content, + $header, + $actions, + $title, + $tool, + $description, + $status, + $detailsLabel, + $toolIcon, + $statusIcon, + $indicator, + $pendingStatus, + $decidingStatus, + $allowedStatus, + $runningStatus, + $completedStatus, + $deniedStatus, + $errorStatus, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/dashboard/lib/ui/components/plan.dart b/apps/dashboard/lib/ui/components/plan.dart new file mode 100644 index 000000000..4d8711260 --- /dev/null +++ b/apps/dashboard/lib/ui/components/plan.dart @@ -0,0 +1,301 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/plan_item.dart'; +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'plan.g.dart'; + +typedef UiPlanStatusBuilder = + Widget Function(BuildContext context, UiPlanItem item); +typedef UiPlanStatusLabelBuilder = String Function(UiPlanItem item); +typedef UiPlanIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Toggleable task plan with lifecycle-aware uncontrolled disclosure state. +class UiPlan extends StatefulWidget { + const UiPlan({ + super.key, + required this.items, + this.title = 'Plan', + this.emptyLabel = 'No tasks yet', + this.semanticLabel = 'Task plan', + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.statusBuilder, + this.statusLabelBuilder, + this.indicatorBuilder, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + this.disclosureStyle = const DisclosureStyler.create(), + this.style = const UiPlanStyler.create(), + this.styleSpec, + }); + + final List items; + final String title; + final String emptyLabel; + final String semanticLabel; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final UiPlanStatusBuilder? statusBuilder; + final UiPlanStatusLabelBuilder? statusLabelBuilder; + final UiPlanIndicatorBuilder? indicatorBuilder; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + final DisclosureStyler disclosureStyle; + final UiPlanStyler style; + final UiPlanSpec? styleSpec; + + int get settledCount => items.where((item) => item.status.isDone).length; + bool get isWorking => items.any((item) => !item.status.isDone); + + @override + State createState() => _UiPlanState(); +} + +class _UiPlanState extends State { + late final UiDisclosureEngine _disclosure; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = UiDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget(UiPlan oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + final wasWorking = oldWidget.isWorking; + final working = widget.isWorking; + if (wasWorking && !working && widget.collapseOnComplete) { + _request(false); + } else if (!wasWorking && working) { + _request(true); + } + } + + void _request(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String _statusLabel(UiPlanItem item) => + widget.statusLabelBuilder?.call(item) ?? + switch (item.status) { + UiPlanItemStatus.pending => 'Pending', + UiPlanItemStatus.inProgress => 'In progress', + UiPlanItemStatus.completed => 'Completed', + UiPlanItemStatus.cancelled => 'Cancelled', + }; + + UiFunctionalGlyphKind _statusGlyph(UiPlanItemStatus status) => + switch (status) { + UiPlanItemStatus.pending => .pending, + UiPlanItemStatus.inProgress => .active, + UiPlanItemStatus.completed => .completed, + UiPlanItemStatus.cancelled => .cancelled, + }; + + StyleSpec _statusContainer( + UiPlanSpec spec, + UiPlanItemStatus status, + ) => switch (status) { + UiPlanItemStatus.pending => spec.pendingItem, + UiPlanItemStatus.inProgress => spec.activeItem, + UiPlanItemStatus.completed => spec.completedItem, + UiPlanItemStatus.cancelled => spec.cancelledItem, + }; + + StyleSpec _statusStyle(UiPlanSpec spec, UiPlanItemStatus status) => + switch (status) { + UiPlanItemStatus.pending => spec.pendingStatus, + UiPlanItemStatus.inProgress => spec.activeStatus, + UiPlanItemStatus.completed => spec.completedStatus, + UiPlanItemStatus.cancelled => spec.cancelledStatus, + }; + + Widget _defaultStatus( + BuildContext context, + UiPlanSpec spec, + UiPlanItem item, + ) { + return StyleSpecBuilder( + styleSpec: _statusStyle(spec, item.status), + builder: (context, iconSpec) => + UiFunctionalGlyph(kind: _statusGlyph(item.status), spec: iconSpec), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixDisclosure( + expanded: _expanded, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + UiDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: Row( + children: [ + Expanded( + child: StyledText(widget.title, styleSpec: spec.summaryTitle), + ), + StyledText( + '${widget.settledCount}/${widget.items.length}', + styleSpec: spec.count, + ), + ], + ), + content: Box( + styleSpec: spec.viewport, + child: widget.items.isEmpty + ? StyledText(widget.emptyLabel, styleSpec: spec.itemDetail) + : UiLiveEdgeScrollView( + followOutput: widget.followOutput, + followThreshold: widget.followThreshold, + onFollowChanged: widget.onFollowChanged, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in widget.items) + Semantics( + container: true, + excludeSemantics: true, + label: [ + item.title, + if (item.detail != null) item.detail!, + _statusLabel(item), + ].join(', '), + child: Box( + styleSpec: _statusContainer(spec, item.status), + child: RowBox( + key: ValueKey('ui-plan-item-${item.id}'), + styleSpec: spec.item, + children: [ + widget.statusBuilder?.call(context, item) ?? + _defaultStatus(context, spec, item), + Expanded( + child: StyledText( + item.title, + styleSpec: spec.itemTitle, + ), + ), + if (item.detail != null) + StyledText( + item.detail!, + styleSpec: spec.itemDetail, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: UiPlan.new) +@immutable +final class UiPlanSpec with _$UiPlanSpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final StyleSpec summaryTitle; + @override + final StyleSpec itemTitle; + @override + final StyleSpec itemDetail; + @override + final StyleSpec count; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingItem; + @override + final StyleSpec activeItem; + @override + final StyleSpec completedItem; + @override + final StyleSpec cancelledItem; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec activeStatus; + @override + final StyleSpec completedStatus; + @override + final StyleSpec cancelledStatus; + + const UiPlanSpec({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? cancelledItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + StyleSpec? cancelledStatus, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: FlexBoxSpec()), + summaryTitle = summaryTitle ?? const StyleSpec(spec: TextSpec()), + itemTitle = itemTitle ?? const StyleSpec(spec: TextSpec()), + itemDetail = itemDetail ?? const StyleSpec(spec: TextSpec()), + count = count ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingItem = pendingItem ?? const StyleSpec(spec: BoxSpec()), + activeItem = activeItem ?? const StyleSpec(spec: BoxSpec()), + completedItem = completedItem ?? const StyleSpec(spec: BoxSpec()), + cancelledItem = cancelledItem ?? const StyleSpec(spec: BoxSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: IconSpec()), + activeStatus = activeStatus ?? const StyleSpec(spec: IconSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: IconSpec()), + cancelledStatus = cancelledStatus ?? const StyleSpec(spec: IconSpec()); +} diff --git a/apps/dashboard/lib/ui/components/plan.g.dart b/apps/dashboard/lib/ui/components/plan.g.dart new file mode 100644 index 000000000..51c0a6512 --- /dev/null +++ b/apps/dashboard/lib/ui/components/plan.g.dart @@ -0,0 +1,549 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plan.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$UiPlanSpec implements Spec, Diagnosticable { + StyleSpec get viewport; + StyleSpec get item; + StyleSpec get summaryTitle; + StyleSpec get itemTitle; + StyleSpec get itemDetail; + StyleSpec get count; + StyleSpec get indicator; + StyleSpec get pendingItem; + StyleSpec get activeItem; + StyleSpec get completedItem; + StyleSpec get cancelledItem; + StyleSpec get pendingStatus; + StyleSpec get activeStatus; + StyleSpec get completedStatus; + StyleSpec get cancelledStatus; + + @override + Type get type => UiPlanSpec; + + @override + UiPlanSpec copyWith({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? cancelledItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + StyleSpec? cancelledStatus, + }) { + return UiPlanSpec( + viewport: viewport ?? this.viewport, + item: item ?? this.item, + summaryTitle: summaryTitle ?? this.summaryTitle, + itemTitle: itemTitle ?? this.itemTitle, + itemDetail: itemDetail ?? this.itemDetail, + count: count ?? this.count, + indicator: indicator ?? this.indicator, + pendingItem: pendingItem ?? this.pendingItem, + activeItem: activeItem ?? this.activeItem, + completedItem: completedItem ?? this.completedItem, + cancelledItem: cancelledItem ?? this.cancelledItem, + pendingStatus: pendingStatus ?? this.pendingStatus, + activeStatus: activeStatus ?? this.activeStatus, + completedStatus: completedStatus ?? this.completedStatus, + cancelledStatus: cancelledStatus ?? this.cancelledStatus, + ); + } + + @override + UiPlanSpec lerp(UiPlanSpec? other, double t) { + return UiPlanSpec( + viewport: viewport.lerp(other?.viewport, t), + item: item.lerp(other?.item, t), + summaryTitle: summaryTitle.lerp(other?.summaryTitle, t), + itemTitle: itemTitle.lerp(other?.itemTitle, t), + itemDetail: itemDetail.lerp(other?.itemDetail, t), + count: count.lerp(other?.count, t), + indicator: indicator.lerp(other?.indicator, t), + pendingItem: pendingItem.lerp(other?.pendingItem, t), + activeItem: activeItem.lerp(other?.activeItem, t), + completedItem: completedItem.lerp(other?.completedItem, t), + cancelledItem: cancelledItem.lerp(other?.cancelledItem, t), + pendingStatus: pendingStatus.lerp(other?.pendingStatus, t), + activeStatus: activeStatus.lerp(other?.activeStatus, t), + completedStatus: completedStatus.lerp(other?.completedStatus, t), + cancelledStatus: cancelledStatus.lerp(other?.cancelledStatus, t), + ); + } + + @override + List get props => [ + viewport, + item, + summaryTitle, + itemTitle, + itemDetail, + count, + indicator, + pendingItem, + activeItem, + completedItem, + cancelledItem, + pendingStatus, + activeStatus, + completedStatus, + cancelledStatus, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is UiPlanSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('viewport', viewport)) + ..add(DiagnosticsProperty('item', item)) + ..add(DiagnosticsProperty('summaryTitle', summaryTitle)) + ..add(DiagnosticsProperty('itemTitle', itemTitle)) + ..add(DiagnosticsProperty('itemDetail', itemDetail)) + ..add(DiagnosticsProperty('count', count)) + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('pendingItem', pendingItem)) + ..add(DiagnosticsProperty('activeItem', activeItem)) + ..add(DiagnosticsProperty('completedItem', completedItem)) + ..add(DiagnosticsProperty('cancelledItem', cancelledItem)) + ..add(DiagnosticsProperty('pendingStatus', pendingStatus)) + ..add(DiagnosticsProperty('activeStatus', activeStatus)) + ..add(DiagnosticsProperty('completedStatus', completedStatus)) + ..add(DiagnosticsProperty('cancelledStatus', cancelledStatus)); + } +} + +@Deprecated( + 'Rename to `_\$UiPlanSpec` and migrate the class declaration to `class UiPlanSpec with _\$UiPlanSpec`. The `_\$UiPlanSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$UiPlanSpecMethods = _$UiPlanSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class UiPlanStyler extends MixStyler + implements StylerFieldMetadata { + final Prop>? $viewport; + final Prop>? $item; + final Prop>? $summaryTitle; + final Prop>? $itemTitle; + final Prop>? $itemDetail; + final Prop>? $count; + final Prop>? $indicator; + final Prop>? $pendingItem; + final Prop>? $activeItem; + final Prop>? $completedItem; + final Prop>? $cancelledItem; + final Prop>? $pendingStatus; + final Prop>? $activeStatus; + final Prop>? $completedStatus; + final Prop>? $cancelledStatus; + + const UiPlanStyler.create({ + Prop>? viewport, + Prop>? item, + Prop>? summaryTitle, + Prop>? itemTitle, + Prop>? itemDetail, + Prop>? count, + Prop>? indicator, + Prop>? pendingItem, + Prop>? activeItem, + Prop>? completedItem, + Prop>? cancelledItem, + Prop>? pendingStatus, + Prop>? activeStatus, + Prop>? completedStatus, + Prop>? cancelledStatus, + super.variants, + super.modifier, + super.animation, + }) : $viewport = viewport, + $item = item, + $summaryTitle = summaryTitle, + $itemTitle = itemTitle, + $itemDetail = itemDetail, + $count = count, + $indicator = indicator, + $pendingItem = pendingItem, + $activeItem = activeItem, + $completedItem = completedItem, + $cancelledItem = cancelledItem, + $pendingStatus = pendingStatus, + $activeStatus = activeStatus, + $completedStatus = completedStatus, + $cancelledStatus = cancelledStatus; + + UiPlanStyler({ + BoxStyler? viewport, + FlexBoxStyler? item, + TextStyler? summaryTitle, + TextStyler? itemTitle, + TextStyler? itemDetail, + TextStyler? count, + IconStyler? indicator, + BoxStyler? pendingItem, + BoxStyler? activeItem, + BoxStyler? completedItem, + BoxStyler? cancelledItem, + IconStyler? pendingStatus, + IconStyler? activeStatus, + IconStyler? completedStatus, + IconStyler? cancelledStatus, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + viewport: Prop.maybeMix(viewport), + item: Prop.maybeMix(item), + summaryTitle: Prop.maybeMix(summaryTitle), + itemTitle: Prop.maybeMix(itemTitle), + itemDetail: Prop.maybeMix(itemDetail), + count: Prop.maybeMix(count), + indicator: Prop.maybeMix(indicator), + pendingItem: Prop.maybeMix(pendingItem), + activeItem: Prop.maybeMix(activeItem), + completedItem: Prop.maybeMix(completedItem), + cancelledItem: Prop.maybeMix(cancelledItem), + pendingStatus: Prop.maybeMix(pendingStatus), + activeStatus: Prop.maybeMix(activeStatus), + completedStatus: Prop.maybeMix(completedStatus), + cancelledStatus: Prop.maybeMix(cancelledStatus), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory UiPlanStyler.viewport(BoxStyler value) => + UiPlanStyler().viewport(value); + factory UiPlanStyler.item(FlexBoxStyler value) => UiPlanStyler().item(value); + factory UiPlanStyler.summaryTitle(TextStyler value) => + UiPlanStyler().summaryTitle(value); + factory UiPlanStyler.itemTitle(TextStyler value) => + UiPlanStyler().itemTitle(value); + factory UiPlanStyler.itemDetail(TextStyler value) => + UiPlanStyler().itemDetail(value); + factory UiPlanStyler.count(TextStyler value) => UiPlanStyler().count(value); + factory UiPlanStyler.indicator(IconStyler value) => + UiPlanStyler().indicator(value); + factory UiPlanStyler.pendingItem(BoxStyler value) => + UiPlanStyler().pendingItem(value); + factory UiPlanStyler.activeItem(BoxStyler value) => + UiPlanStyler().activeItem(value); + factory UiPlanStyler.completedItem(BoxStyler value) => + UiPlanStyler().completedItem(value); + factory UiPlanStyler.cancelledItem(BoxStyler value) => + UiPlanStyler().cancelledItem(value); + factory UiPlanStyler.pendingStatus(IconStyler value) => + UiPlanStyler().pendingStatus(value); + factory UiPlanStyler.activeStatus(IconStyler value) => + UiPlanStyler().activeStatus(value); + factory UiPlanStyler.completedStatus(IconStyler value) => + UiPlanStyler().completedStatus(value); + factory UiPlanStyler.cancelledStatus(IconStyler value) => + UiPlanStyler().cancelledStatus(value); + + @override + Set get $stylerFieldNames => const { + 'viewport', + 'item', + 'summaryTitle', + 'itemTitle', + 'itemDetail', + 'count', + 'indicator', + 'pendingItem', + 'activeItem', + 'completedItem', + 'cancelledItem', + 'pendingStatus', + 'activeStatus', + 'completedStatus', + 'cancelledStatus', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the viewport. + UiPlanStyler viewport(BoxStyler value) { + return merge(UiPlanStyler(viewport: value)); + } + + /// Sets the item. + UiPlanStyler item(FlexBoxStyler value) { + return merge(UiPlanStyler(item: value)); + } + + /// Sets the summaryTitle. + UiPlanStyler summaryTitle(TextStyler value) { + return merge(UiPlanStyler(summaryTitle: value)); + } + + /// Sets the itemTitle. + UiPlanStyler itemTitle(TextStyler value) { + return merge(UiPlanStyler(itemTitle: value)); + } + + /// Sets the itemDetail. + UiPlanStyler itemDetail(TextStyler value) { + return merge(UiPlanStyler(itemDetail: value)); + } + + /// Sets the count. + UiPlanStyler count(TextStyler value) { + return merge(UiPlanStyler(count: value)); + } + + /// Sets the indicator. + UiPlanStyler indicator(IconStyler value) { + return merge(UiPlanStyler(indicator: value)); + } + + /// Sets the pendingItem. + UiPlanStyler pendingItem(BoxStyler value) { + return merge(UiPlanStyler(pendingItem: value)); + } + + /// Sets the activeItem. + UiPlanStyler activeItem(BoxStyler value) { + return merge(UiPlanStyler(activeItem: value)); + } + + /// Sets the completedItem. + UiPlanStyler completedItem(BoxStyler value) { + return merge(UiPlanStyler(completedItem: value)); + } + + /// Sets the cancelledItem. + UiPlanStyler cancelledItem(BoxStyler value) { + return merge(UiPlanStyler(cancelledItem: value)); + } + + /// Sets the pendingStatus. + UiPlanStyler pendingStatus(IconStyler value) { + return merge(UiPlanStyler(pendingStatus: value)); + } + + /// Sets the activeStatus. + UiPlanStyler activeStatus(IconStyler value) { + return merge(UiPlanStyler(activeStatus: value)); + } + + /// Sets the completedStatus. + UiPlanStyler completedStatus(IconStyler value) { + return merge(UiPlanStyler(completedStatus: value)); + } + + /// Sets the cancelledStatus. + UiPlanStyler cancelledStatus(IconStyler value) { + return merge(UiPlanStyler(cancelledStatus: value)); + } + + /// Sets the animation configuration. + @override + UiPlanStyler animate(AnimationConfig value) { + return merge(UiPlanStyler(animation: value)); + } + + /// Sets the style variants. + @override + UiPlanStyler variants(List> value) { + return merge(UiPlanStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + UiPlanStyler wrap(WidgetModifierConfig value) { + return merge(UiPlanStyler(modifier: value)); + } + + /// Sets the widget modifier. + UiPlanStyler modifier(WidgetModifierConfig value) { + return merge(UiPlanStyler(modifier: value)); + } + + UiPlan call({ + Key? key, + required List items, + String title = 'Plan', + String emptyLabel = 'No tasks yet', + String semanticLabel = 'Task plan', + bool collapseOnComplete = true, + bool? expanded, + bool defaultExpanded = true, + ValueChanged? onExpandedChanged, + UiPlanStatusBuilder? statusBuilder, + UiPlanStatusLabelBuilder? statusLabelBuilder, + UiPlanIndicatorBuilder? indicatorBuilder, + bool followOutput = true, + double followThreshold = 48, + ValueChanged? onFollowChanged, + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + }) { + return UiPlan( + key: key, + style: this, + items: items, + title: title, + emptyLabel: emptyLabel, + semanticLabel: semanticLabel, + collapseOnComplete: collapseOnComplete, + expanded: expanded, + defaultExpanded: defaultExpanded, + onExpandedChanged: onExpandedChanged, + statusBuilder: statusBuilder, + statusLabelBuilder: statusLabelBuilder, + indicatorBuilder: indicatorBuilder, + followOutput: followOutput, + followThreshold: followThreshold, + onFollowChanged: onFollowChanged, + disclosureStyle: disclosureStyle, + ); + } + + /// Merges with another [UiPlanStyler]. + @override + UiPlanStyler merge(UiPlanStyler? other) { + return UiPlanStyler.create( + viewport: MixOps.merge($viewport, other?.$viewport), + item: MixOps.merge($item, other?.$item), + summaryTitle: MixOps.merge($summaryTitle, other?.$summaryTitle), + itemTitle: MixOps.merge($itemTitle, other?.$itemTitle), + itemDetail: MixOps.merge($itemDetail, other?.$itemDetail), + count: MixOps.merge($count, other?.$count), + indicator: MixOps.merge($indicator, other?.$indicator), + pendingItem: MixOps.merge($pendingItem, other?.$pendingItem), + activeItem: MixOps.merge($activeItem, other?.$activeItem), + completedItem: MixOps.merge($completedItem, other?.$completedItem), + cancelledItem: MixOps.merge($cancelledItem, other?.$cancelledItem), + pendingStatus: MixOps.merge($pendingStatus, other?.$pendingStatus), + activeStatus: MixOps.merge($activeStatus, other?.$activeStatus), + completedStatus: MixOps.merge($completedStatus, other?.$completedStatus), + cancelledStatus: MixOps.merge($cancelledStatus, other?.$cancelledStatus), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = UiPlanSpec( + viewport: MixOps.resolve(context, $viewport), + item: MixOps.resolve(context, $item), + summaryTitle: MixOps.resolve(context, $summaryTitle), + itemTitle: MixOps.resolve(context, $itemTitle), + itemDetail: MixOps.resolve(context, $itemDetail), + count: MixOps.resolve(context, $count), + indicator: MixOps.resolve(context, $indicator), + pendingItem: MixOps.resolve(context, $pendingItem), + activeItem: MixOps.resolve(context, $activeItem), + completedItem: MixOps.resolve(context, $completedItem), + cancelledItem: MixOps.resolve(context, $cancelledItem), + pendingStatus: MixOps.resolve(context, $pendingStatus), + activeStatus: MixOps.resolve(context, $activeStatus), + completedStatus: MixOps.resolve(context, $completedStatus), + cancelledStatus: MixOps.resolve(context, $cancelledStatus), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('viewport', $viewport)) + ..add(DiagnosticsProperty('item', $item)) + ..add(DiagnosticsProperty('summaryTitle', $summaryTitle)) + ..add(DiagnosticsProperty('itemTitle', $itemTitle)) + ..add(DiagnosticsProperty('itemDetail', $itemDetail)) + ..add(DiagnosticsProperty('count', $count)) + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('pendingItem', $pendingItem)) + ..add(DiagnosticsProperty('activeItem', $activeItem)) + ..add(DiagnosticsProperty('completedItem', $completedItem)) + ..add(DiagnosticsProperty('cancelledItem', $cancelledItem)) + ..add(DiagnosticsProperty('pendingStatus', $pendingStatus)) + ..add(DiagnosticsProperty('activeStatus', $activeStatus)) + ..add(DiagnosticsProperty('completedStatus', $completedStatus)) + ..add(DiagnosticsProperty('cancelledStatus', $cancelledStatus)); + } + + @override + List get props => [ + $viewport, + $item, + $summaryTitle, + $itemTitle, + $itemDetail, + $count, + $indicator, + $pendingItem, + $activeItem, + $completedItem, + $cancelledItem, + $pendingStatus, + $activeStatus, + $completedStatus, + $cancelledStatus, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/dashboard/lib/ui/components/popover.dart b/apps/dashboard/lib/ui/components/popover.dart new file mode 100644 index 000000000..08f65cb0b --- /dev/null +++ b/apps/dashboard/lib/ui/components/popover.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'popover.g.dart'; + +/// Ui popover size presets matching Radix Themes 3.3.0. +enum UiPopoverSize { size1, size2, size3, size4 } + +/// Ui-themed preset for [RemixPopover]. +/// +/// The generated [UiPopover] defaults to [UiPopoverSize.size2], a +/// 480-pixel maximum width, and no arrow. +@MixWidget(target: RemixPopover.new) +PopoverStyler uiPopoverStyle({ + UiPopoverSize size = UiPopoverSize.size2, + PopoverStyler style = const PopoverStyler.create(), +}) { + final radius = switch (size) { + UiPopoverSize.size1 || UiPopoverSize.size2 => UiTokens.radius4(), + UiPopoverSize.size3 || UiPopoverSize.size4 => UiTokens.radius5(), + }; + final padding = switch (size) { + UiPopoverSize.size1 => UiTokens.space3(), + UiPopoverSize.size2 => UiTokens.space4(), + UiPopoverSize.size3 => UiTokens.space5(), + UiPopoverSize.size4 => UiTokens.space6(), + }; + + return PopoverStyler() + .maxWidth(480) + .padding(.all(padding)) + .borderRadius(.all(radius)) + .color(UiTokens.colorPanel()) + .decoration(BoxDecorationMix.create(boxShadow: UiTokens.shadow5.mix())) + .containerEffects(RemixBoxEffectsMix.backdropBlur(UiTokens.panelBlur())) + .merge(style); +} diff --git a/apps/dashboard/lib/ui/components/popover.g.dart b/apps/dashboard/lib/ui/components/popover.g.dart new file mode 100644 index 000000000..71ed6160f --- /dev/null +++ b/apps/dashboard/lib/ui/components/popover.g.dart @@ -0,0 +1,87 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'popover.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixPopover]. +/// +/// The generated [UiPopover] defaults to [UiPopoverSize.size2], a +/// 480-pixel maximum width, and no arrow. +class UiPopover extends StatelessWidget { + const UiPopover({ + super.key, + this.size = UiPopoverSize.size2, + this.style = const PopoverStyler.create(), + required this.popoverChild, + required this.child, + this.positioning = const OverlayPositionConfig(), + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.openOnTap = true, + this.triggerFocusNode, + this.onOpen, + this.onClose, + this.onOpenRequested, + this.onCloseRequested, + this.controller, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final UiPopoverSize size; + + final PopoverStyler style; + + final Widget popoverChild; + + final Widget child; + + final OverlayPositionConfig positioning; + + final bool consumeOutsideTaps; + + final bool useRootOverlay; + + final bool openOnTap; + + final FocusNode? triggerFocusNode; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final RawMenuAnchorOpenRequestedCallback? onOpenRequested; + + final RawMenuAnchorCloseRequestedCallback? onCloseRequested; + + final MenuController? controller; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixPopover( + key: this.key, + style: uiPopoverStyle(size: this.size, style: this.style), + popoverChild: this.popoverChild, + child: this.child, + positioning: this.positioning, + consumeOutsideTaps: this.consumeOutsideTaps, + useRootOverlay: this.useRootOverlay, + openOnTap: this.openOnTap, + triggerFocusNode: this.triggerFocusNode, + onOpen: this.onOpen, + onClose: this.onClose, + onOpenRequested: this.onOpenRequested, + onCloseRequested: this.onCloseRequested, + controller: this.controller, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/progress.dart b/apps/dashboard/lib/ui/components/progress.dart new file mode 100644 index 000000000..2a32f7f84 --- /dev/null +++ b/apps/dashboard/lib/ui/components/progress.dart @@ -0,0 +1,118 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'progress.g.dart'; + +/// Ui progress size presets. +enum UiProgressSize { size1, size2, size3 } + +/// Ui progress color variants. +enum UiProgressVariant { classic, surface, soft } + +/// Ui-themed preset for [RemixProgress]. +@MixWidget(target: RemixProgress.new) +ProgressStyler uiProgressStyle({ + UiProgressVariant variant = .surface, + UiProgressSize size = .size2, + bool highContrast = false, + ProgressStyler style = const ProgressStyler.create(), +}) { + return (switch (variant) { + .classic => _uiProgressClassicStyler(size, highContrast: highContrast), + .surface => _uiProgressSurfaceStyler(size, highContrast: highContrast), + .soft => _uiProgressSoftStyler(size, highContrast: highContrast), + }).merge(style); +} + +ProgressStyler _uiProgressBaseStyler(UiProgressSize size) { + final metrics = _uiProgressMetrics(size); + return ProgressStyler( + container: .width(.infinity) + .height(metrics.height) + .borderRadius(.all(metrics.radius)) + .clipBehavior(.antiAlias), + track: .width(.infinity).height(metrics.height), + indicator: .height(metrics.height).borderRadius(.all(metrics.radius)), + trackEffects: RemixBoxEffectsMix( + behindContent: _uiProgressLayer(), + overContent: _uiProgressLayer(), + ), + indicatorEffects: RemixBoxEffectsMix( + behindContent: _uiProgressLayer(), + overContent: _uiProgressLayer(), + ), + ); +} + +ProgressStyler _uiProgressClassicStyler( + UiProgressSize size, { + required bool highContrast, +}) { + return _uiProgressBaseStyler(size) + .trackColor(UiTokens.grayA3()) + .trackEffects( + RemixBoxEffectsMix.overContent( + _uiProgressLayer(shadowToken: UiTokens.shadow1Layers), + ), + ) + .indicatorColor( + highContrast ? UiTokens.accent12() : UiTokens.accentTrack(), + ); +} + +ProgressStyler _uiProgressSurfaceStyler( + UiProgressSize size, { + required bool highContrast, +}) { + return _uiProgressBaseStyler(size) + .trackColor(UiTokens.grayA3()) + .trackEffects( + RemixBoxEffectsMix.overContent( + _uiProgressLayer( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: UiTokens.grayA4(), + spreadRadius: 1, + ), + ], + ), + ), + ) + .indicatorColor( + highContrast ? UiTokens.accent12() : UiTokens.accentTrack(), + ); +} + +ProgressStyler _uiProgressSoftStyler( + UiProgressSize size, { + required bool highContrast, +}) { + return _uiProgressBaseStyler(size) + .trackColor(UiTokens.grayA4()) + .track(.foregroundDecoration(BoxDecorationMix(color: UiTokens.whiteA1()))) + .indicatorColor(highContrast ? UiTokens.accent12() : UiTokens.accent8()) + .indicator( + .foregroundDecoration( + BoxDecorationMix(color: highContrast ? null : UiTokens.accentA5()), + ), + ); +} + +({double height, Radius radius}) _uiProgressMetrics(UiProgressSize size) => + switch (size) { + .size1 => (height: UiTokens.space1(), radius: UiTokens.progressRadius1()), + .size2 => ( + height: UiTokens.progressHeight2(), + radius: UiTokens.progressRadius2(), + ), + .size3 => (height: UiTokens.space2(), radius: UiTokens.progressRadius3()), + }; + +RemixBoxEffectLayerMix _uiProgressLayer({ + List? shadows, + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix(shadows: shadows, shadowToken: shadowToken); diff --git a/apps/dashboard/lib/ui/components/progress.g.dart b/apps/dashboard/lib/ui/components/progress.g.dart new file mode 100644 index 000000000..ba2ab4df1 --- /dev/null +++ b/apps/dashboard/lib/ui/components/progress.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'progress.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixProgress]. +class UiProgress extends StatelessWidget { + const UiProgress({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const ProgressStyler.create(), + required this.value, + this.semanticsLabel, + this.semanticsValue, + }); + + const UiProgress.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ProgressStyler.create(), + required this.value, + this.semanticsLabel, + this.semanticsValue, + }) : variant = UiProgressVariant.classic; + + const UiProgress.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ProgressStyler.create(), + required this.value, + this.semanticsLabel, + this.semanticsValue, + }) : variant = UiProgressVariant.surface; + + const UiProgress.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ProgressStyler.create(), + required this.value, + this.semanticsLabel, + this.semanticsValue, + }) : variant = UiProgressVariant.soft; + + final UiProgressVariant variant; + + final UiProgressSize size; + + final bool highContrast; + + final ProgressStyler style; + + final double value; + + final String? semanticsLabel; + + final String? semanticsValue; + + @override + Widget build(BuildContext context) { + return RemixProgress( + key: this.key, + style: uiProgressStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + value: this.value, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/radio.dart b/apps/dashboard/lib/ui/components/radio.dart new file mode 100644 index 000000000..9cbd178ea --- /dev/null +++ b/apps/dashboard/lib/ui/components/radio.dart @@ -0,0 +1,238 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'radio.g.dart'; + +/// Ui radio size presets. +enum UiRadioSize { + /// Compact radio. + size1, + + /// Default radio. + size2, + + /// Large radio. + size3, +} + +/// Ui radio color variants. +enum UiRadioVariant { + /// Raised treatment with Radix's classic shadow and gradient layers. + classic, + + /// Surface treatment with neutral border. + surface, + + /// Soft accent treatment. + soft, +} + +/// Ui-themed preset for [RemixRadio]. +@MixWidget(target: RemixRadio.new) +RadioStyler uiRadioStyle({ + UiRadioVariant variant = .surface, + UiRadioSize size = .size2, + bool highContrast = false, + RadioStyler style = const RadioStyler.create(), +}) { + return (switch (variant) { + .classic => _uiRadioClassicStyler(size, highContrast: highContrast), + .surface => _uiRadioSurfaceStyler(size, highContrast: highContrast), + .soft => _uiRadioSoftStyler(size, highContrast: highContrast), + }).merge(style); +} + +RadioStyler _uiRadioBaseStyler(UiRadioSize size) { + final metrics = _uiRadioMetrics(size); + return RadioStyler( + container: .size( + metrics.size, + metrics.size, + ).alignment(.center).borderRadius(.all(UiTokens.radiusCircle())), + indicator: .size( + metrics.indicatorSize, + metrics.indicatorSize, + ).borderRadius(.all(UiTokens.radiusCircle())), + containerEffects: RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix(), + overContent: RemixBoxEffectLayerMix(), + ), + ).onFocusVisible( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: UiTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ); +} + +RadioStyler _uiRadioClassicStyler( + UiRadioSize size, { + required bool highContrast, +}) { + final selectedColor = highContrast + ? UiTokens.accent12() + : UiTokens.accentIndicator(); + return _uiRadioBaseStyler(size) + .color(UiTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadowToken: UiTokens.shadow1Layers), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + uiInsetSurface(strokes: [UiTokens.gray7()]), + ), + ) + .indicatorColor( + highContrast ? UiTokens.accent1() : UiTokens.accentContrast(), + ) + .onSelected( + .color(selectedColor) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [ + UiTokens.whiteA3(), + const Color(0x00000000), + UiTokens.blackA3(), + ], + ), + ], + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: UiTokens.whiteA4(), + offset: const Offset(0, 0.5), + blurRadius: 0.5, + ), + RemixBoxShadowMix( + kind: .inset, + color: UiTokens.blackA4(), + offset: const Offset(0, -0.5), + blurRadius: 0.5, + ), + ], + ), + ), + ) + .indicatorColor( + highContrast ? UiTokens.accent1() : UiTokens.accentContrast(), + ), + ) + .onDisabled( + .color(UiTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadowToken: UiTokens.shadow1Layers), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor(UiTokens.grayA8()), + ); +} + +RadioStyler _uiRadioSurfaceStyler( + UiRadioSize size, { + required bool highContrast, +}) { + return _uiRadioBaseStyler(size) + .color(UiTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + uiInsetSurface(strokes: [UiTokens.grayA7()]), + ), + ) + .indicator( + .color(UiTokens.accent9()).borderRadius(.all(UiTokens.radiusCircle())), + ) + .onSelected( + .color(highContrast ? UiTokens.accent12() : UiTokens.accentIndicator()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor( + highContrast ? UiTokens.accent1() : UiTokens.accentContrast(), + ), + ) + .onDisabled( + .color(UiTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + uiInsetSurface(strokes: [UiTokens.grayA6()]), + ), + ) + .indicatorColor(UiTokens.grayA8()), + ); +} + +RadioStyler _uiRadioSoftStyler(UiRadioSize size, {required bool highContrast}) { + return _uiRadioBaseStyler(size) + .color(UiTokens.accentA4()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .indicator( + .color( + highContrast ? UiTokens.accent12() : UiTokens.accent11(), + ).borderRadius(.all(UiTokens.radiusCircle())), + ) + .onSelected( + .color(UiTokens.accentA4()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .indicator( + .color(highContrast ? UiTokens.accent12() : UiTokens.accent11()), + ), + ) + .onDisabled( + .color(UiTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .indicatorColor(UiTokens.grayA8()), + ); +} + +({double size, double indicatorSize}) _uiRadioMetrics(UiRadioSize size) => + switch (size) { + .size1 => ( + size: UiTokens.checkboxSize1(), + indicatorSize: UiTokens.radioIndicatorSize1(), + ), + .size2 => ( + size: UiTokens.space4(), + indicatorSize: UiTokens.radioIndicatorSize2(), + ), + .size3 => ( + size: UiTokens.checkboxSize3(), + indicatorSize: UiTokens.radioIndicatorSize3(), + ), + }; diff --git a/apps/dashboard/lib/ui/components/radio.g.dart b/apps/dashboard/lib/ui/components/radio.g.dart new file mode 100644 index 000000000..03d95eb43 --- /dev/null +++ b/apps/dashboard/lib/ui/components/radio.g.dart @@ -0,0 +1,119 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'radio.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixRadio]. +class UiRadio extends StatelessWidget { + const UiRadio({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const RadioStyler.create(), + required this.value, + required this.semanticLabel, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + }); + + /// Raised treatment with Radix's classic shadow and gradient layers. + const UiRadio.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const RadioStyler.create(), + required this.value, + required this.semanticLabel, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + }) : variant = UiRadioVariant.classic; + + /// Surface treatment with neutral border. + const UiRadio.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const RadioStyler.create(), + required this.value, + required this.semanticLabel, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + }) : variant = UiRadioVariant.surface; + + /// Soft accent treatment. + const UiRadio.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const RadioStyler.create(), + required this.value, + required this.semanticLabel, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + }) : variant = UiRadioVariant.soft; + + final UiRadioVariant variant; + + final UiRadioSize size; + + final bool highContrast; + + final RadioStyler style; + + final T value; + + final String semanticLabel; + + final bool enabled; + + final bool toggleable; + + final MouseCursor? mouseCursor; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixRadio( + key: this.key, + style: uiRadioStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + value: this.value, + semanticLabel: this.semanticLabel, + enabled: this.enabled, + toggleable: this.toggleable, + mouseCursor: this.mouseCursor, + focusNode: this.focusNode, + autofocus: this.autofocus, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/segmented_control.dart b/apps/dashboard/lib/ui/components/segmented_control.dart new file mode 100644 index 000000000..6a1c2359e --- /dev/null +++ b/apps/dashboard/lib/ui/components/segmented_control.dart @@ -0,0 +1,240 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'segmented_control.g.dart'; + +double _resolveSegmentedControlActiveLetterSpacing3(BuildContext context) { + final fontSize = UiTokens.text3.resolve(context).fontSize!; + return -0.01 * fontSize; +} + +const _segmentedControlActiveLetterSpacing3 = ContextToken( + _resolveSegmentedControlActiveLetterSpacing3, +); + +/// Radix layers the track as `color-surface` under a `gray-a3` +/// background-image. One BoxDecoration cannot stack two background fills, so +/// the recipe pre-blends the pair; a foreground overlay would instead paint +/// over the selected indicator fill, breaking the source z-order. +Color _resolveSegmentedControlTrackBackground(BuildContext context) => + Color.alphaBlend( + UiTokens.grayA3.resolve(context), + UiTokens.colorSurface.resolve(context), + ); + +const _segmentedControlTrackBackground = ContextToken( + _resolveSegmentedControlTrackBackground, +); + +/// The disabled root swaps only `background-color` to `gray-3`; the `gray-a3` +/// background-image layer persists in the source, so it stays in the blend. +Color _resolveSegmentedControlDisabledTrackBackground(BuildContext context) => + Color.alphaBlend( + UiTokens.grayA3.resolve(context), + UiTokens.gray3.resolve(context), + ); + +const _segmentedControlDisabledTrackBackground = ContextToken( + _resolveSegmentedControlDisabledTrackBackground, +); + +/// Radix Themes SegmentedControl size presets. +enum UiSegmentedControlSize { size1, size2, size3 } + +/// Radix Themes SegmentedControl variants. +enum UiSegmentedControlVariant { surface, classic } + +/// Ui recipe for [RemixSegmentedControl]. +/// +/// Content icons use size-matched 12/16/20 token defaults rather than the +/// ambient icon size. Control and item styles may override these defaults. +/// +/// Paints the selected item in place. It does not reproduce Radix's sliding +/// indicator, duplicate-label crossfade, inactive separators, or max-content +/// overflow. Changing an item's label with the selection can therefore cause a +/// small intrinsic-width shift. +@MixWidget(target: RemixSegmentedControl.new) +SegmentedControlStyler uiSegmentedControlStyle({ + UiSegmentedControlVariant variant = .surface, + UiSegmentedControlSize size = .size2, + SegmentedControlStyler style = const SegmentedControlStyler.create(), +}) { + final metrics = _uiSegmentedControlMetrics(size); + final item = _uiSegmentedControlItemStyle(variant, metrics); + + return SegmentedControlStyler() + .mainAxisSize(.min) + .minHeight(metrics.height) + .borderRadius(.all(metrics.radius)) + .color(_segmentedControlTrackBackground()) + .clipBehavior(.antiAlias) + .item(item) + .onDisabled( + SegmentedControlStyler().color( + _segmentedControlDisabledTrackBackground(), + ), + ) + .merge(style); +} + +SegmentedControlItemStyler _uiSegmentedControlItemStyle( + UiSegmentedControlVariant variant, + _UiSegmentedControlMetrics metrics, +) { + final base = SegmentedControlItemStyler() + .minHeight(metrics.height) + .padding(.horizontal(metrics.paddingX)) + .spacing(metrics.itemGap) + .label( + TextStyler() + .style(metrics.text.mix()) + .color(UiTokens.gray12()) + .fontWeight(UiTokens.fontWeightRegular()) + .letterSpacing(0) + .wordSpacing(0) + // Radix keeps `min-width: max-content` on the track, so a label + // never wraps and the track overflows a narrow parent instead. + // The equal-segment layout shrinks to fit, so pin one line and + // ellipsize to preserve the same single-line behavior. + .maxLines(1) + .overflow(TextOverflow.ellipsis), + ) + .icon(IconStyler().color(UiTokens.gray12()).size(metrics.iconSize)) + .containerEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix(), + overContent: RemixBoxEffectLayerMix(), + ), + ); + final selected = _uiSegmentedControlSelectedItem(variant, metrics); + final disabled = SegmentedControlItemStyler() + .label(TextStyler().color(UiTokens.grayA8())) + .icon(IconStyler().color(UiTokens.grayA8())); + final disabledSelected = disabled + .color(const Color(0x00000000)) + .borderRadius(.all(metrics.radius)) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _uiSegmentedControlFill(UiTokens.grayA3()), + overContent: RemixBoxEffectLayerMix(shadows: const []), + ), + ); + + return base + .onHovered(.color(UiTokens.grayA2())) + .onSelected( + selected + .onHovered(.color(const Color(0x00000000))) + .onDisabled(disabledSelected), + ) + .onFocusVisible( + SegmentedControlItemStyler() + .borderRadius(.all(metrics.radius)) + .containerEffects(uiFocusOutline(UiTokens.focus8(), offset: -1)), + ) + .onDisabled(disabled.onSelected(disabledSelected)); +} + +SegmentedControlItemStyler _uiSegmentedControlSelectedItem( + UiSegmentedControlVariant variant, + _UiSegmentedControlMetrics metrics, +) { + final overContent = switch (variant) { + .surface => RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + color: UiTokens.grayA4(), + spreadRadius: 1, + shapeInset: 1, + ), + ], + ), + .classic => RemixBoxEffectLayerMix( + shadowToken: UiTokens.segmentedControlClassicIndicatorShadows, + ), + }; + + return SegmentedControlItemStyler() + .color(const Color(0x00000000)) + .borderRadius(.all(metrics.radius)) + .label( + TextStyler() + .fontWeight(UiTokens.fontWeightMedium()) + .letterSpacing(metrics.activeLetterSpacing) + .wordSpacing(0), + ) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _uiSegmentedControlFill( + UiTokens.segmentedControlIndicatorBackground(), + inset: 1, + ), + overContent: overContent, + ), + ); +} + +RemixBoxEffectLayerMix _uiSegmentedControlFill(Color color, {double? inset}) => + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix(colors: [color, color]), + ], + gradientInsets: inset == null ? const [] : [inset], + ); + +class _UiSegmentedControlMetrics { + const _UiSegmentedControlMetrics({ + required this.height, + required this.paddingX, + required this.itemGap, + required this.radius, + required this.text, + required this.activeLetterSpacing, + required this.iconSize, + }); + + final double height; + final double paddingX; + final double itemGap; + final Radius radius; + final TextStyleToken text; + final double activeLetterSpacing; + final double iconSize; +} + +_UiSegmentedControlMetrics _uiSegmentedControlMetrics( + UiSegmentedControlSize size, +) => switch (size) { + .size1 => _UiSegmentedControlMetrics( + height: UiTokens.space5(), + paddingX: UiTokens.space3(), + itemGap: UiTokens.space1(), + radius: UiTokens.radius2OrFull(), + text: UiTokens.text1, + activeLetterSpacing: UiTokens.tabActiveLetterSpacing1(), + iconSize: UiTokens.space3(), + ), + .size2 => _UiSegmentedControlMetrics( + height: UiTokens.space6(), + paddingX: UiTokens.space4(), + itemGap: UiTokens.space2(), + radius: UiTokens.radius2OrFull(), + text: UiTokens.text2, + activeLetterSpacing: UiTokens.tabActiveLetterSpacing2(), + iconSize: UiTokens.space4(), + ), + .size3 => _UiSegmentedControlMetrics( + height: UiTokens.space7(), + paddingX: UiTokens.space4(), + itemGap: UiTokens.space3(), + radius: UiTokens.radius3OrFull(), + text: UiTokens.text3, + // The pinned `-0.01em` is derived from the resolved size-3 text token so + // it remains exact at every Ui scaling without adding an eighth token. + activeLetterSpacing: _segmentedControlActiveLetterSpacing3(), + iconSize: UiTokens.spinnerSize3(), + ), +}; diff --git a/apps/dashboard/lib/ui/components/segmented_control.g.dart b/apps/dashboard/lib/ui/components/segmented_control.g.dart new file mode 100644 index 000000000..579b60291 --- /dev/null +++ b/apps/dashboard/lib/ui/components/segmented_control.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'segmented_control.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui recipe for [RemixSegmentedControl]. +/// +/// Content icons use size-matched 12/16/20 token defaults rather than the +/// ambient icon size. Control and item styles may override these defaults. +/// +/// Paints the selected item in place. It does not reproduce Radix's sliding +/// indicator, duplicate-label crossfade, inactive separators, or max-content +/// overflow. Changing an item's label with the selection can therefore cause a +/// small intrinsic-width shift. +class UiSegmentedControl extends StatelessWidget { + const UiSegmentedControl({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const SegmentedControlStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }); + + const UiSegmentedControl.surface({ + super.key, + this.size = .size2, + this.style = const SegmentedControlStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = UiSegmentedControlVariant.surface; + + const UiSegmentedControl.classic({ + super.key, + this.size = .size2, + this.style = const SegmentedControlStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = UiSegmentedControlVariant.classic; + + final UiSegmentedControlVariant variant; + + final UiSegmentedControlSize size; + + final SegmentedControlStyler style; + + final List> items; + + final T? selectedValue; + + final ValueChanged? onChanged; + + final bool enabled; + + final Axis orientation; + + final bool loop; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixSegmentedControl( + key: this.key, + style: uiSegmentedControlStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + items: this.items, + selectedValue: this.selectedValue, + onChanged: this.onChanged, + enabled: this.enabled, + orientation: this.orientation, + loop: this.loop, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/select.dart b/apps/dashboard/lib/ui/components/select.dart new file mode 100644 index 000000000..e4ad6b811 --- /dev/null +++ b/apps/dashboard/lib/ui/components/select.dart @@ -0,0 +1,304 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'select.g.dart'; + +/// Radix Themes Select root size presets. +enum UiSelectSize { size1, size2, size3 } + +/// Radix Themes Select variants. +enum UiSelectVariant { surface, soft, ghost } + +/// Ui-themed Select with Radix-owned trigger and content configuration. +/// +/// Content icons use size-matched 12/16/20 token defaults rather than the +/// ambient icon size. Override the trigger icon through [style] when needed. +@MixWidget(target: RemixSelect.new) +SelectStyler uiSelectStyle({ + UiSelectVariant variant = .surface, + UiSelectSize size = .size2, + bool highContrast = false, + SelectStyler style = const SelectStyler.create(), +}) { + return SelectStyler() + .trigger(_uiSelectTriggerStyler(variant, size)) + .content(_uiSelectContentStyler(size)) + .item(_uiSelectItemStyler(variant, size, highContrast: highContrast)) + .merge(style); +} + +/// Creates the established combined-variant Select item recipe. +SelectMenuItemStyler uiSelectMenuItemStyle({ + UiSelectVariant variant = .surface, + UiSelectSize size = .size2, + bool highContrast = false, +}) => _uiSelectItemStyler(variant, size, highContrast: highContrast); + +SelectTriggerStyler _uiSelectTriggerStyler( + UiSelectVariant variant, + UiSelectSize size, +) { + final radius = _uiSelectTriggerRadius(size); + final base = SelectTriggerStyler() + .direction(.horizontal) + .mainAxisAlignment(.spaceBetween) + .borderRadius(.all(radius)) + .label(_uiSelectTriggerText(size, color: UiTokens.gray12())) + .placeholder(_uiSelectTriggerText(size, color: UiTokens.grayA10())) + .icon( + .color(UiTokens.gray12()).size(switch (size) { + .size1 => UiTokens.space3(), + .size2 => UiTokens.space4(), + .size3 => UiTokens.spinnerSize3(), + }), + ) + .indicator(.color(UiTokens.gray12()).size(size == .size3 ? 11 : 9)) + .onFocusVisible( + .containerEffects(RemixBoxEffectsMix.overContent(_uiSelectFocusRing())), + ) + .merge(_uiSelectTriggerSizeStyler(variant, size)); + + return switch (variant) { + .surface => _uiSelectSurfaceTrigger(base), + .soft => _uiSelectSoftTrigger(base), + .ghost => _uiSelectGhostTrigger(base), + }; +} + +TextStyler _uiSelectTriggerText(UiSelectSize size, {Color? color}) { + final token = switch (size) { + .size1 => UiTokens.text1, + .size2 => UiTokens.text2, + .size3 => UiTokens.text3, + }; + return TextStyler( + style: token.mix(), + ).fontWeight(UiTokens.fontWeightRegular()).color(color ?? UiTokens.gray12()); +} + +Radius _uiSelectTriggerRadius(UiSelectSize size) => switch (size) { + .size1 => UiTokens.radius1OrFull(), + .size2 => UiTokens.radius2OrFull(), + .size3 => UiTokens.radius3OrFull(), +}; + +SelectTriggerStyler _uiSelectTriggerSizeStyler( + UiSelectVariant variant, + UiSelectSize size, +) { + final style = SelectTriggerStyler().spacing(switch (size) { + .size1 => UiTokens.space1(), + .size2 => UiTokens.selectSpace1Half(), + .size3 => UiTokens.space2(), + }); + return switch (variant) { + .ghost => switch (size) { + .size1 || .size2 => + style + .padding(.horizontal(UiTokens.space2())) + .padding(.vertical(UiTokens.space1())) + .margin(.horizontal(UiTokens.selectGhostMarginX12())) + .margin(.vertical(UiTokens.selectGhostMarginY12())), + .size3 => + style + .padding(.horizontal(UiTokens.space3())) + .padding(.vertical(UiTokens.selectSpace1Half())) + .margin(.horizontal(UiTokens.selectGhostMarginX3())) + .margin(.vertical(UiTokens.selectGhostMarginY3())), + }, + .surface || .soft => switch (size) { + .size1 => + style.height(UiTokens.space5()).padding(.horizontal(UiTokens.space2())), + .size2 => + style.height(UiTokens.space6()).padding(.horizontal(UiTokens.space3())), + .size3 => + style.height(UiTokens.space7()).padding(.horizontal(UiTokens.space4())), + }, + }; +} + +RemixBoxEffectLayerMix _uiSelectFocusRing() { + return RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix(color: UiTokens.focus8(), spreadRadius: 1), + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: UiTokens.focus8(), + spreadRadius: 1, + ), + ], + ); +} + +SelectTriggerStyler _uiSelectSurfaceTrigger(SelectTriggerStyler base) { + return base + .indicatorOpacity(0.9) + .color(UiTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.grayA7()]), + ), + ) + .onHovered( + .containerEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.grayA8()]), + ), + ), + ) + .onSelected( + .containerEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.grayA8()]), + ), + ), + ) + .onDisabled( + .color(UiTokens.grayA2()) + .label(.color(UiTokens.grayA11())) + .icon(.color(UiTokens.grayA9())) + .indicator(.color(UiTokens.grayA9())) + .containerEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.grayA6()]), + ), + ), + ); +} + +SelectTriggerStyler _uiSelectSoftTrigger(SelectTriggerStyler base) { + return base + .label(.color(UiTokens.accent12())) + .placeholder(.color(UiTokens.accent12())) + .placeholderOpacity(0.6) + .icon(.color(UiTokens.accent12())) + .indicator(.color(UiTokens.accent12())) + .color(UiTokens.accentA3()) + .onHovered(.color(UiTokens.accentA4())) + .onSelected(.color(UiTokens.accentA4())) + .onDisabled( + .label(.color(UiTokens.grayA11())) + .icon(.color(UiTokens.grayA9())) + .indicator(.color(UiTokens.grayA9())) + .color(UiTokens.grayA3()), + ); +} + +SelectTriggerStyler _uiSelectGhostTrigger(SelectTriggerStyler base) { + return base + .label(.color(UiTokens.accent12())) + .placeholder(.color(UiTokens.accent12())) + .placeholderOpacity(0.6) + .icon(.color(UiTokens.accent12())) + .indicator(.color(UiTokens.accent12())) + .color(const Color(0x00000000)) + .onHovered(.color(UiTokens.accentA3())) + .onSelected(.color(UiTokens.accentA3())) + .onDisabled( + .label(.color(UiTokens.grayA11())) + .icon(.color(UiTokens.grayA9())) + .indicator(.color(UiTokens.grayA9())) + .color(const Color(0x00000000)), + ); +} + +SelectContentStyler _uiSelectContentStyler(UiSelectSize size) { + final radius = switch (size) { + .size1 => UiTokens.radius3(), + .size2 || .size3 => UiTokens.radius4(), + }; + return SelectContentStyler() + .padding( + .all(switch (size) { + .size1 => UiTokens.space1(), + .size2 || .size3 => UiTokens.space2(), + }), + ) + .borderRadius(.all(radius)) + .color(UiTokens.colorPanel()) + .decoration(BoxDecorationMix.create(boxShadow: UiTokens.shadow5.mix())) + .clipBehavior(Clip.antiAlias) + .containerEffects(RemixBoxEffectsMix.backdropBlur(UiTokens.panelBlur())); +} + +SelectMenuItemStyler _uiSelectItemStyler( + UiSelectVariant variant, + UiSelectSize size, { + bool highContrast = false, +}) { + final metrics = _uiSelectContentMetrics(size); + final base = SelectMenuItemStyler() + .direction(.horizontal) + .height(metrics.itemHeight) + .padding(.horizontal(metrics.indicatorWidth)) + .borderRadius(.all(metrics.itemRadius)) + .text(TextStyler(style: metrics.itemText.mix()).color(UiTokens.gray12())) + .indicator( + BoxStyler( + alignment: .center, + constraints: BoxConstraintsMix.width(metrics.indicatorWidth), + ), + ) + .icon(IconStyler(color: UiTokens.gray12(), size: metrics.indicatorSize)); + + final highlighted = switch (variant) { + .surface || .ghost => + SelectMenuItemStyler() + .color(highContrast ? UiTokens.accent12() : UiTokens.accent9()) + .text( + TextStyler().color( + highContrast ? UiTokens.accent1() : UiTokens.accentContrast(), + ), + ) + .iconColor( + highContrast ? UiTokens.accent1() : UiTokens.accentContrast(), + ), + .soft => SelectMenuItemStyler().color(UiTokens.accentA4()), + }; + + // Naked's focused option is Radix's roving `data-highlighted` item, not a + // CSS focus ring, so this intentionally follows raw focus. + return base + .onHovered(highlighted) + .onFocused(highlighted) + .onPressed(highlighted) + .onDisabled( + .color( + const Color(0x00000000), + ).text(.color(UiTokens.grayA8())).iconColor(UiTokens.grayA8()), + ); +} + +({ + double itemHeight, + double indicatorWidth, + double indicatorSize, + Radius itemRadius, + TextStyleToken itemText, +}) +_uiSelectContentMetrics(UiSelectSize size) => switch (size) { + .size1 => ( + itemHeight: UiTokens.space5(), + indicatorWidth: UiTokens.selectIndicatorWidth1(), + indicatorSize: UiTokens.selectIndicatorSize1(), + itemRadius: UiTokens.radius1(), + itemText: UiTokens.text1, + ), + .size2 => ( + itemHeight: UiTokens.space6(), + indicatorWidth: UiTokens.space5(), + indicatorSize: UiTokens.selectIndicatorSize2(), + itemRadius: UiTokens.radius2(), + itemText: UiTokens.text2, + ), + .size3 => ( + itemHeight: UiTokens.space6(), + indicatorWidth: UiTokens.space5(), + indicatorSize: UiTokens.selectIndicatorSize2(), + itemRadius: UiTokens.radius2(), + itemText: UiTokens.text3, + ), +}; diff --git a/apps/dashboard/lib/ui/components/select.g.dart b/apps/dashboard/lib/ui/components/select.g.dart new file mode 100644 index 000000000..4b4e67586 --- /dev/null +++ b/apps/dashboard/lib/ui/components/select.g.dart @@ -0,0 +1,159 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'select.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed Select with Radix-owned trigger and content configuration. +/// +/// Content icons use size-matched 12/16/20 token defaults rather than the +/// ambient icon size. Override the trigger icon through [style] when needed. +class UiSelect extends StatelessWidget { + const UiSelect({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const SelectStyler.create(), + required this.trigger, + required this.items, + this.selectedValue, + this.positioning = const OverlayPositionConfig( + side: .bottom, + alignment: .center, + ), + this.onChanged, + this.onOpen, + this.onClose, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.closeOnSelect = true, + this.focusNode, + }); + + const UiSelect.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SelectStyler.create(), + required this.trigger, + required this.items, + this.selectedValue, + this.positioning = const OverlayPositionConfig( + side: .bottom, + alignment: .center, + ), + this.onChanged, + this.onOpen, + this.onClose, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.closeOnSelect = true, + this.focusNode, + }) : variant = UiSelectVariant.surface; + + const UiSelect.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SelectStyler.create(), + required this.trigger, + required this.items, + this.selectedValue, + this.positioning = const OverlayPositionConfig( + side: .bottom, + alignment: .center, + ), + this.onChanged, + this.onOpen, + this.onClose, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.closeOnSelect = true, + this.focusNode, + }) : variant = UiSelectVariant.soft; + + const UiSelect.ghost({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SelectStyler.create(), + required this.trigger, + required this.items, + this.selectedValue, + this.positioning = const OverlayPositionConfig( + side: .bottom, + alignment: .center, + ), + this.onChanged, + this.onOpen, + this.onClose, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.closeOnSelect = true, + this.focusNode, + }) : variant = UiSelectVariant.ghost; + + final UiSelectVariant variant; + + final UiSelectSize size; + + final bool highContrast; + + final SelectStyler style; + + final RemixSelectTrigger trigger; + + final List> items; + + final T? selectedValue; + + final OverlayPositionConfig positioning; + + final ValueChanged? onChanged; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final bool enabled; + + final MouseCursor mouseCursor; + + final String? semanticLabel; + + final bool closeOnSelect; + + final FocusNode? focusNode; + + @override + Widget build(BuildContext context) { + return RemixSelect( + key: this.key, + style: uiSelectStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + trigger: this.trigger, + items: this.items, + selectedValue: this.selectedValue, + positioning: this.positioning, + onChanged: this.onChanged, + onOpen: this.onOpen, + onClose: this.onClose, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + semanticLabel: this.semanticLabel, + closeOnSelect: this.closeOnSelect, + focusNode: this.focusNode, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/sidebar.dart b/apps/dashboard/lib/ui/components/sidebar.dart new file mode 100644 index 000000000..46efd5069 --- /dev/null +++ b/apps/dashboard/lib/ui/components/sidebar.dart @@ -0,0 +1,140 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'text.dart'; +import 'toggle.dart'; +import 'tooltip.dart'; + +part 'sidebar.g.dart'; + +const _sectionLabelHorizontalPadding = 14.0; +const _sectionLabelVerticalPadding = 6.0; +const _sectionLabelLetterSpacing = 0.7; +const _destinationSpacing = 2.0; +const _minimumDestinationTargetHeight = 48.0; + +/// Ui-themed preset for [RemixSidebar]. +/// +/// The recipe paints the solid panel surface with a trailing edge border, +/// pads the scrolling destination region, keeps section labels compact and +/// muted, separates sections with Ui's `space3` token, and reuses the +/// ghost `size2` toggle treatment inside full-width destinations with a +/// 48-logical-pixel minimum height. The footer carries the divider that +/// separates account content from navigation. [highContrast] strengthens +/// section and selected destination content without changing layout. +/// [panelPadding] applies host-owned insets inside the painted panel surface. +/// +/// The recipe sets no panel width and no header padding. The host can supply +/// expanded/collapsed widths to the widget for coordinated animation, or size +/// the panel itself. Header metrics usually match an application top bar. +@MixWidget(target: RemixSidebar.new) +SidebarStyler uiSidebarStyle({ + bool highContrast = false, + bool collapsed = false, + EdgeInsetsGeometry? panelPadding, + SidebarStyler style = const SidebarStyler.create(), +}) { + final horizontalPadding = Prop.mix(_SidebarHorizontalPadding(collapsed)); + return SidebarStyler( + container: + FlexBoxStyler(padding: EdgeInsetsGeometryMix.maybeValue(panelPadding)) + .color(UiTokens.colorPanelSolid()) + .border( + .end(.color(UiTokens.grayA5()).width(UiTokens.borderWidth1())), + ), + content: FlexBoxStyler() + .spacing(UiTokens.space3()) + .padding( + EdgeInsetsMix.create( + left: horizontalPadding, + right: horizontalPadding, + top: Prop.token(UiTokens.space4), + bottom: Prop.token(UiTokens.space4), + ), + ), + footer: BoxStyler().border( + .top(.color(UiTokens.gray6()).width(UiTokens.borderWidth1())), + ), + sectionLabel: uiTextStyle(size: .size1, weight: .medium) + .color(highContrast ? UiTokens.gray12() : UiTokens.gray11()) + .uppercase() + .letterSpacing(_sectionLabelLetterSpacing) + .wrap( + .padding( + .symmetric( + horizontal: _sectionLabelHorizontalPadding, + vertical: _sectionLabelVerticalPadding, + ), + ), + ), + tooltip: uiTooltipStyle(), + destinations: FlexBoxStyler().spacing(_destinationSpacing), + destination: + uiToggleStyle(variant: .ghost, size: .size2, highContrast: highContrast) + .minHeight(_minimumDestinationTargetHeight) + .padding( + EdgeInsetsMix.create( + left: Prop.mix( + _DestinationInlinePadding(collapsed, left: true), + ), + right: Prop.mix( + _DestinationInlinePadding(collapsed, left: false), + ), + ), + ) + .container(.mainAxisSize(.max).mainAxisAlignment(.start)), + ).merge(style); +} + +/// Repays the narrowing panel inset on the leading side so icons hold still. +/// +/// Icons stay `space3 + space4` from the panel's leading edge: expanded rows +/// start at `space4`, and the rail's narrower inset is added back here. A 72px +/// dashboard rail then settles each icon on its center line. The trailing side +/// keeps the toggle's `space3`. Sides are physical so this merges with the +/// toggle's own horizontal padding; the text direction picks the leading one. +final class _DestinationInlinePadding extends Mix { + const _DestinationInlinePadding(this.collapsed, {required this.left}); + final bool collapsed; + final bool left; + + @override + double resolve(BuildContext context) { + final trailing = UiTokens.space3.resolve(context); + final leading = (Directionality.of(context) == TextDirection.ltr) == left; + if (!leading) return trailing; + return trailing + + UiTokens.space4.resolve(context) - + _SidebarHorizontalPadding(collapsed).resolve(context); + } + + @override + Mix merge(Mix? other) => other ?? this; + + @override + List get props => [collapsed, left]; +} + +/// Resolve both endpoints before interpolation so theme scaling stays live. +final class _SidebarHorizontalPadding extends Mix { + const _SidebarHorizontalPadding(this.collapsed); + final bool collapsed; + + @override + double resolve(BuildContext context) { + final expansion = + RemixSidebar.maybeAnimationOf(context)?.expansion ?? + (collapsed ? 0.0 : 1.0); + final rail = UiTokens.space2.resolve(context); + final expanded = UiTokens.space3.resolve(context); + return rail + (expanded - rail) * expansion; + } + + @override + Mix merge(Mix? other) => other ?? this; + + @override + List get props => [collapsed]; +} diff --git a/apps/dashboard/lib/ui/components/sidebar.g.dart b/apps/dashboard/lib/ui/components/sidebar.g.dart new file mode 100644 index 000000000..e3df5dd29 --- /dev/null +++ b/apps/dashboard/lib/ui/components/sidebar.g.dart @@ -0,0 +1,105 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sidebar.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixSidebar]. +/// +/// The recipe paints the solid panel surface with a trailing edge border, +/// pads the scrolling destination region, keeps section labels compact and +/// muted, separates sections with Ui's `space3` token, and reuses the +/// ghost `size2` toggle treatment inside full-width destinations with a +/// 48-logical-pixel minimum height. The footer carries the divider that +/// separates account content from navigation. [highContrast] strengthens +/// section and selected destination content without changing layout. +/// [panelPadding] applies host-owned insets inside the painted panel surface. +/// +/// The recipe sets no panel width and no header padding. The host can supply +/// expanded/collapsed widths to the widget for coordinated animation, or size +/// the panel itself. Header metrics usually match an application top bar. +class UiSidebar extends StatelessWidget { + const UiSidebar({ + super.key, + this.highContrast = false, + this.collapsed = false, + this.panelPadding, + this.style = const SidebarStyler.create(), + this.header, + this.showTooltips = true, + this.tooltipPositioning, + this.expandedWidth, + this.collapsedWidth, + this.animationStyle = const AnimationStyle(), + required this.sections, + required this.selectedValue, + this.onSelected, + this.footer, + this.enabled = true, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final bool highContrast; + + final bool collapsed; + + final EdgeInsetsGeometry? panelPadding; + + final SidebarStyler style; + + final Widget? header; + + final bool showTooltips; + + final OverlayPositionConfig? tooltipPositioning; + + final double? expandedWidth; + + final double? collapsedWidth; + + final AnimationStyle animationStyle; + + final List> sections; + + final T? selectedValue; + + final ValueChanged? onSelected; + + final Widget? footer; + + final bool enabled; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixSidebar( + key: this.key, + style: uiSidebarStyle( + highContrast: this.highContrast, + collapsed: this.collapsed, + panelPadding: this.panelPadding, + style: this.style, + ), + header: this.header, + collapsed: this.collapsed, + showTooltips: this.showTooltips, + tooltipPositioning: this.tooltipPositioning, + expandedWidth: this.expandedWidth, + collapsedWidth: this.collapsedWidth, + animationStyle: this.animationStyle, + sections: this.sections, + selectedValue: this.selectedValue, + onSelected: this.onSelected, + footer: this.footer, + enabled: this.enabled, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/sidebar_layout.dart b/apps/dashboard/lib/ui/components/sidebar_layout.dart new file mode 100644 index 000000000..8af27a052 --- /dev/null +++ b/apps/dashboard/lib/ui/components/sidebar_layout.dart @@ -0,0 +1,361 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +/// Accessible name for the compact navigation sheet's dialog barrier. +const _closeNavigationLabel = 'Close navigation'; + +/// Accessible name for the compact navigation sheet itself. +const _navigationSemanticLabel = 'Navigation'; + +/// A width, in logical pixels, reserved outside the compact sheet so its +/// scrim stays reachable on narrow screens. +const _compactSheetBarrierGutter = 56.0; + +/// Ui-themed shell layout pairing a [sidebar] with a [body]. +/// +/// A layout, not a styled component: it owns no `Spec`, ships no generated +/// adapter, and paints nothing of its own beyond the compact sheet's panel +/// surface. [sidebar] is expected to be an already-configured `UiSidebar` +/// (or any widget) that renders its own collapsed/expanded content; this +/// widget only decides where that content sits. +/// +/// At or above [compactBreakpoint] logical pixels of available width, the +/// layout renders a row: [sidebar] at [collapsedWidth] or [sidebarWidth] +/// (matching [collapsed]), animated over 200ms with an ease-in-out curve — +/// the same timing `RemixSidebar` uses by default — next to an expanded +/// column holding the optional [header] above [body]. +/// +/// Below [compactBreakpoint], [sidebar] is hidden from the row entirely and +/// instead presented as a full-height sheet pinned to the layout's *start* +/// edge (end edge in RTL), opened and closed through +/// [UiSidebarLayoutScope]. The sheet is a [showRemixDialog] route, which +/// supplies the barrier, Escape-to-dismiss, and focus containment; this +/// widget only positions the sheet's content and supplies its panel surface. +/// +/// [compactOpen] and [onCompactOpenChanged] make the sheet's open state +/// controlled. Leave [compactOpen] null to let the layout manage it, still +/// observing changes through [onCompactOpenChanged] if supplied. +/// +/// A controlled [compactOpen] is the single source of truth: a barrier tap, +/// Escape, or a back gesture requests closure through [onCompactOpenChanged]. +/// The host must set [compactOpen] to `false` to dismiss it. Crossing back +/// above [compactBreakpoint] hides the sheet and requests a closed state. +/// +/// ```dart +/// UiSidebarLayout( +/// sidebar: UiSidebar( +/// sections: sections, +/// selectedValue: page, +/// onSelected: (value) { +/// setState(() => page = value); +/// UiSidebarLayoutScope.of(context).closeCompact(); +/// }, +/// ), +/// header: const TopBar(), +/// body: PageBody(page: page), +/// ) +/// ``` +class UiSidebarLayout extends StatefulWidget { + const UiSidebarLayout({ + super.key, + required this.sidebar, + required this.body, + this.header, + this.compactBreakpoint = 720, + this.sidebarWidth = 256, + this.collapsedWidth = 72, + this.collapsed = false, + this.compactOpen, + this.onCompactOpenChanged, + }) : assert(compactBreakpoint > 0), + assert(sidebarWidth > 0), + assert(collapsedWidth > 0 && collapsedWidth <= sidebarWidth); + + /// The navigation panel. Rendered inline while wide, and inside the + /// compact sheet while narrow. + final Widget sidebar; + + /// The page content, always visible. + final Widget body; + + /// Optional fixed content above [body], in both presentations. + final Widget? header; + + /// The available-width threshold, in logical pixels, below which the + /// layout switches to its compact presentation. + final double compactBreakpoint; + + /// The wide-mode panel width when [collapsed] is false. + final double sidebarWidth; + + /// The wide-mode panel width when [collapsed] is true. + final double collapsedWidth; + + /// Whether the wide-mode panel renders at [collapsedWidth] instead of + /// [sidebarWidth]. The host toggles this; [sidebar] itself decides how its + /// own content responds. + final bool collapsed; + + /// Controlled compact-sheet visibility. Null lets the layout manage it. + final bool? compactOpen; + + /// Called when the user requests a different open state, or an + /// uncontrolled sheet changes state. Updating [compactOpen] itself does + /// not emit another callback. + final ValueChanged? onCompactOpenChanged; + + @override + State createState() => _UiSidebarLayoutState(); +} + +class _UiSidebarLayoutState extends State { + bool _selfOpen = false; + bool _sheetShowing = false; + + /// The layout's presentation as of its most recent build, so [_openCompact] + /// can no-op while wide even when called outside that build. + bool _isCompact = false; + + /// The route [showRemixDialog] pushed for the open sheet, captured from + /// inside its own builder via `ModalRoute.of` so [_removeSheetRoute] can + /// close exactly that route on the Navigator that actually owns it, + /// rather than popping whatever a bare `Navigator.of(context)` finds. + Route? _sheetRoute; + + bool get _effectiveOpen => widget.compactOpen ?? _selfOpen; + + void _setOpen(bool value) { + if (_effectiveOpen == value) return; + if (widget.compactOpen == null) { + setState(() => _selfOpen = value); + } + widget.onCompactOpenChanged?.call(value); + } + + // No-op while wide, so open state never carries over to the next compact + // presentation. + void _openCompact() { + if (!_isCompact) return; + _setOpen(true); + } + + void _closeCompact() => _setOpen(false); + + void _reconcileSheet(bool isCompact) { + _isCompact = isCompact; + final desiredOpen = isCompact && _effectiveOpen; + if (desiredOpen == _sheetShowing) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_isCompact && _effectiveOpen) { + if (!_sheetShowing) _pushSheet(); + } else if (_sheetShowing) { + _removeSheetRoute(); + } + }); + } + + void _removeSheetRoute() { + final route = _sheetRoute; + if (route == null || !route.isActive) return; + route.navigator?.removeRoute(route); + } + + Future _pushSheet() async { + _sheetShowing = true; + final reduceMotion = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + await showRemixDialog( + context: context, + barrierDismissible: true, + barrierLabel: _closeNavigationLabel, + barrierColor: MixScope.tokenOf(UiTokens.colorOverlay, context), + transitionDuration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 250), + builder: (dialogContext) { + _sheetRoute = ModalRoute.of(dialogContext); + final available = MediaQuery.sizeOf(dialogContext).width; + final width = math.min( + widget.sidebarWidth, + math.max(0.0, available - _compactSheetBarrierGutter), + ); + return PopScope( + canPop: widget.compactOpen == null, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _closeCompact(); + }, + child: UiSidebarLayoutScope._( + isCompact: true, + isCompactOpen: true, + openCompact: _openCompact, + closeCompact: _closeCompact, + child: Align( + alignment: AlignmentDirectional.centerStart, + // A plain DecoratedBox paints the panel surface without + // affecting layout, unlike a Mix `Box`, whose border-box sizing + // would shrink `width` by the border's own stroke width. + child: DecoratedBox( + decoration: BoxDecoration( + color: MixScope.tokenOf( + UiTokens.colorPanelSolid, + dialogContext, + ), + border: BorderDirectional( + end: BorderSide( + color: MixScope.tokenOf(UiTokens.grayA5, dialogContext), + width: MixScope.tokenOf( + UiTokens.borderWidth1, + dialogContext, + ), + ), + ), + ), + child: SizedBox( + width: width, + height: double.infinity, + child: RemixDialog( + semanticLabel: _navigationSemanticLabel, + child: widget.sidebar, + ), + ), + ), + ), + ), + ); + }, + ); + // Reached once, however the route completed: a user dismissal or + // _removeSheetRoute above. + _sheetRoute = null; + _sheetShowing = false; + if (mounted) _setOpen(false); + } + + @override + void dispose() { + final route = _sheetRoute; + if (route != null) { + // Navigator mutations must wait until the current tree update finishes. + WidgetsBinding.instance.addPostFrameCallback((_) { + final navigator = route.navigator; + if (navigator != null && navigator.mounted && route.isActive) { + navigator.removeRoute(route); + } + }); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < widget.compactBreakpoint; + _reconcileSheet(isCompact); + + return UiSidebarLayoutScope._( + isCompact: isCompact, + // Anded with isCompact so it can't read true while wide. + isCompactOpen: isCompact && _effectiveOpen, + openCompact: _openCompact, + closeCompact: _closeCompact, + child: isCompact ? _body() : _wideRow(), + ); + }, + ); + } + + Widget _wideRow() { + final reduceMotion = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedContainer( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: widget.collapsed ? widget.collapsedWidth : widget.sidebarWidth, + child: widget.sidebar, + ), + Expanded(child: _body()), + ], + ); + } + + Widget _body() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ?widget.header, + Expanded(child: widget.body), + ], + ); + } +} + +/// Reads the layout's compact state and drives its compact sheet. +/// +/// Available to both the layout's normal subtree (for example, a [header]'s +/// menu button) and the compact sheet's own subtree (for example, a +/// destination's `onSelected` callback closing the sheet after navigating), +/// since the layout re-provides this scope inside the sheet route. +class UiSidebarLayoutScope extends InheritedWidget { + // Ui source floors at Dart 3.11, one release before private named + // parameters, so this assigns the private fields explicitly instead of + // naming the parameters after them. + const UiSidebarLayoutScope._({ + required this.isCompact, + required this.isCompactOpen, + required VoidCallback openCompact, + required VoidCallback closeCompact, + required super.child, + }) : _openCompact = openCompact, // ignore: prefer_initializing_formals + _closeCompact = closeCompact; // ignore: prefer_initializing_formals + + /// Whether the layout is currently in its compact presentation. + final bool isCompact; + + /// Whether the compact sheet is currently open. + /// + /// Always false outside a compact presentation. + final bool isCompactOpen; + + final VoidCallback _openCompact; + final VoidCallback _closeCompact; + + /// Opens the compact sheet. A no-op while wide. + void openCompact() => _openCompact(); + + /// Closes the compact sheet. A no-op when already closed. + void closeCompact() => _closeCompact(); + + /// Reads the nearest [UiSidebarLayoutScope]. + /// + /// Throws a [FlutterError] outside a [UiSidebarLayout]. + static UiSidebarLayoutScope of(BuildContext context) { + final scope = maybeOf(context); + if (scope == null) { + throw FlutterError( + 'UiSidebarLayoutScope.of requires a UiSidebarLayout ancestor.', + ); + } + return scope; + } + + /// Reads the nearest [UiSidebarLayoutScope], or null outside a + /// [UiSidebarLayout]. + static UiSidebarLayoutScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(UiSidebarLayoutScope oldWidget) => + isCompact != oldWidget.isCompact || + isCompactOpen != oldWidget.isCompactOpen; +} diff --git a/apps/dashboard/lib/ui/components/skeleton.dart b/apps/dashboard/lib/ui/components/skeleton.dart new file mode 100644 index 000000000..59942acfb --- /dev/null +++ b/apps/dashboard/lib/ui/components/skeleton.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'skeleton.g.dart'; + +/// Ui recipe for [RemixSkeleton]. +/// +/// The pulse starts and rests on `grayA3` before moving toward `grayA4`; +/// Radix's CSS `alternate-reverse` phase starts from `grayA4`. +@MixWidget(target: RemixSkeleton.new) +SkeletonStyler uiSkeletonStyle({ + SkeletonStyler style = const SkeletonStyler.create(), +}) { + return SkeletonStyler() + .container( + BoxStyler() + .minHeight(UiTokens.space3()) + .color(UiTokens.grayA3()) + .borderRadius(.all(UiTokens.radius1())), + ) + .pulseColor(UiTokens.grayA4()) + .duration(UiTokens.skeletonPulseDuration()) + .merge(style); +} diff --git a/apps/dashboard/lib/ui/components/skeleton.g.dart b/apps/dashboard/lib/ui/components/skeleton.g.dart new file mode 100644 index 000000000..5b14b783d --- /dev/null +++ b/apps/dashboard/lib/ui/components/skeleton.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'skeleton.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui recipe for [RemixSkeleton]. +/// +/// The pulse starts and rests on `grayA3` before moving toward `grayA4`; +/// Radix's CSS `alternate-reverse` phase starts from `grayA4`. +class UiSkeleton extends StatelessWidget { + const UiSkeleton({ + super.key, + this.style = const SkeletonStyler.create(), + this.child, + this.loading = true, + }); + + final SkeletonStyler style; + + final Widget? child; + + final bool loading; + + @override + Widget build(BuildContext context) { + return RemixSkeleton( + key: this.key, + style: uiSkeletonStyle(style: this.style), + child: this.child, + loading: this.loading, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/slider.dart b/apps/dashboard/lib/ui/components/slider.dart new file mode 100644 index 000000000..350e1941c --- /dev/null +++ b/apps/dashboard/lib/ui/components/slider.dart @@ -0,0 +1,347 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'slider.g.dart'; + +/// Radix Themes slider sizes. +enum UiSliderSize { size1, size2, size3 } + +/// Radix Themes slider variants. +enum UiSliderVariant { classic, surface, soft } + +/// Ui slider with Radix-owned size, variant, and component overrides. +@MixWidget(target: RemixSlider.new) +SliderStyler uiSliderStyle({ + UiSliderVariant variant = .surface, + UiSliderSize size = .size2, + bool highContrast = false, + SliderStyler style = const SliderStyler.create(), +}) { + final metrics = _uiSliderMetrics(size); + final radius = BorderRadiusMix.all(metrics.trackRadius); + final thumbRadius = BorderRadiusMix.all(UiTokens.radius1OrThumb()); + final base = SliderStyler() + .track(.borderRadius(radius)) + .range(.borderRadius(radius)) + .thumb( + .size(metrics.thumbSize, metrics.thumbSize).borderRadius(thumbRadius), + ) + .thickness(metrics.trackSize) + .thumbFocusEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix(color: UiTokens.accent3(), spreadRadius: 3), + RemixBoxShadowMix(color: UiTokens.focus8(), spreadRadius: 5), + ], + ), + ), + ); + + final styled = switch (variant) { + .classic => _uiSliderClassic( + base, + trackRadius: radius, + thumbRadius: thumbRadius, + highContrast: highContrast, + ), + .surface => _uiSliderSurface( + base, + trackRadius: radius, + thumbRadius: thumbRadius, + highContrast: highContrast, + ), + .soft => _uiSliderSoft( + base, + trackRadius: radius, + thumbRadius: thumbRadius, + highContrast: highContrast, + ), + }; + return styled + .onDisabled( + _uiSliderDisabled( + variant, + trackRadius: radius, + thumbRadius: thumbRadius, + ), + ) + .variant( + ContextVariant( + 'uiSliderDisabledDarkBlend', + (context) => UiTheme.of(context).isDark, + ), + SliderStyler().onDisabled(.blendMode(BlendMode.screen)), + ) + .merge(style); +} + +SliderStyler _uiSliderSurface( + SliderStyler base, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, + required bool highContrast, +}) => base + .track(.color(UiTokens.grayA3())) + .range(.color(UiTokens.accentTrack())) + .thumbColor(const Color(0xFFFFFFFF)) + .trackEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.grayA5()]), + ), + ) + .rangeEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.grayA5()]).merge( + RemixBoxEffectLayerMix( + gradients: _uiSliderHighContrastGradients(highContrast), + ), + ), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([BoxShadowMix(color: UiTokens.blackA4(), spreadRadius: 1)]), + ), + ); + +SliderStyler _uiSliderClassic( + SliderStyler base, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, + required bool highContrast, +}) => base + .track(.color(UiTokens.grayA3())) + .range(.color(UiTokens.accentTrack())) + .thumbColor(const Color(0xFFFFFFFF)) + .trackEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadowToken: UiTokens.shadow1Layers), + ), + ) + .rangeEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: _uiSliderHighContrastGradients(highContrast), + shadows: highContrast + ? [ + _uiSliderInset(UiTokens.grayA3()), + _uiSliderInset(UiTokens.blackA2()), + _uiSliderInset( + UiTokens.blackA2(), + offset: const Offset(0, 1.5), + blurRadius: 2, + spreadRadius: 0, + ), + ] + : [ + _uiSliderInset(UiTokens.grayA3()), + _uiSliderInset(UiTokens.accentA4()), + _uiSliderInset(UiTokens.blackA1()), + _uiSliderInset( + UiTokens.blackA2(), + offset: const Offset(0, 1.5), + blurRadius: 2, + spreadRadius: 0, + ), + ], + ), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([ + BoxShadowMix(color: UiTokens.blackA3(), spreadRadius: 1), + BoxShadowMix( + color: UiTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: UiTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + ]), + ), + ); + +SliderStyler _uiSliderSoft( + SliderStyler base, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, + required bool highContrast, +}) => base + .track(.color(UiTokens.grayA4())) + .range(.color(UiTokens.accent6())) + .thumbColor(const Color(0xFFFFFFFF)) + .trackEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [UiTokens.whiteA1(), UiTokens.whiteA1()], + ), + ], + ), + ), + ) + .rangeEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [UiTokens.accentA5(), UiTokens.accentA5()], + ), + ..._uiSliderHighContrastGradients(highContrast), + ], + ), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([ + BoxShadowMix(color: UiTokens.blackA3(), spreadRadius: 1), + BoxShadowMix(color: UiTokens.grayA2(), spreadRadius: 1), + BoxShadowMix(color: UiTokens.accentA2(), spreadRadius: 1), + BoxShadowMix( + color: UiTokens.grayA4(), + offset: const Offset(0, 1), + blurRadius: 2, + ), + BoxShadowMix( + color: UiTokens.grayA3(), + offset: const Offset(0, 1), + blurRadius: 3, + spreadRadius: -0.5, + ), + ]), + ), + ); + +SliderStyler _uiSliderDisabled( + UiSliderVariant variant, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, +}) { + final track = switch (variant) { + .surface => + SliderStyler() + .track(.color(UiTokens.grayA3())) + .trackEffects( + RemixBoxEffectsMix.behindContent( + uiInsetSurface(strokes: [UiTokens.grayA4()]), + ), + ), + .classic => + SliderStyler() + .track(.color(UiTokens.grayA3())) + .trackEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadowToken: UiTokens.sliderClassicDisabledTrackShadows, + ), + ), + ), + .soft => + SliderStyler() + .track(.color(UiTokens.grayA4())) + .trackEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(gradients: const []), + ), + ), + }; + return track + .range(.color(const Color(0x00000000))) + .thumbColor(UiTokens.gray1()) + .rangeEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(gradients: const [], shadows: const []), + ), + ) + .rangeEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(gradients: const [], shadows: const []), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([ + BoxShadowMix( + color: switch (variant) { + .soft => UiTokens.gray5(), + .classic || .surface => UiTokens.gray6(), + }, + spreadRadius: 1, + ), + ]), + ), + ) + .thumbFocusEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .blendMode(BlendMode.multiply); +} + +RemixBoxShadowMix _uiSliderInset( + Color color, { + Offset offset = Offset.zero, + double blurRadius = 0, + double spreadRadius = 1, +}) => RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: color, + offset: offset, + blurRadius: blurRadius, + spreadRadius: spreadRadius, +); + +List _uiSliderHighContrastGradients( + bool highContrast, +) => highContrast + ? [ + RemixLinearGradientMix( + colors: [ + UiTokens.sliderHighContrastOverlay(), + UiTokens.sliderHighContrastOverlay(), + ], + ), + ] + : const []; + +class _UiSliderMetrics { + const _UiSliderMetrics({ + required this.trackSize, + required this.thumbSize, + required this.trackRadius, + }); + + final double trackSize; + final double thumbSize; + final Radius trackRadius; +} + +_UiSliderMetrics _uiSliderMetrics(UiSliderSize size) => switch (size) { + .size1 => _UiSliderMetrics( + trackSize: UiTokens.sliderTrackSize1(), + thumbSize: UiTokens.sliderThumbSize1(), + trackRadius: UiTokens.sliderTrackRadius1(), + ), + .size2 => _UiSliderMetrics( + trackSize: UiTokens.sliderTrackSize2(), + thumbSize: UiTokens.sliderThumbSize2(), + trackRadius: UiTokens.sliderTrackRadius2(), + ), + .size3 => _UiSliderMetrics( + trackSize: UiTokens.sliderTrackSize3(), + thumbSize: UiTokens.sliderThumbSize3(), + trackRadius: UiTokens.sliderTrackRadius3(), + ), +}; diff --git a/apps/dashboard/lib/ui/components/slider.g.dart b/apps/dashboard/lib/ui/components/slider.g.dart new file mode 100644 index 000000000..6ea54934b --- /dev/null +++ b/apps/dashboard/lib/ui/components/slider.g.dart @@ -0,0 +1,158 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'slider.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui slider with Radix-owned size, variant, and component overrides. +class UiSlider extends StatelessWidget { + const UiSlider({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const SliderStyler.create(), + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + this.semanticLabel, + this.semanticFormatterCallback, + this.excludeSemantics = false, + }); + + const UiSlider.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SliderStyler.create(), + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + this.semanticLabel, + this.semanticFormatterCallback, + this.excludeSemantics = false, + }) : variant = UiSliderVariant.classic; + + const UiSlider.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SliderStyler.create(), + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + this.semanticLabel, + this.semanticFormatterCallback, + this.excludeSemantics = false, + }) : variant = UiSliderVariant.surface; + + const UiSlider.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SliderStyler.create(), + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + this.semanticLabel, + this.semanticFormatterCallback, + this.excludeSemantics = false, + }) : variant = UiSliderVariant.soft; + + final UiSliderVariant variant; + + final UiSliderSize size; + + final bool highContrast; + + final SliderStyler style; + + final double value; + + final ValueChanged? onChanged; + + final ValueChanged? onChangeStart; + + final ValueChanged? onChangeEnd; + + final double min; + + final double max; + + final bool enabled; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final int? snapDivisions; + + final String? semanticLabel; + + final NakedSliderSemanticFormatterCallback? semanticFormatterCallback; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixSlider( + key: this.key, + style: uiSliderStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + value: this.value, + onChanged: this.onChanged, + onChangeStart: this.onChangeStart, + onChangeEnd: this.onChangeEnd, + min: this.min, + max: this.max, + enabled: this.enabled, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + snapDivisions: this.snapDivisions, + semanticLabel: this.semanticLabel, + semanticFormatterCallback: this.semanticFormatterCallback, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/spinner.dart b/apps/dashboard/lib/ui/components/spinner.dart new file mode 100644 index 000000000..0c2778a64 --- /dev/null +++ b/apps/dashboard/lib/ui/components/spinner.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'spinner.g.dart'; + +/// Ui spinner size presets. +enum UiSpinnerSize { size1, size2, size3 } + +/// Ui-themed preset for [RemixSpinner] using the inherited foreground color. +@MixWidget(target: RemixSpinner.new) +SpinnerStyler uiSpinnerStyle({ + UiSpinnerSize size = .size2, + SpinnerStyler style = const SpinnerStyler.create(), +}) { + return SpinnerStyler( + opacity: 0.65, + leafRadius: UiTokens.radius1(), + duration: const Duration(milliseconds: 800), + ).merge(_uiSpinnerSizeStyler(size)).merge(style); +} + +SpinnerStyler _uiSpinnerSizeStyler(UiSpinnerSize size) { + return switch (size) { + .size1 => SpinnerStyler(size: UiTokens.space3()), + .size2 => SpinnerStyler(size: UiTokens.space4()), + .size3 => SpinnerStyler(size: UiTokens.spinnerSize3()), + }; +} diff --git a/apps/dashboard/lib/ui/components/spinner.g.dart b/apps/dashboard/lib/ui/components/spinner.g.dart new file mode 100644 index 000000000..c6c3e2274 --- /dev/null +++ b/apps/dashboard/lib/ui/components/spinner.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spinner.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixSpinner] using the inherited foreground color. +class UiSpinner extends StatelessWidget { + const UiSpinner({ + super.key, + this.size = .size2, + this.style = const SpinnerStyler.create(), + this.semanticsLabel, + this.semanticsValue, + }); + + final UiSpinnerSize size; + + final SpinnerStyler style; + + final String? semanticsLabel; + + final String? semanticsValue; + + @override + Widget build(BuildContext context) { + return RemixSpinner( + key: this.key, + style: uiSpinnerStyle(size: this.size, style: this.style), + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/switch.dart b/apps/dashboard/lib/ui/components/switch.dart new file mode 100644 index 000000000..05e41108c --- /dev/null +++ b/apps/dashboard/lib/ui/components/switch.dart @@ -0,0 +1,323 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'switch.g.dart'; + +/// Ui switch size presets. +enum UiSwitchSize { + /// Compact switch. + size1, + + /// Default switch. + size2, + + /// Large switch. + size3, +} + +/// Ui switch color variants. +enum UiSwitchVariant { + /// Raised treatment with Radix's classic shadows. + classic, + + /// Surface treatment with a visible border. + surface, + + /// Softer accent treatment. + soft, +} + +/// Ui-themed preset for [RemixSwitch]. +@MixWidget(target: RemixSwitch.new) +SwitchStyler uiSwitchStyle({ + UiSwitchVariant variant = .surface, + UiSwitchSize size = .size2, + bool highContrast = false, + SwitchStyler style = const SwitchStyler.create(), +}) { + return (switch (variant) { + .classic => _uiSwitchClassicStyler(size, highContrast: highContrast), + .surface => _uiSwitchSurfaceStyler(size, highContrast: highContrast), + .soft => _uiSwitchSoftStyler(size, highContrast: highContrast), + }).merge(style); +} + +SwitchStyler _uiSwitchBaseStyler(UiSwitchSize size) { + final metrics = _uiSwitchMetrics(size); + return SwitchStyler( + container: .size( + metrics.width, + metrics.height, + ).padding(.all(1)).borderRadius(.all(metrics.radius)), + thumb: .size( + metrics.thumbSize, + metrics.thumbSize, + ).borderRadius(.all(metrics.radius)), + trackEffects: RemixBoxEffectsMix( + behindContent: _uiSwitchLayer(), + overContent: _uiSwitchLayer(), + ), + ) + .thumbColor(const Color(0xFFFFFFFF)) + .onFocusVisible( + .trackEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: UiTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ); +} + +SwitchStyler _uiSwitchClassicStyler( + UiSwitchSize size, { + required bool highContrast, +}) { + return _uiSwitchBaseStyler(size) + .trackColor(UiTokens.grayA4()) + .trackEffects( + RemixBoxEffectsMix.behindContent( + _uiSwitchLayer(shadowToken: UiTokens.shadow1Layers), + ), + ) + .thumb(_uiSwitchThumbStyler(selected: false, highContrast: highContrast)) + .onSelected( + SwitchStyler() + .trackColor( + highContrast ? UiTokens.accent12() : UiTokens.accentTrack(), + ) + .trackEffects( + RemixBoxEffectsMix.behindContent( + _uiSwitchLayer( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: UiTokens.grayA3(), + spreadRadius: 1, + ), + RemixBoxShadowMix( + kind: .inset, + color: highContrast + ? UiTokens.blackA2() + : UiTokens.accentA4(), + spreadRadius: 1, + ), + RemixBoxShadowMix( + kind: .inset, + color: UiTokens.blackA2(), + offset: const Offset(0, 1.5), + blurRadius: 2, + ), + ], + ), + ), + ) + .thumb( + _uiSwitchThumbStyler(selected: true, highContrast: highContrast), + ), + ) + .onPressed( + SwitchStyler() + .trackColor(UiTokens.grayA5()) + .trackEffects(RemixBoxEffectsMix.behindContent(_uiSwitchLayer())), + ) + .onDisabled(_uiSwitchDisabledStyler(classic: true)); +} + +SwitchStyler _uiSwitchSurfaceStyler( + UiSwitchSize size, { + required bool highContrast, +}) { + return _uiSwitchBaseStyler(size) + .trackColor(UiTokens.grayA3()) + .trackEffects(RemixBoxEffectsMix.behindContent(_uiSwitchLayer())) + .trackEffects( + RemixBoxEffectsMix.overContent(_uiSwitchInsetRing(UiTokens.grayA5())), + ) + .thumb(_uiSwitchThumbStyler(selected: false, highContrast: highContrast)) + .onSelected( + SwitchStyler() + .trackColor( + highContrast ? UiTokens.accent12() : UiTokens.accentTrack(), + ) + .trackEffects(RemixBoxEffectsMix.behindContent(_uiSwitchLayer())) + .thumb( + _uiSwitchThumbStyler(selected: true, highContrast: highContrast), + ), + ) + .onPressed( + SwitchStyler() + .trackColor(UiTokens.grayA4()) + .trackEffects(RemixBoxEffectsMix.behindContent(_uiSwitchLayer())), + ) + .onDisabled(_uiSwitchDisabledStyler()); +} + +SwitchStyler _uiSwitchSoftStyler( + UiSwitchSize size, { + required bool highContrast, +}) { + return _uiSwitchBaseStyler(size) + .trackColor(UiTokens.grayA3()) + .trackEffects(RemixBoxEffectsMix.behindContent(_uiSwitchLayer())) + .thumb(_uiSwitchSoftThumbStyler(false)) + .onSelected( + SwitchStyler() + .trackColor( + highContrast ? UiTokens.accentA6() : UiTokens.accentA4(), + ) + .trackEffects(RemixBoxEffectsMix.behindContent(_uiSwitchLayer())) + .thumb(_uiSwitchSoftThumbStyler(true)), + ) + .onPressed( + SwitchStyler() + .trackColor(UiTokens.grayA4()) + .trackEffects(RemixBoxEffectsMix.behindContent(_uiSwitchLayer())), + ) + .onDisabled(_uiSwitchDisabledStyler(soft: true)); +} + +({double width, double height, double thumbSize, Radius radius}) +_uiSwitchMetrics(UiSwitchSize size) { + final height = switch (size) { + .size1 => UiTokens.space4(), + .size2 => UiTokens.switchHeight2(), + .size3 => UiTokens.space5(), + }; + final width = switch (size) { + .size1 => UiTokens.switchWidth1(), + .size2 => UiTokens.switchWidth2(), + .size3 => UiTokens.switchWidth3(), + }; + final thumbSize = switch (size) { + .size1 => UiTokens.switchThumbSize1(), + .size2 => UiTokens.switchThumbSize2(), + .size3 => UiTokens.switchThumbSize3(), + }; + final radius = switch (size) { + .size1 => UiTokens.radius1OrThumb(), + .size2 || .size3 => UiTokens.radius2OrThumb(), + }; + return (width: width, height: height, thumbSize: thumbSize, radius: radius); +} + +SwitchStyler _uiSwitchDisabledStyler({ + bool classic = false, + bool soft = false, +}) { + final trackColor = switch ((classic, soft)) { + (true, _) => UiTokens.grayA5(), + (_, true) => UiTokens.grayA4(), + _ => UiTokens.grayA3(), + }; + return SwitchStyler() + .trackColor(trackColor) + .trackEffects( + RemixBoxEffectsMix.behindContent( + _uiSwitchLayer(shadowToken: classic ? UiTokens.shadow1Layers : null), + ), + ) + .trackEffects( + RemixBoxEffectsMix.overContent( + classic || soft + ? _uiSwitchLayer(shadows: const []) + : _uiSwitchInsetRing(UiTokens.grayA3()), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([ + BoxShadowMix(color: UiTokens.grayA2(), spreadRadius: 1), + BoxShadowMix( + color: UiTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + ]), + ), + ) + .thumbColor(UiTokens.gray2()); +} + +BoxStyler _uiSwitchThumbStyler({ + required bool selected, + required bool highContrast, +}) => BoxStyler().decoration( + .boxShadow( + selected + ? [ + BoxShadowMix( + color: UiTokens.blackA2(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: UiTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + BoxShadowMix( + color: highContrast ? UiTokens.blackA2() : UiTokens.accentA4(), + spreadRadius: 1, + ), + BoxShadowMix( + color: UiTokens.blackA2(), + offset: const Offset(-1, 0), + blurRadius: 1, + ), + ] + : [ + BoxShadowMix(color: UiTokens.blackA2(), spreadRadius: 1), + BoxShadowMix( + color: UiTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: UiTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + ], + ), +); + +BoxStyler _uiSwitchSoftThumbStyler(bool selected) => BoxStyler().decoration( + .boxShadow([ + BoxShadowMix(color: UiTokens.blackA1(), spreadRadius: 1), + BoxShadowMix( + color: selected ? UiTokens.blackA2() : UiTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: selected ? UiTokens.accentA3() : UiTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: selected ? UiTokens.accentA3() : UiTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + ]), +); + +RemixBoxEffectLayerMix _uiSwitchInsetRing(Color color) => _uiSwitchLayer( + shadows: [RemixBoxShadowMix(kind: .inset, color: color, spreadRadius: 1)], +); + +RemixBoxEffectLayerMix _uiSwitchLayer({ + List? shadows, + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix(shadows: shadows, shadowToken: shadowToken); diff --git a/apps/dashboard/lib/ui/components/switch.g.dart b/apps/dashboard/lib/ui/components/switch.g.dart new file mode 100644 index 000000000..e8824336d --- /dev/null +++ b/apps/dashboard/lib/ui/components/switch.g.dart @@ -0,0 +1,126 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'switch.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixSwitch]. +class UiSwitch extends StatelessWidget { + const UiSwitch({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const SwitchStyler.create(), + required this.selected, + required this.semanticLabel, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + /// Raised treatment with Radix's classic shadows. + const UiSwitch.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SwitchStyler.create(), + required this.selected, + required this.semanticLabel, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiSwitchVariant.classic; + + /// Surface treatment with a visible border. + const UiSwitch.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SwitchStyler.create(), + required this.selected, + required this.semanticLabel, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiSwitchVariant.surface; + + /// Softer accent treatment. + const UiSwitch.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SwitchStyler.create(), + required this.selected, + required this.semanticLabel, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiSwitchVariant.soft; + + final UiSwitchVariant variant; + + final UiSwitchSize size; + + final bool highContrast; + + final SwitchStyler style; + + final bool selected; + + final String semanticLabel; + + final ValueChanged? onChanged; + + final bool enabled; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixSwitch( + key: this.key, + style: uiSwitchStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + selected: this.selected, + semanticLabel: this.semanticLabel, + onChanged: this.onChanged, + enabled: this.enabled, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/tabs.dart b/apps/dashboard/lib/ui/components/tabs.dart new file mode 100644 index 000000000..9c4fd2687 --- /dev/null +++ b/apps/dashboard/lib/ui/components/tabs.dart @@ -0,0 +1,116 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'tabs.g.dart'; + +/// Ui tab-list size presets matching Radix Themes 3.3.0. +enum UiTabsSize { size1, size2 } + +/// Ui-themed preset for [RemixTabBar]. +/// +/// The tab-list bottom border is a single hairline at every Radix size, so this +/// preset takes no `size` — unlike [uiTabStyle], whose per-tab metrics vary. +@MixWidget(target: RemixTabBar.new) +TabBarStyler uiTabBarStyle({TabBarStyler style = const TabBarStyler.create()}) { + return TabBarStyler() + .border(.bottom(.color(UiTokens.grayA5()).width(UiTokens.borderWidth1()))) + .merge(style); +} + +/// Ui-themed preset for [RemixTabView]. +@MixWidget(target: RemixTabView.new) +TabViewStyler uiTabViewStyle({ + TabViewStyler style = const TabViewStyler.create(), +}) => TabViewStyler().merge(style); + +/// Ui-themed preset for [RemixTab]. +@MixWidget(target: RemixTab.new) +TabStyler uiTabStyle({ + UiTabsSize size = UiTabsSize.size2, + bool highContrast = false, + TabStyler style = const TabStyler.create(), +}) { + final metrics = switch (size) { + UiTabsSize.size1 => ( + height: UiTokens.space6(), + outerPaddingX: UiTokens.space1(), + innerPaddingX: UiTokens.space1(), + innerPaddingY: UiTokens.tabInnerPaddingY1(), + radius: UiTokens.radius1(), + text: UiTokens.text1.mix(), + activeLetterSpacing: UiTokens.tabActiveLetterSpacing1(), + ), + UiTabsSize.size2 => ( + height: UiTokens.space7(), + outerPaddingX: UiTokens.space2(), + innerPaddingX: UiTokens.space2(), + innerPaddingY: UiTokens.space1(), + radius: UiTokens.radius2(), + text: UiTokens.text2.mix(), + activeLetterSpacing: UiTokens.tabActiveLetterSpacing2(), + ), + }; + + return TabStyler() + .label(.style(metrics.text).letterSpacing(0.0).color(UiTokens.grayA11())) + .icon(.color(UiTokens.grayA11()).size(UiTokens.space4())) + .wrap( + .box( + BoxStyler() + .height(metrics.height) + .padding(.horizontal(metrics.outerPaddingX)) + .alignment(.center) + .border( + .bottom( + .color( + const Color(0x00000000), + ).width(UiTokens.borderWidth2()), + ), + ), + ), + ) + .container( + .direction(.horizontal) + .padding(.horizontal(metrics.innerPaddingX)) + .padding(.vertical(metrics.innerPaddingY)) + .borderRadius(.all(metrics.radius)) + .mainAxisAlignment(.center) + .spacing(UiTokens.space2()), + ) + .onHovered( + .label(.color(UiTokens.gray12())) + .icon(.color(UiTokens.gray12())) + .color(UiTokens.grayA3()) + .onFocusVisible(.color(UiTokens.accentA3())), + ) + .onFocusVisible( + // Solid `focus-8` where the other three rings use alpha `focus-a8`. + // See uiFocusRing: unresolved whether that is intentional. + TabStyler().uiFocusRing(color: UiTokens.focus8(), strokeAlign: null), + ) + .onSelected( + .label( + .color(UiTokens.gray12()) + .fontWeight(UiTokens.fontWeightMedium()) + .letterSpacing(metrics.activeLetterSpacing), + ) + .icon(.color(UiTokens.gray12())) + .wrap( + .box( + BoxStyler().border( + .bottom( + .color( + highContrast + ? UiTokens.accent12() + : UiTokens.accentIndicator(), + ).width(UiTokens.borderWidth2()), + ), + ), + ), + ), + ) + .merge(style); +} diff --git a/apps/dashboard/lib/ui/components/tabs.g.dart b/apps/dashboard/lib/ui/components/tabs.g.dart new file mode 100644 index 000000000..ef6adb678 --- /dev/null +++ b/apps/dashboard/lib/ui/components/tabs.g.dart @@ -0,0 +1,146 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tabs.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixTabBar]. +/// +/// The tab-list bottom border is a single hairline at every Radix size, so this +/// preset takes no `size` — unlike [uiTabStyle], whose per-tab metrics vary. +class UiTabBar extends StatelessWidget { + const UiTabBar({ + super.key, + this.style = const TabBarStyler.create(), + required this.child, + }); + + final TabBarStyler style; + + final Widget child; + + @override + Widget build(BuildContext context) { + return RemixTabBar( + key: this.key, + style: uiTabBarStyle(style: this.style), + child: this.child, + ); + } +} + +/// Ui-themed preset for [RemixTabView]. +class UiTabView extends StatelessWidget { + const UiTabView({ + super.key, + this.style = const TabViewStyler.create(), + required this.tabId, + required this.child, + this.maintainState = true, + }); + + final TabViewStyler style; + + final String tabId; + + final Widget child; + + final bool maintainState; + + @override + Widget build(BuildContext context) { + return RemixTabView( + key: this.key, + style: uiTabViewStyle(style: this.style), + tabId: this.tabId, + child: this.child, + maintainState: this.maintainState, + ); + } +} + +/// Ui-themed preset for [RemixTab]. +class UiTab extends StatelessWidget { + const UiTab({ + super.key, + this.size = UiTabsSize.size2, + this.highContrast = false, + this.style = const TabStyler.create(), + required this.tabId, + this.child, + this.label, + this.icon, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.builder, + this.semanticLabel, + }); + + final UiTabsSize size; + + final bool highContrast; + + final TabStyler style; + + final String tabId; + + final Widget? child; + + final String? label; + + final IconData? icon; + + final bool enabled; + + final MouseCursor mouseCursor; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final ValueChanged? onFocusChange; + + final ValueChanged? onHoverChange; + + final ValueChanged? onPressChange; + + final ValueWidgetBuilder? builder; + + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + return RemixTab( + key: this.key, + style: uiTabStyle( + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + tabId: this.tabId, + child: this.child, + label: this.label, + icon: this.icon, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + onFocusChange: this.onFocusChange, + onHoverChange: this.onHoverChange, + onPressChange: this.onPressChange, + builder: this.builder, + semanticLabel: this.semanticLabel, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/text.dart b/apps/dashboard/lib/ui/components/text.dart new file mode 100644 index 000000000..9de641406 --- /dev/null +++ b/apps/dashboard/lib/ui/components/text.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +part 'text.g.dart'; + +/// Ui-themed body text on the Radix nine-step scale. +/// +/// Omitted [size] and [weight] resolve to the Radix root run (`text3`, +/// regular) from the active [UiScope]'s tokens rather than the ambient +/// `DefaultTextStyle`. This deliberately deviates from Radix's CSS `1em` +/// inheritance: a token default cannot be silently replaced by a host-installed +/// text run (a `Material` surface, or a host with no run at all), which keeps +/// Ui text a function of the theme alone. Set [accent] to take the +/// surrounding [UiScope]'s accent colour; leaving it false uses the +/// neutral `gray12` foreground. +@MixWidget() +TextStyler uiTextStyle({ + UiTextSize? size, + UiTextWeight? weight, + TextAlign? align, + bool softWrap = true, + bool truncate = false, + bool accent = false, + bool highContrast = false, + TextStyler style = const TextStyler.create(), +}) { + var recipe = TextStyler().style( + uiTextSizeToken(size ?? UiTextSize.size3).mix(), + ); + recipe = recipe.fontWeight( + uiTextWeightToken(weight ?? UiTextWeight.regular)(), + ); + recipe = accent + ? uiAccentForeground(recipe, highContrast: highContrast) + : recipe.color(UiTokens.gray12()); + recipe = recipe.inherit(false); + + return uiApplyTextFlow( + recipe, + align: align, + softWrap: softWrap, + truncate: truncate, + ).merge(style); +} diff --git a/apps/dashboard/lib/ui/components/text.g.dart b/apps/dashboard/lib/ui/components/text.g.dart new file mode 100644 index 000000000..f28b5fb43 --- /dev/null +++ b/apps/dashboard/lib/ui/components/text.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'text.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed body text on the Radix nine-step scale. +/// +/// Omitted [size] and [weight] resolve to the Radix root run (`text3`, +/// regular) from the active [UiScope]'s tokens rather than the ambient +/// `DefaultTextStyle`. This deliberately deviates from Radix's CSS `1em` +/// inheritance: a token default cannot be silently replaced by a host-installed +/// text run (a `Material` surface, or a host with no run at all), which keeps +/// Ui text a function of the theme alone. Set [accent] to take the +/// surrounding [UiScope]'s accent colour; leaving it false uses the +/// neutral `gray12` foreground. +class UiText extends StatelessWidget { + const UiText( + this.text, { + super.key, + this.size, + this.weight, + this.align, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const TextStyler.create(), + }); + + final UiTextSize? size; + + final UiTextWeight? weight; + + final TextAlign? align; + + final bool softWrap; + + final bool truncate; + + final bool accent; + + final bool highContrast; + + final TextStyler style; + + final String text; + + @override + Widget build(BuildContext context) { + return uiTextStyle( + size: this.size, + weight: this.weight, + align: this.align, + softWrap: this.softWrap, + truncate: this.truncate, + accent: this.accent, + highContrast: this.highContrast, + style: this.style, + ).call(this.text, key: this.key); + } +} diff --git a/apps/dashboard/lib/ui/components/textfield.dart b/apps/dashboard/lib/ui/components/textfield.dart new file mode 100644 index 000000000..f702fa4ff --- /dev/null +++ b/apps/dashboard/lib/ui/components/textfield.dart @@ -0,0 +1,367 @@ +// `DragStartBehavior` and `MaxLengthEnforcement` appear in the generated +// UiTextField/UiTextArea constructors, so they must be visible from +// this library even though nothing here references them directly. +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'textfield.g.dart'; + +/// Ui text field size presets. +enum UiTextFieldSize { + /// Compact text field. + size1, + + /// Default text field. + size2, + + /// Large text field. + size3, +} + +/// Ui text field color variants. +enum UiTextFieldVariant { + /// Raised treatment with Radix's level-one shadow. + classic, + + /// Surface treatment with neutral border and text colors. + surface, + + /// Soft accent treatment. + soft, +} + +Color _resolveNeutralTextInputPlaceholder(BuildContext context) { + final color = UiTokens.grayA10.resolve(context); + return color.withValues(alpha: color.a * 0.5); +} + +const _neutralTextInputPlaceholder = ContextToken( + _resolveNeutralTextInputPlaceholder, +); + +/// Ui-themed preset for [RemixTextField]. +@MixWidget(target: RemixTextField.new) +TextFieldStyler uiTextFieldStyle({ + UiTextFieldVariant variant = .surface, + UiTextFieldSize size = .size2, + TextFieldStyler style = const TextFieldStyler.create(), +}) { + final metrics = _uiTextFieldMetrics(size, bordered: variant != .soft); + final base = _uiTextInputBaseStyle( + container: BoxStyler() + .height(metrics.height) + .padding(.horizontal(metrics.paddingX)) + .borderRadius(.all(metrics.radius)) + .clipBehavior(.antiAlias), + spacing: metrics.spacing, + crossAxisAlignment: .center, + text: metrics.text, + focusColor: switch (variant) { + .soft => UiTokens.accent8(), + .classic || .surface => UiTokens.focus8(), + }, + ); + + final recipe = switch (variant) { + .classic => _uiApplyClassicTextInput(base), + .surface => _uiApplySurfaceTextInput(base), + .soft => _uiApplySoftTextInput(base, placeholderOpacity: 0.60), + }; + + return recipe + .variant(ContextVariant.widgetState(.error), _uiTextInputErrorStyle()) + .merge(style); +} + +TextFieldStyler _uiTextInputBaseStyle({ + required BoxStyler container, + required double spacing, + required CrossAxisAlignment crossAxisAlignment, + required TextStyleToken text, + required Color focusColor, +}) => + TextFieldStyler( + container: container, + spacing: spacing, + crossAxisAlignment: crossAxisAlignment, + text: .style(text.mix()), + hintText: .style(text.mix()).textHeightBehavior( + TextHeightBehaviorMix() + .applyHeightToFirstAscent(false) + .applyHeightToLastDescent(true), + ), + helperText: .style(UiTokens.text1.mix()), + label: .style(UiTokens.text2.mix()), + cursorWidth: 1.5, + containerEffects: RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix(), + overContent: RemixBoxEffectLayerMix(), + ), + ) + .wrap(.iconTheme(color: UiTokens.gray11(), size: 16.0)) + // Radix keys text-input rings from :focus/:focus-within, so unlike + // control focus rings this intentionally follows raw focus. + .onFocused(.containerEffects(uiFocusOutline(focusColor, offset: -1))); + +TextFieldStyler _uiApplyClassicTextInput(TextFieldStyler base) => + _uiApplyNeutralTextInput(base) + .color(UiTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadowToken: UiTokens.shadow1Layers), + ), + ) + .onDisabled( + _uiNeutralTextInputDisabledStyle() + .color(UiTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [UiTokens.grayA2(), UiTokens.grayA2()], + ), + ], + shadowToken: UiTokens.shadow1Layers, + ), + ), + ), + ); + +TextFieldStyler _uiApplySurfaceTextInput(TextFieldStyler base) => + _uiApplyNeutralTextInput(base) + .color(UiTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + uiInsetSurface(strokes: [UiTokens.grayA7()]), + ), + ) + .onDisabled( + _uiNeutralTextInputDisabledStyle() + .color(UiTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [UiTokens.grayA2(), UiTokens.grayA2()], + ), + ], + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + uiInsetSurface(strokes: [UiTokens.grayA6()]), + ), + ), + ); + +TextFieldStyler _uiApplySoftTextInput( + TextFieldStyler base, { + required double placeholderOpacity, +}) => base + .merge( + TextFieldStyler( + text: .fontWeight(UiTokens.fontWeightRegular()), + hintText: .fontWeight(UiTokens.fontWeightRegular()), + cursorColor: UiTokens.accent12(), + helperText: .color( + UiTokens.gray11(), + ).fontWeight(UiTokens.fontWeightRegular()), + label: .color( + UiTokens.gray12(), + ).fontWeight(UiTokens.fontWeightMedium()), + ), + ) + .textColor(UiTokens.accent12()) + .text(.selectionColor(UiTokens.accentA5())) + .onEnabled( + .hintText( + .color(UiTokens.accent12().withValues(alpha: placeholderOpacity)), + ), + ) + .wrap(.iconTheme(color: UiTokens.accent10())) + .color(UiTokens.accentA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .onDisabled( + _uiSoftTextInputDisabledStyle() + .color(UiTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ), + ); + +TextFieldStyler _uiApplyNeutralTextInput(TextFieldStyler base) => base.merge( + TextFieldStyler( + text: .color(UiTokens.gray12()).selectionColor(UiTokens.focusA5()), + hintText: .color(_neutralTextInputPlaceholder()), + cursorColor: UiTokens.gray12(), + helperText: .color(UiTokens.gray11()), + label: .color(UiTokens.gray12()).fontWeight(UiTokens.fontWeightMedium()), + ), +); + +// Keep the disabled-color branch on raw focus for the same :focus-within +// contract as the enabled text input. +TextFieldStyler _uiTextInputDisabledBaseStyle() => TextFieldStyler( + text: .color(UiTokens.grayA11()).selectionColor(UiTokens.grayA5()), + cursorColor: UiTokens.grayA11(), +).onFocused(.containerEffects(uiFocusOutline(UiTokens.gray8(), offset: -1))); + +TextFieldStyler _uiNeutralTextInputDisabledStyle() => + _uiTextInputDisabledBaseStyle().hintText( + .color(_neutralTextInputPlaceholder()), + ); + +TextFieldStyler _uiSoftTextInputDisabledStyle() => + _uiTextInputDisabledBaseStyle().hintText( + .color(UiTokens.accent12().withValues(alpha: 0.5)), + ); + +TextFieldStyler _uiTextInputErrorStyle() => TextFieldStyler( + helperText: .color(UiTokens.error11()), + label: .color(UiTokens.error11()), + cursorColor: UiTokens.error9(), + containerEffects: RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: UiTokens.errorA7(), + spreadRadius: 1, + ), + ], + ), + outline: BorderSideMix( + color: UiTokens.error8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: -1, + ), +); + +({ + double height, + double paddingX, + double spacing, + Radius radius, + TextStyleToken text, +}) +_uiTextFieldMetrics(UiTextFieldSize size, {required bool bordered}) => + switch (size) { + .size1 => ( + height: UiTokens.space5(), + paddingX: bordered + ? UiTokens.textFieldPadding1() + : UiTokens.selectSpace1Half(), + spacing: UiTokens.space2(), + radius: UiTokens.radius2OrFull(), + text: UiTokens.text1, + ), + .size2 => ( + height: UiTokens.space6(), + paddingX: bordered ? UiTokens.textFieldPadding2() : UiTokens.space2(), + spacing: UiTokens.space2(), + radius: UiTokens.radius2OrFull(), + text: UiTokens.text2, + ), + .size3 => ( + height: UiTokens.space7(), + paddingX: bordered ? UiTokens.textFieldPadding3() : UiTokens.space3(), + spacing: UiTokens.space3(), + radius: UiTokens.radius3OrFull(), + text: UiTokens.text3, + ), + }; + +/// Radix Themes TextArea size presets. +enum UiTextAreaSize { size1, size2, size3 } + +/// Radix Themes TextArea variants. +enum UiTextAreaVariant { classic, surface, soft } + +/// Ui recipe for [RemixTextArea]. +/// +/// Scrolling follows the host platform; this recipe does not reproduce Radix's +/// themed browser scrollbar or resize handle. +@MixWidget(target: RemixTextArea.new) +TextFieldStyler uiTextAreaStyle({ + UiTextAreaVariant variant = .surface, + UiTextAreaSize size = .size2, + TextFieldStyler style = const TextFieldStyler.create(), +}) { + final metrics = _uiTextAreaMetrics(size); + final base = _uiTextInputBaseStyle( + container: BoxStyler() + .minHeight(metrics.minHeight) + .padding( + .symmetric(horizontal: metrics.paddingX, vertical: metrics.paddingY), + ) + .borderRadius(.all(metrics.radius)) + .clipBehavior(.antiAlias), + spacing: metrics.spacing, + crossAxisAlignment: .start, + text: metrics.text, + focusColor: switch (variant) { + .soft => UiTokens.accent8(), + .classic || .surface => UiTokens.focus8(), + }, + ); + + final recipe = switch (variant) { + .classic => _uiApplyClassicTextInput(base), + .surface => _uiApplySurfaceTextInput(base), + .soft => _uiApplySoftTextInput(base, placeholderOpacity: 0.65), + }; + + return recipe + .variant(ContextVariant.widgetState(.error), _uiTextInputErrorStyle()) + .merge(style); +} + +({ + double minHeight, + double paddingX, + double paddingY, + double spacing, + Radius radius, + TextStyleToken text, +}) +_uiTextAreaMetrics(UiTextAreaSize size) => switch (size) { + .size1 => ( + minHeight: UiTokens.space8(), + paddingX: UiTokens.selectSpace1Half(), + paddingY: UiTokens.space1(), + spacing: UiTokens.space2(), + radius: UiTokens.radius2(), + text: UiTokens.text1, + ), + .size2 => ( + minHeight: UiTokens.space9(), + paddingX: UiTokens.space2(), + paddingY: UiTokens.selectSpace1Half(), + spacing: UiTokens.space2(), + radius: UiTokens.radius2(), + text: UiTokens.text2, + ), + .size3 => ( + minHeight: UiTokens.textAreaMinHeight3(), + paddingX: UiTokens.space3(), + paddingY: UiTokens.space2(), + spacing: UiTokens.space3(), + radius: UiTokens.radius3(), + text: UiTokens.text3, + ), +}; diff --git a/apps/dashboard/lib/ui/components/textfield.g.dart b/apps/dashboard/lib/ui/components/textfield.g.dart new file mode 100644 index 000000000..43dc7933f --- /dev/null +++ b/apps/dashboard/lib/ui/components/textfield.g.dart @@ -0,0 +1,882 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'textfield.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixTextField]. +class UiTextField extends StatelessWidget { + const UiTextField({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = .none, + this.textDirection, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.obscuringCharacter = '•', + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }); + + /// Raised treatment with Radix's level-one shadow. + const UiTextField.classic({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = .none, + this.textDirection, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.obscuringCharacter = '•', + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = UiTextFieldVariant.classic; + + /// Surface treatment with neutral border and text colors. + const UiTextField.surface({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = .none, + this.textDirection, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.obscuringCharacter = '•', + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = UiTextFieldVariant.surface; + + /// Soft accent treatment. + const UiTextField.soft({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = .none, + this.textDirection, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.obscuringCharacter = '•', + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = UiTextFieldVariant.soft; + + final UiTextFieldVariant variant; + + final UiTextFieldSize size; + + final TextFieldStyler style; + + final TextEditingController? controller; + + final FocusNode? focusNode; + + final String? label; + + final String? hintText; + + final String? helperText; + + final bool error; + + final TextInputType? keyboardType; + + final TextInputAction? textInputAction; + + final TextCapitalization textCapitalization; + + final TextDirection? textDirection; + + final bool obscureText; + + final bool enabled; + + final bool readOnly; + + final bool autofocus; + + final int? maxLines; + + final int? minLines; + + final bool expands; + + final int? maxLength; + + final MaxLengthEnforcement? maxLengthEnforcement; + + final ValueChanged? onChanged; + + final VoidCallback? onEditingComplete; + + final ValueChanged? onSubmitted; + + final AppPrivateCommandCallback? onAppPrivateCommand; + + final List? inputFormatters; + + final bool? showCursor; + + final String obscuringCharacter; + + final bool autocorrect; + + final bool enableSuggestions; + + final SmartDashesType? smartDashesType; + + final SmartQuotesType? smartQuotesType; + + final DragStartBehavior dragStartBehavior; + + final bool enableInteractiveSelection; + + final TextSelectionControls? selectionControls; + + final GestureTapCallback? onTap; + + final TapRegionCallback? onTapOutside; + + final TapRegionUpCallback? onPressUpOutside; + + final bool onTapAlwaysCalled; + + final ScrollController? scrollController; + + final ScrollPhysics? scrollPhysics; + + final Iterable? autofillHints; + + final ContentInsertionConfiguration? contentInsertionConfiguration; + + final Clip clipBehavior; + + final String? restorationId; + + final bool stylusHandwritingEnabled; + + final bool enableIMEPersonalizedLearning; + + final EditableTextContextMenuBuilder? contextMenuBuilder; + + final SpellCheckConfiguration? spellCheckConfiguration; + + final TextMagnifierConfiguration? magnifierConfiguration; + + final bool canRequestFocus; + + final bool? ignorePointers; + + final UndoHistoryController? undoController; + + final Object groupId; + + final Widget? leading; + + final Widget? trailing; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixTextField( + key: this.key, + style: uiTextFieldStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + controller: this.controller, + focusNode: this.focusNode, + label: this.label, + hintText: this.hintText, + helperText: this.helperText, + error: this.error, + keyboardType: this.keyboardType, + textInputAction: this.textInputAction, + textCapitalization: this.textCapitalization, + textDirection: this.textDirection, + obscureText: this.obscureText, + enabled: this.enabled, + readOnly: this.readOnly, + autofocus: this.autofocus, + maxLines: this.maxLines, + minLines: this.minLines, + expands: this.expands, + maxLength: this.maxLength, + maxLengthEnforcement: this.maxLengthEnforcement, + onChanged: this.onChanged, + onEditingComplete: this.onEditingComplete, + onSubmitted: this.onSubmitted, + onAppPrivateCommand: this.onAppPrivateCommand, + inputFormatters: this.inputFormatters, + showCursor: this.showCursor, + obscuringCharacter: this.obscuringCharacter, + autocorrect: this.autocorrect, + enableSuggestions: this.enableSuggestions, + smartDashesType: this.smartDashesType, + smartQuotesType: this.smartQuotesType, + dragStartBehavior: this.dragStartBehavior, + enableInteractiveSelection: this.enableInteractiveSelection, + selectionControls: this.selectionControls, + onTap: this.onTap, + onTapOutside: this.onTapOutside, + onPressUpOutside: this.onPressUpOutside, + onTapAlwaysCalled: this.onTapAlwaysCalled, + scrollController: this.scrollController, + scrollPhysics: this.scrollPhysics, + autofillHints: this.autofillHints, + contentInsertionConfiguration: this.contentInsertionConfiguration, + clipBehavior: this.clipBehavior, + restorationId: this.restorationId, + stylusHandwritingEnabled: this.stylusHandwritingEnabled, + enableIMEPersonalizedLearning: this.enableIMEPersonalizedLearning, + contextMenuBuilder: this.contextMenuBuilder, + spellCheckConfiguration: this.spellCheckConfiguration, + magnifierConfiguration: this.magnifierConfiguration, + canRequestFocus: this.canRequestFocus, + ignorePointers: this.ignorePointers, + undoController: this.undoController, + groupId: this.groupId, + leading: this.leading, + trailing: this.trailing, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + ); + } +} + +/// Ui recipe for [RemixTextArea]. +/// +/// Scrolling follows the host platform; this recipe does not reproduce Radix's +/// themed browser scrollbar or resize handle. +class UiTextArea extends StatelessWidget { + const UiTextArea({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType = TextInputType.multiline, + this.textInputAction = TextInputAction.newline, + this.textCapitalization = .none, + this.textDirection, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines, + this.minLines = 2, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }); + + const UiTextArea.classic({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType = TextInputType.multiline, + this.textInputAction = TextInputAction.newline, + this.textCapitalization = .none, + this.textDirection, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines, + this.minLines = 2, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = UiTextAreaVariant.classic; + + const UiTextArea.surface({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType = TextInputType.multiline, + this.textInputAction = TextInputAction.newline, + this.textCapitalization = .none, + this.textDirection, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines, + this.minLines = 2, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = UiTextAreaVariant.surface; + + const UiTextArea.soft({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType = TextInputType.multiline, + this.textInputAction = TextInputAction.newline, + this.textCapitalization = .none, + this.textDirection, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines, + this.minLines = 2, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = UiTextAreaVariant.soft; + + final UiTextAreaVariant variant; + + final UiTextAreaSize size; + + final TextFieldStyler style; + + final TextEditingController? controller; + + final FocusNode? focusNode; + + final String? label; + + final String? hintText; + + final String? helperText; + + final bool error; + + final TextInputType? keyboardType; + + final TextInputAction? textInputAction; + + final TextCapitalization textCapitalization; + + final TextDirection? textDirection; + + final bool enabled; + + final bool readOnly; + + final bool autofocus; + + final int? maxLines; + + final int? minLines; + + final int? maxLength; + + final MaxLengthEnforcement? maxLengthEnforcement; + + final ValueChanged? onChanged; + + final VoidCallback? onEditingComplete; + + final ValueChanged? onSubmitted; + + final AppPrivateCommandCallback? onAppPrivateCommand; + + final List? inputFormatters; + + final bool? showCursor; + + final bool autocorrect; + + final bool enableSuggestions; + + final SmartDashesType? smartDashesType; + + final SmartQuotesType? smartQuotesType; + + final DragStartBehavior dragStartBehavior; + + final bool enableInteractiveSelection; + + final TextSelectionControls? selectionControls; + + final GestureTapCallback? onTap; + + final TapRegionCallback? onTapOutside; + + final TapRegionUpCallback? onPressUpOutside; + + final bool onTapAlwaysCalled; + + final ScrollController? scrollController; + + final ScrollPhysics? scrollPhysics; + + final Iterable? autofillHints; + + final ContentInsertionConfiguration? contentInsertionConfiguration; + + final Clip clipBehavior; + + final String? restorationId; + + final bool stylusHandwritingEnabled; + + final bool enableIMEPersonalizedLearning; + + final EditableTextContextMenuBuilder? contextMenuBuilder; + + final SpellCheckConfiguration? spellCheckConfiguration; + + final TextMagnifierConfiguration? magnifierConfiguration; + + final bool canRequestFocus; + + final bool? ignorePointers; + + final UndoHistoryController? undoController; + + final Object groupId; + + final Widget? leading; + + final Widget? trailing; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixTextArea( + key: this.key, + style: uiTextAreaStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + controller: this.controller, + focusNode: this.focusNode, + label: this.label, + hintText: this.hintText, + helperText: this.helperText, + error: this.error, + keyboardType: this.keyboardType, + textInputAction: this.textInputAction, + textCapitalization: this.textCapitalization, + textDirection: this.textDirection, + enabled: this.enabled, + readOnly: this.readOnly, + autofocus: this.autofocus, + maxLines: this.maxLines, + minLines: this.minLines, + maxLength: this.maxLength, + maxLengthEnforcement: this.maxLengthEnforcement, + onChanged: this.onChanged, + onEditingComplete: this.onEditingComplete, + onSubmitted: this.onSubmitted, + onAppPrivateCommand: this.onAppPrivateCommand, + inputFormatters: this.inputFormatters, + showCursor: this.showCursor, + autocorrect: this.autocorrect, + enableSuggestions: this.enableSuggestions, + smartDashesType: this.smartDashesType, + smartQuotesType: this.smartQuotesType, + dragStartBehavior: this.dragStartBehavior, + enableInteractiveSelection: this.enableInteractiveSelection, + selectionControls: this.selectionControls, + onTap: this.onTap, + onTapOutside: this.onTapOutside, + onPressUpOutside: this.onPressUpOutside, + onTapAlwaysCalled: this.onTapAlwaysCalled, + scrollController: this.scrollController, + scrollPhysics: this.scrollPhysics, + autofillHints: this.autofillHints, + contentInsertionConfiguration: this.contentInsertionConfiguration, + clipBehavior: this.clipBehavior, + restorationId: this.restorationId, + stylusHandwritingEnabled: this.stylusHandwritingEnabled, + enableIMEPersonalizedLearning: this.enableIMEPersonalizedLearning, + contextMenuBuilder: this.contextMenuBuilder, + spellCheckConfiguration: this.spellCheckConfiguration, + magnifierConfiguration: this.magnifierConfiguration, + canRequestFocus: this.canRequestFocus, + ignorePointers: this.ignorePointers, + undoController: this.undoController, + groupId: this.groupId, + leading: this.leading, + trailing: this.trailing, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/toast.dart b/apps/dashboard/lib/ui/components/toast.dart new file mode 100644 index 000000000..3f522145f --- /dev/null +++ b/apps/dashboard/lib/ui/components/toast.dart @@ -0,0 +1,149 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'button.dart'; +import 'icon_button.dart'; + +part 'toast.g.dart'; + +/// Ui toast size presets. +enum UiToastSize { size1, size2, size3 } + +/// Ui toast surfaces, matching the Card surface and classic treatments. +enum UiToastVariant { surface, classic } + +/// The color role of the leading icon. +/// +/// Visual only: it never changes [RemixToastData.priority]. Choose +/// [RemixToastPriority.assertive] explicitly when a message needs an +/// immediate announcement. +enum UiToastIntent { accent, neutral, error } + +/// Ui-themed toast surface for [RemixToast] and [RemixToastScope]. +/// +/// A Ui extension: Radix Themes has no toast, so the recipe reuses the +/// Card panel and shadow tokens. The surface caps at 360 logical pixels and +/// shrinks with the available width. +/// +/// ```dart +/// RemixToastScope(style: uiToastStyle(), child: const Shell()) +/// ``` +@MixWidget(target: RemixToast.new) +ToastStyler uiToastStyle({ + UiToastVariant variant = .classic, + UiToastSize size = .size2, + UiToastIntent intent = .accent, + ToastStyler style = const ToastStyler.create(), +}) { + final metrics = _uiToastMetrics(size); + final base = + ToastStyler( + container: FlexBoxStyler().spacing(metrics.gap), + content: FlexBoxStyler().spacing(UiTokens.space1()), + icon: IconStyler() + .size(metrics.iconSize) + .color(_uiToastIntentColor(intent)), + title: TextStyler( + style: metrics.text.mix(), + ).fontWeight(UiTokens.fontWeightMedium()).color(UiTokens.gray12()), + description: TextStyler( + style: metrics.text.mix(), + ).color(UiTokens.gray11()), + action: uiButtonStyle(variant: .ghost, size: .size1), + closeButton: uiIconButtonStyle(variant: .ghost, size: .size1).merge( + IconButtonStyler().icon(IconStyler().color(UiTokens.gray11())), + ), + ) + .padding(.all(metrics.padding)) + .borderRadius(.all(metrics.radius)) + .maxWidth(360) + .containerEffects( + RemixBoxEffectsMix.backdropBlur(UiTokens.panelBlur()), + ); + + return (switch (variant) { + .surface => + base + .containerEffects(RemixBoxEffectsMix.behindContent(_uiToastPanel())) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + color: UiTokens.grayStroke5(), + spreadRadius: 1, + shapeInset: 1, + ), + ], + ), + ), + ) + .decoration( + BoxDecorationMix.create(boxShadow: UiTokens.shadow4.mix()), + ), + .classic => + base + .containerEffects( + RemixBoxEffectsMix.behindContent( + _uiToastPanel(shadowToken: UiTokens.cardClassicOuterShadows), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadowToken: UiTokens.cardClassicInnerShadows, + ), + ), + ), + }).merge(style); +} + +({ + double padding, + double gap, + double iconSize, + Radius radius, + TextStyleToken text, +}) +_uiToastMetrics(UiToastSize size) => switch (size) { + .size1 => ( + padding: UiTokens.space3(), + gap: UiTokens.space2(), + iconSize: UiTokens.space4(), + radius: UiTokens.radius3(), + text: UiTokens.text1, + ), + .size2 => ( + padding: UiTokens.space4(), + gap: UiTokens.space3(), + iconSize: UiTokens.space4(), + radius: UiTokens.radius4(), + text: UiTokens.text2, + ), + .size3 => ( + padding: UiTokens.space5(), + gap: UiTokens.space3(), + iconSize: UiTokens.space5(), + radius: UiTokens.radius5(), + text: UiTokens.text3, + ), +}; + +Color _uiToastIntentColor(UiToastIntent intent) => switch (intent) { + .accent => UiTokens.accent11(), + .neutral => UiTokens.gray11(), + .error => UiTokens.error11(), +}; + +RemixBoxEffectLayerMix _uiToastPanel({RemixBoxShadowListToken? shadowToken}) => + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [UiTokens.colorPanel(), UiTokens.colorPanel()], + ), + ], + gradientInsets: const [1], + shadowToken: shadowToken, + ); diff --git a/apps/dashboard/lib/ui/components/toast.g.dart b/apps/dashboard/lib/ui/components/toast.g.dart new file mode 100644 index 000000000..97e2bd139 --- /dev/null +++ b/apps/dashboard/lib/ui/components/toast.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toast.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed toast surface for [RemixToast] and [RemixToastScope]. +/// +/// A Ui extension: Radix Themes has no toast, so the recipe reuses the +/// Card panel and shadow tokens. The surface caps at 360 logical pixels and +/// shrinks with the available width. +/// +/// ```dart +/// RemixToastScope(style: uiToastStyle(), child: const Shell()) +/// ``` +class UiToast extends StatelessWidget { + const UiToast({ + super.key, + this.variant = .classic, + this.size = .size2, + this.intent = .accent, + this.style = const ToastStyler.create(), + required this.title, + this.description, + this.icon, + this.action, + this.onDismiss, + this.dismissLabel, + this.excludeMessageSemantics = false, + }); + + const UiToast.surface({ + super.key, + this.size = .size2, + this.intent = .accent, + this.style = const ToastStyler.create(), + required this.title, + this.description, + this.icon, + this.action, + this.onDismiss, + this.dismissLabel, + this.excludeMessageSemantics = false, + }) : variant = UiToastVariant.surface; + + const UiToast.classic({ + super.key, + this.size = .size2, + this.intent = .accent, + this.style = const ToastStyler.create(), + required this.title, + this.description, + this.icon, + this.action, + this.onDismiss, + this.dismissLabel, + this.excludeMessageSemantics = false, + }) : variant = UiToastVariant.classic; + + final UiToastVariant variant; + + final UiToastSize size; + + final UiToastIntent intent; + + final ToastStyler style; + + final String title; + + final String? description; + + final IconData? icon; + + final RemixToastAction? action; + + final VoidCallback? onDismiss; + + final String? dismissLabel; + + final bool excludeMessageSemantics; + + @override + Widget build(BuildContext context) { + return RemixToast( + key: this.key, + style: uiToastStyle( + variant: this.variant, + size: this.size, + intent: this.intent, + style: this.style, + ), + title: this.title, + description: this.description, + icon: this.icon, + action: this.action, + onDismiss: this.onDismiss, + dismissLabel: this.dismissLabel, + excludeMessageSemantics: this.excludeMessageSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/toggle.dart b/apps/dashboard/lib/ui/components/toggle.dart new file mode 100644 index 000000000..08b4fa587 --- /dev/null +++ b/apps/dashboard/lib/ui/components/toggle.dart @@ -0,0 +1,136 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'toggle.g.dart'; + +/// Ui toggle size presets. +enum UiToggleSize { size1, size2, size3 } + +/// Ui toggle color and border variants. +enum UiToggleVariant { ghost, outline } + +/// Ui-themed preset for [RemixToggle]. +@MixWidget(target: RemixToggle.new) +ToggleStyler uiToggleStyle({ + UiToggleVariant variant = .ghost, + UiToggleSize size = .size2, + bool highContrast = false, + ToggleStyler style = const ToggleStyler.create(), +}) { + return (switch (variant) { + .ghost => _uiToggleGhostStyler(size, highContrast: highContrast), + .outline => _uiToggleOutlineStyler(size, highContrast: highContrast), + }).merge(style); +} + +ToggleStyler _uiToggleBaseStyler(UiToggleSize size) { + return ToggleStyler() + .container(.mainAxisSize(.min)) + .labelColor(UiTokens.gray12()) + .iconColor(UiTokens.gray12()) + .labelFontWeight(UiTokens.fontWeightMedium()) + .merge(_uiToggleSizeStyler(size)); +} + +ToggleStyler _uiToggleFocusStyler() => ToggleStyler().uiFocusRing(); + +ToggleStyler _uiToggleDisabledStyler({bool outlined = false}) { + final style = ToggleStyler() + .color(UiTokens.grayA3()) + .labelColor(UiTokens.gray8()) + .iconColor(UiTokens.gray8()); + return outlined + ? style.border( + .color(UiTokens.grayA6()) + .width(UiTokens.borderWidth1()) + .strokeAlign(BorderSide.strokeAlignInside), + ) + : style; +} + +ToggleStyler _uiToggleGhostStyler( + UiToggleSize size, { + required bool highContrast, +}) { + final selectedContent = highContrast + ? UiTokens.accent12() + : UiTokens.accent11(); + return _uiToggleBaseStyler(size) + .color(const Color(0x00000000)) + .onHovered(ToggleStyler().color(UiTokens.grayA3())) + .onPressed(ToggleStyler().color(UiTokens.grayA4())) + .onSelected( + ToggleStyler() + .color(UiTokens.accent3()) + .labelColor(selectedContent) + .iconColor(selectedContent) + .onHovered(ToggleStyler().color(UiTokens.accent4())) + .onPressed(ToggleStyler().color(UiTokens.accent5())), + ) + .onFocusVisible(_uiToggleFocusStyler()) + .onDisabled(_uiToggleDisabledStyler()); +} + +ToggleStyler _uiToggleOutlineStyler( + UiToggleSize size, { + required bool highContrast, +}) { + final selectedContent = highContrast + ? UiTokens.accent12() + : UiTokens.accent11(); + return _uiToggleBaseStyler(size) + .color(const Color(0x00000000)) + .border( + .color(UiTokens.gray7()) + .width(UiTokens.borderWidth1()) + .strokeAlign(BorderSide.strokeAlignInside), + ) + .onHovered(ToggleStyler().color(UiTokens.grayA3())) + .onPressed(ToggleStyler().color(UiTokens.grayA4())) + .onSelected( + ToggleStyler() + .color(UiTokens.accentA3()) + .labelColor(selectedContent) + .iconColor(selectedContent) + .border(.color(UiTokens.accentA5())) + .onHovered(ToggleStyler().color(UiTokens.accentA4())) + .onPressed(ToggleStyler().color(UiTokens.accentA5())), + ) + .onFocusVisible(_uiToggleFocusStyler()) + .onDisabled(_uiToggleDisabledStyler(outlined: true)); +} + +ToggleStyler _uiToggleSizeStyler(UiToggleSize size) { + return switch (size) { + .size1 => ToggleStyler( + container: FlexBoxStyler() + .padding(.horizontal(UiTokens.space2())) + .padding(.vertical(UiTokens.space1())) + .borderRadius(.all(UiTokens.radius2())) + .spacing(UiTokens.toggleGap1()), + label: .style(UiTokens.text1.mix()), + icon: .size(UiTokens.space3()), + ), + .size2 => ToggleStyler( + container: FlexBoxStyler() + .padding(.horizontal(UiTokens.space3())) + .padding(.vertical(UiTokens.space2())) + .borderRadius(.all(UiTokens.radius2())) + .spacing(UiTokens.space1()), + label: .style(UiTokens.text2.mix()), + icon: .size(UiTokens.space4()), + ), + .size3 => ToggleStyler( + container: FlexBoxStyler() + .padding(.horizontal(UiTokens.space4())) + .padding(.vertical(UiTokens.space2())) + .borderRadius(.all(UiTokens.radius3())) + .spacing(UiTokens.toggleGap3()), + label: .style(UiTokens.text3.mix()), + icon: .size(UiTokens.spinnerSize3()), + ), + }; +} diff --git a/apps/dashboard/lib/ui/components/toggle.g.dart b/apps/dashboard/lib/ui/components/toggle.g.dart new file mode 100644 index 000000000..5d8760844 --- /dev/null +++ b/apps/dashboard/lib/ui/components/toggle.g.dart @@ -0,0 +1,119 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toggle.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixToggle]. +class UiToggle extends StatelessWidget { + const UiToggle({ + super.key, + this.variant = .ghost, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.label, + this.icon, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + const UiToggle.ghost({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.label, + this.icon, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiToggleVariant.ghost; + + const UiToggle.outline({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.label, + this.icon, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = UiToggleVariant.outline; + + final UiToggleVariant variant; + + final UiToggleSize size; + + final bool highContrast; + + final ToggleStyler style; + + final bool selected; + + final ValueChanged? onChanged; + + final bool enabled; + + final String? label; + + final IconData? icon; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final String? semanticLabel; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixToggle( + key: this.key, + style: uiToggleStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + selected: this.selected, + onChanged: this.onChanged, + enabled: this.enabled, + label: this.label, + icon: this.icon, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/toggle_group.dart b/apps/dashboard/lib/ui/components/toggle_group.dart new file mode 100644 index 000000000..838e74f78 --- /dev/null +++ b/apps/dashboard/lib/ui/components/toggle_group.dart @@ -0,0 +1,111 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'toggle_group.g.dart'; + +/// Ui toggle-group size presets. +enum UiToggleGroupSize { size1, size2, size3 } + +/// Ui toggle-group color treatments. +enum UiToggleGroupVariant { soft, surface } + +/// Ui-themed segmented-control preset for [RemixToggleGroup]. +@MixWidget(target: RemixToggleGroup.new) +ToggleGroupStyler uiToggleGroupStyle({ + UiToggleGroupVariant variant = .soft, + UiToggleGroupSize size = .size2, + bool highContrast = false, + ToggleGroupStyler style = const ToggleGroupStyler.create(), +}) { + final ( + selectedColor, + selectedHoverColor, + selectedPressedColor, + ) = switch (variant) { + .soft => (UiTokens.accent3(), UiTokens.accent4(), UiTokens.accent5()), + .surface => ( + UiTokens.accentSurface(), + UiTokens.accentA4(), + UiTokens.accentA5(), + ), + }; + final selectedForeground = highContrast + ? UiTokens.accent12() + : UiTokens.accent11(); + + return ToggleGroupStyler( + container: FlexBoxStyler( + decoration: BoxDecorationMix( + border: BorderMix.all( + BorderSideMix( + color: UiTokens.gray7(), + width: UiTokens.borderWidth1(), + ), + ), + color: UiTokens.colorSurface(), + ), + clipBehavior: .hardEdge, + mainAxisSize: .min, + spacing: 0, + ), + item: .alignment(.center) + .labelColor(UiTokens.gray11()) + .iconColor(UiTokens.gray11()) + .labelFontWeight(UiTokens.fontWeightMedium()) + .onHovered(ToggleGroupItemStyler().color(UiTokens.grayA3())) + .onPressed(ToggleGroupItemStyler().color(UiTokens.grayA4())) + .onSelected( + ToggleGroupItemStyler() + .color(selectedColor) + .labelColor(selectedForeground) + .iconColor(selectedForeground) + .onHovered(ToggleGroupItemStyler().color(selectedHoverColor)) + .onPressed(ToggleGroupItemStyler().color(selectedPressedColor)), + ) + .onFocusVisible(ToggleGroupItemStyler().uiFocusRing()) + .onDisabled( + ToggleGroupItemStyler() + .color(UiTokens.grayA3()) + .labelColor(UiTokens.gray8()) + .iconColor(UiTokens.gray8()), + ), + ).merge(_uiToggleGroupSizeStyler(size)).merge(style); +} + +ToggleGroupStyler _uiToggleGroupSizeStyler(UiToggleGroupSize size) { + return switch (size) { + .size1 => ToggleGroupStyler( + container: FlexBoxStyler().borderRadius(.all(UiTokens.radius2())), + item: .container( + FlexBoxStyler() + .padding(.horizontal(UiTokens.space2())) + .padding(.vertical(UiTokens.space1())) + .spacing(UiTokens.toggleGap1()), + ).label(.style(UiTokens.text1.mix())).icon(.size(UiTokens.space3())), + ), + .size2 => ToggleGroupStyler( + container: FlexBoxStyler().borderRadius(.all(UiTokens.radius2())), + item: .container( + FlexBoxStyler() + .padding(.horizontal(UiTokens.space3())) + .padding(.vertical(UiTokens.space2())) + .spacing(UiTokens.space1()), + ).label(.style(UiTokens.text2.mix())).icon(.size(UiTokens.space4())), + ), + .size3 => ToggleGroupStyler( + container: FlexBoxStyler().borderRadius(.all(UiTokens.radius3())), + item: + .container( + FlexBoxStyler() + .padding(.horizontal(UiTokens.space4())) + .padding(.vertical(UiTokens.space2())) + .spacing(UiTokens.toggleGap3()), + ) + .label(.style(UiTokens.text3.mix())) + .icon(.size(UiTokens.spinnerSize3())), + ), + }; +} diff --git a/apps/dashboard/lib/ui/components/toggle_group.g.dart b/apps/dashboard/lib/ui/components/toggle_group.g.dart new file mode 100644 index 000000000..f3183128a --- /dev/null +++ b/apps/dashboard/lib/ui/components/toggle_group.g.dart @@ -0,0 +1,101 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toggle_group.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed segmented-control preset for [RemixToggleGroup]. +class UiToggleGroup extends StatelessWidget { + const UiToggleGroup({ + super.key, + this.variant = .soft, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleGroupStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }); + + const UiToggleGroup.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleGroupStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = UiToggleGroupVariant.soft; + + const UiToggleGroup.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleGroupStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = UiToggleGroupVariant.surface; + + final UiToggleGroupVariant variant; + + final UiToggleGroupSize size; + + final bool highContrast; + + final ToggleGroupStyler style; + + final List> items; + + final T? selectedValue; + + final ValueChanged? onChanged; + + final bool enabled; + + final Axis orientation; + + final bool loop; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixToggleGroup( + key: this.key, + style: uiToggleGroupStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + items: this.items, + selectedValue: this.selectedValue, + onChanged: this.onChanged, + enabled: this.enabled, + orientation: this.orientation, + loop: this.loop, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/tooltip.dart b/apps/dashboard/lib/ui/components/tooltip.dart new file mode 100644 index 000000000..5f00ef467 --- /dev/null +++ b/apps/dashboard/lib/ui/components/tooltip.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'tooltip.g.dart'; + +/// Ui-themed preset for [RemixTooltip]. +@MixWidget(target: RemixTooltip.new) +TooltipStyler uiTooltipStyle({ + TooltipStyler style = const TooltipStyler.create(), +}) { + return TooltipStyler( + label: .style(UiTokens.text1.mix()), + waitDuration: const Duration(milliseconds: 200), + ) + .borderRadius(.all(UiTokens.radius2())) + .padding(.vertical(UiTokens.space1())) + .padding(.horizontal(UiTokens.space2())) + .label(.color(UiTokens.gray1())) + .color(UiTokens.gray12()) + .merge(style); +} diff --git a/apps/dashboard/lib/ui/components/tooltip.g.dart b/apps/dashboard/lib/ui/components/tooltip.g.dart new file mode 100644 index 000000000..7619037a4 --- /dev/null +++ b/apps/dashboard/lib/ui/components/tooltip.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tooltip.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Ui-themed preset for [RemixTooltip]. +class UiTooltip extends StatelessWidget { + const UiTooltip({ + super.key, + this.style = const TooltipStyler.create(), + required this.tooltipChild, + required this.child, + this.open, + this.onOpenChanged, + this.tooltipSemantics, + this.positioning = const OverlayPositionConfig(), + }); + + final TooltipStyler style; + + final Widget tooltipChild; + + final Widget child; + + final bool? open; + + final ValueChanged? onOpenChanged; + + final String? tooltipSemantics; + + final OverlayPositionConfig positioning; + + @override + Widget build(BuildContext context) { + return RemixTooltip( + key: this.key, + style: uiTooltipStyle(style: this.style), + tooltipChild: this.tooltipChild, + child: this.child, + open: this.open, + onOpenChanged: this.onOpenChanged, + tooltipSemantics: this.tooltipSemantics, + positioning: this.positioning, + ); + } +} diff --git a/apps/dashboard/lib/ui/components/transcript.dart b/apps/dashboard/lib/ui/components/transcript.dart new file mode 100644 index 000000000..cc39bebf6 --- /dev/null +++ b/apps/dashboard/lib/ui/components/transcript.dart @@ -0,0 +1,273 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../support/live_edge.dart'; + +part 'transcript.g.dart'; + +/// Chronological transcript with reader-aware live-edge following. +class UiTranscript extends StatefulWidget { + const UiTranscript({ + super.key, + required List this.children, + this.followOutput = true, + this.followThreshold = 48.0, + this.busy = false, + this.busyLabel = 'Busy', + this.label = 'Conversation', + this.onFollowChanged, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.style = const UiTranscriptStyler.create(), + this.styleSpec, + }) : itemCount = null, + itemBuilder = null; + + const UiTranscript.builder({ + super.key, + required int this.itemCount, + required IndexedWidgetBuilder this.itemBuilder, + this.followOutput = true, + this.followThreshold = 48.0, + this.busy = false, + this.busyLabel = 'Busy', + this.label = 'Conversation', + this.onFollowChanged, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.style = const UiTranscriptStyler.create(), + this.styleSpec, + }) : children = null; + + final List? children; + final int? itemCount; + final IndexedWidgetBuilder? itemBuilder; + final bool followOutput; + final double followThreshold; + final bool busy; + final String busyLabel; + final String label; + final ValueChanged? onFollowChanged; + final ScrollController? controller; + final Clip clipBehavior; + final UiTranscriptStyler style; + final UiTranscriptSpec? styleSpec; + + @override + State createState() => _UiTranscriptState(); +} + +class _UiTranscriptState extends State { + ScrollController? _ownedController; + late ScrollController _controller; + late final UiLiveEdgeEngine _liveEdge; + + /// Publishes this surface's focus to the styles resolved above it. + /// + /// `focused` has no other source here: Ui's slots resolve above any Naked + /// control, so without this the `focus-visible` state the transcript + /// worksheet documents could never activate. + /// + /// Only `focused`. The pointer-driven states do not resolve on this slot, and + /// did not before this controller existed either — a host's `onHovered` on + /// [UiTranscriptSpec.viewport] has never had an effect. Passing a + /// controller also means Mix will not mount its own pointer detector, so + /// restoring hover would be this object's job; nothing asks for it yet. + final WidgetStatesController _statesController = WidgetStatesController(); + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? (_ownedController = ScrollController()); + _liveEdge = UiLiveEdgeEngine( + enabled: widget.followOutput, + threshold: widget.followThreshold, + onChanged: widget.onFollowChanged, + ); + _scheduleFollow(); + } + + @override + void didUpdateWidget(UiTranscript oldWidget) { + super.didUpdateWidget(oldWidget); + _liveEdge + ..enabled = widget.followOutput + ..threshold = widget.followThreshold + ..onChanged = widget.onFollowChanged; + if (!identical(oldWidget.controller, widget.controller)) { + final offset = _controller.hasClients ? _controller.offset : 0.0; + final oldOwned = _ownedController; + _ownedController = null; + _controller = + widget.controller ?? + (_ownedController = ScrollController(initialScrollOffset: offset)); + if (oldOwned != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => oldOwned.dispose()); + } + } + _scheduleFollow(); + } + + void _scheduleFollow() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _liveEdge.follow(_controller); + }); + } + + bool _handleScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + _liveEdge.handleScroll(notification, _controller); + return notification is OverscrollNotification; + } + + void _handleIntent(_TranscriptScrollIntent intent) { + if (!_controller.hasClients) return; + final position = _controller.position; + final target = switch (intent.kind) { + _TranscriptScrollKind.lineUp => position.pixels - 50, + _TranscriptScrollKind.lineDown => position.pixels + 50, + _TranscriptScrollKind.pageUp => + position.pixels - position.viewportDimension * 0.8, + _TranscriptScrollKind.pageDown => + position.pixels + position.viewportDimension * 0.8, + _TranscriptScrollKind.home => position.minScrollExtent, + _TranscriptScrollKind.end => position.maxScrollExtent, + }; + position.jumpTo( + target + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(), + ); + _liveEdge.handlePosition(position); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + controller: _statesController, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.label, + value: widget.busy ? widget.busyLabel : null, + child: FocusableActionDetector( + onFocusChange: (focused) => + _statesController.update(WidgetState.focused, focused), + shortcuts: _transcriptShortcuts, + actions: >{ + _TranscriptScrollIntent: CallbackAction<_TranscriptScrollIntent>( + onInvoke: (intent) { + _handleIntent(intent); + return null; + }, + ), + }, + child: Box( + styleSpec: spec.viewport, + child: LayoutBuilder( + builder: (context, constraints) => + NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && _liveEdge.following) { + _scheduleFollow(); + } + return false; + }, + child: NotificationListener( + onNotification: _handleScroll, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + overscroll: false, + physics: const ClampingScrollPhysics(), + ), + child: _buildList( + spec, + shrinkWrap: !constraints.hasBoundedHeight, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildList(UiTranscriptSpec spec, {required bool shrinkWrap}) { + final children = widget.children; + final count = children?.length ?? widget.itemCount!; + final spacing = spec.spacing ?? 0; + assert(spacing >= 0, 'UiTranscript spacing must be non-negative.'); + return ListView.separated( + controller: _controller, + shrinkWrap: shrinkWrap, + physics: const ClampingScrollPhysics(), + clipBehavior: widget.clipBehavior, + itemCount: count, + itemBuilder: (context, index) => Box( + styleSpec: spec.item, + child: children?[index] ?? widget.itemBuilder!(context, index), + ), + separatorBuilder: (context, index) => SizedBox(height: spacing), + ); + } + + @override + void dispose() { + _ownedController?.dispose(); + _statesController.dispose(); + super.dispose(); + } +} + +enum _TranscriptScrollKind { lineUp, lineDown, pageUp, pageDown, home, end } + +class _TranscriptScrollIntent extends Intent { + const _TranscriptScrollIntent(this.kind); + final _TranscriptScrollKind kind; +} + +const _transcriptShortcuts = { + SingleActivator(LogicalKeyboardKey.arrowUp): _TranscriptScrollIntent( + _TranscriptScrollKind.lineUp, + ), + SingleActivator(LogicalKeyboardKey.arrowDown): _TranscriptScrollIntent( + _TranscriptScrollKind.lineDown, + ), + SingleActivator(LogicalKeyboardKey.pageUp): _TranscriptScrollIntent( + _TranscriptScrollKind.pageUp, + ), + SingleActivator(LogicalKeyboardKey.pageDown): _TranscriptScrollIntent( + _TranscriptScrollKind.pageDown, + ), + SingleActivator(LogicalKeyboardKey.home): _TranscriptScrollIntent( + _TranscriptScrollKind.home, + ), + SingleActivator(LogicalKeyboardKey.end): _TranscriptScrollIntent( + _TranscriptScrollKind.end, + ), +}; + +@MixableSpec(target: UiTranscript.new) +@immutable +final class UiTranscriptSpec with _$UiTranscriptSpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final double? spacing; + + const UiTranscriptSpec({ + StyleSpec? viewport, + StyleSpec? item, + this.spacing, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/apps/dashboard/lib/ui/components/transcript.g.dart b/apps/dashboard/lib/ui/components/transcript.g.dart new file mode 100644 index 000000000..db6a23da7 --- /dev/null +++ b/apps/dashboard/lib/ui/components/transcript.g.dart @@ -0,0 +1,259 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'transcript.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$UiTranscriptSpec implements Spec, Diagnosticable { + StyleSpec get viewport; + StyleSpec get item; + double? get spacing; + + @override + Type get type => UiTranscriptSpec; + + @override + UiTranscriptSpec copyWith({ + StyleSpec? viewport, + StyleSpec? item, + double? spacing, + }) { + return UiTranscriptSpec( + viewport: viewport ?? this.viewport, + item: item ?? this.item, + spacing: spacing ?? this.spacing, + ); + } + + @override + UiTranscriptSpec lerp(UiTranscriptSpec? other, double t) { + return UiTranscriptSpec( + viewport: viewport.lerp(other?.viewport, t), + item: item.lerp(other?.item, t), + spacing: MixOps.lerp(spacing, other?.spacing, t), + ); + } + + @override + List get props => [viewport, item, spacing]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is UiTranscriptSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('viewport', viewport)) + ..add(DiagnosticsProperty('item', item)) + ..add(DoubleProperty('spacing', spacing)); + } +} + +@Deprecated( + 'Rename to `_\$UiTranscriptSpec` and migrate the class declaration to `class UiTranscriptSpec with _\$UiTranscriptSpec`. The `_\$UiTranscriptSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$UiTranscriptSpecMethods = _$UiTranscriptSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class UiTranscriptStyler extends MixStyler + implements StylerFieldMetadata { + final Prop>? $viewport; + final Prop>? $item; + final Prop? $spacing; + + const UiTranscriptStyler.create({ + Prop>? viewport, + Prop>? item, + Prop? spacing, + super.variants, + super.modifier, + super.animation, + }) : $viewport = viewport, + $item = item, + $spacing = spacing; + + UiTranscriptStyler({ + BoxStyler? viewport, + BoxStyler? item, + double? spacing, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + viewport: Prop.maybeMix(viewport), + item: Prop.maybeMix(item), + spacing: Prop.maybe(spacing), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory UiTranscriptStyler.viewport(BoxStyler value) => + UiTranscriptStyler().viewport(value); + factory UiTranscriptStyler.item(BoxStyler value) => + UiTranscriptStyler().item(value); + factory UiTranscriptStyler.spacing(double value) => + UiTranscriptStyler().spacing(value); + + @override + Set get $stylerFieldNames => const { + 'viewport', + 'item', + 'spacing', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the viewport. + UiTranscriptStyler viewport(BoxStyler value) { + return merge(UiTranscriptStyler(viewport: value)); + } + + /// Sets the item. + UiTranscriptStyler item(BoxStyler value) { + return merge(UiTranscriptStyler(item: value)); + } + + /// Sets the spacing. + UiTranscriptStyler spacing(double value) { + return merge(UiTranscriptStyler(spacing: value)); + } + + /// Sets the animation configuration. + @override + UiTranscriptStyler animate(AnimationConfig value) { + return merge(UiTranscriptStyler(animation: value)); + } + + /// Sets the style variants. + @override + UiTranscriptStyler variants(List> value) { + return merge(UiTranscriptStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + UiTranscriptStyler wrap(WidgetModifierConfig value) { + return merge(UiTranscriptStyler(modifier: value)); + } + + /// Sets the widget modifier. + UiTranscriptStyler modifier(WidgetModifierConfig value) { + return merge(UiTranscriptStyler(modifier: value)); + } + + UiTranscript call({ + Key? key, + required List children, + bool followOutput = true, + double followThreshold = 48.0, + bool busy = false, + String busyLabel = 'Busy', + String label = 'Conversation', + ValueChanged? onFollowChanged, + ScrollController? controller, + Clip clipBehavior = Clip.hardEdge, + }) { + return UiTranscript( + key: key, + style: this, + children: children, + followOutput: followOutput, + followThreshold: followThreshold, + busy: busy, + busyLabel: busyLabel, + label: label, + onFollowChanged: onFollowChanged, + controller: controller, + clipBehavior: clipBehavior, + ); + } + + /// Merges with another [UiTranscriptStyler]. + @override + UiTranscriptStyler merge(UiTranscriptStyler? other) { + return UiTranscriptStyler.create( + viewport: MixOps.merge($viewport, other?.$viewport), + item: MixOps.merge($item, other?.$item), + spacing: MixOps.merge($spacing, other?.$spacing), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = UiTranscriptSpec( + viewport: MixOps.resolve(context, $viewport), + item: MixOps.resolve(context, $item), + spacing: MixOps.resolve(context, $spacing), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('viewport', $viewport)) + ..add(DiagnosticsProperty('item', $item)) + ..add(DiagnosticsProperty('spacing', $spacing)); + } + + @override + List get props => [ + $viewport, + $item, + $spacing, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/dashboard/lib/ui/components/typography.dart b/apps/dashboard/lib/ui/components/typography.dart new file mode 100644 index 000000000..217315dc0 --- /dev/null +++ b/apps/dashboard/lib/ui/components/typography.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +/// The nine-step Radix Themes text scale supplied by [UiTokens]. +enum UiTextSize { + size1, + size2, + size3, + size4, + size5, + size6, + size7, + size8, + size9, +} + +/// Font weights supported by the Ui typography scale. +/// +/// Closed rather than Flutter's [FontWeight], which is an open class accepting +/// any value from 1 to 1000; Radix ships exactly these four. +enum UiTextWeight { light, regular, medium, bold } + +TextStyleToken uiTextSizeToken(UiTextSize size) => switch (size) { + .size1 => UiTokens.text1, + .size2 => UiTokens.text2, + .size3 => UiTokens.text3, + .size4 => UiTokens.text4, + .size5 => UiTokens.text5, + .size6 => UiTokens.text6, + .size7 => UiTokens.text7, + .size8 => UiTokens.text8, + .size9 => UiTokens.text9, +}; + +FontWeightToken uiTextWeightToken(UiTextWeight weight) => switch (weight) { + .light => UiTokens.fontWeightLight, + .regular => UiTokens.fontWeightRegular, + .medium => UiTokens.fontWeightMedium, + .bold => UiTokens.fontWeightBold, +}; + +/// [truncate] deliberately wins over [softWrap], forcing one ellipsized line. +TextStyler uiApplyTextFlow( + TextStyler style, { + TextAlign? align, + required bool softWrap, + required bool truncate, +}) { + if (align != null) style = style.textAlign(align); + if (truncate) { + return style.maxLines(1).softWrap(false).overflow(TextOverflow.ellipsis); + } + + return style.softWrap(softWrap); +} + +TextStyler uiAccentForeground(TextStyler style, {required bool highContrast}) => + style.color(highContrast ? UiTokens.accent12() : UiTokens.accentA11()); + +/// Code, Kbd, and Link derive em-relative geometry from the resolved font size, +/// so unlike the other recipes they cannot stay context-free. +TextStyle uiResolveTextToken(BuildContext context, UiTextSize size) => + MixScope.tokenOf(uiTextSizeToken(size), context); + +Color uiResolveColor(BuildContext context, ColorToken token) => + MixScope.tokenOf(token, context); + +/// Derived from the resolved `radius1` rather than duplicating the Ui +/// radius enum table, so theme radius and scaling changes flow through. +double uiRadiusFactor(BuildContext context) { + final scaling = UiTheme.of(context).scaling.factor; + final radius = MixScope.tokenOf(UiTokens.radius1, context); + + return radius.x / (3 * scaling); +} diff --git a/apps/dashboard/lib/ui/icons.dart b/apps/dashboard/lib/ui/icons.dart new file mode 100644 index 000000000..e057daa2c --- /dev/null +++ b/apps/dashboard/lib/ui/icons.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix_ui_icons/remix_ui_icons.dart'; + +/// Application-owned aliases for the icons used by this UI layer. +/// +/// The complete 318-glyph catalog remains available through [RemixIcons]. +/// Add, rename, or remove aliases here as the application vocabulary evolves. +abstract final class UiIcons { + /// Confirms a successful or selected action. + static const IconData check = RemixIcons.check; + + /// Dismisses, clears, or marks a failed action. + static const IconData cross = RemixIcons.cross2; + + /// Opens content positioned below the current control. + static const IconData chevronDown = RemixIcons.chevronDown; +} diff --git a/apps/dashboard/lib/ui/models/activity_item.dart b/apps/dashboard/lib/ui/models/activity_item.dart new file mode 100644 index 000000000..0bc0f734c --- /dev/null +++ b/apps/dashboard/lib/ui/models/activity_item.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; + +import 'statuses.dart'; + +/// One row in an [UiActivity] ledger. +@immutable +class UiActivityItem { + /// Creates an activity row. + const UiActivityItem({ + required this.id, + required this.title, + this.status = UiActivityItemStatus.pending, + this.detail, + this.child, + }); + + /// Stable identity across list updates. + final String id; + + /// Visible title. + final String title; + + /// Current status. + final UiActivityItemStatus status; + + /// Optional compact detail rendered with the activity detail style slot. + final String? detail; + + /// Optional host-rendered detail. The catalog does not parse this child. + final Widget? child; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UiActivityItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail && + identical(other.child, child); + + @override + int get hashCode => Object.hash( + runtimeType, + id, + title, + status, + detail, + identityHashCode(child), + ); + + @override + String toString() => + 'UiActivityItem(id: $id, title: $title, status: $status, detail: $detail, child: $child)'; +} diff --git a/apps/dashboard/lib/ui/models/plan_item.dart b/apps/dashboard/lib/ui/models/plan_item.dart new file mode 100644 index 000000000..500547996 --- /dev/null +++ b/apps/dashboard/lib/ui/models/plan_item.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; + +import 'statuses.dart'; + +/// One row in an [UiPlan]. +@immutable +class UiPlanItem { + /// Creates a plan item. + const UiPlanItem({ + required this.id, + required this.title, + this.status = UiPlanItemStatus.pending, + this.detail, + }); + + /// Stable identity across list updates. + final String id; + + /// Visible title. + final String title; + + /// Current status. + final UiPlanItemStatus status; + + /// Optional compact metadata (elapsed time, percent, path). + final String? detail; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UiPlanItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail; + + @override + int get hashCode => Object.hash(runtimeType, id, title, status, detail); + + @override + String toString() => + 'UiPlanItem(id: $id, title: $title, status: $status, detail: $detail)'; +} diff --git a/apps/dashboard/lib/ui/models/statuses.dart b/apps/dashboard/lib/ui/models/statuses.dart new file mode 100644 index 000000000..638edcf77 --- /dev/null +++ b/apps/dashboard/lib/ui/models/statuses.dart @@ -0,0 +1,145 @@ +/// Status of a long-running turn or activity ledger. +enum UiRunStatus { + /// Work is in progress. Disclosures stay open. + working, + + /// Work finished. Disclosures may collapse. + complete, +} + +/// Status of a streamed answer. +enum UiAnswerStatus { + /// Tokens are still arriving. + streaming, + + /// The answer finished successfully. + complete, + + /// The answer failed. + error, +} + +/// Status of an in-transcript tool permission. +/// +/// This is a machine, not a boolean loading flag. Actions are offered only +/// while [pending]. +enum UiPermissionStatus { + /// Waiting for a human decision. + pending, + + /// A decision was submitted and is being recorded. + deciding, + + /// The host accepted this invocation. + allowed, + + /// The approved tool is executing. + running, + + /// The approved tool finished. + complete, + + /// The host refused this invocation. + denied, + + /// Permission or execution failed. + error, +} + +/// Status of a tool execution disclosure. +enum UiExecutionStatus { + /// Output is still arriving. + running, + + /// The tool finished successfully. + success, + + /// The tool failed. + error, + + /// The host or runtime cancelled the tool. + cancelled, +} + +/// Status of one item in a task plan. +enum UiPlanItemStatus { + /// Not started. + pending, + + /// Currently underway. + inProgress, + + /// Finished successfully. + completed, + + /// Abandoned or skipped. + cancelled, +} + +/// Status of one row in an activity ledger. +enum UiActivityItemStatus { + /// Not yet started. + pending, + + /// The current step. + active, + + /// Finished. + complete, +} + +/// Who authored a transcript row. +enum UiRole { + /// The human operator. + user, + + /// The assistant replying to the operator. + assistant, +} + +/// Whether a permission or execution is still occupying the operator. +extension UiPermissionStatusX on UiPermissionStatus { + /// True until a terminal outcome. [pending] is working (HITL in flight) + /// but does not keep parameter details open. + bool get isWorking => !isSettled; + + /// True after a terminal decision or outcome. + bool get isSettled => + this == UiPermissionStatus.complete || + this == UiPermissionStatus.denied || + this == UiPermissionStatus.error; + + /// True while parameter details stay open without a user toggle. + /// Pending starts closed. + bool get keepsDetailsOpen => + this == UiPermissionStatus.deciding || + this == UiPermissionStatus.allowed || + this == UiPermissionStatus.running; +} + +/// Working vs settled for an execution disclosure. +extension UiExecutionStatusX on UiExecutionStatus { + /// True while output should stay expanded. + bool get isWorking => this == UiExecutionStatus.running; + + /// True after a terminal outcome. + bool get isSettled => !isWorking; +} + +/// Working vs settled for a streamed answer. +extension UiAnswerStatusX on UiAnswerStatus { + /// True while tokens are still arriving. + bool get isStreaming => this == UiAnswerStatus.streaming; + + /// True when completion actions may appear. + bool get showsActions => + this == UiAnswerStatus.complete || this == UiAnswerStatus.error; +} + +/// Working vs settled for a plan item. +extension UiPlanItemStatusX on UiPlanItemStatus { + bool get isActive => this == UiPlanItemStatus.inProgress; + + bool get isDone => + this == UiPlanItemStatus.completed || this == UiPlanItemStatus.cancelled; +} diff --git a/apps/dashboard/lib/ui/recipes/activity_recipe.dart b/apps/dashboard/lib/ui/recipes/activity_recipe.dart new file mode 100644 index 000000000..02731dfce --- /dev/null +++ b/apps/dashboard/lib/ui/recipes/activity_recipe.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/activity.dart'; +import '../components/disclosure.dart'; +import '../theme/theme.dart'; + +@immutable +final class UiAgentActivityRecipe { + const UiAgentActivityRecipe({ + required this.style, + required this.disclosureStyle, + }); + final UiActivityStyler style; + final DisclosureStyler disclosureStyle; +} + +UiAgentActivityRecipe uiAgentActivityRecipe({ + UiActivityStyler style = const UiActivityStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => UiAgentActivityRecipe( + style: UiActivityStyler( + viewport: BoxStyler().maxHeight(200), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color(UiTokens.gray12()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color(UiTokens.gray12()).fontSize(14), + itemDetail: TextStyler().color(UiTokens.gray11()).fontSize(12), + count: TextStyler() + .color(UiTokens.gray11()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color(UiTokens.gray12()).size(16), + pendingStatus: IconStyler().color(UiTokens.gray9()).size(12), + activeStatus: IconStyler().color(UiTokens.accent9()).size(12), + completedStatus: IconStyler().color(UiTokens.accent9()).size(12), + ).merge(style), + disclosureStyle: uiDisclosureStyle( + style: DisclosureStyler() + .content(BoxStyler().padding(.all(0))) + .merge(disclosureStyle), + ), +); diff --git a/apps/dashboard/lib/ui/recipes/answer_recipe.dart b/apps/dashboard/lib/ui/recipes/answer_recipe.dart new file mode 100644 index 000000000..c3c4c0385 --- /dev/null +++ b/apps/dashboard/lib/ui/recipes/answer_recipe.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/answer.dart'; +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/icon_button.dart'; +import '../theme/theme.dart'; + +@immutable +final class UiAgentAnswerRecipe { + const UiAgentAnswerRecipe({ + required this.style, + required this.surfaceStyle, + required this.sourcesStyle, + required this.copyStyle, + required this.retryStyle, + }); + final UiAnswerStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +UiAgentAnswerRecipe uiAgentAnswerRecipe({ + UiAnswerStyler style = const UiAnswerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler sourcesStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => UiAgentAnswerRecipe( + style: UiAnswerStyler( + body: BoxStyler(), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + feedback: BoxStyler().padding(.only(top: 6)), + sourcesLabel: TextStyler().color(UiTokens.gray12()).fontSize(13), + indicator: IconStyler().color(UiTokens.gray12()).size(16), + ).merge(style), + surfaceStyle: uiCardStyle(size: .size2, style: surfaceStyle), + sourcesStyle: uiDisclosureStyle(style: sourcesStyle), + copyStyle: uiIconButtonStyle(variant: .ghost, size: .size1, style: copyStyle), + retryStyle: uiIconButtonStyle( + variant: .ghost, + size: .size1, + style: retryStyle, + ), +); diff --git a/apps/dashboard/lib/ui/recipes/composer_recipe.dart b/apps/dashboard/lib/ui/recipes/composer_recipe.dart new file mode 100644 index 000000000..85f430147 --- /dev/null +++ b/apps/dashboard/lib/ui/recipes/composer_recipe.dart @@ -0,0 +1,64 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/card.dart'; +import '../components/composer.dart'; +import '../components/icon_button.dart'; +import '../components/textfield.dart'; +import '../theme/theme.dart'; + +@immutable +final class UiAgentComposerRecipe { + const UiAgentComposerRecipe({ + required this.style, + required this.surfaceStyle, + required this.fieldStyle, + required this.submitStyle, + required this.stopStyle, + }); + final UiComposerStyler style; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; +} + +UiAgentComposerRecipe uiAgentComposerRecipe({ + UiComposerStyler style = const UiComposerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + TextFieldStyler fieldStyle = const TextFieldStyler.create(), + IconButtonStyler submitStyle = const IconButtonStyler.create(), + IconButtonStyler stopStyle = const IconButtonStyler.create(), +}) => UiAgentComposerRecipe( + style: UiComposerStyler( + toolbar: FlexBoxStyler() + .direction(.horizontal) + .mainAxisSize(.max) + .crossAxisAlignment(.center) + .spacing(8) + .padding(.only(top: 8)), + ).merge(style), + surfaceStyle: uiCardStyle( + size: .size2, + style: CardStyler().padding(.all(12)).merge(surfaceStyle), + ), + fieldStyle: uiTextAreaStyle( + style: TextFieldStyler() + .color(const Color(0x00000000)) + .border(.style(.none)) + .minHeight(56) + .padding(.all(4)) + .merge(fieldStyle), + ), + submitStyle: uiIconButtonStyle( + size: .size2, + style: IconButtonStyler().size(40, 40).merge(submitStyle), + ), + stopStyle: uiIconButtonStyle( + size: .size2, + style: IconButtonStyler() + .color(UiTokens.error9()) + .size(40, 40) + .merge(stopStyle), + ), +); diff --git a/apps/dashboard/lib/ui/recipes/execution_recipe.dart b/apps/dashboard/lib/ui/recipes/execution_recipe.dart new file mode 100644 index 000000000..37928cefe --- /dev/null +++ b/apps/dashboard/lib/ui/recipes/execution_recipe.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/execution.dart'; +import '../components/icon_button.dart'; +import '../theme/theme.dart'; + +@immutable +final class UiAgentExecutionRecipe { + const UiAgentExecutionRecipe({ + required this.style, + required this.surfaceStyle, + required this.disclosureStyle, + required this.copyStyle, + required this.retryStyle, + }); + final UiExecutionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +UiAgentExecutionRecipe uiAgentExecutionRecipe({ + UiExecutionStyler style = const UiExecutionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => UiAgentExecutionRecipe( + style: UiExecutionStyler( + header: FlexBoxStyler().spacing(8), + output: BoxStyler() + .color(UiTokens.gray3()) + .borderRadius(.circular(6)) + .padding(.all(12)), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + tool: TextStyler().color(UiTokens.gray11()).fontSize(12), + title: TextStyler().color(UiTokens.gray12()).fontWeight(FontWeight.w600), + meta: TextStyler().color(UiTokens.gray11()).fontSize(12), + status: TextStyler().color(UiTokens.gray11()).fontSize(12), + toolIcon: IconStyler().color(UiTokens.gray12()).size(16), + statusIcon: IconStyler().color(UiTokens.accent9()).size(12), + indicator: IconStyler().color(UiTokens.gray12()).size(16), + ).merge(style), + surfaceStyle: uiCardStyle(size: .size2, style: surfaceStyle), + disclosureStyle: uiDisclosureStyle(style: disclosureStyle), + copyStyle: uiIconButtonStyle(variant: .ghost, size: .size1, style: copyStyle), + retryStyle: uiIconButtonStyle( + variant: .ghost, + size: .size1, + style: retryStyle, + ), +); diff --git a/apps/dashboard/lib/ui/recipes/message_recipe.dart b/apps/dashboard/lib/ui/recipes/message_recipe.dart new file mode 100644 index 000000000..58050ba02 --- /dev/null +++ b/apps/dashboard/lib/ui/recipes/message_recipe.dart @@ -0,0 +1,44 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/message.dart'; + +@immutable +final class UiAgentMessageRecipe { + const UiAgentMessageRecipe({ + required this.style, + required this.surfaceStyle, + required this.collapsibleStyle, + required this.toggleStyle, + }); + final UiMessageStyler style; + final CardStyler surfaceStyle; + final UiMessageCollapsibleStyler collapsibleStyle; + final ButtonStyler toggleStyle; +} + +UiAgentMessageRecipe uiAgentMessageRecipe({ + UiMessageStyler style = const UiMessageStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + UiMessageCollapsibleStyler collapsibleStyle = + const UiMessageCollapsibleStyler.create(), + ButtonStyler toggleStyle = const ButtonStyler.create(), +}) => UiAgentMessageRecipe( + style: UiMessageStyler( + row: FlexBoxStyler().mainAxisSize(.max).spacing(8), + avatar: BoxStyler().size(28, 28), + header: BoxStyler().padding(.only(bottom: 6)), + body: BoxStyler(), + footer: BoxStyler().padding(.only(top: 4)), + maxWidth: 640, + ).merge(style), + surfaceStyle: uiCardStyle(style: surfaceStyle), + collapsibleStyle: UiMessageCollapsibleStyler( + collapsedHeight: 72, + container: BoxStyler(), + clipped: BoxStyler(), + ).merge(collapsibleStyle), + toggleStyle: uiButtonStyle(variant: .ghost, size: .size1, style: toggleStyle), +); diff --git a/apps/dashboard/lib/ui/recipes/permission_recipe.dart b/apps/dashboard/lib/ui/recipes/permission_recipe.dart new file mode 100644 index 000000000..b1021e909 --- /dev/null +++ b/apps/dashboard/lib/ui/recipes/permission_recipe.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/data_list.dart'; +import '../components/disclosure.dart'; +import '../components/permission.dart'; +import '../theme/theme.dart'; + +@immutable +final class UiAgentPermissionRecipe { + const UiAgentPermissionRecipe({ + required this.style, + required this.surfaceStyle, + required this.detailsStyle, + required this.parametersStyle, + required this.allowOnceStyle, + required this.alwaysAllowStyle, + required this.denyStyle, + }); + final UiPermissionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; +} + +UiAgentPermissionRecipe uiAgentPermissionRecipe({ + UiPermissionStyler style = const UiPermissionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler detailsStyle = const DisclosureStyler.create(), + DataListStyler parametersStyle = const DataListStyler.create(), + ButtonStyler allowOnceStyle = const ButtonStyler.create(), + ButtonStyler alwaysAllowStyle = const ButtonStyler.create(), + ButtonStyler denyStyle = const ButtonStyler.create(), +}) => UiAgentPermissionRecipe( + style: UiPermissionStyler( + header: FlexBoxStyler().spacing(8), + actions: FlexBoxStyler().spacing(8).padding(.only(top: 8)), + title: TextStyler().color(UiTokens.gray12()).fontWeight(FontWeight.w600), + tool: TextStyler().color(UiTokens.gray11()).fontSize(12), + description: TextStyler() + .color(UiTokens.gray11()) + .wrap(.padding(.symmetric(vertical: 8))), + status: TextStyler().color(UiTokens.gray11()).fontSize(12), + detailsLabel: TextStyler().color(UiTokens.gray12()).fontSize(13), + toolIcon: IconStyler().color(UiTokens.gray12()).size(16), + statusIcon: IconStyler().color(UiTokens.accent9()).size(12), + indicator: IconStyler().color(UiTokens.gray12()).size(16), + ).merge(style), + surfaceStyle: uiCardStyle(size: .size2, style: surfaceStyle), + detailsStyle: uiDisclosureStyle(style: detailsStyle), + parametersStyle: uiDataListStyle(style: parametersStyle), + allowOnceStyle: uiButtonStyle(style: allowOnceStyle), + alwaysAllowStyle: uiButtonStyle(variant: .outline, style: alwaysAllowStyle), + denyStyle: uiButtonStyle(variant: .ghost, style: denyStyle), +); diff --git a/apps/dashboard/lib/ui/recipes/plan_recipe.dart b/apps/dashboard/lib/ui/recipes/plan_recipe.dart new file mode 100644 index 000000000..b80a022d6 --- /dev/null +++ b/apps/dashboard/lib/ui/recipes/plan_recipe.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/disclosure.dart'; +import '../components/plan.dart'; +import '../theme/theme.dart'; + +@immutable +final class UiAgentPlanRecipe { + const UiAgentPlanRecipe({required this.style, required this.disclosureStyle}); + final UiPlanStyler style; + final DisclosureStyler disclosureStyle; +} + +UiAgentPlanRecipe uiAgentPlanRecipe({ + UiPlanStyler style = const UiPlanStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => UiAgentPlanRecipe( + style: UiPlanStyler( + viewport: BoxStyler().maxHeight(220), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color(UiTokens.gray12()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color(UiTokens.gray12()).fontSize(14), + itemDetail: TextStyler().color(UiTokens.gray11()).fontSize(12), + count: TextStyler() + .color(UiTokens.gray11()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color(UiTokens.gray12()).size(16), + pendingStatus: IconStyler().color(UiTokens.gray9()).size(18), + activeStatus: IconStyler().color(UiTokens.accent9()).size(18), + completedStatus: IconStyler().color(UiTokens.accent9()).size(18), + cancelledStatus: IconStyler().color(UiTokens.gray9()).size(18), + ).merge(style), + disclosureStyle: uiDisclosureStyle(style: disclosureStyle), +); diff --git a/apps/dashboard/lib/ui/recipes/transcript_recipe.dart b/apps/dashboard/lib/ui/recipes/transcript_recipe.dart new file mode 100644 index 000000000..2a40d573e --- /dev/null +++ b/apps/dashboard/lib/ui/recipes/transcript_recipe.dart @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/transcript.dart'; + +@immutable +final class UiAgentTranscriptRecipe { + const UiAgentTranscriptRecipe({required this.style}); + final UiTranscriptStyler style; +} + +UiAgentTranscriptRecipe uiAgentTranscriptRecipe({ + UiTranscriptStyler style = const UiTranscriptStyler.create(), +}) => UiAgentTranscriptRecipe( + style: UiTranscriptStyler( + viewport: BoxStyler().padding(.only(right: 12)), + item: BoxStyler(), + spacing: 16, + ).merge(style), +); diff --git a/apps/dashboard/lib/ui/support/disclosure.dart b/apps/dashboard/lib/ui/support/disclosure.dart new file mode 100644 index 000000000..1e3ec33ca --- /dev/null +++ b/apps/dashboard/lib/ui/support/disclosure.dart @@ -0,0 +1,29 @@ +/// Controlled/uncontrolled storage shared by collapsible surfaces. +/// +/// Widgets own lifecycle policy, rebuilding, and request callbacks. In +/// particular, a request that does not change storage may still notify a host. +class UiDisclosureEngine { + UiDisclosureEngine({required bool? value, required bool defaultValue}) + : _controlled = value, + _uncontrolled = value ?? defaultValue; + + bool? _controlled; + bool _uncontrolled; + + bool get value => _controlled ?? _uncontrolled; + + /// Adopt the last controlled value when the host releases control. + void reconcile(bool? value) { + if (_controlled != null && value == null) { + _uncontrolled = _controlled!; + } + _controlled = value; + } + + /// Returns whether local storage changed and the widget needs a rebuild. + bool request(bool next) { + if (_controlled != null || next == _uncontrolled) return false; + _uncontrolled = next; + return true; + } +} diff --git a/apps/dashboard/lib/ui/support/functional_glyph.dart b/apps/dashboard/lib/ui/support/functional_glyph.dart new file mode 100644 index 000000000..fe493e632 --- /dev/null +++ b/apps/dashboard/lib/ui/support/functional_glyph.dart @@ -0,0 +1,182 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import 'package:remix_ui_icons/remix_ui_icons.dart'; + +abstract final class _Glyphs { + static const arrowUp = RemixIcons.arrowUp; + static const square = RemixIcons.square; + static const copy = RemixIcons.copy; + static const rotateCcw = RemixIcons.reload; + static const chevronUp = RemixIcons.chevronUp; + static const chevronDown = RemixIcons.chevronDown; + static const circle = RemixIcons.circle; + static const circleDot = RemixIcons.dotFilled; + static const check = RemixIcons.check; + static const x = RemixIcons.cross2; + static const circleAlert = RemixIcons.exclamationTriangle; + static const squareTerminal = RemixIcons.code; + static const loaderCircle = RemixIcons.update; + static const circleCheck = RemixIcons.checkCircled; + static const ban = RemixIcons.circleBackslash; + static const circleX = RemixIcons.crossCircled; + static const shieldCheck = RemixIcons.lockClosed; +} + +/// Builds the chevron that reports a collapsible surface's state. +/// +/// Every collapsible Ui surface offers the host the same escape hatch — a +/// builder that replaces the glyph outright — over the same default. Each takes +/// that builder under its own name, because a permission card discloses +/// *details* and an answer discloses *sources*, so the shared part is this +/// body and not the parameter. +class UiDisclosureIndicator extends StatelessWidget { + const UiDisclosureIndicator({ + super.key, + required this.styleSpec, + required this.expanded, + this.builder, + }); + + final StyleSpec styleSpec; + final bool expanded; + final Widget Function(BuildContext context, bool expanded)? builder; + + @override + Widget build(BuildContext context) => + builder?.call(context, expanded) ?? + StyleSpecBuilder( + styleSpec: styleSpec, + builder: (context, iconSpec) => UiFunctionalGlyph( + kind: .chevron, + spec: iconSpec, + expanded: expanded, + ), + ); +} + +/// Internal Material-free glyph set used by Ui's functional defaults. +/// +/// The types are intentionally not exported from the package barrel. Public +/// icon/status builders remain the replacement mechanism. +enum UiFunctionalGlyphKind { + send, + stop, + copy, + retry, + chevron, + pending, + active, + completed, + cancelled, + error, + tool, + loading, + completedCircle, + cancelledCircle, + errorCircle, + permission, +} + +class UiFunctionalGlyph extends StatelessWidget { + const UiFunctionalGlyph({ + super.key, + required this.kind, + required this.spec, + this.expanded = false, + }); + + final UiFunctionalGlyphKind kind; + final IconSpec spec; + final bool expanded; + + IconData get _icon => switch (kind) { + .send => _Glyphs.arrowUp, + .stop => _Glyphs.square, + .copy => _Glyphs.copy, + .retry => _Glyphs.rotateCcw, + .chevron => expanded ? _Glyphs.chevronUp : _Glyphs.chevronDown, + .pending => _Glyphs.circle, + .active => _Glyphs.circleDot, + .completed => _Glyphs.check, + .cancelled => _Glyphs.x, + .error => _Glyphs.circleAlert, + .tool => _Glyphs.squareTerminal, + .loading => _Glyphs.loaderCircle, + .completedCircle => _Glyphs.circleCheck, + .cancelledCircle => _Glyphs.ban, + .errorCircle => _Glyphs.circleX, + .permission => _Glyphs.shieldCheck, + }; + + @override + Widget build(BuildContext context) { + final theme = IconTheme.of(context); + final opacity = spec.opacity ?? theme.opacity; + final baseColor = spec.color ?? theme.color; + final color = opacity == null || baseColor == null + ? baseColor + : baseColor.withValues(alpha: baseColor.a * opacity.clamp(0, 1)); + + final icon = Icon( + _icon, + size: spec.size ?? theme.size, + fill: spec.fill ?? theme.fill, + weight: spec.weight ?? theme.weight, + grade: spec.grade ?? theme.grade, + opticalSize: spec.opticalSize ?? theme.opticalSize, + color: color, + shadows: spec.shadows ?? theme.shadows, + textDirection: spec.textDirection, + applyTextScaling: + spec.applyTextScaling ?? theme.applyTextScaling ?? false, + blendMode: spec.blendMode ?? BlendMode.srcOver, + ); + return ExcludeSemantics( + child: kind == UiFunctionalGlyphKind.loading + ? _LoadingGlyph(child: icon) + : icon, + ); + } +} + +/// Animate only indeterminate loading; status labels own the semantics. +class _LoadingGlyph extends StatefulWidget { + const _LoadingGlyph({required this.child}); + + final Widget child; + + @override + State<_LoadingGlyph> createState() => _LoadingGlyphState(); +} + +class _LoadingGlyphState extends State<_LoadingGlyph> + with SingleTickerProviderStateMixin { + late final _turns = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final animate = + !(MediaQuery.maybeOf(context)?.disableAnimations ?? false) && + TickerMode.valuesOf(context).enabled; + if (animate) { + if (!_turns.isAnimating) _turns.repeat(); + } else { + _turns.stop(); + _turns.value = 0; + } + } + + @override + Widget build(BuildContext context) => + RotationTransition(turns: _turns, child: widget.child); + + @override + void dispose() { + _turns.dispose(); + super.dispose(); + } +} diff --git a/apps/dashboard/lib/ui/support/live_edge.dart b/apps/dashboard/lib/ui/support/live_edge.dart new file mode 100644 index 000000000..d64589aa5 --- /dev/null +++ b/apps/dashboard/lib/ui/support/live_edge.dart @@ -0,0 +1,142 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Shared private-package live-edge state machine. +class UiLiveEdgeEngine { + UiLiveEdgeEngine({ + required this._enabled, + required this.threshold, + this.onChanged, + }); + + bool _enabled; + bool get enabled => _enabled; + + set enabled(bool value) { + // An explicit false-to-true transition is the host's resume action. + // Ordinary rebuilds with follow enabled must preserve a reader's release. + if (value && !_enabled) _following = true; + _enabled = value; + } + + double threshold; + ValueChanged? onChanged; + bool _following = true; + bool _programmatic = false; + + bool get following => _following; + + void handleScroll( + ScrollNotification notification, + ScrollController controller, + ) { + if (_programmatic || !controller.hasClients) return; + final fromDrag = + notification is ScrollUpdateNotification && + notification.dragDetails != null; + final fromUserDirection = + notification is UserScrollNotification && + notification.direction != ScrollDirection.idle; + if (fromDrag || fromUserDirection) handlePosition(controller.position); + } + + void handlePosition(ScrollPosition position) { + final distance = position.maxScrollExtent - position.pixels; + _setFollowing(distance <= threshold); + } + + void follow(ScrollController controller) { + if (!enabled || !following || !controller.hasClients) return; + final position = controller.position; + if (!position.hasContentDimensions) return; + _programmatic = true; + position.jumpTo(position.maxScrollExtent); + _programmatic = false; + } + + void _setFollowing(bool next) { + if (following == next) return; + _following = next; + onChanged?.call(next); + } +} + +/// Small non-lazy scroll view used by plan and activity ledgers. +class UiLiveEdgeScrollView extends StatefulWidget { + const UiLiveEdgeScrollView({ + super.key, + required this.child, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + }); + + final Widget child; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + + @override + State createState() => _UiLiveEdgeScrollViewState(); +} + +class _UiLiveEdgeScrollViewState extends State { + late final ScrollController _controller; + late final UiLiveEdgeEngine _liveEdge; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + _liveEdge = UiLiveEdgeEngine( + enabled: widget.followOutput, + threshold: widget.followThreshold, + onChanged: widget.onFollowChanged, + ); + _scheduleFollow(); + } + + @override + void didUpdateWidget(UiLiveEdgeScrollView oldWidget) { + super.didUpdateWidget(oldWidget); + _liveEdge + ..enabled = widget.followOutput + ..threshold = widget.followThreshold + ..onChanged = widget.onFollowChanged; + _scheduleFollow(); + } + + void _scheduleFollow() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _liveEdge.follow(_controller); + }); + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && _liveEdge.following) _scheduleFollow(); + return false; + }, + child: NotificationListener( + onNotification: (notification) { + if (notification.depth == 0) { + _liveEdge.handleScroll(notification, _controller); + } + return false; + }, + child: SingleChildScrollView( + controller: _controller, + child: widget.child, + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/apps/dashboard/lib/ui/theme/computed.dart b/apps/dashboard/lib/ui/theme/computed.dart new file mode 100644 index 000000000..dead4b5be --- /dev/null +++ b/apps/dashboard/lib/ui/theme/computed.dart @@ -0,0 +1,558 @@ +/// Ui computed tokens and functional color utilities. +/// +/// Implements computed role tokens (accent-contrast, accent-track, etc.) and +/// background/overlay colors that mirror Radix Themes behavior while keeping the +/// original Radix Colors data. +/// +/// Components should use these functional roles rather than raw color steps. +library; + +import 'dart:math' as math; +import 'dart:ui' show Color, Offset; + +import 'package:flutter/painting.dart' show BoxShadow, ColorSwatch; +import 'package:remix/remix.dart' + show MixToken, RemixBoxShadow, RemixBoxShadowKind; + +import 'radix_colors.dart'; +import 'theme_data.dart'; +import 'tokens.dart'; + +// ============================================================================ +// FUNCTIONAL / COMPUTED IMPLEMENTATIONS +// ============================================================================ + +/// Computes solid focus ring color (accent step 8). +Color computeFocus8(RadixColorScale accent) => accent.step(8); + +/// Computes translucent text selection color (accent alpha step 5). +Color computeFocusA5(RadixColorScale accent) => accent.alphaStep(5); + +/// Computes translucent focus ring color (accent alpha step 8). +Color computeFocusA8(RadixColorScale accent) => accent.alphaStep(8); + +// ============================================================================ +// BACKGROUND / PANEL / OVERLAY +// ============================================================================ + +/// Computes primary page background color. +/// +/// Radix Themes uses white in light mode and gray step 1 in dark mode. +Color computeColorBackground(RadixColorScale gray, {required bool isDark}) => + isDark ? gray.step(1) : const Color(0xFFFFFFFF); + +/// Computes solid background for panels and input surfaces. +/// +/// Radix Themes uses white in light mode and gray step 2 in dark mode. +Color computeColorPanelSolid(RadixColorScale gray, {required bool isDark}) => + isDark ? gray.step(2) : const Color(0xFFFFFFFF); + +/// Computes translucent background for floating panels. +/// +/// Radix Themes uses 70% white in light mode and gray alpha step 2 in dark +/// mode. +Color computeColorPanelTranslucent( + RadixColorScale gray, { + required bool isDark, +}) => isDark ? gray.alphaStep(2) : const Color(0xB3FFFFFF); + +/// Computes the neutral control surface color. +/// +/// Radix Themes uses 85% white in light mode and 25% black in dark mode. +Color computeColorSurface({required bool isDark}) => + isDark ? const Color(0x40000000) : const Color(0xD9FFFFFF); + +/// Computes modal backdrop overlay color. +/// +/// Uses black alpha step 6 (light mode) or step 8 (dark mode). +Color computeColorOverlay({required bool isDark}) => + isDark ? blackAlpha[8]! : blackAlpha[6]!; + +/// Computes the mode-aware stroke used by elevation shadows. +Color computeShadowStroke(RadixColorScale gray, {required bool isDark}) => + mixOklabPremultiplied( + gray.alphaStep(isDark ? 6 : 3), + gray.step(isDark ? 6 : 3), + 0.25, + ); + +/// Mixes two sRGB colors in OKLab after premultiplying their channels by +/// alpha, matching CSS `color-mix(in oklab, ...)` for translucent colors. +Color mixOklabPremultiplied(Color first, Color second, double amount) { + if (!amount.isFinite || amount < 0 || amount > 1) { + throw ArgumentError.value( + amount, + 'amount', + 'Expected a value from 0 to 1.', + ); + } + final firstWeight = 1 - amount; + final alpha = first.a * firstWeight + second.a * amount; + if (alpha == 0) return const Color(0x00000000); + + final firstLab = _srgbToOklab(first); + final secondLab = _srgbToOklab(second); + final mixedLab = ( + lightness: + (firstLab.lightness * first.a * firstWeight + + secondLab.lightness * second.a * amount) / + alpha, + a: + (firstLab.a * first.a * firstWeight + secondLab.a * second.a * amount) / + alpha, + b: + (firstLab.b * first.a * firstWeight + secondLab.b * second.a * amount) / + alpha, + ); + final rgb = _oklabToSrgb(mixedLab); + + return Color.from( + alpha: alpha, + red: rgb.red.clamp(0, 1), + green: rgb.green.clamp(0, 1), + blue: rgb.blue.clamp(0, 1), + ); +} + +({double lightness, double a, double b}) _srgbToOklab(Color color) { + final red = _linearizeSrgb(color.r); + final green = _linearizeSrgb(color.g); + final blue = _linearizeSrgb(color.b); + final l = math + .pow( + 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue, + 1 / 3, + ) + .toDouble(); + final m = math + .pow( + 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue, + 1 / 3, + ) + .toDouble(); + final s = math + .pow( + 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue, + 1 / 3, + ) + .toDouble(); + + return ( + lightness: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ); +} + +({double red, double green, double blue}) _oklabToSrgb( + ({double lightness, double a, double b}) color, +) { + final l = math + .pow(color.lightness + 0.3963377774 * color.a + 0.2158037573 * color.b, 3) + .toDouble(); + final m = math + .pow(color.lightness - 0.1055613458 * color.a - 0.0638541728 * color.b, 3) + .toDouble(); + final s = math + .pow(color.lightness - 0.0894841775 * color.a - 1.2914855480 * color.b, 3) + .toDouble(); + + return ( + red: _encodeSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + green: _encodeSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + blue: _encodeSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), + ); +} + +double _linearizeSrgb(double channel) => channel <= 0.04045 + ? channel / 12.92 + : math.pow((channel + 0.055) / 1.055, 2.4).toDouble(); + +double _encodeSrgb(double channel) => channel <= 0.0031308 + ? 12.92 * channel + : 1.055 * math.pow(channel, 1 / 2.4).toDouble() - 0.055; + +/// Builds Radix Themes elevation shadows for the active brightness. +Map, Object> buildUiShadows({ + required bool isDark, + required UiThemeColors colors, +}) { + if (isDark) { + final shadows = >{ + 'shadow1': [ + _shadow( + colors.gray.scale.alphaStep(3), + kind: .inset, + offset: const Offset(0, -1), + blur: 1, + ), + _shadow(colors.gray.scale.alphaStep(3), kind: .inset, spread: 1), + _shadow( + colors.blackAlpha[5]!, + kind: .inset, + offset: const Offset(0, 3), + blur: 4, + ), + _shadow(colors.gray.scale.alphaStep(4), kind: .inset, spread: 1), + ], + 'shadow2': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, blur: 0.5), + _shadow(colors.blackAlpha[6]!, offset: const Offset(0, 1), blur: 1), + _shadow( + colors.blackAlpha[6]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + _shadow(colors.blackAlpha[5]!, offset: const Offset(0, 1), blur: 3), + ], + 'shadow3': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow( + colors.blackAlpha[3]!, + offset: const Offset(0, 2), + blur: 3, + spread: -2, + ), + _shadow( + colors.blackAlpha[6]!, + offset: const Offset(0, 3), + blur: 8, + spread: -2, + ), + _shadow( + colors.blackAlpha[7]!, + offset: const Offset(0, 4), + blur: 12, + spread: -4, + ), + ], + 'shadow4': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, offset: const Offset(0, 8), blur: 40), + _shadow( + colors.blackAlpha[5]!, + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow5': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[5]!, offset: const Offset(0, 12), blur: 60), + _shadow( + colors.blackAlpha[7]!, + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow6': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[4]!, offset: const Offset(0, 12), blur: 60), + _shadow(colors.blackAlpha[6]!, offset: const Offset(0, 16), blur: 64), + _shadow( + colors.blackAlpha[11]!, + offset: const Offset(0, 16), + blur: 36, + spread: -20, + ), + ], + }; + return _uiShadowTokens(shadows); + } + + final shadows = >{ + 'shadow1': [ + _shadow(colors.gray.scale.alphaStep(5), kind: .inset, spread: 1), + _shadow( + colors.gray.scale.alphaStep(2), + kind: .inset, + offset: const Offset(0, 1.5), + blur: 2, + ), + _shadow( + colors.blackAlpha[2]!, + kind: .inset, + offset: const Offset(0, 1.5), + blur: 2, + ), + ], + 'shadow2': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[1]!, blur: 0.5), + _shadow( + colors.gray.scale.alphaStep(2), + offset: const Offset(0, 1), + blur: 1, + ), + _shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + _shadow(colors.blackAlpha[1]!, offset: const Offset(0, 1), blur: 3), + ], + 'shadow3': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 2), + blur: 3, + spread: -2, + ), + _shadow( + colors.blackAlpha[2]!, + offset: const Offset(0, 3), + blur: 12, + spread: -4, + ), + _shadow( + colors.blackAlpha[2]!, + offset: const Offset(0, 4), + blur: 16, + spread: -8, + ), + ], + 'shadow4': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[1]!, offset: const Offset(0, 8), blur: 40), + _shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow5': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, offset: const Offset(0, 12), blur: 60), + _shadow( + colors.gray.scale.alphaStep(5), + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow6': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, offset: const Offset(0, 12), blur: 60), + _shadow( + colors.gray.scale.alphaStep(2), + offset: const Offset(0, 16), + blur: 64, + ), + _shadow( + colors.gray.scale.alphaStep(7), + offset: const Offset(0, 16), + blur: 36, + spread: -20, + ), + ], + }; + return _uiShadowTokens(shadows); +} + +Map, Object> _uiShadowTokens( + Map> shadows, +) { + final shadow1 = shadows['shadow1']!; + final shadow2 = shadows['shadow2']!; + return { + UiTokens.shadow1: _ordinaryShadows(shadow1), + UiTokens.shadow1Layers: shadow1, + UiTokens.shadow2: _ordinaryShadows(shadow2), + UiTokens.segmentedControlClassicIndicatorShadows: [ + for (final shadow in shadow2) + RemixBoxShadow( + kind: shadow.kind, + color: shadow.color, + offset: shadow.offset, + blurRadius: shadow.blurRadius, + spreadRadius: shadow.spreadRadius, + shapeInset: 1, + ), + ], + UiTokens.shadow3: _ordinaryShadows(shadows['shadow3']!), + UiTokens.shadow4: _ordinaryShadows(shadows['shadow4']!), + UiTokens.shadow5: _ordinaryShadows(shadows['shadow5']!), + UiTokens.shadow6: _ordinaryShadows(shadows['shadow6']!), + }; +} + +List _ordinaryShadows(List shadows) => [ + for (final shadow in shadows) + BoxShadow( + color: shadow.color, + offset: shadow.offset, + blurRadius: shadow.blurRadius, + spreadRadius: shadow.spreadRadius, + ), +]; + +RemixBoxShadow _shadow( + Color color, { + RemixBoxShadowKind kind = RemixBoxShadowKind.outer, + Offset offset = Offset.zero, + double blur = 0, + double spread = 0, +}) => RemixBoxShadow( + kind: kind, + color: color, + offset: offset, + blurRadius: blur, + spreadRadius: spread, +); + +// ============================================================================ +// RESOLVER (merged from resolver.dart) +// ============================================================================ + +/// Container for all computed Ui theme colors and scales. +/// +/// Holds resolved color system for a specific theme configuration. +/// Created by [resolveUiTokens] for internal use by the token system. +class UiThemeColors { + final RadixColor accent; + final RadixColor gray; + final ColorSwatch blackAlpha; + final ColorSwatch whiteAlpha; + + // Functional colors + final Color colorBackground; + final Color colorSurface; + final Color colorPanelSolid; + final Color colorPanelTranslucent; + final Color colorOverlay; + final Color shadowStroke; + + // Focus + final Color focus8; + final Color focusA5; + final Color focusA8; + + const UiThemeColors({ + required this.accent, + required this.gray, + required this.blackAlpha, + required this.whiteAlpha, + required this.colorBackground, + required this.colorSurface, + required this.colorPanelSolid, + required this.colorPanelTranslucent, + required this.colorOverlay, + required this.shadowStroke, + required this.focus8, + required this.focusA5, + required this.focusA8, + }); +} + +// Map by enum .name to generated RadixColorTheme instances (light/dark contained). +const Map _accentThemesByName = { + 'gray': gray, + 'mauve': mauve, + 'slate': slate, + 'sage': sage, + 'olive': olive, + 'sand': sand, + 'amber': amber, + 'blue': blue, + 'bronze': bronze, + 'brown': brown, + 'crimson': crimson, + 'cyan': cyan, + 'gold': gold, + 'grass': grass, + 'green': green, + 'indigo': indigo, + 'iris': iris, + 'jade': jade, + 'lime': lime, + 'mint': mint, + 'orange': orange, + 'pink': pink, + 'plum': plum, + 'purple': purple, + 'red': red, + 'ruby': ruby, + 'sky': sky, + 'teal': teal, + 'tomato': tomato, + 'violet': violet, + 'yellow': yellow, +}; + +const Map _grayThemesByName = { + 'gray': gray, + 'mauve': mauve, + 'slate': slate, + 'sage': sage, + 'olive': olive, + 'sand': sand, +}; + +/// Resolves all computed tokens for a theme configuration. +UiThemeColors resolveUiTokens(UiThemeConfig theme) { + // Pick light/dark RadixColor for accent and neutral using enum .name keys + final accentColor = theme.accent ?? UiAccentColor.indigo; + final grayColor = theme.gray ?? UiGrayColor.slate; + final String accentName = accentColor.name; + final String grayName = grayColor.name; + final RadixColorTheme grayTheme = _grayThemesByName[grayName]!; + final RadixColorTheme accentTheme = accentColor == .gray + ? grayTheme + : _accentThemesByName[accentName]!; + final RadixColor accentRC = theme.isDark + ? accentTheme.dark + : accentTheme.light; + final RadixColor grayRC = theme.isDark ? grayTheme.dark : grayTheme.light; + + // Extract scales + final RadixColorScale accent = accentRC.scale; + final RadixColorScale gray = grayRC.scale; + + // Neutral alpha swatches + const ColorSwatch blackA = blackAlpha; + const ColorSwatch whiteA = whiteAlpha; + + // Backgrounds/panels/overlay + final Color colorBackground = computeColorBackground( + gray, + isDark: theme.isDark, + ); + final Color colorPanelSolid = computeColorPanelSolid( + gray, + isDark: theme.isDark, + ); + final Color colorPanelTranslucent = computeColorPanelTranslucent( + gray, + isDark: theme.isDark, + ); + final Color colorSurface = computeColorSurface(isDark: theme.isDark); + final Color colorOverlay = computeColorOverlay(isDark: theme.isDark); + final Color shadowStroke = computeShadowStroke(gray, isDark: theme.isDark); + + // Focus + final Color focus8 = computeFocus8(accent); + final Color focusA5 = computeFocusA5(accent); + final Color focusA8 = computeFocusA8(accent); + + return UiThemeColors( + accent: accentRC, + gray: grayRC, + blackAlpha: blackA, + whiteAlpha: whiteA, + colorBackground: colorBackground, + colorSurface: colorSurface, + colorPanelSolid: colorPanelSolid, + colorPanelTranslucent: colorPanelTranslucent, + colorOverlay: colorOverlay, + shadowStroke: shadowStroke, + focus8: focus8, + focusA5: focusA5, + focusA8: focusA8, + ); +} diff --git a/apps/dashboard/lib/ui/theme/control_styles.dart b/apps/dashboard/lib/ui/theme/control_styles.dart new file mode 100644 index 000000000..fe291007c --- /dev/null +++ b/apps/dashboard/lib/ui/theme/control_styles.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import 'tokens.dart'; + +/// A CSS outline that does not affect layout. +RemixBoxEffectsMix uiFocusOutline(Color color, {required double offset}) => + RemixBoxEffectsMix( + outline: BorderSideMix( + color: color, + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: offset, + ); + +/// The focus ring for stylers that can only express a border. +/// +/// [uiFocusOutline] is the preferred form, but it returns a +/// [RemixBoxEffectsMix] and several stylers — `ToggleStyler`, +/// `ToggleGroupItemStyler`, `TabStyler` — expose no `containerEffects` slot to +/// put one in. Those recipes each re-derived the same border, so the width +/// token lives here instead of in four places. +extension UiFocusRing> on RemixBoxStylerAnchors { + /// Applies the ring to this styler. + /// + /// [color] defaults to `focus-a8`. Tabs is the one caller that differs: it + /// passes the solid `focus-8`. Whether that is intentional is unresolved — + /// the pinned Chromium probes capture computed styles only, not + /// `:focus-visible`, so it cannot be settled from the reference fixtures. + /// Preserved as-is rather than unified on a guess. + /// + /// Tabs also leaves [strokeAlign] unset, but that is not a second + /// difference: `BorderSide` itself defaults to `strokeAlignInside`, so unset + /// and explicit resolve to the same -1.0. The parameter is nullable only so + /// tabs can keep expressing it as absent. + T uiFocusRing({ + Color? color, + double? strokeAlign = BorderSide.strokeAlignInside, + }) => border( + .all( + BorderSideMix( + color: color ?? _focusRingColor(), + width: _focusRingWidth(), + strokeAlign: strokeAlign, + ), + ), + ); +} + +/// The same ring for a Mix [FlexBoxStyler], which sits outside Remix's +/// `RemixBoxStylerAnchors` interface — accordion rings its trigger, not itself. +/// Separate extension rather than a differently-named function so both read as +/// `.uiFocusRing()`; the receiver type picks the right one. +extension UiFocusRingFlexBox on FlexBoxStyler { + FlexBoxStyler uiFocusRing() => border( + .color( + _focusRingColor(), + ).width(_focusRingWidth()).strokeAlign(BorderSide.strokeAlignInside), + ); +} + +Color _focusRingColor() => UiTokens.focusA8(); +double _focusRingWidth() => UiTokens.focusRingWidth(); + +/// A one-pixel inset stroke, optionally layered over a fill. +RemixBoxEffectLayerMix uiInsetSurface({required List strokes}) => + RemixBoxEffectLayerMix( + shadows: [ + for (final stroke in strokes) + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: stroke, + spreadRadius: 1, + ), + ], + ); diff --git a/packages/remix_fortal/lib/src/theme/radix_colors.dart b/apps/dashboard/lib/ui/theme/radix_colors.dart similarity index 100% rename from packages/remix_fortal/lib/src/theme/radix_colors.dart rename to apps/dashboard/lib/ui/theme/radix_colors.dart diff --git a/apps/dashboard/lib/ui/theme/surface_frame.dart b/apps/dashboard/lib/ui/theme/surface_frame.dart new file mode 100644 index 000000000..2d98b2a6a --- /dev/null +++ b/apps/dashboard/lib/ui/theme/surface_frame.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +/// Builds a rounded panel whose visible frame paints above its children. +/// +/// Composite surfaces often contain edge-to-edge child backgrounds. A regular +/// [BoxDecoration] border paints behind those children, so their antialiased +/// rounded edges can partially cover the frame. This keeps the fill and clip on +/// the background decoration, reserves the same inset explicitly, and paints +/// the frame through the container's foreground decoration. +BoxStyler uiSurfaceFrame({ + required Color fillColor, + required Color borderColor, + required double borderWidth, + required Radius radius, +}) { + final borderRadius = BorderRadiusMix.all(radius); + return BoxStyler() + .color(fillColor) + .padding(.all(borderWidth)) + .borderRadius(borderRadius) + .clipBehavior(.antiAlias) + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.all( + BorderSideMix(color: borderColor, width: borderWidth), + ), + borderRadius: borderRadius, + ), + ); +} diff --git a/apps/dashboard/lib/ui/theme/theme.dart b/apps/dashboard/lib/ui/theme/theme.dart new file mode 100644 index 000000000..089fd5718 --- /dev/null +++ b/apps/dashboard/lib/ui/theme/theme.dart @@ -0,0 +1,14 @@ +// Internal entrypoint for the theme layer, imported by every component under +// `src/components/`. The public entrypoint exports this barrel. + +export 'computed.dart'; +export 'control_styles.dart'; +export 'surface_frame.dart'; +export 'theme_data.dart' hide buildUiScopeTokens; +export 'theme_scope.dart'; +export 'tokens.dart'; + +// `radix_colors.dart` deliberately stays out of this barrel because it exposes +// bare color names such as `gray` and `red`. The public package barrel exports +// it explicitly for compatibility; installed applications import it only from +// `theme_data.dart`. diff --git a/apps/dashboard/lib/ui/theme/theme_data.dart b/apps/dashboard/lib/ui/theme/theme_data.dart new file mode 100644 index 000000000..6bb20d7fc --- /dev/null +++ b/apps/dashboard/lib/ui/theme/theme_data.dart @@ -0,0 +1,1078 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import 'computed.dart'; +import 'radix_colors.dart' as radix; +import 'theme_scope.dart' show UiScope; +import 'tokens.dart'; + +/// Available accent colors matching Radix Themes names. +enum UiAccentColor { + gray, + mauve, + slate, + sage, + olive, + sand, + gold, + bronze, + brown, + yellow, + amber, + orange, + tomato, + red, + ruby, + crimson, + pink, + plum, + purple, + violet, + iris, + indigo, + blue, + cyan, + teal, + jade, + green, + grass, + lime, + mint, + sky, +} + +/// Available neutral gray families matching Radix Themes names. +enum UiGrayColor { gray, mauve, slate, sage, olive, sand } + +/// Theme-level radius multipliers matching the Radix Themes presets. +enum UiRadius { none, small, medium, large, full } + +/// Background treatment used by floating panels. +enum UiPanelBackground { solid, translucent } + +/// Discrete theme scaling values supported by Radix Themes. +enum UiScaling { + percent90(0.9), + percent95(0.95), + percent100(1.0), + percent105(1.05), + percent110(1.1); + + const UiScaling(this.factor); + + /// Numeric multiplier represented by this preset. + final double factor; +} + +/// Partial theme values applied by a [UiScope]. +@immutable +class UiThemeConfig { + const UiThemeConfig({ + this.accent, + this.gray, + this.brightness, + this.panelBackground, + this.radius, + this.scaling, + this.hasBackground, + }); + + final UiAccentColor? accent; + final UiGrayColor? gray; + final Brightness? brightness; + final UiPanelBackground? panelBackground; + final UiRadius? radius; + final UiScaling? scaling; + final bool? hasBackground; + + bool get isDark => brightness == .dark; + + UiThemeConfig copyWith({ + UiAccentColor? accent, + UiGrayColor? gray, + Brightness? brightness, + UiPanelBackground? panelBackground, + UiRadius? radius, + UiScaling? scaling, + bool? hasBackground, + }) => UiThemeConfig( + accent: accent ?? this.accent, + gray: gray ?? this.gray, + brightness: brightness ?? this.brightness, + panelBackground: panelBackground ?? this.panelBackground, + radius: radius ?? this.radius, + scaling: scaling ?? this.scaling, + hasBackground: hasBackground ?? this.hasBackground, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UiThemeConfig && + accent == other.accent && + gray == other.gray && + brightness == other.brightness && + panelBackground == other.panelBackground && + radius == other.radius && + scaling == other.scaling && + hasBackground == other.hasBackground; + + @override + int get hashCode => Object.hash( + accent, + gray, + brightness, + panelBackground, + radius, + scaling, + hasBackground, + ); + + Widget createScope({List? orderOfModifiers, required Widget child}) => + UiScope( + accent: accent, + gray: gray, + brightness: brightness, + panelBackground: panelBackground, + radius: radius, + scaling: scaling, + hasBackground: hasBackground, + orderOfModifiers: orderOfModifiers, + child: child, + ); +} + +/// Fully resolved theme values inherited by a Ui subtree. +@immutable +class UiThemeData extends UiThemeConfig { + const UiThemeData({ + required UiAccentColor super.accent, + required UiGrayColor super.gray, + required Brightness super.brightness, + required UiPanelBackground super.panelBackground, + required UiRadius super.radius, + required UiScaling super.scaling, + required bool super.hasBackground, + }); + + @override + UiAccentColor get accent => super.accent!; + @override + UiGrayColor get gray => super.gray!; + @override + Brightness get brightness => super.brightness!; + @override + UiPanelBackground get panelBackground => super.panelBackground!; + @override + UiRadius get radius => super.radius!; + @override + UiScaling get scaling => super.scaling!; + @override + bool get hasBackground => super.hasBackground!; + + @override + bool get isDark => brightness == .dark; + + @override + UiThemeData copyWith({ + UiAccentColor? accent, + UiGrayColor? gray, + Brightness? brightness, + UiPanelBackground? panelBackground, + UiRadius? radius, + UiScaling? scaling, + bool? hasBackground, + }) => UiThemeData( + accent: accent ?? this.accent, + gray: gray ?? this.gray, + brightness: brightness ?? this.brightness, + panelBackground: panelBackground ?? this.panelBackground, + radius: radius ?? this.radius, + scaling: scaling ?? this.scaling, + hasBackground: hasBackground ?? this.hasBackground, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UiThemeData && + accent == other.accent && + gray == other.gray && + brightness == other.brightness && + panelBackground == other.panelBackground && + radius == other.radius && + scaling == other.scaling && + hasBackground == other.hasBackground; + + @override + int get hashCode => Object.hash( + accent, + gray, + brightness, + panelBackground, + radius, + scaling, + hasBackground, + ); +} + +/// Builds the token map for a Ui scope. Used by [UiScope]. +Map, Object> buildUiScopeTokens(UiThemeData theme) { + final tokens = resolveUiTokens(theme); + final scaling = theme.scaling.factor; + final shadows = buildUiShadows(isDark: theme.isDark, colors: tokens); + + final colorTokens = { + // Role and functional tokens + UiTokens.colorBackground: tokens.colorBackground, + UiTokens.colorSurface: tokens.colorSurface, + UiTokens.segmentedControlIndicatorBackground: theme.isDark + ? tokens.gray.scale.alphaStep(3) + : tokens.colorBackground, + UiTokens.colorPanelSolid: tokens.colorPanelSolid, + UiTokens.colorPanelTranslucent: tokens.colorPanelTranslucent, + UiTokens.colorPanel: theme.panelBackground == .solid + ? tokens.colorPanelSolid + : tokens.colorPanelTranslucent, + UiTokens.colorOverlay: tokens.colorOverlay, + UiTokens.sliderHighContrastOverlay: theme.isDark + ? const Color(0x00000000) + : tokens.blackAlpha[8]!, + UiTokens.error3: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(3), + UiTokens.error7: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(7), + UiTokens.error8: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(8), + UiTokens.error9: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(9), + UiTokens.error11: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(11), + UiTokens.error12: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(12), + UiTokens.errorA7: (theme.isDark ? radix.red.dark : radix.red.light).scale + .alphaStep(7), + ..._accentColorTokens(tokens), + // Gray steps + UiTokens.gray1: tokens.gray.scale.step(1), + UiTokens.gray2: tokens.gray.scale.step(2), + UiTokens.gray3: tokens.gray.scale.step(3), + UiTokens.gray4: tokens.gray.scale.step(4), + UiTokens.gray5: tokens.gray.scale.step(5), + UiTokens.gray6: tokens.gray.scale.step(6), + UiTokens.gray7: tokens.gray.scale.step(7), + UiTokens.gray8: tokens.gray.scale.step(8), + UiTokens.gray9: tokens.gray.scale.step(9), + UiTokens.gray10: tokens.gray.scale.step(10), + UiTokens.gray11: tokens.gray.scale.step(11), + UiTokens.gray12: tokens.gray.scale.step(12), + // Gray role tokens (from resolved colors) + UiTokens.graySurface: tokens.gray.surface, + UiTokens.grayIndicator: tokens.gray.indicator, + UiTokens.grayTrack: tokens.gray.track, + UiTokens.grayContrast: tokens.gray.contrast, + // Gray alpha a1..a12 + UiTokens.grayA1: tokens.gray.scale.alphaStep(1), + UiTokens.grayA2: tokens.gray.scale.alphaStep(2), + UiTokens.grayA3: tokens.gray.scale.alphaStep(3), + UiTokens.grayA4: tokens.gray.scale.alphaStep(4), + UiTokens.grayA5: tokens.gray.scale.alphaStep(5), + UiTokens.grayA6: tokens.gray.scale.alphaStep(6), + UiTokens.grayA7: tokens.gray.scale.alphaStep(7), + UiTokens.grayA8: tokens.gray.scale.alphaStep(8), + UiTokens.grayA9: tokens.gray.scale.alphaStep(9), + UiTokens.grayA10: tokens.gray.scale.alphaStep(10), + UiTokens.grayA11: tokens.gray.scale.alphaStep(11), + UiTokens.grayA12: tokens.gray.scale.alphaStep(12), + // Neutral helpers derived from primitives + UiTokens.blackA1: tokens.blackAlpha[1]!, + UiTokens.blackA2: tokens.blackAlpha[2]!, + UiTokens.blackA3: tokens.blackAlpha[3]!, + UiTokens.blackA4: tokens.blackAlpha[4]!, + UiTokens.blackA5: tokens.blackAlpha[5]!, + UiTokens.blackA6: tokens.blackAlpha[6]!, + UiTokens.blackA7: tokens.blackAlpha[7]!, + UiTokens.blackA8: tokens.blackAlpha[8]!, + UiTokens.blackA9: tokens.blackAlpha[9]!, + UiTokens.blackA10: tokens.blackAlpha[10]!, + UiTokens.blackA11: tokens.blackAlpha[11]!, + UiTokens.blackA12: tokens.blackAlpha[12]!, + UiTokens.whiteA1: tokens.whiteAlpha[1]!, + UiTokens.whiteA2: tokens.whiteAlpha[2]!, + UiTokens.whiteA3: tokens.whiteAlpha[3]!, + UiTokens.whiteA4: tokens.whiteAlpha[4]!, + UiTokens.whiteA5: tokens.whiteAlpha[5]!, + UiTokens.whiteA6: tokens.whiteAlpha[6]!, + UiTokens.whiteA7: tokens.whiteAlpha[7]!, + UiTokens.whiteA8: tokens.whiteAlpha[8]!, + UiTokens.whiteA9: tokens.whiteAlpha[9]!, + UiTokens.whiteA10: tokens.whiteAlpha[10]!, + UiTokens.whiteA11: tokens.whiteAlpha[11]!, + UiTokens.whiteA12: tokens.whiteAlpha[12]!, + UiTokens.shadowStroke: tokens.shadowStroke, + UiTokens.grayStroke3: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(3), + tokens.gray.scale.step(3), + 0.25, + ), + UiTokens.grayStroke4: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(4), + tokens.gray.scale.step(4), + 0.25, + ), + UiTokens.grayStroke5: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(5), + tokens.gray.scale.step(5), + 0.25, + ), + UiTokens.grayStroke6: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(6), + tokens.gray.scale.step(6), + 0.25, + ), + UiTokens.grayStroke7: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(7), + tokens.gray.scale.step(7), + 0.25, + ), + UiTokens.dataTableBorder: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(5), + tokens.gray.scale.step(6), + 0.5, + ), + }; + final allTokens = , Object>{ + ...colorTokens, + UiTokens.panelBlur: theme.panelBackground == UiPanelBackground.translucent + ? 64.0 + : 0.0, + UiTokens.space1: 4.0 * scaling, + UiTokens.space2: 8.0 * scaling, + UiTokens.space3: 12.0 * scaling, + UiTokens.space4: 16.0 * scaling, + UiTokens.space5: 24.0 * scaling, + UiTokens.space6: 32.0 * scaling, + UiTokens.space7: 40.0 * scaling, + UiTokens.space8: 48.0 * scaling, + UiTokens.space9: 64.0 * scaling, + UiTokens.spinnerSize3: 20.0 * scaling, + UiTokens.dataTableRowHeight1: 36.0 * scaling, + UiTokens.dataTableRowHeight2: 44.0 * scaling, + UiTokens.toggleGap1: 2.0 * scaling, + UiTokens.toggleGap3: 6.0 * scaling, + UiTokens.avatarSize6: 80.0 * scaling, + UiTokens.avatarSize7: 96.0 * scaling, + UiTokens.avatarSize8: 128.0 * scaling, + UiTokens.avatarSize9: 160.0 * scaling, + UiTokens.avatarIconSize1: 12.0 * scaling, + UiTokens.avatarIconSize2: 16.0 * scaling, + UiTokens.avatarIconSize3: 20.0 * scaling, + UiTokens.avatarIconSize4: 24.0 * scaling, + UiTokens.avatarIconSize5: 32.0 * scaling, + UiTokens.avatarIconSize6: 40.0 * scaling, + UiTokens.avatarIconSize7: 48.0 * scaling, + UiTokens.avatarIconSize8: 64.0 * scaling, + UiTokens.avatarIconSize9: 80.0 * scaling, + UiTokens.badgePaddingX1: 6.0 * scaling, + UiTokens.badgePaddingY1: 2.0 * scaling, + UiTokens.badgePaddingX3: 10.0 * scaling, + UiTokens.checkboxSize1: 14.0 * scaling, + UiTokens.checkboxSize3: 20.0 * scaling, + UiTokens.checkboxIndicatorSize1: 9.0 * scaling, + UiTokens.checkboxIndicatorSize2: 10.0 * scaling, + UiTokens.checkboxIndicatorSize3: 12.0 * scaling, + UiTokens.checkboxGroupItemGap1: 6.0 * scaling, + UiTokens.checkboxGroupItemGap2: 7.0 * scaling, + UiTokens.checkboxGroupItemGap3: 8.0 * scaling, + UiTokens.radioIndicatorSize1: 5.6 * scaling, + UiTokens.radioIndicatorSize2: 6.4 * scaling, + UiTokens.radioIndicatorSize3: 8.0 * scaling, + UiTokens.checkboxRadius1: _scaledRadiusToken( + theme.radius, + scaling, + 3.0 * 0.875, + ), + UiTokens.checkboxRadius3: _scaledRadiusToken( + theme.radius, + scaling, + 3.0 * 1.25, + ), + UiTokens.switchHeight2: 20.0 * scaling, + UiTokens.switchWidth1: 28.0 * scaling, + UiTokens.switchWidth2: 35.0 * scaling, + UiTokens.switchWidth3: 42.0 * scaling, + UiTokens.switchThumbSize1: 16.0 * scaling - 2.0, + UiTokens.switchThumbSize2: 20.0 * scaling - 2.0, + UiTokens.switchThumbSize3: 24.0 * scaling - 2.0, + UiTokens.progressHeight2: 6.0 * scaling, + UiTokens.sliderTrackSize1: 6.0 * scaling, + UiTokens.sliderTrackSize2: 8.0 * scaling, + UiTokens.sliderTrackSize3: 10.0 * scaling, + UiTokens.sliderThumbSize1: 13.0 * scaling, + UiTokens.sliderThumbSize2: 16.0 * scaling, + UiTokens.sliderThumbSize3: 19.0 * scaling, + UiTokens.textFieldPadding1: 6.0 * scaling, + UiTokens.textFieldPadding2: 8.0 * scaling, + UiTokens.textFieldPadding3: 12.0 * scaling, + UiTokens.textAreaMinHeight3: 80.0, + UiTokens.dataListRowGap3: 20.0 * scaling, + UiTokens.dataListLabelMinWidth: 120.0, + UiTokens.tabInnerPaddingY1: 2.0 * scaling, + UiTokens.tabActiveLetterSpacing1: -0.12 * scaling, + UiTokens.tabActiveLetterSpacing2: -0.14 * scaling, + UiTokens.selectSpace1Half: 6.0 * scaling, + UiTokens.selectIndicatorWidth1: 20.0 * scaling, + UiTokens.selectIndicatorSize1: 8.0 * scaling, + UiTokens.selectIndicatorSize2: 10.0 * scaling, + UiTokens.selectGhostMarginX12: -8.0 * scaling, + UiTokens.selectGhostMarginY12: -4.0 * scaling, + UiTokens.selectGhostMarginX3: -12.0 * scaling, + UiTokens.selectGhostMarginY3: -6.0 * scaling, + ..._radiusTokensFor(theme.radius, scaling), + + // Exact layered Radix shadow tokens, resolved for the active color scales. + ...shadows, + UiTokens.sliderClassicDisabledTrackShadows: _scaleShadowOpacity( + shadows[UiTokens.shadow1Layers]! as List, + 0.5, + ), + UiTokens.cardClassicOuterShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .outer, + state: .idle, + ), + UiTokens.cardClassicInnerShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .inner, + state: .idle, + ), + UiTokens.cardClassicHoverOuterShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .outer, + state: .hovered, + ), + UiTokens.cardClassicHoverInnerShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .inner, + state: .hovered, + ), + UiTokens.cardClassicActiveOuterShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .outer, + state: .active, + ), + UiTokens.cardClassicActiveInnerShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .inner, + state: .active, + ), + UiTokens.selectTriggerClassicShadows: _selectClassicShadows( + tokens, + isDark: theme.isDark, + ), + UiTokens.selectTriggerClassicHoverShadows: [ + _insetShadow(tokens.gray.scale.alphaStep(3), spread: 1), + ..._selectClassicShadows(tokens, isDark: theme.isDark), + ], + UiTokens.baseButtonClassicDisabledShadows: + _baseButtonClassicDisabledShadows(tokens, isDark: theme.isDark), + UiTokens.baseButtonClassicShadows: _baseButtonClassicShadows( + tokens, + isDark: theme.isDark, + highContrast: false, + ), + UiTokens.baseButtonClassicHighContrastShadows: _baseButtonClassicShadows( + tokens, + isDark: theme.isDark, + highContrast: true, + ), + UiTokens.baseButtonClassicActiveShadows: _baseButtonClassicActiveShadows( + tokens, + highContrast: false, + ), + UiTokens.baseButtonClassicActiveHighContrastShadows: + _baseButtonClassicActiveShadows(tokens, highContrast: true), + UiTokens.baseButtonClassicAfterInset: theme.isDark ? 1.0 : 2.0, + UiTokens.baseButtonGhostPaddingY3: 6.0 * scaling, + UiTokens.baseButtonGhostMarginX12: -8.0 * scaling, + UiTokens.baseButtonGhostMarginY12: -4.0 * scaling, + UiTokens.baseButtonGhostMarginX3: -12.0 * scaling, + UiTokens.baseButtonGhostMarginY3: -6.0 * scaling, + UiTokens.baseButtonGhostMarginX4: -16.0 * scaling, + UiTokens.baseButtonGhostMarginY4: -8.0 * scaling, + UiTokens.iconButtonGhostPadding2: 6.0 * scaling, + UiTokens.iconButtonGhostMargin1: -4.0 * scaling, + UiTokens.iconButtonGhostMargin2: -6.0 * scaling, + UiTokens.iconButtonGhostMargin3: -8.0 * scaling, + UiTokens.iconButtonGhostMargin4: -12.0 * scaling, + UiTokens.cardGhostMargin1: -12.0 * scaling, + UiTokens.cardGhostMargin2: -16.0 * scaling, + UiTokens.cardGhostMargin3: -24.0 * scaling, + UiTokens.cardGhostMargin4: -32.0 * scaling, + UiTokens.cardGhostMargin5: -48.0 * scaling, + UiTokens.borderWidth1: 1.0, + UiTokens.borderWidth2: 2.0, + UiTokens.focusRingWidth: 2.0, + UiTokens.focusRingOffset: 2.0, + UiTokens.text1: TextStyle( + fontSize: 12.0 * scaling, + letterSpacing: 0.0025 * 12.0 * scaling, + height: 16.0 / 12.0, + ), + UiTokens.text2: TextStyle( + fontSize: 14.0 * scaling, + letterSpacing: 0.0, + height: 20.0 / 14.0, + ), + UiTokens.text3: TextStyle( + fontSize: 16.0 * scaling, + letterSpacing: 0.0, + height: 24.0 / 16.0, + ), + UiTokens.accordionText2: TextStyle( + fontSize: 15.0 * scaling, + letterSpacing: 0.0, + height: 20.0 / 15.0, + ), + UiTokens.text4: TextStyle( + fontSize: 18.0 * scaling, + letterSpacing: -0.0025 * 18.0 * scaling, + height: 26.0 / 18.0, + ), + UiTokens.text5: TextStyle( + fontSize: 20.0 * scaling, + letterSpacing: -0.005 * 20.0 * scaling, + height: 28.0 / 20.0, + ), + UiTokens.text6: TextStyle( + fontSize: 24.0 * scaling, + letterSpacing: -0.00625 * 24.0 * scaling, + height: 30.0 / 24.0, + ), + UiTokens.text7: TextStyle( + fontSize: 28.0 * scaling, + letterSpacing: -0.0075 * 28.0 * scaling, + height: 36.0 / 28.0, + ), + UiTokens.text8: TextStyle( + fontSize: 35.0 * scaling, + letterSpacing: -0.01 * 35.0 * scaling, + height: 40.0 / 35.0, + ), + UiTokens.text9: TextStyle( + fontSize: 60.0 * scaling, + letterSpacing: -0.025 * 60.0 * scaling, + height: 1.0, + ), + UiTokens.avatarFallback1One: _avatarFallbackText( + fontSize: 14, + letterSpacing: 0.0025 * 12, + scaling: scaling, + ), + UiTokens.avatarFallback1Two: _avatarFallbackText( + fontSize: 12, + letterSpacing: 0.0025 * 12, + scaling: scaling, + ), + UiTokens.avatarFallback2One: _avatarFallbackText( + fontSize: 16, + letterSpacing: 0, + scaling: scaling, + ), + UiTokens.avatarFallback2Two: _avatarFallbackText( + fontSize: 14, + letterSpacing: 0, + scaling: scaling, + ), + UiTokens.avatarFallback3One: _avatarFallbackText( + fontSize: 18, + letterSpacing: 0, + scaling: scaling, + ), + UiTokens.avatarFallback3Two: _avatarFallbackText( + fontSize: 16, + letterSpacing: 0, + scaling: scaling, + ), + UiTokens.avatarFallback4One: _avatarFallbackText( + fontSize: 20, + letterSpacing: -0.0025 * 18, + scaling: scaling, + ), + UiTokens.avatarFallback4Two: _avatarFallbackText( + fontSize: 18, + letterSpacing: -0.0025 * 18, + scaling: scaling, + ), + UiTokens.avatarFallback5: _avatarFallbackText( + fontSize: 24, + letterSpacing: -0.00625 * 24, + scaling: scaling, + ), + UiTokens.avatarFallback6: _avatarFallbackText( + fontSize: 28, + letterSpacing: -0.0075 * 28, + scaling: scaling, + ), + UiTokens.avatarFallback7: _avatarFallbackText( + fontSize: 28, + letterSpacing: -0.0075 * 28, + scaling: scaling, + ), + UiTokens.avatarFallback8: _avatarFallbackText( + fontSize: 35, + letterSpacing: -0.01 * 35, + scaling: scaling, + ), + UiTokens.avatarFallback9: _avatarFallbackText( + fontSize: 60, + letterSpacing: -0.025 * 60, + scaling: scaling, + ), + + // Font weights (token values) + UiTokens.fontWeightLight: FontWeight.w300, + UiTokens.fontWeightRegular: FontWeight.w400, + UiTokens.fontWeightMedium: FontWeight.w500, + // Match Radix Themes font weights (bold = 700) + UiTokens.fontWeightBold: FontWeight.w700, + + // Durations (token values) + UiTokens.transitionFast: const Duration(milliseconds: 100), + UiTokens.transitionSlow: const Duration(milliseconds: 300), + UiTokens.skeletonPulseDuration: const Duration(milliseconds: 1000), + }; + + return allTokens; +} + +TextStyle _avatarFallbackText({ + required double fontSize, + required double letterSpacing, + required double scaling, +}) => TextStyle( + fontSize: fontSize * scaling, + letterSpacing: letterSpacing * scaling, + height: 1, +); + +enum _CardShadowLayer { outer, inner } + +enum _CardShadowState { idle, hovered, active } + +List _cardClassicShadows( + UiThemeColors colors, { + required bool isDark, + required _CardShadowLayer layer, + required _CardShadowState state, +}) { + final inner = layer == _CardShadowLayer.inner; + final shapeInset = inner ? 1.0 : 0.0; + final border = switch ((isDark, state)) { + (true, _) => mixOklabPremultiplied( + colors.gray.scale.alphaStep(6), + colors.gray.scale.step(6), + 0.25, + ), + (false, _CardShadowState.hovered) => mixOklabPremultiplied( + colors.gray.scale.alphaStep(4), + colors.gray.scale.step(4), + 0.25, + ), + (false, _) => mixOklabPremultiplied( + colors.gray.scale.alphaStep(3), + colors.gray.scale.step(3), + 0.25, + ), + }; + + RemixBoxShadow shadow( + Color color, { + Offset offset = Offset.zero, + double blur = 0, + required double spread, + }) => RemixBoxShadow( + color: color, + offset: offset, + blurRadius: blur, + spreadRadius: spread, + shapeInset: shapeInset, + ); + + if (state == _CardShadowState.hovered) { + if (isDark) { + return [ + shadow(border, spread: inner ? 1 : 0), + shadow(colors.gray.scale.alphaStep(4), blur: 1, spread: inner ? 1 : 0), + shadow( + colors.gray.scale.alphaStep(4), + blur: 1, + spread: inner ? -1 : -2, + ), + shadow( + colors.gray.scale.alphaStep(3), + blur: 3, + spread: inner ? -2 : -3, + ), + shadow( + colors.gray.scale.alphaStep(3), + blur: 12, + spread: inner ? -2 : -3, + ), + shadow( + colors.gray.scale.alphaStep(7), + blur: 16, + spread: inner ? -8 : -9, + ), + ]; + } + return [ + shadow(border, spread: inner ? 1 : 0), + shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 1), + blur: 1, + spread: inner ? 1 : 0, + ), + shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 2), + blur: 1, + spread: inner ? -1 : -2, + ), + shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 2), + blur: 3, + spread: inner ? -2 : -3, + ), + shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 3), + blur: 12, + spread: inner ? -4 : -5, + ), + shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 4), + blur: 16, + spread: inner ? -8 : -9, + ), + ]; + } + + final active = state == _CardShadowState.active; + final subtle = isDark ? colors.blackAlpha[3]! : colors.blackAlpha[1]!; + final middle = isDark + ? colors.blackAlpha[6]! + : colors.gray.scale.alphaStep(active ? 4 : 2); + final bottom = isDark ? colors.blackAlpha[5]! : colors.blackAlpha[1]!; + return [ + shadow(border, spread: inner ? 1 : 0), + shadow(const Color(0x00000000), spread: inner ? 1 : 0), + shadow(subtle, spread: inner ? 0.5 : 0), + shadow(middle, offset: const Offset(0, 1), blur: 1, spread: inner ? 0 : -1), + shadow( + isDark ? colors.blackAlpha[6]! : colors.blackAlpha[1]!, + offset: const Offset(0, 2), + blur: 1, + spread: inner ? -1 : -2, + ), + shadow(bottom, offset: const Offset(0, 1), blur: 3, spread: inner ? 0 : -1), + ]; +} + +RemixBoxShadow _insetShadow( + Color color, { + Offset offset = Offset.zero, + double blur = 0, + double spread = 0, + double shapeInset = 0, +}) => RemixBoxShadow( + kind: RemixBoxShadowKind.inset, + color: color, + offset: offset, + blurRadius: blur, + spreadRadius: spread, + shapeInset: shapeInset, +); + +List _selectClassicShadows( + UiThemeColors colors, { + required bool isDark, +}) { + if (isDark) { + return [ + _insetShadow(colors.whiteAlpha[4]!, spread: 1), + _insetShadow(colors.whiteAlpha[4]!, offset: const Offset(0, 1), blur: 1), + _insetShadow(colors.blackAlpha[9]!, offset: const Offset(0, -1), blur: 1), + ]; + } + return [ + _insetShadow(colors.gray.scale.alphaStep(5), spread: 1), + _insetShadow(colors.whiteAlpha[11]!, offset: const Offset(0, 2), blur: 1), + _insetShadow( + colors.gray.scale.alphaStep(4), + offset: const Offset(0, -2), + blur: 1, + ), + ]; +} + +List _baseButtonClassicDisabledShadows( + UiThemeColors colors, { + required bool isDark, +}) { + if (isDark) { + return [ + _insetShadow(colors.gray.scale.alphaStep(5), spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(2), + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.gray.scale.alphaStep(5), + offset: const Offset(0, 1), + blur: 1, + ), + _insetShadow(colors.blackAlpha[3]!, offset: const Offset(0, -1), blur: 1), + _insetShadow(colors.gray.scale.alphaStep(2), spread: 1), + ]; + } + return [ + _insetShadow(colors.gray.scale.alphaStep(4), spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, -2), + blur: 1, + ), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + ]; +} + +List _baseButtonClassicShadows( + UiThemeColors colors, { + required bool isDark, + required bool highContrast, +}) { + final accent = highContrast + ? colors.accent.scale.step(12) + : colors.accent.scale.step(9); + if (isDark) { + return [ + _insetShadow( + colors.whiteAlpha[4]!, + offset: const Offset(0, 2), + blur: 3, + spread: -1, + shapeInset: 1, + ), + _insetShadow(colors.whiteAlpha[2]!, spread: 1), + _insetShadow( + colors.whiteAlpha[3]!, + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow(colors.whiteAlpha[6]!, offset: const Offset(0, 1), blur: 1), + _insetShadow(colors.blackAlpha[6]!, offset: const Offset(0, -1), blur: 1), + _insetShadow(accent, spread: 1), + ]; + } + return [ + _insetShadow( + colors.whiteAlpha[4]!, + offset: const Offset(0, 2), + blur: 3, + spread: -1, + shapeInset: 2, + ), + _insetShadow(colors.gray.scale.alphaStep(4), spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, -2), + blur: 1, + ), + _insetShadow(accent, spread: 1), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + ]; +} + +List _baseButtonClassicActiveShadows( + UiThemeColors colors, { + required bool highContrast, +}) { + final accent = highContrast + ? colors.accent.scale.step(12) + : colors.accent.scale.step(9); + return [ + _insetShadow( + colors.gray.scale.alphaStep(4), + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.gray.scale.alphaStep(7), + offset: const Offset(0, 1), + blur: 1, + ), + _insetShadow(colors.gray.scale.alphaStep(5), spread: 1), + _insetShadow(accent, spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 3), + blur: 2, + ), + _insetShadow(colors.whiteAlpha[7]!, spread: 1), + _insetShadow(colors.whiteAlpha[5]!, offset: const Offset(0, -2), blur: 1), + ]; +} + +Map _radiusTokensFor(UiRadius radius, double scaling) { + final factor = _radiusFactor(radius); + final thumb = switch (radius) { + .none || .small => const Radius.circular(0.5), + .medium || .large || .full => const Radius.circular(9999.0), + }; + Radius scaled(double base) => Radius.circular(base * factor * scaling); + Radius larger(Radius first, Radius second) => Radius.elliptical( + first.x > second.x ? first.x : second.x, + first.y > second.y ? first.y : second.y, + ); + final radius1 = scaled(3.0); + final radius2 = scaled(4.0); + final radius3 = scaled(6.0); + final radius4 = scaled(8.0); + final radius5 = scaled(12.0); + final radius6 = scaled(16.0); + final full = radius == .full ? const Radius.circular(9999.0) : Radius.zero; + Radius progressRadius(double height) { + final thumbBase = switch (radius) { + .none || .small => 0.5, + .medium || .large || .full => 9999.0, + }; + return Radius.circular(math.max(factor * height / 3, factor * thumbBase)); + } + + return { + UiTokens.radius1: radius1, + UiTokens.radius2: radius2, + UiTokens.radius3: radius3, + UiTokens.radius4: radius4, + UiTokens.radius5: radius5, + UiTokens.radius6: radius6, + UiTokens.radiusFull: full, + UiTokens.radiusThumb: thumb, + UiTokens.radiusCircle: const Radius.circular(9999.0), + UiTokens.radius1OrFull: larger(radius1, full), + UiTokens.radius2OrFull: larger(radius2, full), + UiTokens.radius3OrFull: larger(radius3, full), + UiTokens.radius4OrFull: larger(radius4, full), + UiTokens.radius5OrFull: larger(radius5, full), + UiTokens.radius6OrFull: larger(radius6, full), + UiTokens.radius1OrThumb: larger(radius1, thumb), + UiTokens.radius2OrThumb: larger(radius2, thumb), + UiTokens.progressRadius1: progressRadius(4.0 * scaling), + UiTokens.progressRadius2: progressRadius(6.0 * scaling), + UiTokens.progressRadius3: progressRadius(8.0 * scaling), + UiTokens.sliderTrackRadius1: progressRadius(6.0 * scaling), + UiTokens.sliderTrackRadius2: progressRadius(8.0 * scaling), + UiTokens.sliderTrackRadius3: progressRadius(10.0 * scaling), + }; +} + +List _scaleShadowOpacity( + List shadows, + double factor, +) => [ + for (final shadow in shadows) + RemixBoxShadow( + kind: shadow.kind, + color: shadow.color.withValues(alpha: shadow.color.a * factor), + offset: shadow.offset, + blurRadius: shadow.blurRadius, + spreadRadius: shadow.spreadRadius, + shapeInset: shadow.shapeInset, + ), +]; + +double _radiusFactor(UiRadius radius) => switch (radius) { + .none => 0.0, + .small => 0.75, + .medium => 1.0, + .large || .full => 1.5, +}; + +Radius _scaledRadiusToken(UiRadius radius, double scaling, double base) => + Radius.circular(base * scaling * _radiusFactor(radius)); + +Map _accentColorTokens(UiThemeColors tokens) { + final scale = tokens.accent.scale; + + return { + UiTokens.accentSurface: tokens.accent.surface, + UiTokens.accentIndicator: tokens.accent.indicator, + UiTokens.accentTrack: tokens.accent.track, + UiTokens.accentContrast: tokens.accent.contrast, + UiTokens.focus8: tokens.focus8, + UiTokens.focusA5: tokens.focusA5, + UiTokens.focusA8: tokens.focusA8, + UiTokens.accent1: scale.step(1), + UiTokens.accent2: scale.step(2), + UiTokens.accent3: scale.step(3), + UiTokens.accent4: scale.step(4), + UiTokens.accent5: scale.step(5), + UiTokens.accent6: scale.step(6), + UiTokens.accent7: scale.step(7), + UiTokens.accent8: scale.step(8), + UiTokens.accent9: scale.step(9), + UiTokens.accent10: scale.step(10), + UiTokens.accent11: scale.step(11), + UiTokens.accent12: scale.step(12), + UiTokens.accentA1: scale.alphaStep(1), + UiTokens.accentA2: scale.alphaStep(2), + UiTokens.accentA3: scale.alphaStep(3), + UiTokens.accentA4: scale.alphaStep(4), + UiTokens.accentA5: scale.alphaStep(5), + UiTokens.accentA6: scale.alphaStep(6), + UiTokens.accentA7: scale.alphaStep(7), + UiTokens.accentA8: scale.alphaStep(8), + UiTokens.accentA9: scale.alphaStep(9), + UiTokens.accentA10: scale.alphaStep(10), + UiTokens.accentA11: scale.alphaStep(11), + UiTokens.accentA12: scale.alphaStep(12), + }; +} diff --git a/apps/dashboard/lib/ui/theme/theme_scope.dart b/apps/dashboard/lib/ui/theme/theme_scope.dart new file mode 100644 index 000000000..78d9f55fb --- /dev/null +++ b/apps/dashboard/lib/ui/theme/theme_scope.dart @@ -0,0 +1,187 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import 'theme_data.dart'; +import 'tokens.dart'; + +/// Establishes a courtesy default text run for bare [Text] descendants. +/// +/// `.radix-themes` is not only a token carrier upstream: `color.css` sets +/// `color: var(--gray-12)` in the same rule as the `data-has-background` fill, +/// and `typography.css` pins the root to `--default-font-size` +/// (`--font-size-3`), `--default-line-height`, `--default-letter-spacing`, and +/// `--default-font-weight`. Those resolve to exactly [UiTokens.text3] plus +/// [UiTokens.gray12] at regular weight. +/// +/// Ui text recipes resolve and pin their own runs. This fallback keeps +/// deliberately bare [Text] descendants aligned with Radix's root typography +/// and neutral foreground. A nearer descendant `DefaultTextStyle` still wins +/// through Flutter's normal inheritance. +/// +/// Only the outermost [UiScope] installs this. A nested scope re-scopes +/// tokens for its subtree and nothing more: upstream, `.radix-themes` inside +/// another `.radix-themes` still inherits `color` and the font properties from +/// its parent chain, and a nested scope that reinstalled the root run here +/// would silently replace whatever `DefaultTextStyle` the subtree sits in. +/// +/// The font family is deliberately left unset. Radix's `--default-font-family` +/// is the platform system stack, and a null family is Flutter's equivalent; +/// naming a concrete family here would pin every consumer to one typeface. +Widget _uiRootTextStyle({ + required Map, Object> tokens, + required Widget child, +}) { + final root = tokens[UiTokens.text3]! as TextStyle; + + return DefaultTextStyle( + style: root.copyWith( + color: tokens[UiTokens.gray12]! as Color, + fontWeight: tokens[UiTokens.fontWeightRegular]! as FontWeight, + ), + child: child, + ); +} + +/// Widget that provides Ui design tokens to its subtree via [MixScope]. +/// +/// Place [UiScope] below the application host so its text defaults apply. +/// For a routed app, wrap the navigator in the host's builder. This also keeps +/// [UiTokens] available to routes and dialogs. +/// +/// ```dart +/// MaterialApp( +/// builder: (_, child) => UiScope(child: child!), +/// home: const HomePage(), +/// ) +/// ``` +class UiScope extends StatelessWidget { + const UiScope({ + super.key, + this.accent, + this.gray, + this.brightness, + this.panelBackground, + this.radius, + this.scaling, + this.hasBackground, + this.orderOfModifiers, + required this.child, + }); + + final UiAccentColor? accent; + final UiGrayColor? gray; + final Brightness? brightness; + final UiPanelBackground? panelBackground; + final UiRadius? radius; + final UiScaling? scaling; + final bool? hasBackground; + final List? orderOfModifiers; + final Widget child; + + @override + Widget build(BuildContext context) { + final config = UiThemeConfig( + accent: accent, + gray: gray, + brightness: brightness, + panelBackground: panelBackground, + radius: radius, + scaling: scaling, + hasBackground: hasBackground, + ); + final parent = UiTheme.maybeOf(context); + final data = _resolveUiTheme(config, parent: parent); + final tokens = buildUiScopeTokens(data); + Widget result = MixScope( + tokens: tokens, + orderOfModifiers: orderOfModifiers, + // Theme-root identity, not `hasBackground`, decides who owns the text + // run: a scope nested for its accent or scaling must leave the current + // run alone, while a root scope with `hasBackground: false` still + // establishes it. + child: parent == null + ? _uiRootTextStyle(tokens: tokens, child: child) + : child, + ); + if (data.hasBackground) { + result = ColoredBox( + color: tokens[UiTokens.colorBackground]! as Color, + child: result, + ); + } + + return UiTheme( + data: data, + orderOfModifiers: orderOfModifiers, + child: result, + ); + } +} + +UiThemeData _resolveUiTheme(UiThemeConfig config, {UiThemeData? parent}) { + final accent = config.accent ?? parent?.accent ?? UiAccentColor.indigo; + + return UiThemeData( + accent: accent, + gray: config.gray ?? parent?.gray ?? UiGrayColor.slate, + brightness: config.brightness ?? parent?.brightness ?? Brightness.light, + panelBackground: + config.panelBackground ?? + parent?.panelBackground ?? + UiPanelBackground.translucent, + radius: config.radius ?? parent?.radius ?? UiRadius.medium, + scaling: config.scaling ?? parent?.scaling ?? UiScaling.percent100, + hasBackground: config.hasBackground ?? parent == null, + ); +} + +/// Makes the active [UiThemeData] available to descendants. +class UiTheme extends InheritedTheme { + const UiTheme({ + super.key, + required this.data, + this.orderOfModifiers, + required super.child, + }); + + final UiThemeData data; + final List? orderOfModifiers; + + /// Returns the closest resolved Ui theme. + static UiThemeData of(BuildContext context) { + final data = maybeOf(context); + if (data != null) return data; + throw FlutterError.fromParts([ + ErrorSummary('No UiTheme found.'), + ErrorDescription( + '${context.widget.runtimeType} tried to read the Ui theme, but no UiScope was found above it.', + ), + context.describeElement('The context used was'), + ]); + } + + /// Returns the closest resolved Ui theme, if one is available. + static UiThemeData? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.data; + + /// Rebuilds only the theme and its Mix tokens. + /// + /// The captured subtree's text run is *not* synthesized here. + /// `DefaultTextStyle` is itself an [InheritedTheme], so + /// `InheritedTheme.capture` already carries the actual nearest ambient run + /// across to the new route; installing the Radix root run alongside it would + /// overwrite that capture with a value the source context never had. + @override + Widget wrap(BuildContext context, Widget child) => UiTheme( + data: data, + orderOfModifiers: orderOfModifiers, + child: MixScope( + tokens: buildUiScopeTokens(data), + orderOfModifiers: orderOfModifiers, + child: child, + ), + ); + + @override + bool updateShouldNotify(UiTheme oldWidget) => data != oldWidget.data; +} diff --git a/apps/dashboard/lib/ui/theme/tokens.dart b/apps/dashboard/lib/ui/theme/tokens.dart new file mode 100644 index 000000000..469e27119 --- /dev/null +++ b/apps/dashboard/lib/ui/theme/tokens.dart @@ -0,0 +1,903 @@ +import 'package:remix/remix.dart'; + +import 'theme_scope.dart' show UiScope; + +/// Design tokens for the Ui UI system (powered by Radix Colors). +/// +/// Provides color scales (12-step accent/gray), spacing (9-step), radius (6-step), +/// shadows (6-level), typography (9-size), and functional colors. +/// +/// Example: +/// ```dart +/// Style( +/// $box.color.ref(UiTokens.accent9), +/// $text.style.ref(UiTokens.text3), +/// $box.padding.ref(UiTokens.space4), +/// ) +/// ``` +/// +/// Must be used within [UiScope] to resolve actual values. +class UiTokens { + // ============================================================================ + // BACKGROUND AND SURFACE COLORS + // ============================================================================ + + /// Page background color selected for the active brightness. + static const colorBackground = ColorToken('ui.color.background'); + + /// Neutral surface color for input fields and controls. + static const colorSurface = ColorToken('ui.color.surface'); + + /// Selected SegmentedControl surface for the active brightness. + static const segmentedControlIndicatorBackground = ColorToken( + 'ui.segmented-control.indicator-background', + ); + + /// Solid panel background selected for the active brightness. + static const colorPanelSolid = ColorToken('ui.color.panel.solid'); + + /// Translucent panel background with alpha transparency. + static const colorPanelTranslucent = ColorToken('ui.color.panel.translucent'); + + /// Panel background selected by [UiPanelBackground]. + static const colorPanel = ColorToken('ui.color.panel'); + + /// Backdrop blur applied to translucent floating panels. + static const panelBlur = DoubleToken('ui.panel.blur'); + + /// Dark overlay for modals and dialogs. + static const colorOverlay = ColorToken('ui.color.overlay'); + + // ============================================================================ + // FUNCTIONAL ACCENT COLORS + // ============================================================================ + + /// Subtle accent surface for soft button variants and chips. + static const accentSurface = ColorToken('ui.accent.surface'); + + /// Active indicator color for progress bars and sliders. + static const accentIndicator = ColorToken('ui.accent.indicator'); + + /// Track/rail background color for sliders and progress bars. + static const accentTrack = ColorToken('ui.accent.track'); + + /// Mode-aware overlay used by high-contrast slider ranges. + static const sliderHighContrastOverlay = ColorToken( + 'ui.slider.high-contrast-overlay', + ); + + /// High contrast foreground for solid accent backgrounds. + static const accentContrast = ColorToken('ui.accent.contrast'); + + // ============================================================================ + // FOCUS AND INTERACTION STATES + // ============================================================================ + + /// Solid focus ring color (accent step 8). + static const focus8 = ColorToken('ui.focus.8'); + + /// Translucent text-selection color (accent alpha step 5). + static const focusA5 = ColorToken('ui.focus.a5'); + + /// Translucent focus ring color with alpha transparency. + static const focusA8 = ColorToken('ui.focus.a8'); + + /// Mode-aware red roles used by documented validation extensions. + static const error3 = ColorToken('ui.error.3'); + static const error7 = ColorToken('ui.error.7'); + static const error8 = ColorToken('ui.error.8'); + static const error9 = ColorToken('ui.error.9'); + static const error11 = ColorToken('ui.error.11'); + static const error12 = ColorToken('ui.error.12'); + static const errorA7 = ColorToken('ui.error.a7'); + + // ============================================================================ + // ACCENT COLOR SCALE (12 STEPS) + // ============================================================================ + // + // Ui uses a 12-step color scale (inherited from Radix Themes) that provides semantic meaning: + // + // Steps 1-2: App backgrounds (subtle → more visible) + // Steps 3-5: Component backgrounds (rest → hover → active) + // Steps 6-8: Borders (subtle → component → hover) + // Steps 9-10: Solid backgrounds (default → hover) + // Steps 11-12: Text (low contrast → high contrast) + // + + /// Accent step 1 - App background, most subtle. + static const accent1 = ColorToken('ui.accent.1'); + + /// Accent step 2 - Subtle background. + static const accent2 = ColorToken('ui.accent.2'); + + /// Accent step 3 - Component background at rest. + static const accent3 = ColorToken('ui.accent.3'); + + /// Accent step 4 - Component background on hover. + static const accent4 = ColorToken('ui.accent.4'); + + /// Accent step 5 - Component background when active/pressed. + static const accent5 = ColorToken('ui.accent.5'); + + /// Accent step 6 - Subtle borders and separators. + static const accent6 = ColorToken('ui.accent.6'); + + /// Accent step 7 - Component borders at rest. + static const accent7 = ColorToken('ui.accent.7'); + + /// Accent step 8 - Component borders on hover and focus. + static const accent8 = ColorToken('ui.accent.8'); + + /// Accent step 9 - Primary solid background. + static const accent9 = ColorToken('ui.accent.9'); + + /// Accent step 10 - Solid background on hover. + static const accent10 = ColorToken('ui.accent.10'); + + /// Accent step 11 - Low contrast text. + static const accent11 = ColorToken('ui.accent.11'); + + /// Accent step 12 - High contrast text. + static const accent12 = ColorToken('ui.accent.12'); + + // ============================================================================ + // GRAY COLOR SCALE (12 STEPS) + // ============================================================================ + // + // The gray scale follows the same 12-step semantic structure as accent colors, + // but provides neutral colors for text, borders, and backgrounds. + // The specific gray variant (slate, mauve, sage, etc.) is chosen in the theme. + // + + /// Gray step 1 - Page background. + static const gray1 = ColorToken('ui.gray.1'); + + /// Gray step 2 - Panel and card backgrounds. + static const gray2 = ColorToken('ui.gray.2'); + + /// Gray step 3 - Input backgrounds and pressed states. + static const gray3 = ColorToken('ui.gray.3'); + + /// Gray step 4 - Input backgrounds on hover. + static const gray4 = ColorToken('ui.gray.4'); + + /// Gray step 5 - Active states and disabled backgrounds. + static const gray5 = ColorToken('ui.gray.5'); + + /// Gray step 6 - Subtle borders and dividers. + static const gray6 = ColorToken('ui.gray.6'); + + /// Gray step 7 - Standard borders and outlines. + /// + /// Primary border color for form inputs, cards, + /// and component boundaries. + static const gray7 = ColorToken('ui.gray.7'); + + /// Gray step 8 - Borders on hover and focus. + /// + /// Interactive border states and stronger separators + /// that need more visual weight. + static const gray8 = ColorToken('ui.gray.8'); + + /// Gray step 9 - Solid neutral backgrounds. + /// + /// For neutral buttons, badges, and other elements + /// that need a solid background without accent color. + static const gray9 = ColorToken('ui.gray.9'); + + /// Gray step 10 - Solid neutral backgrounds on hover. + /// + /// Hover state for neutral solid backgrounds, + /// providing interactive feedback. + static const gray10 = ColorToken('ui.gray.10'); + + /// Gray step 11 - Low contrast text and secondary content. + /// + /// For secondary text, placeholders, and content that should + /// be readable but not prominent. + static const gray11 = ColorToken('ui.gray.11'); + + /// Gray step 12 - High contrast text and primary content. + /// + /// Primary text color for body content, headings, and any text + /// that needs maximum readability and prominence. + static const gray12 = ColorToken('ui.gray.12'); + + // ============================================================================ + // GRAY ROLE TOKENS (parity with generated JSON roles) + // ============================================================================ + /// Neutral surface baseline for the selected gray scale (matches JSON surface) + static const graySurface = ColorToken('ui.gray.surface'); + + /// Neutral indicator color (typically gray step 9) + static const grayIndicator = ColorToken('ui.gray.indicator'); + + /// Neutral track color (typically gray step 9) + static const grayTrack = ColorToken('ui.gray.track'); + + /// Contrast color for content over neutral solid backgrounds (white) + static const grayContrast = ColorToken('ui.gray.contrast'); + + // ============================================================================ + // ALPHA VARIANTS (FULL 12-STEP FOR ACCENT AND GRAY) + // ============================================================================ + + // Accent alpha steps a1..a12 + static const accentA1 = ColorToken('ui.accent.a1'); + static const accentA2 = ColorToken('ui.accent.a2'); + static const accentA3 = ColorToken('ui.accent.a3'); + static const accentA4 = ColorToken('ui.accent.a4'); + static const accentA5 = ColorToken('ui.accent.a5'); + static const accentA6 = ColorToken('ui.accent.a6'); + static const accentA7 = ColorToken('ui.accent.a7'); + static const accentA8 = ColorToken('ui.accent.a8'); + static const accentA9 = ColorToken('ui.accent.a9'); + static const accentA10 = ColorToken('ui.accent.a10'); + static const accentA11 = ColorToken('ui.accent.a11'); + static const accentA12 = ColorToken('ui.accent.a12'); + + // Gray alpha steps a1..a12 + static const grayA1 = ColorToken('ui.gray.a1'); + static const grayA2 = ColorToken('ui.gray.a2'); + static const grayA3 = ColorToken('ui.gray.a3'); + static const grayA4 = ColorToken('ui.gray.a4'); + static const grayA5 = ColorToken('ui.gray.a5'); + static const grayA6 = ColorToken('ui.gray.a6'); + static const grayA7 = ColorToken('ui.gray.a7'); + static const grayA8 = ColorToken('ui.gray.a8'); + static const grayA9 = ColorToken('ui.gray.a9'); + static const grayA10 = ColorToken('ui.gray.a10'); + static const grayA11 = ColorToken('ui.gray.a11'); + static const grayA12 = ColorToken('ui.gray.a12'); + + // ============================================================================ + // NEUTRALS FOR SHADOWS (HELPER TOKENS) + // ============================================================================ + /// Gray alpha steps are declared above (grayA1..grayA12). + + /// Black alpha steps used in layered shadows. + static const blackA1 = ColorToken('ui.black.a1'); + static const blackA2 = ColorToken('ui.black.a2'); + static const blackA3 = ColorToken('ui.black.a3'); + static const blackA4 = ColorToken('ui.black.a4'); + static const blackA5 = ColorToken('ui.black.a5'); + static const blackA6 = ColorToken('ui.black.a6'); + static const blackA7 = ColorToken('ui.black.a7'); + static const blackA8 = ColorToken('ui.black.a8'); + static const blackA9 = ColorToken('ui.black.a9'); + static const blackA10 = ColorToken('ui.black.a10'); + static const blackA11 = ColorToken('ui.black.a11'); + static const blackA12 = ColorToken('ui.black.a12'); + + /// White alpha steps used by layered classic-control recipes. + static const whiteA1 = ColorToken('ui.white.a1'); + static const whiteA2 = ColorToken('ui.white.a2'); + static const whiteA3 = ColorToken('ui.white.a3'); + static const whiteA4 = ColorToken('ui.white.a4'); + static const whiteA5 = ColorToken('ui.white.a5'); + static const whiteA6 = ColorToken('ui.white.a6'); + static const whiteA7 = ColorToken('ui.white.a7'); + static const whiteA8 = ColorToken('ui.white.a8'); + static const whiteA9 = ColorToken('ui.white.a9'); + static const whiteA10 = ColorToken('ui.white.a10'); + static const whiteA11 = ColorToken('ui.white.a11'); + static const whiteA12 = ColorToken('ui.white.a12'); + + /// Mode-aware mixed shadow stroke. + static const shadowStroke = ColorToken('ui.shadow.stroke'); + + /// Premultiplied OKLab mixes used by Radix neutral one-pixel strokes. + static const grayStroke3 = ColorToken('ui.gray.stroke.3'); + static const grayStroke4 = ColorToken('ui.gray.stroke.4'); + static const grayStroke5 = ColorToken('ui.gray.stroke.5'); + static const grayStroke6 = ColorToken('ui.gray.stroke.6'); + static const grayStroke7 = ColorToken('ui.gray.stroke.7'); + + // ============================================================================ + // SPACING SCALE (9 STEPS) + // ============================================================================ + // + // A consistent spacing scale based on 4px increments. + // + + /// Space step 1 - 4px. + /// + /// Smallest spacing for tight layouts, borders, + /// and fine-grained adjustments. + static const space1 = SpaceToken('ui.space.1'); + + /// Space step 2 - 8px. + /// + /// Small spacing for component padding and margins. + /// Good for button padding and form element spacing. + static const space2 = SpaceToken('ui.space.2'); + + /// Space step 3 - 12px. + /// + /// Medium-small spacing for comfortable padding + /// and moderate element separation. + static const space3 = SpaceToken('ui.space.3'); + + /// Space step 4 - 16px. + /// + /// Standard spacing for most layouts. Good default + /// for card padding and section margins. + static const space4 = SpaceToken('ui.space.4'); + + /// Space step 5 - 24px. + /// + /// Medium spacing for generous padding and + /// comfortable separation between sections. + static const space5 = SpaceToken('ui.space.5'); + + /// Space step 6 - 32px. + /// + /// Large spacing for significant visual separation + /// and generous component padding. + static const space6 = SpaceToken('ui.space.6'); + + /// Space step 7 - 40px. + /// + /// Extra large spacing for major layout sections + /// and prominent visual separation. + static const space7 = SpaceToken('ui.space.7'); + + /// Space step 8 - 48px. + /// + /// Very large spacing for significant page sections + /// and major layout boundaries. + static const space8 = SpaceToken('ui.space.8'); + + /// Space step 9 - 64px. + /// + /// Maximum spacing for major page sections + /// and substantial layout separation. + static const space9 = SpaceToken('ui.space.9'); + + /// Spinner size 3 - 20px at 100% scaling. + /// + /// Radix defines this as 1.25 times space 4, so it needs its own resolved + /// token rather than arithmetic on an unresolved token reference. + static const spinnerSize3 = DoubleToken('ui.spinner.size.3'); + + /// Compact gap shared by size-1 toggle extensions (2px at 100% scaling). + static const toggleGap1 = DoubleToken('ui.toggle.gap.1'); + + /// Comfortable gap shared by size-3 toggle extensions (6px at 100%). + static const toggleGap3 = DoubleToken('ui.toggle.gap.3'); + + /// Avatar sizes expressed as scaled pixels rather than spacing steps. + static const avatarSize6 = DoubleToken('ui.avatar.size.6'); + static const avatarSize7 = DoubleToken('ui.avatar.size.7'); + static const avatarSize8 = DoubleToken('ui.avatar.size.8'); + static const avatarSize9 = DoubleToken('ui.avatar.size.9'); + + /// Avatar icon sizes are half of each resolved avatar dimension. + /// + /// These values need dedicated tokens because arithmetic on an unresolved + /// token reference would destroy its identity before Mix can resolve it. + static const avatarIconSize1 = DoubleToken('ui.avatar.icon-size.1'); + static const avatarIconSize2 = DoubleToken('ui.avatar.icon-size.2'); + static const avatarIconSize3 = DoubleToken('ui.avatar.icon-size.3'); + static const avatarIconSize4 = DoubleToken('ui.avatar.icon-size.4'); + static const avatarIconSize5 = DoubleToken('ui.avatar.icon-size.5'); + static const avatarIconSize6 = DoubleToken('ui.avatar.icon-size.6'); + static const avatarIconSize7 = DoubleToken('ui.avatar.icon-size.7'); + static const avatarIconSize8 = DoubleToken('ui.avatar.icon-size.8'); + static const avatarIconSize9 = DoubleToken('ui.avatar.icon-size.9'); + + /// Badge measurements that are fractional spacing expressions upstream. + static const badgePaddingX1 = DoubleToken('ui.badge.padding-x.1'); + static const badgePaddingY1 = DoubleToken('ui.badge.padding-y.1'); + static const badgePaddingX3 = DoubleToken('ui.badge.padding-x.3'); + + /// Checkbox dimensions expressed as scaled pixels by Radix Themes. + static const checkboxSize1 = DoubleToken('ui.checkbox.size.1'); + static const checkboxSize3 = DoubleToken('ui.checkbox.size.3'); + static const checkboxIndicatorSize1 = DoubleToken( + 'ui.checkbox.indicator-size.1', + ); + static const checkboxIndicatorSize2 = DoubleToken( + 'ui.checkbox.indicator-size.2', + ); + static const checkboxIndicatorSize3 = DoubleToken( + 'ui.checkbox.indicator-size.3', + ); + + /// Checkbox-group label gaps derived from Radix's size-linked `0.5em`. + static const checkboxGroupItemGap1 = DoubleToken( + 'ui.checkbox-group.item-gap.1', + ); + static const checkboxGroupItemGap2 = DoubleToken( + 'ui.checkbox-group.item-gap.2', + ); + static const checkboxGroupItemGap3 = DoubleToken( + 'ui.checkbox-group.item-gap.3', + ); + + /// Radio indicators are 40% of their control size in Radix Themes. + /// + /// These values need dedicated tokens because arithmetic on an unresolved + /// token reference would destroy its identity before Mix can resolve it. + static const radioIndicatorSize1 = DoubleToken('ui.radio.indicator-size.1'); + static const radioIndicatorSize2 = DoubleToken('ui.radio.indicator-size.2'); + static const radioIndicatorSize3 = DoubleToken('ui.radio.indicator-size.3'); + + /// Checkbox radii derived from fractional radius-step expressions. + static const checkboxRadius1 = RadiusToken('ui.checkbox.radius.1'); + static const checkboxRadius3 = RadiusToken('ui.checkbox.radius.3'); + + /// Switch geometry that cannot be derived from unresolved token references. + static const switchHeight2 = DoubleToken('ui.switch.height.2'); + static const switchWidth1 = DoubleToken('ui.switch.width.1'); + static const switchWidth2 = DoubleToken('ui.switch.width.2'); + static const switchWidth3 = DoubleToken('ui.switch.width.3'); + static const switchThumbSize1 = DoubleToken('ui.switch.thumb-size.1'); + static const switchThumbSize2 = DoubleToken('ui.switch.thumb-size.2'); + static const switchThumbSize3 = DoubleToken('ui.switch.thumb-size.3'); + + /// Progress geometry derived from scaled fractional upstream expressions. + static const progressHeight2 = DoubleToken('ui.progress.height.2'); + static const progressRadius1 = RadiusToken('ui.progress.radius.1'); + static const progressRadius2 = RadiusToken('ui.progress.radius.2'); + static const progressRadius3 = RadiusToken('ui.progress.radius.3'); + + /// Slider geometry expressed as scaled Radix component dimensions. + static const sliderTrackSize1 = DoubleToken('ui.slider.track-size.1'); + static const sliderTrackSize2 = DoubleToken('ui.slider.track-size.2'); + static const sliderTrackSize3 = DoubleToken('ui.slider.track-size.3'); + static const sliderThumbSize1 = DoubleToken('ui.slider.thumb-size.1'); + static const sliderThumbSize2 = DoubleToken('ui.slider.thumb-size.2'); + static const sliderThumbSize3 = DoubleToken('ui.slider.thumb-size.3'); + static const sliderTrackRadius1 = RadiusToken('ui.slider.track-radius.1'); + static const sliderTrackRadius2 = RadiusToken('ui.slider.track-radius.2'); + static const sliderTrackRadius3 = RadiusToken('ui.slider.track-radius.3'); + + /// TextField content insets after its fixed one-pixel border. + static const textFieldPadding1 = DoubleToken('ui.text-field.padding.1'); + static const textFieldPadding2 = DoubleToken('ui.text-field.padding.2'); + static const textFieldPadding3 = DoubleToken('ui.text-field.padding.3'); + + /// TextArea metrics that cannot be expressed by existing spacing tokens. + static const textAreaMinHeight3 = DoubleToken('ui.text-area.min-height.3'); + + /// DataList metrics that cannot be expressed by existing spacing tokens. + static const dataListRowGap3 = DoubleToken('ui.data-list.row-gap.3'); + static const dataListLabelMinWidth = DoubleToken( + 'ui.data-list.label-min-width', + ); + + /// Exact uppercase fallback typography for each Avatar size. + static const avatarFallback1One = TextStyleToken('ui.avatar.fallback.1.one'); + static const avatarFallback1Two = TextStyleToken('ui.avatar.fallback.1.two'); + static const avatarFallback2One = TextStyleToken('ui.avatar.fallback.2.one'); + static const avatarFallback2Two = TextStyleToken('ui.avatar.fallback.2.two'); + static const avatarFallback3One = TextStyleToken('ui.avatar.fallback.3.one'); + static const avatarFallback3Two = TextStyleToken('ui.avatar.fallback.3.two'); + static const avatarFallback4One = TextStyleToken('ui.avatar.fallback.4.one'); + static const avatarFallback4Two = TextStyleToken('ui.avatar.fallback.4.two'); + static const avatarFallback5 = TextStyleToken('ui.avatar.fallback.5'); + static const avatarFallback6 = TextStyleToken('ui.avatar.fallback.6'); + static const avatarFallback7 = TextStyleToken('ui.avatar.fallback.7'); + static const avatarFallback8 = TextStyleToken('ui.avatar.fallback.8'); + static const avatarFallback9 = TextStyleToken('ui.avatar.fallback.9'); + + /// Tabs size 1 inner vertical padding - 2px at 100% scaling. + static const tabInnerPaddingY1 = DoubleToken('ui.tabs.inner-padding-y.1'); + + /// Tabs size 1 active tracking - -0.12px at 100% scaling. + static const tabActiveLetterSpacing1 = DoubleToken( + 'ui.tabs.active-letter-spacing.1', + ); + + /// Tabs size 2 active tracking - -0.14px at 100% scaling. + static const tabActiveLetterSpacing2 = DoubleToken( + 'ui.tabs.active-letter-spacing.2', + ); + + /// Table size-1 minimum cell height (36px at 100% scaling). + /// + /// Radix writes `calc(36px * var(--scaling))` literally, so no existing + /// spacing step expresses it. + static const dataTableRowHeight1 = DoubleToken('ui.data-table.height.1'); + + /// Table size-2 minimum cell height (44px at 100% scaling). + static const dataTableRowHeight2 = DoubleToken('ui.data-table.height.2'); + + /// Table surface border - `color-mix(in oklab, gray-a5, gray-6)`. + /// + /// The existing `grayStroke*` tokens blend an alpha step with the *same* + /// numbered solid step at 25%; Table blends step 5 with step 6 at 50%. + static const dataTableBorder = ColorToken('ui.data-table.border'); + + /// Select's 1.5 × space-1 measurement (6px at 100% scaling). + static const selectSpace1Half = DoubleToken('ui.select.space.1-half'); + + /// Select size-1 indicator column width (20px at 100% scaling). + static const selectIndicatorWidth1 = DoubleToken( + 'ui.select.indicator-width.1', + ); + + /// Select size-1 check size (8px at 100% scaling). + static const selectIndicatorSize1 = DoubleToken('ui.select.indicator-size.1'); + + /// Select size-2/3 check size (10px at 100% scaling). + static const selectIndicatorSize2 = DoubleToken('ui.select.indicator-size.2'); + + /// Negative margins that cancel Select ghost-trigger padding. + static const selectGhostMarginX12 = DoubleToken( + 'ui.select.ghost-margin-x.1-2', + ); + static const selectGhostMarginY12 = DoubleToken( + 'ui.select.ghost-margin-y.1-2', + ); + static const selectGhostMarginX3 = DoubleToken('ui.select.ghost-margin-x.3'); + static const selectGhostMarginY3 = DoubleToken('ui.select.ghost-margin-y.3'); + + // ============================================================================ + // BORDER RADIUS SCALE (6 STEPS + FULL) + // ============================================================================ + + /// Radius step 1 - 3px. + /// + /// Subtle rounding for small elements like buttons + /// and form inputs. Provides gentle softening of corners. + static const radius1 = RadiusToken('ui.radius.1'); + + /// Radius step 2 - 4px. + /// + /// Small radius for compact components and minor rounding. + /// Good for small badges and tight layouts. + static const radius2 = RadiusToken('ui.radius.2'); + + /// Radius step 3 - 6px. + /// + /// Medium radius for standard components like buttons + /// and cards. Balances modern look with usability. + static const radius3 = RadiusToken('ui.radius.3'); + + /// Radius step 4 - 8px. + /// + /// Large radius for prominent components and generous rounding. + /// Good for larger buttons and feature cards. + static const radius4 = RadiusToken('ui.radius.4'); + + /// Radius step 5 - 12px. + /// + /// Extra large radius for major components and modern aesthetics. + /// Suitable for large cards and prominent interface elements. + static const radius5 = RadiusToken('ui.radius.5'); + + /// Radius step 6 - 16px. + /// + /// Very large radius for distinctive styling and major components. + /// Creates a soft, friendly appearance for large interface elements. + static const radius6 = RadiusToken('ui.radius.6'); + + /// Theme-level full radius, enabled only by [UiRadius.full]. + static const radiusFull = RadiusToken('ui.radius.full'); + + /// Radius used by control thumbs. + static const radiusThumb = RadiusToken('ui.radius.thumb'); + + /// Fixed circle radius for shapes that stay circular across theme presets. + static const radiusCircle = RadiusToken('ui.radius.circle'); + + /// Radius step 1 promoted to a pill when the theme radius is full. + static const radius1OrFull = RadiusToken('ui.radius.1-or-full'); + + /// Radius step 2 promoted to a pill when the theme radius is full. + static const radius2OrFull = RadiusToken('ui.radius.2-or-full'); + + /// Radius step 3 promoted to a pill when the theme radius is full. + static const radius3OrFull = RadiusToken('ui.radius.3-or-full'); + + /// Radius step 4 promoted to a pill when the theme radius is full. + static const radius4OrFull = RadiusToken('ui.radius.4-or-full'); + + /// Radius step 5 promoted to a pill when the theme radius is full. + static const radius5OrFull = RadiusToken('ui.radius.5-or-full'); + + /// Radius step 6 promoted to a pill when the theme radius is full. + static const radius6OrFull = RadiusToken('ui.radius.6-or-full'); + + /// Radius step 1 promoted to the control-thumb radius when larger. + static const radius1OrThumb = RadiusToken('ui.radius.1-or-thumb'); + + /// Radius step 2 promoted to the control-thumb radius when larger. + static const radius2OrThumb = RadiusToken('ui.radius.2-or-thumb'); + + // ============================================================================ + // ELEVATION SHADOWS (6 LEVELS) + // ============================================================================ + + /// Shadow level 1 - Subtle elevation. + /// + /// Minimal shadow for slight elevation effects. + /// Good for cards and buttons in their resting state. + static const shadow1 = BoxShadowToken('ui.shadow.1'); + + /// Exact layered shadow level 1, including inset layers. + /// + /// This additive token powers Ui's Radix-compatible rendering while + /// [shadow1] retains the original Remix public token type. + static const shadow1Layers = RemixBoxShadowListToken('ui.shadow.1.layers'); + + /// Half-opacity shadow-1 layers used by a disabled classic slider track. + static const sliderClassicDisabledTrackShadows = RemixBoxShadowListToken( + 'ui.slider.classic.disabled-track-shadows', + ); + + /// Shadow level 2 - Low elevation. + /// + /// Light shadow for gentle elevation and hover states. + /// Suitable for interactive elements and small modals. + static const shadow2 = BoxShadowToken('ui.shadow.2'); + + /// Shadow-2 painted on SegmentedControl's fixed one-pixel inset shape. + static const segmentedControlClassicIndicatorShadows = + RemixBoxShadowListToken('ui.segmented-control.classic.indicator-shadows'); + + /// Shadow level 3 - Medium elevation. + /// + /// Moderate shadow for clear visual separation. + /// Good for dropdowns, tooltips, and floating elements. + static const shadow3 = BoxShadowToken('ui.shadow.3'); + + /// Shadow level 4 - High elevation. + /// + /// Prominent shadow for important floating content. + /// Suitable for modal dialogs and important overlays. + static const shadow4 = BoxShadowToken('ui.shadow.4'); + + /// Shadow level 5 - Very high elevation. + /// + /// Strong shadow for primary modals and major overlays. + /// Creates clear hierarchy and focus on important content. + static const shadow5 = BoxShadowToken('ui.shadow.5'); + + /// Shadow level 6 - Maximum elevation. + /// + /// Maximum shadow depth for critical dialogs and notifications. + /// Ensures content appears above all other interface elements. + static const shadow6 = BoxShadowToken('ui.shadow.6'); + + /// Card classic outer and inset-pseudo-element shadow lists. + static const cardClassicOuterShadows = RemixBoxShadowListToken( + 'ui.card.classic.outer-shadows', + ); + static const cardClassicInnerShadows = RemixBoxShadowListToken( + 'ui.card.classic.inner-shadows', + ); + static const cardClassicHoverOuterShadows = RemixBoxShadowListToken( + 'ui.card.classic.hover.outer-shadows', + ); + static const cardClassicHoverInnerShadows = RemixBoxShadowListToken( + 'ui.card.classic.hover.inner-shadows', + ); + static const cardClassicActiveOuterShadows = RemixBoxShadowListToken( + 'ui.card.classic.active.outer-shadows', + ); + static const cardClassicActiveInnerShadows = RemixBoxShadowListToken( + 'ui.card.classic.active.inner-shadows', + ); + + /// Mode-aware inset layers for a classic Select trigger. + static const selectTriggerClassicShadows = RemixBoxShadowListToken( + 'ui.select.trigger.classic.shadows', + ); + + /// Mode-aware open/hover layers for a classic Select trigger. + static const selectTriggerClassicHoverShadows = RemixBoxShadowListToken( + 'ui.select.trigger.classic.hover-shadows', + ); + + /// Mode-aware disabled layers shared by classic button-shaped controls. + static const baseButtonClassicDisabledShadows = RemixBoxShadowListToken( + 'ui.base-button.classic.disabled.shadows', + ); + + /// Mode-aware classic Button/IconButton layers. + static const baseButtonClassicShadows = RemixBoxShadowListToken( + 'ui.base-button.classic.shadows', + ); + static const baseButtonClassicHighContrastShadows = RemixBoxShadowListToken( + 'ui.base-button.classic.high-contrast.shadows', + ); + static const baseButtonClassicActiveShadows = RemixBoxShadowListToken( + 'ui.base-button.classic.active.shadows', + ); + static const baseButtonClassicActiveHighContrastShadows = + RemixBoxShadowListToken( + 'ui.base-button.classic.active.high-contrast.shadows', + ); + static const baseButtonClassicAfterInset = DoubleToken( + 'ui.base-button.classic.after-inset', + ); + static const baseButtonGhostPaddingY3 = DoubleToken( + 'ui.base-button.ghost.padding-y.3', + ); + static const baseButtonGhostMarginX12 = DoubleToken( + 'ui.base-button.ghost.margin-x.1-2', + ); + static const baseButtonGhostMarginY12 = DoubleToken( + 'ui.base-button.ghost.margin-y.1-2', + ); + static const baseButtonGhostMarginX3 = DoubleToken( + 'ui.base-button.ghost.margin-x.3', + ); + static const baseButtonGhostMarginY3 = DoubleToken( + 'ui.base-button.ghost.margin-y.3', + ); + static const baseButtonGhostMarginX4 = DoubleToken( + 'ui.base-button.ghost.margin-x.4', + ); + static const baseButtonGhostMarginY4 = DoubleToken( + 'ui.base-button.ghost.margin-y.4', + ); + static const iconButtonGhostPadding2 = DoubleToken( + 'ui.icon-button.ghost.padding.2', + ); + static const iconButtonGhostMargin1 = DoubleToken( + 'ui.icon-button.ghost.margin.1', + ); + static const iconButtonGhostMargin2 = DoubleToken( + 'ui.icon-button.ghost.margin.2', + ); + static const iconButtonGhostMargin3 = DoubleToken( + 'ui.icon-button.ghost.margin.3', + ); + static const iconButtonGhostMargin4 = DoubleToken( + 'ui.icon-button.ghost.margin.4', + ); + static const cardGhostMargin1 = DoubleToken('ui.card.ghost.margin.1'); + static const cardGhostMargin2 = DoubleToken('ui.card.ghost.margin.2'); + static const cardGhostMargin3 = DoubleToken('ui.card.ghost.margin.3'); + static const cardGhostMargin4 = DoubleToken('ui.card.ghost.margin.4'); + static const cardGhostMargin5 = DoubleToken('ui.card.ghost.margin.5'); + + // ============================================================================ + // BORDER AND STROKE WIDTHS + // ============================================================================ + + /// Standard border width (1px). + /// + /// Default border thickness for most components like inputs, + /// cards, and dividers. Provides clear boundaries without visual weight. + static const borderWidth1 = SpaceToken('ui.border.width.1'); + + /// Thick border width (2px). + /// + /// Heavier border for emphasis, selected states, and components + /// that need stronger visual definition. + static const borderWidth2 = SpaceToken('ui.border.width.2'); + + /// Focus ring border width (2px). + /// + /// Standard width for focus outlines to ensure accessibility + /// compliance and clear keyboard navigation feedback. + static const focusRingWidth = SpaceToken('ui.focus.ring.width'); + + /// Focus ring offset distance from element edge. + /// + /// Space between the component border and focus ring, + /// ensuring the focus indicator doesn't interfere with the element. + static const focusRingOffset = SpaceToken('ui.focus.ring.offset'); + + // ============================================================================ + // TYPOGRAPHY SCALE (9 LEVELS) + // ============================================================================ + // + // Text sizes with carefully tuned line heights and letter spacing + // for optimal readability across all scales. + // + + /// Text size 1 - 12px (Small labels and metadata). + /// + /// Smallest readable text for labels, captions, and secondary metadata. + /// Includes tight letter spacing for improved legibility at small sizes. + static const text1 = TextStyleToken('ui.text.1'); + + /// Text size 2 - 14px (Standard UI text). + /// + /// Default size for most interface text including buttons, + /// form labels, and secondary content. + static const text2 = TextStyleToken('ui.text.2'); + + /// Text size 3 - 16px (Body text and primary content). + /// + /// Ideal for body text and primary content. Provides excellent + /// readability for extended reading on all device types. + static const text3 = TextStyleToken('ui.text.3'); + + /// Accordion size-2 text (15px with a 20px line box at 100% scaling). + /// + /// Accordion is a Ui extension, so this intermediate size is kept + /// separate from the upstream Radix typography scale. + static const accordionText2 = TextStyleToken('ui.accordion.text.2'); + + /// Text size 4 - 18px (Prominent body text). + /// + /// For important content that needs more visual weight than + /// standard body text but isn't quite a heading. + static const text4 = TextStyleToken('ui.text.4'); + + /// Text size 5 - 20px (Small headings). + /// + /// For minor headings, subheadings, and content that needs + /// to stand out from body text. + static const text5 = TextStyleToken('ui.text.5'); + + /// Text size 6 - 24px (Medium headings). + /// + /// Standard heading size for section titles and important content. + /// Good balance between prominence and page economy. + static const text6 = TextStyleToken('ui.text.6'); + + /// Text size 7 - 28px (Large headings). + /// + /// For major page headings and important announcements. + /// Creates strong visual hierarchy and draws attention. + static const text7 = TextStyleToken('ui.text.7'); + + /// Text size 8 - 35px (Extra large headings). + /// + /// For hero text, page titles, and major content sections. + /// Strong negative letter spacing improves appearance at large sizes. + static const text8 = TextStyleToken('ui.text.8'); + + /// Text size 9 - 60px (Display text). + /// + /// Maximum text size for hero sections and display typography. + /// Includes significant negative letter spacing and tight line height. + static const text9 = TextStyleToken('ui.text.9'); + + // ============================================================================ + // FONT WEIGHT TOKENS + // ============================================================================ + + /// Light font weight (300). + /// + /// Optional lighter weight occasionally used in display typography or + /// subdued text. Provided for parity with Radix token set. + static const fontWeightLight = FontWeightToken('ui.font.weight.light'); + + /// Regular font weight (400). + /// + /// Standard weight for body text and most interface elements. + /// Provides good readability without visual strain. + static const fontWeightRegular = FontWeightToken('ui.font.weight.regular'); + + /// Medium font weight (500). + /// + /// Slightly heavier than regular for UI elements that need + /// more visual weight, like active states and button text. + static const fontWeightMedium = FontWeightToken('ui.font.weight.medium'); + + /// Bold font weight (700). + /// + /// For headings and content that needs strong emphasis. + /// Provides clear hierarchy without being too heavy. + static const fontWeightBold = FontWeightToken('ui.font.weight.bold'); + + // ============================================================================ + // ANIMATION DURATIONS + // ============================================================================ + + /// Fast animation duration (100ms). + /// + /// For quick micro-interactions like hover states and button presses. + /// Provides immediate feedback without feeling sluggish. + static const transitionFast = DurationToken('ui.transition.fast'); + + /// Slow animation duration (300ms). + /// + /// For more substantial transitions like modal appearances, + /// page transitions, and complex state changes. + static const transitionSlow = DurationToken('ui.transition.slow'); + + /// One leg of the Radix Skeleton pulse. + static const skeletonPulseDuration = DurationToken( + 'ui.skeleton.pulse-duration', + ); +} diff --git a/apps/dashboard/lib/ui/ui.dart b/apps/dashboard/lib/ui/ui.dart new file mode 100644 index 000000000..3f76886cc --- /dev/null +++ b/apps/dashboard/lib/ui/ui.dart @@ -0,0 +1,65 @@ +library; + +// remix_cli:exports:start +export 'components/accordion.dart'; +export 'components/activity.dart'; +export 'components/answer.dart'; +export 'components/avatar.dart'; +export 'components/badge.dart'; +export 'components/base_button.dart'; +export 'components/button.dart'; +export 'components/callout.dart'; +export 'components/card.dart'; +export 'components/chart.dart'; +export 'components/checkbox.dart'; +export 'components/code.dart'; +export 'components/composer.dart'; +export 'components/data_list.dart'; +export 'components/data_table.dart'; +export 'components/dialog.dart'; +export 'components/disclosure.dart'; +export 'components/divider.dart'; +export 'components/execution.dart'; +export 'components/heading.dart'; +export 'components/icon_button.dart'; +export 'components/kbd.dart'; +export 'components/link.dart'; +export 'components/menu.dart'; +export 'components/message.dart'; +export 'components/permission.dart'; +export 'components/plan.dart'; +export 'components/popover.dart'; +export 'components/progress.dart'; +export 'components/radio.dart'; +export 'components/segmented_control.dart'; +export 'components/select.dart'; +export 'components/sidebar.dart'; +export 'components/sidebar_layout.dart'; +export 'components/skeleton.dart'; +export 'components/slider.dart'; +export 'components/spinner.dart'; +export 'components/switch.dart'; +export 'components/tabs.dart'; +export 'components/text.dart'; +export 'components/textfield.dart'; +export 'components/toast.dart'; +export 'components/toggle.dart'; +export 'components/toggle_group.dart'; +export 'components/tooltip.dart'; +export 'components/transcript.dart'; +export 'components/typography.dart'; +export 'icons.dart'; +export 'models/activity_item.dart'; +export 'models/plan_item.dart'; +export 'models/statuses.dart'; +export 'recipes/activity_recipe.dart'; +export 'recipes/answer_recipe.dart'; +export 'recipes/composer_recipe.dart'; +export 'recipes/execution_recipe.dart'; +export 'recipes/message_recipe.dart'; +export 'recipes/permission_recipe.dart'; +export 'recipes/plan_recipe.dart'; +export 'recipes/transcript_recipe.dart'; +export 'theme/theme.dart'; + +// remix_cli:exports:end diff --git a/apps/dashboard/lib/widgets/action_menu.dart b/apps/dashboard/lib/widgets/action_menu.dart index 09e269218..f63f79c39 100644 --- a/apps/dashboard/lib/widgets/action_menu.dart +++ b/apps/dashboard/lib/widgets/action_menu.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; @immutable class DashboardAction { @@ -18,7 +18,7 @@ class DashboardAction { /// An example-local action menu for product controls whose trigger is richer /// than the standard label/icon surface. /// -/// The component gallery continues to use [FortalMenu] directly. This wrapper +/// The component gallery continues to use [UiMenu] directly. This wrapper /// names a repeated product concept and centralizes the width and action /// mapping for avatar, profile-row, and kebab triggers. class DashboardActionMenu extends StatelessWidget { @@ -49,10 +49,10 @@ class DashboardActionMenu extends StatelessWidget { @override Widget build(BuildContext context) { - final contentInset = FortalTokens.space1.resolve(context); + final contentInset = UiTokens.space1.resolve(context); final itemWidth = width - contentInset * 2; - return FortalMenu( + return UiMenu( size: .size1, positioning: positioning, trigger: RemixMenuTrigger.builder( diff --git a/apps/dashboard/lib/widgets/analytics_charts.dart b/apps/dashboard/lib/widgets/analytics_charts.dart index 5d4a8d97b..1b5a784ae 100644 --- a/apps/dashboard/lib/widgets/analytics_charts.dart +++ b/apps/dashboard/lib/widgets/analytics_charts.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mix_chart/mix_chart.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import 'chart_legend.dart'; import 'dashboard_chart_card.dart'; @@ -11,9 +11,9 @@ class AnalyticsCharts extends StatelessWidget { @override Widget build(BuildContext context) { - final palette = resolveFortalChartPalette(context); - final gap = MixScope.tokenOf(FortalTokens.space4, context); - final chartInset = MixScope.tokenOf(FortalTokens.space2, context); + final palette = resolveUiChartPalette(context); + final gap = MixScope.tokenOf(UiTokens.space4, context); + final chartInset = MixScope.tokenOf(UiTokens.space2, context); final slices = _channelSlices(); // Omit autoRows: Mix 1031 defaults implicit rows to content height. final GridBoxStyler gridStyle = .equalColumns(3) @@ -29,7 +29,7 @@ class AnalyticsCharts extends StatelessWidget { title: 'Revenue trend', description: 'Seven-day net revenue', chartPadding: EdgeInsets.zero, - chart: FortalLineChart( + chart: UiLineChart( palette: palette, semanticsLabel: 'Seven-day net revenue', showMarkers: true, @@ -65,7 +65,7 @@ class AnalyticsCharts extends StatelessWidget { ), ], ), - chart: FortalBarChart( + chart: UiBarChart( key: const ValueKey('overview-order-chart'), palette: palette, semanticsLabel: 'Quarterly actual and planned orders', @@ -93,7 +93,7 @@ class AnalyticsCharts extends StatelessWidget { items: percentagePieLegendItems(slices: slices, palette: palette), ), chart: PieChart( - style: fortalPieChartStyle( + style: uiPieChartStyle( palette: palette, centerRadius: 44, ).slice(PieSliceStyler().radius(36)), diff --git a/apps/dashboard/lib/widgets/app_accent_scope.dart b/apps/dashboard/lib/widgets/app_accent_scope.dart index dd5705227..28203d389 100644 --- a/apps/dashboard/lib/widgets/app_accent_scope.dart +++ b/apps/dashboard/lib/widgets/app_accent_scope.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; /// Applies an app-owned local Fortal accent without owning the surface. /// @@ -9,10 +9,10 @@ import 'package:remix_fortal/remix_fortal.dart'; class AppAccentScope extends StatelessWidget { const AppAccentScope({super.key, required this.accent, required this.child}); - final FortalAccentColor accent; + final UiAccentColor accent; final Widget child; @override Widget build(BuildContext context) => - FortalScope(accent: accent, hasBackground: false, child: child); + UiScope(accent: accent, hasBackground: false, child: child); } diff --git a/apps/dashboard/lib/widgets/chart_legend.dart b/apps/dashboard/lib/widgets/chart_legend.dart index 809908f23..843de4832 100644 --- a/apps/dashboard/lib/widgets/chart_legend.dart +++ b/apps/dashboard/lib/widgets/chart_legend.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mix_chart/mix_chart.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import 'typography.dart'; @@ -60,8 +60,8 @@ class ChartLegend extends StatelessWidget { @override Widget build(BuildContext context) { - final itemGap = MixScope.tokenOf(FortalTokens.space2, context); - final groupGap = MixScope.tokenOf(FortalTokens.space4, context); + final itemGap = MixScope.tokenOf(UiTokens.space2, context); + final groupGap = MixScope.tokenOf(UiTokens.space4, context); return Semantics( container: true, @@ -98,7 +98,7 @@ class _LegendMark extends StatelessWidget { @override Widget build(BuildContext context) { - final radius = MixScope.tokenOf(FortalTokens.radius2, context).x; + final radius = MixScope.tokenOf(UiTokens.radius2, context).x; final key = ValueKey('legend-pattern-${item.id}-${item.pattern.name}'); return switch (item.pattern) { diff --git a/apps/dashboard/lib/widgets/dashboard_chart_card.dart b/apps/dashboard/lib/widgets/dashboard_chart_card.dart index df8fa85c1..c4afe421f 100644 --- a/apps/dashboard/lib/widgets/dashboard_chart_card.dart +++ b/apps/dashboard/lib/widgets/dashboard_chart_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import 'page_header.dart'; @@ -34,11 +34,11 @@ class DashboardChartCard extends StatelessWidget { @override Widget build(BuildContext context) { - final gap = MixScope.tokenOf(FortalTokens.space4, context); - final defaultInset = MixScope.tokenOf(FortalTokens.space2, context); - final scaledPlot = _plotHeight * FortalTheme.of(context).scaling.factor; + final gap = MixScope.tokenOf(UiTokens.space4, context); + final defaultInset = MixScope.tokenOf(UiTokens.space2, context); + final scaledPlot = _plotHeight * UiTheme.of(context).scaling.factor; - return FortalCard( + return UiCard( size: .size2, child: Column( mainAxisSize: .min, diff --git a/apps/dashboard/lib/widgets/data_table_cell_text.dart b/apps/dashboard/lib/widgets/data_table_cell_text.dart index b84bc501e..b3acb46b6 100644 --- a/apps/dashboard/lib/widgets/data_table_cell_text.dart +++ b/apps/dashboard/lib/widgets/data_table_cell_text.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import 'typography.dart'; @@ -13,12 +13,12 @@ class DataTableCellText extends StatelessWidget { // Only two styles exist, and a table renders one per cell per build. static final _primary = dashboardTextLine( - FortalTextSize.size2, - weight: FortalTextWeight.medium, + UiTextSize.size2, + weight: UiTextWeight.medium, ); static final _secondary = dashboardTextLine( - FortalTextSize.size2, - weight: FortalTextWeight.regular, + UiTextSize.size2, + weight: UiTextWeight.regular, tone: TextTone.muted, ); diff --git a/apps/dashboard/lib/widgets/disclosure_trigger.dart b/apps/dashboard/lib/widgets/disclosure_trigger.dart index 1236979c0..350364802 100644 --- a/apps/dashboard/lib/widgets/disclosure_trigger.dart +++ b/apps/dashboard/lib/widgets/disclosure_trigger.dart @@ -8,7 +8,7 @@ const dashboardDisclosureAnimationStyle = AnimationStyle( /// Dashboard trigger composition shared by standalone Fortal disclosures. /// -/// `FortalDisclosure` deliberately leaves its trailing affordance to the +/// `UiDisclosure` deliberately leaves its trailing affordance to the /// caller. This widget gives every dashboard disclosure the same expanding /// chevron while preserving arbitrary trigger content. class DashboardDisclosureTrigger extends StatelessWidget { diff --git a/apps/dashboard/lib/widgets/empty_state.dart b/apps/dashboard/lib/widgets/empty_state.dart index e3b7668b9..551422350 100644 --- a/apps/dashboard/lib/widgets/empty_state.dart +++ b/apps/dashboard/lib/widgets/empty_state.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import 'typography.dart'; @@ -34,17 +34,17 @@ class EmptyState extends StatelessWidget { height: 46, alignment: .center, decoration: BoxDecoration( - color: MixScope.tokenOf(FortalTokens.gray4, context), + color: MixScope.tokenOf(UiTokens.gray4, context), shape: .circle, ), child: Icon( icon, - color: MixScope.tokenOf(FortalTokens.gray9, context), + color: MixScope.tokenOf(UiTokens.gray9, context), ), ), // An empty state sits inside a page, so it stays a level-2 // heading and only drops its visual size. - FortalHeading( + UiHeading( title, headingLevel: 2, size: .size3, diff --git a/apps/dashboard/lib/widgets/gallery_scaffold.dart b/apps/dashboard/lib/widgets/gallery_scaffold.dart index ee8c4a0d0..078bdabac 100644 --- a/apps/dashboard/lib/widgets/gallery_scaffold.dart +++ b/apps/dashboard/lib/widgets/gallery_scaffold.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../utils/text.dart'; import 'page_header.dart'; @@ -45,7 +45,7 @@ class GallerySection extends StatelessWidget { final Widget child; @override - Widget build(BuildContext context) => FortalCard( + Widget build(BuildContext context) => UiCard( size: .size2, child: Column( crossAxisAlignment: .stretch, @@ -79,8 +79,8 @@ class GalleryMatrix extends StatelessWidget { @override Widget build(BuildContext context) { - final divider = BorderSideMix(color: FortalTokens.grayA5(), width: 1); - final borderRadius = BorderRadiusMix.all(FortalTokens.radius3()); + final divider = BorderSideMix(color: UiTokens.grayA5(), width: 1); + final borderRadius = BorderRadiusMix.all(UiTokens.radius3()); final GridBoxStyler gridStyle = .columns([ const .fixed(112), for (final _ in columns) .fixed(cellWidth), diff --git a/apps/dashboard/lib/widgets/page_header.dart b/apps/dashboard/lib/widgets/page_header.dart index a5e80660c..c39cc5b05 100644 --- a/apps/dashboard/lib/widgets/page_header.dart +++ b/apps/dashboard/lib/widgets/page_header.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import 'typography.dart'; /// The title, description, and optional actions at the top of a page. /// -/// Titles are [FortalHeading] so the page publishes a real heading tree: the +/// Titles are [UiHeading] so the page publishes a real heading tree: the /// page title is level 1 and every card or section title below it is level 2. /// The visual size is chosen independently of that level, exactly as Radix /// separates `as` from `size`. @@ -32,7 +32,7 @@ class PageHeader extends StatelessWidget { crossAxisAlignment: .start, spacing: 4, children: [ - FortalHeading(title, size: .size6, weight: .bold), + UiHeading(title, size: .size6, weight: .bold), StyledText( description, style: dashboardText(.size2, tone: .muted), @@ -53,7 +53,7 @@ class SectionLabel extends StatelessWidget { @override Widget build(BuildContext context) => - FortalHeading(label, headingLevel: 2, size: .size4, weight: .medium); + UiHeading(label, headingLevel: 2, size: .size4, weight: .medium); } /// A [SectionLabel] with supporting copy beneath it. diff --git a/apps/dashboard/lib/widgets/stat_card.dart b/apps/dashboard/lib/widgets/stat_card.dart index 8ee3a419a..7db5b6185 100644 --- a/apps/dashboard/lib/widgets/stat_card.dart +++ b/apps/dashboard/lib/widgets/stat_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import 'app_accent_scope.dart'; import 'typography.dart'; @@ -41,10 +41,10 @@ class StatCard extends StatelessWidget { style: dashboardText(.size2, tone: .muted), ), ), - FortalAvatar.soft(icon: icon, size: .size2), + UiAvatar.soft(icon: icon, size: .size2), ], ), - FortalText(value, size: .size7, weight: .bold), + UiText(value, size: .size7, weight: .bold), Wrap( spacing: 8, runSpacing: 6, @@ -52,7 +52,7 @@ class StatCard extends StatelessWidget { children: [ AppAccentScope( accent: positive ? .green : .red, - child: FortalBadge( + child: UiBadge( highContrast: true, label: '${positive ? '↑' : '↓'} ${delta.abs().toStringAsFixed(1)}%', @@ -65,7 +65,7 @@ class StatCard extends StatelessWidget { ], ), if (progress case final value?) - FortalProgress( + UiProgress( value: value / 100, size: .size1, semanticsLabel: '$label progress', @@ -73,6 +73,6 @@ class StatCard extends StatelessWidget { ], ); - return FortalCard(size: .size2, child: column); + return UiCard(size: .size2, child: column); } } diff --git a/apps/dashboard/lib/widgets/status_badge.dart b/apps/dashboard/lib/widgets/status_badge.dart index ac8cfad4c..243293ee6 100644 --- a/apps/dashboard/lib/widgets/status_badge.dart +++ b/apps/dashboard/lib/widgets/status_badge.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../data/models.dart'; import '../utils/text.dart'; @@ -19,10 +19,10 @@ class StatusBadge extends StatelessWidget { key: key, label: capitalize(status.name), accent: switch (status) { - OrderStatus.paid => FortalAccentColor.green, - OrderStatus.pending => FortalAccentColor.amber, - OrderStatus.refunded => FortalAccentColor.red, - OrderStatus.cancelled => FortalAccentColor.gray, + OrderStatus.paid => UiAccentColor.green, + OrderStatus.pending => UiAccentColor.amber, + OrderStatus.refunded => UiAccentColor.red, + OrderStatus.cancelled => UiAccentColor.gray, }, ); @@ -31,18 +31,18 @@ class StatusBadge extends StatelessWidget { key: key, label: capitalize(status.name), accent: switch (status) { - CustomerStatus.active => FortalAccentColor.green, - CustomerStatus.invited => FortalAccentColor.blue, - CustomerStatus.suspended => FortalAccentColor.red, + CustomerStatus.active => UiAccentColor.green, + CustomerStatus.invited => UiAccentColor.blue, + CustomerStatus.suspended => UiAccentColor.red, }, ); final String label; - final FortalAccentColor accent; + final UiAccentColor accent; @override Widget build(BuildContext context) => AppAccentScope( accent: accent, - child: FortalBadge(highContrast: true, label: label), + child: UiBadge(highContrast: true, label: label), ); } diff --git a/apps/dashboard/lib/widgets/theme_panel.dart b/apps/dashboard/lib/widgets/theme_panel.dart index 4f5e53edc..e4e12df8f 100644 --- a/apps/dashboard/lib/widgets/theme_panel.dart +++ b/apps/dashboard/lib/widgets/theme_panel.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; import '../theme/theme_scope.dart'; import '../theme/theme_settings.dart'; @@ -24,7 +24,7 @@ class ThemePanel extends StatelessWidget { required ThemeSettings Function(T value) apply, }) => _Control( label: label, - child: FortalSegmentedControl( + child: UiSegmentedControl( size: .size1, selectedValue: selectedValue, semanticLabel: label, @@ -52,7 +52,7 @@ class ThemePanel extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ - for (final accent in FortalAccentColor.values) + for (final accent in UiAccentColor.values) _AccentSwatch( key: ValueKey('accent-${accent.name}'), accent: accent, @@ -65,11 +65,11 @@ class ThemePanel extends StatelessWidget { ), _Control( label: 'Gray color', - child: FortalSelect( + child: UiSelect( trigger: const RemixSelectTrigger(placeholder: 'Choose gray'), selectedValue: settings.grayColor, items: [ - for (final gray in FortalGrayColor.values) + for (final gray in UiGrayColor.values) RemixSelectItem(value: gray, label: enumLabel(gray)), ], onChanged: (value) { @@ -79,21 +79,21 @@ class ThemePanel extends StatelessWidget { }, ), ), - choice( + choice( label: 'Panel background', selectedValue: settings.panelBackground, - items: _enumSegmentedItems(FortalPanelBackground.values), + items: _enumSegmentedItems(UiPanelBackground.values), apply: (value) => settings.copyWith(panelBackground: value), ), Row( spacing: 10, children: [ Expanded( - child: choice( + child: choice( label: 'Radius', selectedValue: settings.radius, items: _enumSegmentedItems( - FortalRadius.values, + UiRadius.values, labelBuilder: _radiusLabel, ), apply: (value) => settings.copyWith(radius: value), @@ -103,9 +103,9 @@ class ThemePanel extends StatelessWidget { width: 28, height: 28, decoration: BoxDecoration( - color: MixScope.tokenOf(FortalTokens.accent9, context), + color: MixScope.tokenOf(UiTokens.accent9, context), borderRadius: BorderRadius.all( - MixScope.tokenOf(FortalTokens.radius3, context), + MixScope.tokenOf(UiTokens.radius3, context), ), ), ), @@ -113,11 +113,11 @@ class ThemePanel extends StatelessWidget { ), SingleChildScrollView( scrollDirection: .horizontal, - child: choice( + child: choice( label: 'Scaling', selectedValue: settings.scaling, items: _enumSegmentedItems( - FortalScaling.values, + UiScaling.values, labelBuilder: _scalingLabel, ), apply: (value) => settings.copyWith(scaling: value), @@ -125,7 +125,7 @@ class ThemePanel extends StatelessWidget { ), Align( alignment: .centerLeft, - child: FortalButton.ghost( + child: UiButton.ghost( key: const ValueKey('theme-reset'), size: .size1, onPressed: () => scope.onChanged(const ThemeSettings()), @@ -150,7 +150,7 @@ List> _enumSegmentedItems( ), ]; -String _radiusLabel(FortalRadius radius) => switch (radius) { +String _radiusLabel(UiRadius radius) => switch (radius) { .none => 'None', .small => 'S', .medium => 'M', @@ -158,8 +158,7 @@ String _radiusLabel(FortalRadius radius) => switch (radius) { .full => 'Full', }; -String _scalingLabel(FortalScaling scaling) => - '${(scaling.factor * 100).round()}%'; +String _scalingLabel(UiScaling scaling) => '${(scaling.factor * 100).round()}%'; class _Control extends StatelessWidget { const _Control({required this.label, required this.child}); @@ -171,7 +170,7 @@ class _Control extends StatelessWidget { crossAxisAlignment: .start, spacing: 8, children: [ - FortalText(label, size: .size2, weight: .medium), + UiText(label, size: .size2, weight: .medium), child, ], ); @@ -193,8 +192,8 @@ class _AccentSwatch extends StatelessWidget { }); static final _ring = ToggleStyler().border( - .color(FortalTokens.focus8()) - .width(FortalTokens.focusRingWidth()) + .color(UiTokens.focus8()) + .width(UiTokens.focusRingWidth()) .strokeAlign(BorderSide.strokeAlignOutside), ); @@ -207,18 +206,16 @@ class _AccentSwatch extends StatelessWidget { .borderRadius(.all(const Radius.circular(15))), icon: .size(15), ) - .color(FortalTokens.accent9()) + .color(UiTokens.accent9()) .iconColor(Colors.transparent) - .onHovered(ToggleStyler().color(FortalTokens.accent10())) - .onPressed(ToggleStyler().color(FortalTokens.accent10())) + .onHovered(ToggleStyler().color(UiTokens.accent10())) + .onPressed(ToggleStyler().color(UiTokens.accent10())) .onSelected( - ToggleStyler() - .iconColor(FortalTokens.accentContrast()) - .merge(_ring), + ToggleStyler().iconColor(UiTokens.accentContrast()).merge(_ring), ) .onFocusVisible(_ring); - final FortalAccentColor accent; + final UiAccentColor accent; final bool selected; final VoidCallback onPressed; diff --git a/apps/dashboard/lib/widgets/typography.dart b/apps/dashboard/lib/widgets/typography.dart index a73fc6008..a00b479f8 100644 --- a/apps/dashboard/lib/widgets/typography.dart +++ b/apps/dashboard/lib/widgets/typography.dart @@ -1,5 +1,5 @@ import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../ui/ui.dart'; /// How much attention a run of text asks for. /// @@ -9,13 +9,13 @@ import 'package:remix_fortal/remix_fortal.dart'; /// of them side by side. enum TextTone { /// `gray-12` — text the eye lands on. - strong(FortalTokens.gray12), + strong(UiTokens.gray12), /// `gray-11` — supporting copy next to [strong]. - muted(FortalTokens.gray11), + muted(UiTokens.gray11), /// `gray-10` — metadata that should recede even beside [muted]. - subtle(FortalTokens.gray10); + subtle(UiTokens.gray10); const TextTone(this._color); final ColorToken _color; @@ -23,26 +23,26 @@ enum TextTone { /// Dashboard body text at a Fortal [size]. /// -/// This starts from `fortalTextStyle()` so the scale, weights, and flow +/// This starts from `uiTextStyle()` so the scale, weights, and flow /// behaviour stay Fortal's; the only thing layered on top is the neutral tone, /// which is the one opinion Fortal does not ship. Text that wants [strong] and -/// nothing else should use [FortalText] directly rather than this helper. +/// nothing else should use [UiText] directly rather than this helper. /// /// Callers that need more than a weight or tone change chain onto the result /// rather than reintroducing an inline `TextStyler`. TextStyler dashboardText( - FortalTextSize size, { - FortalTextWeight? weight, + UiTextSize size, { + UiTextWeight? weight, TextTone tone = TextTone.strong, -}) => fortalTextStyle(size: size, weight: weight).color(tone._color()); +}) => uiTextStyle(size: size, weight: weight).color(tone._color()); /// Single-line [dashboardText] that ellipsizes, for text sharing a row with a /// fixed-width neighbour. TextStyler dashboardTextLine( - FortalTextSize size, { - FortalTextWeight? weight, + UiTextSize size, { + UiTextWeight? weight, TextTone tone = TextTone.strong, -}) => fortalTextStyle( +}) => uiTextStyle( size: size, weight: weight, truncate: true, diff --git a/apps/dashboard/pubspec.yaml b/apps/dashboard/pubspec.yaml index 3caae1f69..ba9276dc2 100644 --- a/apps/dashboard/pubspec.yaml +++ b/apps/dashboard/pubspec.yaml @@ -13,13 +13,16 @@ dependencies: sdk: flutter # Workspace resolution uses the local packages during development; these # hosted constraints keep the app manifest deployable outside the workspace. + mix_annotations: ^2.2.0-beta.1 mix_chart: ^0.0.1-beta.1 remix: ^1.0.0-beta.10 - remix_fortal: ^1.0.0-beta.9 + remix_ui_icons: ^0.1.0 dev_dependencies: + build_runner: ^2.10.1 flutter_test: sdk: flutter + mix_generator: ^2.2.0-beta.3 flutter: uses-material-design: true diff --git a/packages/remix_agent/example/remix.yaml b/apps/dashboard/remix.yaml similarity index 71% rename from packages/remix_agent/example/remix.yaml rename to apps/dashboard/remix.yaml index a78db1ab1..aefbe6f44 100644 --- a/packages/remix_agent/example/remix.yaml +++ b/apps/dashboard/remix.yaml @@ -1,5 +1,5 @@ schema: 2 prefix: Ui -preset: default +preset: fortal paths: ui: lib/ui diff --git a/apps/dashboard/test/agent_recipe_contract_test.dart b/apps/dashboard/test/agent_recipe_contract_test.dart new file mode 100644 index 000000000..c5c25a04a --- /dev/null +++ b/apps/dashboard/test/agent_recipe_contract_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; +import 'package:dashboard/ui/ui.dart'; + +void main() { + testWidgets('answer copy control resolves its own hover override', ( + tester, + ) async { + const idle = Color(0xFF123456); + const hovered = Color(0xFFABCDEF); + final recipe = uiAgentAnswerRecipe( + copyStyle: IconButtonStyler() + .color(idle) + .onHovered(IconButtonStyler().color(hovered)), + ); + final answer = UiAnswer( + status: UiAnswerStatus.complete, + style: recipe.style, + surfaceStyle: recipe.surfaceStyle, + copyStyle: recipe.copyStyle, + onCopy: () {}, + child: const Text('Local answer'), + ); + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFFFFFFFF), + builder: (_, _) => UiScope(child: answer), + ), + ); + final copy = find.byWidgetPredicate( + (widget) => + widget is RemixIconButton && widget.semanticLabel == 'Copy answer', + ); + Iterable colors() => tester + .widgetList( + find.descendant(of: copy, matching: find.byType(DecoratedBox)), + ) + .map( + (box) => box.decoration is BoxDecoration + ? (box.decoration as BoxDecoration).color + : null, + ); + expect(colors(), contains(idle)); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(copy)); + await tester.pumpAndSettle(); + expect(colors(), contains(hovered)); + expect(colors(), isNot(contains(idle))); + }); + + testWidgets('composer recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = uiAgentComposerRecipe( + style: UiComposerStyler(toolbar: FlexBoxStyler().spacing(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.toolbar.spec.flex?.spec.spacing, 37); + }); + testWidgets('message recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = uiAgentMessageRecipe(style: UiMessageStyler(maxWidth: 37)); + final result = await _resolve(tester, recipe.style); + expect(result.spec.maxWidth, 37); + }); + testWidgets('answer recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = uiAgentAnswerRecipe( + style: UiAnswerStyler(actions: FlexBoxStyler().spacing(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.actions.spec.flex?.spec.spacing, 37); + }); + testWidgets('execution recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = uiAgentExecutionRecipe( + style: UiExecutionStyler(header: FlexBoxStyler().spacing(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.header.spec.flex?.spec.spacing, 37); + }); + testWidgets('permission recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = uiAgentPermissionRecipe( + style: UiPermissionStyler(actions: FlexBoxStyler().spacing(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.actions.spec.flex?.spec.spacing, 37); + }); + testWidgets('plan recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = uiAgentPlanRecipe( + style: UiPlanStyler(viewport: BoxStyler().maxHeight(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.viewport.spec.constraints?.maxHeight, 37); + }); + testWidgets('activity recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = uiAgentActivityRecipe( + style: UiActivityStyler(viewport: BoxStyler().maxHeight(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.viewport.spec.constraints?.maxHeight, 37); + }); + testWidgets('transcript recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = uiAgentTranscriptRecipe( + style: UiTranscriptStyler(spacing: 37), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.spacing, 37); + }); +} + +Future> _resolve>( + WidgetTester tester, + Style style, +) async { + late StyleSpec result; + final child = Builder( + builder: (context) { + result = style.build(context); + return const SizedBox.shrink(); + }, + ); + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFFFFFFFF), + builder: (_, _) => UiScope(child: child), + ), + ); + return result; +} diff --git a/apps/dashboard/test/app_smoke_test.dart b/apps/dashboard/test/app_smoke_test.dart index 7ccdd25d9..4c8a2e61c 100644 --- a/apps/dashboard/test/app_smoke_test.dart +++ b/apps/dashboard/test/app_smoke_test.dart @@ -15,17 +15,16 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mix_chart/mix_chart.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:dashboard/ui/ui.dart'; /// Whether the shell's compact navigation sheet is currently open. /// -/// `TopBar` sits inside `FortalSidebarLayout`'s `header` slot in both the +/// `TopBar` sits inside `UiSidebarLayout`'s `header` slot in both the /// wide and compact presentations, so its element is always a descendant of /// the scope the layout re-provides — unlike `DashboardShell`'s own element, /// which sits above the layout it returns. -bool _isCompactSheetOpen(WidgetTester tester) => FortalSidebarLayoutScope.of( - tester.element(find.byType(TopBar)), -).isCompactOpen; +bool _isCompactSheetOpen(WidgetTester tester) => + UiSidebarLayoutScope.of(tester.element(find.byType(TopBar))).isCompactOpen; void main() { testWidgets('renders the dashboard inside one app and one shell', ( @@ -35,10 +34,10 @@ void main() { expect(find.byType(MaterialApp), findsOneWidget); // The shell replaced Material's Scaffold/Drawer with the open-code - // FortalSidebarLayout template; see `compact layout uses a sheet + // UiSidebarLayout template; see `compact layout uses a sheet // without rendering overflows` below for its compact-sheet behavior. expect(find.byType(Scaffold), findsNothing); - expect(find.byType(FortalSidebarLayout), findsOneWidget); + expect(find.byType(UiSidebarLayout), findsOneWidget); expect( find.byKey(const ValueKey('dashboard-fortal-scope')), findsOneWidget, @@ -325,7 +324,7 @@ void main() { await tester.tap(find.byKey(const ValueKey(DashboardPage.customers)).first); await tester.pump(); - // FortalDataTable forwards the key to the Remix widget it builds, so the + // UiDataTable forwards the key to the Remix widget it builds, so the // key matches both. expect(find.byKey(const ValueKey('data-grid-customers')), findsWidgets); expect(find.text('1–10 of 24'), findsOneWidget); @@ -340,8 +339,8 @@ void main() { expect(find.text('Revenue trend'), findsOneWidget); expect(find.text('Order volume'), findsOneWidget); expect(find.text('Channel mix'), findsOneWidget); - expect(find.byType(FortalLineChart), findsOneWidget); - expect(find.byType(FortalBarChart), findsOneWidget); + expect(find.byType(UiLineChart), findsOneWidget); + expect(find.byType(UiBarChart), findsOneWidget); expect(find.byType(PieChart), findsOneWidget); expect(find.text('Recent activity'), findsOneWidget); expect(find.text('Recent orders'), findsOneWidget); @@ -403,10 +402,8 @@ void main() { await tester.pump(); expect( - FortalTheme.of( - tester.element(find.byType(DashboardShell)), - ).panelBackground, - FortalPanelBackground.translucent, + UiTheme.of(tester.element(find.byType(DashboardShell))).panelBackground, + UiPanelBackground.translucent, ); }); @@ -419,8 +416,8 @@ void main() { await tester.tap(nav); await tester.pump(); - expect(find.byType(FortalTextArea), findsWidgets); - expect(find.byType(FortalSegmentedControl), findsWidgets); + expect(find.byType(UiTextArea), findsWidgets); + expect(find.byType(UiSegmentedControl), findsWidgets); expect(find.byType(RemixCheckboxGroupItem), findsNWidgets(3)); expect(find.text('Labelled'), findsOneWidget); }); @@ -434,8 +431,8 @@ void main() { await tester.tap(nav); await tester.pump(); - expect(find.byType(FortalDataList), findsWidgets); - expect(find.byType(FortalSkeleton), findsNWidgets(2)); + expect(find.byType(UiDataList), findsWidgets); + expect(find.byType(UiSkeleton), findsNWidgets(2)); final showContent = find.text('Show content'); await tester.ensureVisible(showContent); @@ -459,8 +456,7 @@ void main() { await tester.pump(); final largestAvatars = find.byWidgetPredicate( - (widget) => - widget is FortalAvatar && widget.size == FortalAvatarSize.size9, + (widget) => widget is UiAvatar && widget.size == UiAvatarSize.size9, ); expect(largestAvatars, findsNWidgets(2)); for (var index = 0; index < 2; index++) { @@ -588,7 +584,7 @@ void main() { .first; expect(tester.getSize(overlay).width, 180); expect(tester.getSize(overlay).height, lessThan(180)); - final contentInset = FortalTokens.space1.resolve( + final contentInset = UiTokens.space1.resolve( tester.element(find.text('View profile')), ); expect( @@ -687,9 +683,11 @@ void main() { ) async { await tester.pumpWidget(const DashboardApp()); - await tester.tap( - find.byKey(const ValueKey(DashboardPage.galleryActions)).first, - ); + final actions = find + .byKey(const ValueKey(DashboardPage.galleryActions)) + .first; + await tester.ensureVisible(actions); + await tester.tap(actions); await tester.pump(); expect(find.text('Button'), findsWidgets); @@ -715,7 +713,7 @@ void main() { expect(items.spacing, 8); expect(items.children, hasLength(2)); - expect(items.children, everyElement(isA>())); + expect(items.children, everyElement(isA>())); }); testWidgets('navigation gallery disclosure collapses independently', ( @@ -732,10 +730,10 @@ void main() { final gallery = find.byType(GalleryNavigationPage); final disclosureFinder = find.descendant( of: gallery, - matching: find.byType(FortalDisclosure), + matching: find.byType(UiDisclosure), ); final disclosureCount = tester - .widgetList(disclosureFinder) + .widgetList(disclosureFinder) .length; final panelCopy = find.descendant( @@ -769,12 +767,9 @@ void main() { final gallery = find.byType(GalleryNavigationPage); final example = find.descendant( of: gallery, - matching: find.byType(FortalSidebar), - ); - expect( - tester.widget>(example).selectedValue, - 'overview', + matching: find.byType(UiSidebar), ); + expect(tester.widget>(example).selectedValue, 'overview'); final activity = find.descendant( of: gallery, @@ -784,10 +779,7 @@ void main() { await tester.tap(activity); await tester.pump(); - expect( - tester.widget>(example).selectedValue, - 'activity', - ); + expect(tester.widget>(example).selectedValue, 'activity'); }); testWidgets('every sidebar destination renders without replacing the shell', ( @@ -835,17 +827,17 @@ void main() { await tester.pumpWidget(const DashboardApp()); final shell = tester.element(find.byType(DashboardShell)); - expect(Theme.of(shell).brightness, FortalTheme.of(shell).brightness); - final before = FortalTheme.of(shell).isDark; + expect(Theme.of(shell).brightness, UiTheme.of(shell).brightness); + final before = UiTheme.of(shell).isDark; await tester.tap(find.byKey(const ValueKey('theme-quick-toggle')).first); await tester.pump(); final updatedShell = tester.element(find.byType(DashboardShell)); - expect(FortalTheme.of(updatedShell).isDark, isNot(before)); + expect(UiTheme.of(updatedShell).isDark, isNot(before)); expect( Theme.of(updatedShell).brightness, - FortalTheme.of(updatedShell).brightness, + UiTheme.of(updatedShell).brightness, ); }, ); @@ -856,7 +848,7 @@ void main() { ); final shell = tester.element(find.byType(DashboardShell)); - expect(FortalTheme.of(shell).isDark, isTrue); + expect(UiTheme.of(shell).isDark, isTrue); expect(Theme.of(shell).brightness, Brightness.dark); }); @@ -871,7 +863,7 @@ void main() { await tester.pump(); final shell = tester.element(find.byType(DashboardShell)); - expect(FortalTheme.of(shell).accent, FortalAccentColor.grass); + expect(UiTheme.of(shell).accent, UiAccentColor.grass); }); testWidgets('theme swatch centers its selected checkmark', (tester) async { @@ -1017,12 +1009,12 @@ void main() { // The nine-step scale, four weights, and every Code and Kbd variant are on // the page at once, so each family appears many times over. - expect(find.byType(FortalText), findsWidgets); - expect(find.byType(FortalHeading), findsWidgets); - expect(find.byType(FortalCode), findsWidgets); - expect(find.byType(FortalKbd), findsWidgets); - expect(find.byType(FortalLink), findsWidgets); - for (final underline in FortalLinkUnderline.values) { + expect(find.byType(UiText), findsWidgets); + expect(find.byType(UiHeading), findsWidgets); + expect(find.byType(UiCode), findsWidgets); + expect(find.byType(UiKbd), findsWidgets); + expect(find.byType(UiLink), findsWidgets); + for (final underline in UiLinkUnderline.values) { expect(find.text(enumLabel(underline)), findsOneWidget); } expect(find.text('Disabled'), findsOneWidget); @@ -1077,9 +1069,11 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); } - await tester.tap( - find.byKey(const ValueKey(DashboardPage.galleryTypography)).first, - ); + final typography = find + .byKey(const ValueKey(DashboardPage.galleryTypography)) + .first; + await tester.ensureVisible(typography); + await tester.tap(typography); for (var frame = 0; frame < 5; frame++) { await tester.pump(const Duration(milliseconds: 100)); } @@ -1135,7 +1129,7 @@ void main() { Color painted(Finder finder) => tester.renderObject(finder).text.style!.color!; Color gray12() => MixScope.tokenOf( - FortalTokens.gray12, + UiTokens.gray12, tester.element(find.byType(DashboardShell)), ); @@ -1145,7 +1139,7 @@ void main() { final themeScope = tester.widget(find.byType(ThemeScope)); themeScope.onChanged( - themeScope.settings.copyWith(grayColor: FortalGrayColor.mauve), + themeScope.settings.copyWith(grayColor: UiGrayColor.mauve), ); await tester.pump(); @@ -1176,7 +1170,7 @@ void main() { expect( tester.renderObject(brand).text.style!.color, MixScope.tokenOf( - FortalTokens.gray12, + UiTokens.gray12, tester.element(find.byType(DashboardShell)), ), ); @@ -1206,7 +1200,7 @@ void main() { /// The bounds of the dialog panel itself. /// -/// `FortalDialog` wraps its surface in padding and align modifiers that carry +/// `UiDialog` wraps its surface in padding and align modifiers that carry /// the safe viewport insets, so the widget's own rect is the whole viewport. /// The panel is the align's child, which is what the size and centring /// assertions are about. diff --git a/apps/dashboard/test/chart_gallery_test.dart b/apps/dashboard/test/chart_gallery_test.dart index 6506170ce..353921e9d 100644 --- a/apps/dashboard/test/chart_gallery_test.dart +++ b/apps/dashboard/test/chart_gallery_test.dart @@ -6,7 +6,7 @@ import 'package:dashboard/widgets/analytics_charts.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mix_chart/mix_chart.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:dashboard/ui/ui.dart'; void main() { testWidgets('charts destination presents every Fortal chart family', ( @@ -32,10 +32,10 @@ void main() { ), findsOneWidget, ); - expect(find.byType(FortalLineChart), findsNWidgets(4)); - expect(find.byType(FortalBarChart), findsNWidgets(4)); + expect(find.byType(UiLineChart), findsNWidgets(4)); + expect(find.byType(UiBarChart), findsNWidgets(4)); expect(find.byType(PieChart), findsNWidgets(4)); - expect(find.byType(FortalPieChart), findsOneWidget); + expect(find.byType(UiPieChart), findsOneWidget); for (final title in const [ 'Revenue momentum', @@ -69,15 +69,15 @@ void main() { final page = find.byKey(const ValueKey('charts-page')); final context = tester.element(page); - final palette = resolveFortalChartPalette(context); + final palette = resolveUiChartPalette(context); - for (final chart in tester.widgetList( - find.byType(FortalLineChart), + for (final chart in tester.widgetList( + find.byType(UiLineChart), )) { expect(chart.palette, palette); } - for (final chart in tester.widgetList( - find.byType(FortalBarChart), + for (final chart in tester.widgetList( + find.byType(UiBarChart), )) { expect(chart.palette, palette); } @@ -86,12 +86,12 @@ void main() { } final quantitativeAxes = [ - for (final chart in tester.widgetList( - find.byType(FortalLineChart), + for (final chart in tester.widgetList( + find.byType(UiLineChart), )) ?chart.yAxis, - for (final chart in tester.widgetList( - find.byType(FortalBarChart), + for (final chart in tester.widgetList( + find.byType(UiBarChart), )) ?chart.yAxis, ]; @@ -100,10 +100,10 @@ void main() { expect(tickCount, closeTo(tickCount.roundToDouble(), 0.0001)); } - final comparison = tester.widget( + final comparison = tester.widget( find.byWidgetPredicate( (widget) => - widget is FortalLineChart && + widget is UiLineChart && widget.key == const ValueKey('charts-line-patterns'), ), ); @@ -111,10 +111,10 @@ void main() { expect(planLine.stroke!.spec.dashArray, [6, 4]); expect(planLine.marker!.spec.shape, ChartMarkerShape.square); - final grouped = tester.widget( + final grouped = tester.widget( find.byWidgetPredicate( (widget) => - widget is FortalBarChart && + widget is UiBarChart && widget.key == const ValueKey('charts-bar-grouped'), ), ); @@ -256,10 +256,10 @@ void main() { ) async { await _pumpCompactCharts(tester); - final chart = tester.widget( + final chart = tester.widget( find.byWidgetPredicate( (widget) => - widget is FortalBarChart && + widget is UiBarChart && widget.semanticsLabel == 'Monthly floating inventory changes', ), ); @@ -286,17 +286,17 @@ void main() { final viewportChart = find.byWidgetPredicate( (widget) => - widget is FortalLineChart && + widget is UiLineChart && widget.semanticsLabel == 'Revenue chart with scalable horizontal viewport', ); final badges = find.descendant( of: viewportChart, - matching: find.byType(FortalBadge), + matching: find.byType(UiBadge), ); expect(badges, findsWidgets); - for (final badge in tester.widgetList(badges)) { - expect(badge.size, FortalBadgeSize.size1); + for (final badge in tester.widgetList(badges)) { + expect(badge.size, UiBadgeSize.size1); } final rects = [ @@ -323,7 +323,7 @@ void main() { Future> pumpAt(double width) async { await tester.pumpWidget( - FortalScope( + UiScope( child: MaterialApp( home: Align( alignment: Alignment.topLeft, @@ -361,13 +361,11 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const FortalScope(child: MaterialApp(home: ChartsPage())), + const UiScope(child: MaterialApp(home: ChartsPage())), ); Rect card(String title) => tester.getRect( - find - .ancestor(of: find.text(title), matching: find.byType(FortalCard)) - .first, + find.ancestor(of: find.text(title), matching: find.byType(UiCard)).first, ); final momentum = card('Revenue momentum'); diff --git a/apps/dashboard/test/chat_page_test.dart b/apps/dashboard/test/chat_page_test.dart new file mode 100644 index 000000000..653e6ef2c --- /dev/null +++ b/apps/dashboard/test/chat_page_test.dart @@ -0,0 +1,316 @@ +import 'package:dashboard/main.dart'; +import 'package:dashboard/pages/chat_page.dart'; +import 'package:dashboard/theme/theme_scope.dart'; +import 'package:dashboard/ui/ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; + +void main() { + testWidgets('copy matches the visible stopped answer and execution output', ( + tester, + ) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.text('Chat').first); + await tester.pump(); + await tester.tap(find.text('Run terminal checks')); + await tester.pump(const Duration(milliseconds: 350)); + await tester.tap(find.bySemanticsLabel('Stop')); + await tester.pump(); + await tester.pump(); + final copy = find.bySemanticsLabel('Copy answer'); + await tester.ensureVisible(copy); + await tester.tap(copy); + await tester.pump(); + expect(copied, 'Stopped before running the tool. No tool ran.'); + await tester.tap(find.text('Review checkout')); + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 350)); + } + await tester.scrollUntilVisible( + find.text('Focused checks'), + -200, + scrollable: find + .descendant( + of: find.byType(UiTranscript), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.tap(find.text('Focused checks')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + final copyOutput = find.byWidgetPredicate( + (w) => w is RemixIconButton && w.semanticLabel == 'Copy output', + ); + await tester.ensureVisible(copyOutput); + await tester.tap(copyOutput); + await tester.pump(); + expect( + copied, + '\$ flutter test\nI inspected the checkout flow. The cart state is shared correctly, and the focused checks pass. The flow is ready for review.', + ); + }); + + testWidgets('return to latest does not animate with reduced motion', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(disableAnimations: true); + addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue); + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.text('Chat').first); + await tester.pump(); + await tester.tap(find.text('Review checkout')); + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 350)); + } + await tester.pump(); + final transcript = find.byType(UiTranscript); + final scrollable = find + .descendant(of: transcript, matching: find.byType(Scrollable)) + .first; + final position = tester.state(scrollable).position; + expect(position.maxScrollExtent, greaterThan(0)); + await tester.drag(transcript, const Offset(0, 300)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Return to latest')); + expect(position.isScrollingNotifier.value, isFalse); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('retry after denial preserves the permission scenario', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.text('Chat').first); + await tester.pump(); + await tester.tap(find.text('Run terminal checks')); + await tester.pump(const Duration(milliseconds: 350)); + final firstRequest = tester + .widget(find.byType(UiPermission)) + .requestId; + await tester.tap(find.text('Deny')); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); + final retry = find.byWidgetPredicate( + (widget) => + widget is RemixIconButton && widget.semanticLabel == 'Retry answer', + ); + await tester.ensureVisible(retry); + await tester.tap(retry); + await tester.pump(const Duration(milliseconds: 350)); + final permission = tester.widget(find.byType(UiPermission)); + expect(permission.status, UiPermissionStatus.pending); + expect(permission.requestId, isNot(firstRequest)); + expect(find.byType(UiExecution), findsNothing); + }); + + testWidgets('responsive breakpoint changes preserve pending permission', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.text('Chat').first); + await tester.pump(); + await tester.tap(find.text('Run terminal checks')); + await tester.pump(const Duration(milliseconds: 350)); + final requestId = tester + .widget(find.byType(UiPermission)) + .requestId; + for (final width in [390.0, 1280.0]) { + tester.view.physicalSize = Size(width, 900); + await tester.pump(); + await tester.pump(); + final permission = tester.widget(find.byType(UiPermission)); + expect(permission.requestId, requestId); + expect(permission.status, UiPermissionStatus.pending); + expect(tester.takeException(), isNull); + } + tester.platformDispatcher.textScaleFactorTestValue = 1.5; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + tester.view.physicalSize = const Size(390, 900); + await tester.pump(); + await tester.pump(); + expect( + tester.widget(find.byType(UiPermission)).requestId, + requestId, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('live theme changes preserve the paused conversation', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.text('Chat').first); + await tester.pump(); + await tester.tap(find.text('Run terminal checks')); + await tester.pump(const Duration(milliseconds: 350)); + final requestId = tester + .widget(find.byType(UiPermission)) + .requestId; + final theme = ThemeScope.of(tester.element(find.byType(ChatPage))); + theme.onChanged( + theme.settings.copyWith( + appearance: ThemeMode.dark, + accentColor: .jade, + grayColor: .sage, + panelBackground: .translucent, + radius: .large, + scaling: .percent110, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + final scope = tester.widget(find.byType(UiScope)); + expect(scope.brightness, Brightness.dark); + expect(scope.accent, UiAccentColor.jade); + expect(scope.gray, UiGrayColor.sage); + expect(scope.panelBackground, UiPanelBackground.translucent); + expect(scope.radius, UiRadius.large); + expect(scope.scaling, UiScaling.percent110); + final permission = tester.widget(find.byType(UiPermission)); + expect(permission.requestId, requestId); + expect(permission.status, UiPermissionStatus.pending); + expect(tester.takeException(), isNull); + }); + + testWidgets('Stop works during permission and reset cancels pending work', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.text('Chat').first); + await tester.pump(); + await tester.tap(find.text('Run terminal checks')); + await tester.pump(const Duration(milliseconds: 350)); + final stop = find.byWidgetPredicate( + (widget) => widget is RemixIconButton && widget.semanticLabel == 'Stop', + ); + await tester.tap(stop); + await tester.pump(); + // The transcript follows the newly inserted stopped outcome after layout. + await tester.pump(); + expect( + find.text('Stopped before running the tool. No tool ran.'), + findsOneWidget, + ); + expect(find.byType(UiExecution), findsNothing); + expect(find.byType(UiPermission), findsNothing); + await tester.tap(find.text('New chat')); + await tester.pump(); + await tester.tap(find.text('Review checkout')); + await tester.pump(); + await tester.tap(find.text('New chat')); + await tester.pump(const Duration(seconds: 3)); + expect(find.text('Choose a starter or write a message.'), findsOneWidget); + expect(find.textContaining('I inspected'), findsNothing); + }); + + testWidgets('chat runs permission flow and survives dashboard navigation', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.text('Chat').first); + await tester.pump(); + + expect(find.text('Interactive demo'), findsOneWidget); + await tester.tap(find.text('Run terminal checks')); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.text('Allow once'), findsOneWidget); + final stopButton = tester.widget( + find.byWidgetPredicate( + (widget) => widget is RemixIconButton && widget.semanticLabel == 'Stop', + ), + ); + expect(stopButton.enabled, isTrue); + expect(stopButton.onPressed, isNotNull); + + await tester.tap(find.text('Allow once')); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.textContaining('I inspected'), findsWidgets); + + await tester.tap(find.text('Overview').first); + await tester.pump(); + await tester.tap(find.text('Chat').first); + await tester.pump(); + expect(find.text('Agent chat'), findsOneWidget); + + for (var i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 350)); + } + expect(find.textContaining('ready for review'), findsWidgets); + }); + + testWidgets('chat failure can retry through a fresh successful attempt', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const DashboardApp()); + await tester.tap(find.text('Chat').first); + await tester.pump(); + await tester.tap(find.text('Recover a failed command')); + for (var i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 350)); + } + + expect(find.textContaining('simulated command failed'), findsWidgets); + // Following runs after layout; let the lazy transcript build its new tail. + await tester.pump(); + final retry = find.byWidgetPredicate( + (widget) => + widget is RemixIconButton && widget.semanticLabel == 'Retry answer', + ); + expect(retry, findsOneWidget); + await tester.ensureVisible(retry); + await tester.tap(retry); + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 350)); + } + expect(find.textContaining('ready for review'), findsWidgets); + }); +} diff --git a/apps/dashboard/test/dashboard_layout_test.dart b/apps/dashboard/test/dashboard_layout_test.dart index 71e85c3b4..fcc74df7c 100644 --- a/apps/dashboard/test/dashboard_layout_test.dart +++ b/apps/dashboard/test/dashboard_layout_test.dart @@ -10,7 +10,7 @@ import 'package:dashboard/widgets/action_menu.dart'; import 'package:dashboard/widgets/analytics_charts.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:dashboard/ui/ui.dart'; void main() { testWidgets('shell switches to the sheet strictly below 720 pixels', ( @@ -20,12 +20,11 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - // `TopBar` sits inside `FortalSidebarLayout`'s `header` slot in both + // `TopBar` sits inside `UiSidebarLayout`'s `header` slot in both // presentations, so it is always a descendant of the scope the layout // re-provides. - bool isCompact() => FortalSidebarLayoutScope.of( - tester.element(find.byType(TopBar)), - ).isCompact; + bool isCompact() => + UiSidebarLayoutScope.of(tester.element(find.byType(TopBar))).isCompact; tester.view.physicalSize = const Size(720, 800); await tester.pumpWidget(const DashboardApp()); @@ -154,10 +153,7 @@ void main() { ) async { Rect field(String label) => tester.getRect( find - .ancestor( - of: find.text(label), - matching: find.byType(FortalTextField), - ) + .ancestor(of: find.text(label), matching: find.byType(UiTextField)) .first, ); @@ -169,7 +165,7 @@ void main() { ThemeScope( settings: const ThemeSettings(), onChanged: (_) {}, - child: const FortalScope(child: MaterialApp(home: SettingsPage())), + child: const UiScope(child: MaterialApp(home: SettingsPage())), ), ); final wideName = field('Name'); @@ -182,7 +178,7 @@ void main() { ThemeScope( settings: const ThemeSettings(), onChanged: (_) {}, - child: const FortalScope(child: MaterialApp(home: SettingsPage())), + child: const UiScope(child: MaterialApp(home: SettingsPage())), ), ); final stackedName = field('Name'); @@ -202,7 +198,7 @@ void main() { Future> pumpAt(double width) async { await tester.pumpWidget( - FortalScope( + UiScope( child: MaterialApp( home: Align( alignment: Alignment.topLeft, @@ -243,7 +239,7 @@ Future> _pumpOverview( addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - FortalScope( + UiScope( child: MaterialApp(home: OverviewPage(onViewOrders: () {})), ), ); @@ -257,6 +253,6 @@ Future> _pumpOverview( Rect _cardAround(WidgetTester tester, Finder label) { return tester.getRect( - find.ancestor(of: label, matching: find.byType(FortalCard)).first, + find.ancestor(of: label, matching: find.byType(UiCard)).first, ); } diff --git a/apps/dashboard/test/reference_contract_test.dart b/apps/dashboard/test/reference_contract_test.dart index 4945d74eb..2cc62ba9e 100644 --- a/apps/dashboard/test/reference_contract_test.dart +++ b/apps/dashboard/test/reference_contract_test.dart @@ -17,7 +17,7 @@ import 'package:dashboard/widgets/theme_panel.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:dashboard/ui/ui.dart'; void main() { group('gallery preset contracts', () { @@ -46,31 +46,27 @@ void main() { ) async { await _pumpPage(tester, const GalleryActionsPage()); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Button'), - rows: FortalButtonVariant.values, - columns: FortalButtonSize.values, + rows: UiButtonVariant.values, + columns: UiButtonSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian< - FortalIconButton, - FortalIconButtonVariant, - FortalIconButtonSize - >( + _expectCartesian( tester, within: _sectionChild(tester, 'Icon button'), - rows: FortalIconButtonVariant.values, - columns: FortalIconButtonSize.values, + rows: UiIconButtonVariant.values, + columns: UiIconButtonSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Toggle'), - rows: FortalToggleVariant.values, - columns: FortalToggleSize.values, + rows: UiToggleVariant.values, + columns: UiToggleSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); @@ -81,99 +77,83 @@ void main() { ) async { await _pumpPage(tester, const GalleryFormsPage()); - _expectCartesian< - FortalTextField, - FortalTextFieldVariant, - FortalTextFieldSize - >( + _expectCartesian( tester, within: _sectionChild(tester, 'Text field'), - rows: FortalTextFieldVariant.values, - columns: FortalTextFieldSize.values, + rows: UiTextFieldVariant.values, + columns: UiTextFieldSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian< - FortalTextArea, - FortalTextAreaVariant, - FortalTextAreaSize - >( + _expectCartesian( tester, within: _sectionChild(tester, 'Text area'), - rows: FortalTextAreaVariant.values, - columns: FortalTextAreaSize.values, + rows: UiTextAreaVariant.values, + columns: UiTextAreaSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); _expectCartesian< - FortalSegmentedControl, - FortalSegmentedControlVariant, - FortalSegmentedControlSize + UiSegmentedControl, + UiSegmentedControlVariant, + UiSegmentedControlSize >( tester, within: _sectionChild(tester, 'Segmented control'), - rows: FortalSegmentedControlVariant.values, - columns: FortalSegmentedControlSize.values, + rows: UiSegmentedControlVariant.values, + columns: UiSegmentedControlSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian< - FortalSelect, - FortalSelectVariant, - FortalSelectSize - >( + _expectCartesian, UiSelectVariant, UiSelectSize>( tester, within: _sectionChild(tester, 'Select'), - rows: FortalSelectVariant.values, - columns: FortalSelectSize.values, + rows: UiSelectVariant.values, + columns: UiSelectSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); _expectCartesian< - FortalToggleGroup, - FortalToggleGroupVariant, - FortalToggleGroupSize + UiToggleGroup, + UiToggleGroupVariant, + UiToggleGroupSize >( tester, within: _sectionChild(tester, 'Toggle group'), - rows: FortalToggleGroupVariant.values, - columns: FortalToggleGroupSize.values, + rows: UiToggleGroupVariant.values, + columns: UiToggleGroupSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian< - FortalCheckbox, - FortalCheckboxVariant, - FortalCheckboxSize - >( + _expectCartesian( tester, within: _sectionChild(tester, 'Checkbox'), - rows: FortalCheckboxVariant.values, - columns: FortalCheckboxSize.values, + rows: UiCheckboxVariant.values, + columns: UiCheckboxSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian, FortalRadioVariant, FortalRadioSize>( + _expectCartesian, UiRadioVariant, UiRadioSize>( tester, within: _sectionChild(tester, 'Radio'), - rows: FortalRadioVariant.values, - columns: FortalRadioSize.values, + rows: UiRadioVariant.values, + columns: UiRadioSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Switch'), - rows: FortalSwitchVariant.values, - columns: FortalSwitchSize.values, + rows: UiSwitchVariant.values, + columns: UiSwitchSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Slider'), - rows: FortalSliderVariant.values, - columns: FortalSliderSize.values, + rows: UiSliderVariant.values, + columns: UiSliderSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); @@ -184,55 +164,51 @@ void main() { ) async { await _pumpPage(tester, const GalleryDisplayPage()); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Avatar'), - rows: FortalAvatarVariant.values, - columns: FortalAvatarSize.values, + rows: UiAvatarVariant.values, + columns: UiAvatarSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Badge'), - rows: FortalBadgeVariant.values, - columns: FortalBadgeSize.values, + rows: UiBadgeVariant.values, + columns: UiBadgeSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Card'), - rows: FortalCardVariant.values, - columns: FortalCardSize.values, + rows: UiCardVariant.values, + columns: UiCardSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Callout'), - rows: FortalCalloutVariant.values, - columns: FortalCalloutSize.values, + rows: UiCalloutVariant.values, + columns: UiCalloutSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian< - FortalProgress, - FortalProgressVariant, - FortalProgressSize - >( + _expectCartesian( tester, within: _sectionChild(tester, 'Progress'), - rows: FortalProgressVariant.values, - columns: FortalProgressSize.values, + rows: UiProgressVariant.values, + columns: UiProgressSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Data list'), rows: Axis.values, - columns: FortalDataListSize.values, + columns: UiDataListSize.values, rowOf: (widget) => widget.orientation, columnOf: (widget) => widget.size, ); @@ -244,81 +220,77 @@ void main() { await _pumpPage(tester, const GalleryNavigationPage()); final sidebarSection = _sectionChild(tester, 'Sidebar'); - final sidebar = tester.widget>( - _within>(sidebarSection), + final sidebar = tester.widget>( + _within>(sidebarSection), ); expect(sidebar.selectedValue, 'overview'); expect(sidebar.sections, hasLength(2)); expect(sidebar.header, isNotNull); expect(sidebar.footer, isNotNull); - _expectValues( + _expectValues( tester, within: _sectionChild(tester, 'Tabs'), - expected: FortalTabsSize.values, + expected: UiTabsSize.values, valueOf: (widget) => widget.size, ); - _expectCartesian< - FortalDisclosure, - FortalDisclosureVariant, - FortalDisclosureSize - >( + _expectCartesian( tester, within: _sectionChild(tester, 'Disclosure'), - rows: FortalDisclosureVariant.values, - columns: FortalDisclosureSize.values, + rows: UiDisclosureVariant.values, + columns: UiDisclosureSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); _expectCartesian< - FortalAccordion, - FortalAccordionVariant, - FortalAccordionSize + UiAccordion, + UiAccordionVariant, + UiAccordionSize >( tester, within: _sectionChild(tester, 'Accordion'), - rows: FortalAccordionVariant.values, - columns: FortalAccordionSize.values, + rows: UiAccordionVariant.values, + columns: UiAccordionSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); await _pumpPage(tester, const GalleryOverlaysPage()); - _expectCartesian, FortalMenuVariant, FortalMenuSize>( + _expectCartesian, UiMenuVariant, UiMenuSize>( tester, within: _sectionChild(tester, 'Menu'), - rows: FortalMenuVariant.values, - columns: FortalMenuSize.values, + rows: UiMenuVariant.values, + columns: UiMenuSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); - _expectValues( + _expectValues( tester, within: _sectionChild(tester, 'Popover'), - expected: FortalPopoverSize.values, + expected: UiPopoverSize.values, valueOf: (widget) => widget.size, ); final dialogSection = _sectionChild(tester, 'Dialog'); final dialogMatrix = tester - .widget>( + .widget>( dialogSection, ); - expect(dialogMatrix.rows, FortalDialogAlign.values); - expect(dialogMatrix.columns, FortalDialogSize.values); + expect(dialogMatrix.rows, UiDialogAlign.values); + expect(dialogMatrix.columns, UiDialogSize.values); expect( tester - .widgetList( + .widgetList( find.descendant( of: dialogSection, - matching: find.byType(FortalButton), + matching: find.byType(UiButton), ), ) .map((button) => button.semanticLabel) .toSet(), { - for (final align in FortalDialogAlign.values) - for (final size in FortalDialogSize.values) + for (final align in UiDialogAlign.values) + for (final size in UiDialogSize.values) 'Open ${enumLabel(align)} ${enumLabel(size)} dialog', }, ); @@ -327,19 +299,19 @@ void main() { testWidgets('typography matrices use typed preset values', (tester) async { await _pumpPage(tester, const GalleryTypographyPage()); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Code'), - rows: FortalCodeVariant.values, + rows: UiCodeVariant.values, columns: const [false, true], rowOf: (widget) => widget.variant, columnOf: (widget) => widget.highContrast, ); - _expectCartesian( + _expectCartesian( tester, within: _sectionChild(tester, 'Keyboard keys'), - rows: FortalKbdVariant.values, - columns: FortalTextSize.values, + rows: UiKbdVariant.values, + columns: UiTextSize.values, rowOf: (widget) => widget.variant, columnOf: (widget) => widget.size, ); @@ -347,21 +319,21 @@ void main() { final weights = _sectionChild(tester, 'Weights'); for (final values in [ tester - .widgetList(_within(weights)) + .widgetList(_within(weights)) .map((widget) => widget.weight), tester - .widgetList(_within(weights)) + .widgetList(_within(weights)) .map((widget) => widget.weight), tester - .widgetList(_within(weights)) + .widgetList(_within(weights)) .map((widget) => widget.weight), tester - .widgetList(_within(weights)) + .widgetList(_within(weights)) .map((widget) => widget.weight), ]) { final actual = values.toList(); - expect(actual, hasLength(FortalTextWeight.values.length)); - expect(actual.toSet(), FortalTextWeight.values.toSet()); + expect(actual, hasLength(UiTextWeight.values.length)); + expect(actual.toSet(), UiTextWeight.values.toSet()); } }); @@ -377,9 +349,9 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); } - final dialog = tester.widget(find.byType(FortalDialog)); - expect(dialog.align, FortalDialogAlign.start); - expect(dialog.size, FortalDialogSize.size1); + final dialog = tester.widget(find.byType(UiDialog)); + expect(dialog.align, UiDialogAlign.start); + expect(dialog.size, UiDialogSize.size1); }); }); @@ -392,7 +364,7 @@ void main() { settings: const ThemeSettings(), onChanged: (_) {}, child: MaterialApp( - builder: (context, child) => FortalScope(child: child!), + builder: (context, child) => UiScope(child: child!), home: const Scaffold(body: ThemePanel()), ), ), @@ -400,20 +372,17 @@ void main() { expect(_segmentedValues(tester), ThemeMode.values); expect( - _segmentedValues(tester), - FortalPanelBackground.values, + _segmentedValues(tester), + UiPanelBackground.values, ); - expect(_segmentedValues(tester), FortalRadius.values); - expect(_segmentedValues(tester), FortalScaling.values); + expect(_segmentedValues(tester), UiRadius.values); + expect(_segmentedValues(tester), UiScaling.values); - final gray = tester.widget>( - find.byType(FortalSelect), - ); - expect( - gray.items.map((item) => item.value).toList(), - FortalGrayColor.values, + final gray = tester.widget>( + find.byType(UiSelect), ); - for (final accent in FortalAccentColor.values) { + expect(gray.items.map((item) => item.value).toList(), UiGrayColor.values); + for (final accent in UiAccentColor.values) { expect(find.byKey(ValueKey('accent-${accent.name}')), findsOneWidget); } }); @@ -438,10 +407,10 @@ void main() { ); await tester.pump(); - final root = FortalTheme.of(tester.element(find.byType(DashboardShell))); - final danger = FortalTheme.of(tester.element(find.text('Danger zone'))); + final root = UiTheme.of(tester.element(find.byType(DashboardShell))); + final danger = UiTheme.of(tester.element(find.text('Danger zone'))); - expect(danger.accent, FortalAccentColor.red); + expect(danger.accent, UiAccentColor.red); expect(danger.gray, root.gray); expect(danger.brightness, root.brightness); expect(danger.panelBackground, root.panelBackground); @@ -464,7 +433,7 @@ void main() { ), ); - const expectedAccents = { + const expectedAccents = { 'Paid': .green, 'Pending': .amber, 'Refunded': .red, @@ -475,15 +444,15 @@ void main() { }; for (final entry in expectedAccents.entries) { expect( - FortalTheme.of(tester.element(find.text(entry.key))).accent, + UiTheme.of(tester.element(find.text(entry.key))).accent, entry.value, reason: entry.key, ); } expect( - tester.widgetList(find.byType(FortalBadge)), + tester.widgetList(find.byType(UiBadge)), everyElement( - isA().having( + isA().having( (badge) => badge.highContrast, 'highContrast', isTrue, @@ -497,7 +466,7 @@ void main() { Future _pumpPage(WidgetTester tester, Widget page) { return tester.pumpWidget( MaterialApp( - builder: (context, child) => FortalScope(child: child!), + builder: (context, child) => UiScope(child: child!), home: Scaffold(body: page), ), ); @@ -549,8 +518,8 @@ void _expectValues( } List _segmentedValues(WidgetTester tester) { - final control = tester.widget>( - find.byType(FortalSegmentedControl), + final control = tester.widget>( + find.byType(UiSegmentedControl), ); return control.items.map((item) => item.value).toList(); } diff --git a/apps/dashboard/test/sidebar_motion_test.dart b/apps/dashboard/test/sidebar_motion_test.dart index d89838e84..0e4f97f74 100644 --- a/apps/dashboard/test/sidebar_motion_test.dart +++ b/apps/dashboard/test/sidebar_motion_test.dart @@ -6,7 +6,7 @@ import 'package:dashboard/shell/sidebar.dart'; import 'package:dashboard/shell/sidebar_sections.dart'; import 'package:dashboard/shell/top_bar.dart'; import 'package:dashboard/theme/theme_settings.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:dashboard/ui/ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -32,10 +32,10 @@ void main() { final toggle = find.byKey(const ValueKey('dashboard-sidebar-toggle')).first; double width(WidgetTester tester) => tester.getSize(find.byType(Sidebar)).width; - // `TopBar` sits inside `FortalSidebarLayout`'s `header` slot in both + // `TopBar` sits inside `UiSidebarLayout`'s `header` slot in both // presentations, so it is always a descendant of the scope the layout // re-provides. - bool isCompactSheetOpen(WidgetTester tester) => FortalSidebarLayoutScope.of( + bool isCompactSheetOpen(WidgetTester tester) => UiSidebarLayoutScope.of( tester.element(find.byType(TopBar)), ).isCompactOpen; @@ -80,7 +80,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - for (final scaling in FortalScaling.values) { + for (final scaling in UiScaling.values) { await tester.pumpWidget( DashboardApp( key: ValueKey(scaling), @@ -93,7 +93,7 @@ void main() { final trigger = find.byKey(const ValueKey('sidebar-account-trigger')); final avatar = find.descendant( of: trigger, - matching: find.byType(FortalAvatar), + matching: find.byType(UiAvatar), ); expect( tester.getCenter(avatar).dx, diff --git a/apps/dashboard/test/sidebar_test.dart b/apps/dashboard/test/sidebar_test.dart index b9a85da44..34e881e7c 100644 --- a/apps/dashboard/test/sidebar_test.dart +++ b/apps/dashboard/test/sidebar_test.dart @@ -8,7 +8,7 @@ import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:dashboard/ui/ui.dart'; const _sections = >[ RemixSidebarSection( @@ -188,7 +188,7 @@ void main() { MaterialApp( home: MediaQuery( data: const MediaQueryData(padding: insets), - child: FortalScope( + child: UiScope( child: Row( children: [ Sidebar(selected: DashboardPage.overview, onSelected: (_) {}), @@ -201,8 +201,8 @@ void main() { final panel = tester.getRect(find.byType(Sidebar)); final brand = tester.getRect(find.byKey(const ValueKey('dashboard-brand'))); - final generated = tester.widget>( - find.byType(FortalSidebar), + final generated = tester.widget>( + find.byType(UiSidebar), ); // The painted panel reaches the display edge while its content clears the @@ -215,7 +215,7 @@ void main() { testWidgets('builds in scrolling and fixed-height column hosts', ( tester, ) async { - FortalSidebar buildPanel() => FortalSidebar( + UiSidebar buildPanel() => UiSidebar( sections: _sections, selectedValue: DashboardPage.overview, onSelected: (_) {}, @@ -257,7 +257,7 @@ void main() { ).copyWith(textScaler: const TextScaler.linear(2)), child: SizedBox( width: 256, - child: FortalSidebar( + child: UiSidebar( sections: const [ RemixSidebarSection( label: 'Workspace', @@ -291,7 +291,7 @@ void main() { textDirection: TextDirection.rtl, child: SizedBox( width: 256, - child: FortalSidebar( + child: UiSidebar( sections: _sections, selectedValue: DashboardPage.overview, onSelected: (_) {}, @@ -330,7 +330,7 @@ Future _pumpSidebar( }) { return _pumpPage( tester, - FortalSidebar( + UiSidebar( sections: sections, selectedValue: selectedValue, onSelected: onSelected ?? (_) {}, @@ -342,7 +342,7 @@ Future _pumpSidebar( Future _pumpPage(WidgetTester tester, Widget page) { return tester.pumpWidget( MaterialApp( - builder: (context, child) => FortalScope(child: child!), + builder: (context, child) => UiScope(child: child!), home: Scaffold(body: page), ), ); diff --git a/apps/dashboard/test/toast_test.dart b/apps/dashboard/test/toast_test.dart index 134d52ac3..e80c96bbc 100644 --- a/apps/dashboard/test/toast_test.dart +++ b/apps/dashboard/test/toast_test.dart @@ -8,7 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:dashboard/ui/ui.dart'; void main() { testWidgets('visible toast follows live Fortal theme changes', ( @@ -28,7 +28,7 @@ void main() { final themeScope = tester.widget(find.byType(ThemeScope)); themeScope.onChanged( - themeScope.settings.copyWith(accentColor: FortalAccentColor.green), + themeScope.settings.copyWith(accentColor: UiAccentColor.green), ); await tester.pump(); @@ -36,7 +36,7 @@ void main() { .widget(find.byIcon(Icons.check_circle_outline)) .color; final expected = MixScope.tokenOf( - FortalTokens.accent11, + UiTokens.accent11, tester.element(find.byType(DashboardShell)), ); @@ -136,7 +136,7 @@ void main() { barrierLabel: 'Dismiss', builder: (context) { dialogContext = context; - return const FortalDialog( + return const UiDialog( title: 'Invite teammates', description: 'Share this workspace with your collaborators.', ); @@ -182,7 +182,7 @@ void main() { expect(style.decoration, anyOf(isNull, TextDecoration.none)); expect(style.fontFamily, isNot('monospace')); - expect(style.color, MixScope.tokenOf(FortalTokens.gray12, context)); + expect(style.color, MixScope.tokenOf(UiTokens.gray12, context)); await tester.pump(const Duration(seconds: 4)); await _settle(tester); @@ -194,7 +194,7 @@ Future _pumpDashboard(WidgetTester tester) async { const DashboardApp( initialSettings: ThemeSettings( appearance: ThemeMode.light, - accentColor: FortalAccentColor.blue, + accentColor: UiAccentColor.blue, ), ), ); @@ -203,7 +203,7 @@ Future _pumpDashboard(WidgetTester tester) async { /// Advances past the toast entrance/exit transitions (180ms / 120ms) without /// `pumpAndSettle()`, which never completes on the full [DashboardApp]: the -/// gallery display page's `FortalSkeleton` keeps a `repeat(reverse: true)` +/// gallery display page's `UiSkeleton` keeps a `repeat(reverse: true)` /// shimmer animation running even while offstage in the shell's /// `IndexedStack`. Future _settle(WidgetTester tester) async { diff --git a/apps/demo/analysis_options.yaml b/apps/demo/analysis_options.yaml index 0d2902135..390cff773 100644 --- a/apps/demo/analysis_options.yaml +++ b/apps/demo/analysis_options.yaml @@ -8,6 +8,9 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - lib/ui/**/*.g.dart linter: # The lint rules applied to this project can be customized in the diff --git a/apps/demo/build.yaml b/apps/demo/build.yaml index 0b6b99472..666555cfe 100644 --- a/apps/demo/build.yaml +++ b/apps/demo/build.yaml @@ -1,6 +1,13 @@ +# The installed Fortal widgets are generated parts, and the Widgetbook use +# cases name them as `type:`. Emit those parts before Widgetbook reads them. +global_options: + source_gen|combining_builder: + runs_before: + - widgetbook_generator|use_case_builder + targets: $default: builders: widgetbook_generator:use_case_builder: options: - nav_path_mode: use-case \ No newline at end of file + nav_path_mode: use-case diff --git a/apps/demo/lib/components/accordion.dart b/apps/demo/lib/components/accordion.dart index cf8feac3b..3616a3af0 100644 --- a/apps/demo/lib/components/accordion.dart +++ b/apps/demo/lib/components/accordion.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; final _key = GlobalKey(); diff --git a/apps/demo/lib/components/avatar.dart b/apps/demo/lib/components/avatar.dart index 1b4911e47..aaf6838a1 100644 --- a/apps/demo/lib/components/avatar.dart +++ b/apps/demo/lib/components/avatar.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/badge.dart b/apps/demo/lib/components/badge.dart index 704929028..8b12d9c35 100644 --- a/apps/demo/lib/components/badge.dart +++ b/apps/demo/lib/components/badge.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart' hide Badge; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/button.dart b/apps/demo/lib/components/button.dart index 9217c79a5..f5d505ca4 100644 --- a/apps/demo/lib/components/button.dart +++ b/apps/demo/lib/components/button.dart @@ -2,7 +2,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:demo/addons/icon_data_knob.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/callout.dart b/apps/demo/lib/components/callout.dart index f0f5b0bcb..ef28c9ffc 100644 --- a/apps/demo/lib/components/callout.dart +++ b/apps/demo/lib/components/callout.dart @@ -2,7 +2,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart' as m; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/card.dart b/apps/demo/lib/components/card.dart index 2125fb139..10bce3a98 100644 --- a/apps/demo/lib/components/card.dart +++ b/apps/demo/lib/components/card.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/checkbox.dart b/apps/demo/lib/components/checkbox.dart index f774f74d3..0226e0c8a 100644 --- a/apps/demo/lib/components/checkbox.dart +++ b/apps/demo/lib/components/checkbox.dart @@ -2,7 +2,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:demo/helpers/use_case_state.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/checkbox_group.dart b/apps/demo/lib/components/checkbox_group.dart index 0da7f3fa6..42d291be8 100644 --- a/apps/demo/lib/components/checkbox_group.dart +++ b/apps/demo/lib/components/checkbox_group.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Interests', type: RemixCheckboxGroup) diff --git a/apps/demo/lib/components/code.dart b/apps/demo/lib/components/code.dart index 393717356..e8bc6b7b0 100644 --- a/apps/demo/lib/components/code.dart +++ b/apps/demo/lib/components/code.dart @@ -1,6 +1,6 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Catalog', type: FortalCode) diff --git a/apps/demo/lib/components/data_list.dart b/apps/demo/lib/components/data_list.dart index 873d2dd3a..c8a797cd9 100644 --- a/apps/demo/lib/components/data_list.dart +++ b/apps/demo/lib/components/data_list.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Catalog', type: RemixDataList) diff --git a/apps/demo/lib/components/data_table.dart b/apps/demo/lib/components/data_table.dart index 8e8900faa..870914c5a 100644 --- a/apps/demo/lib/components/data_table.dart +++ b/apps/demo/lib/components/data_table.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Catalog', type: RemixDataTable) diff --git a/apps/demo/lib/components/dialog.dart b/apps/demo/lib/components/dialog.dart index c71f79e9b..b2fb6b16e 100644 --- a/apps/demo/lib/components/dialog.dart +++ b/apps/demo/lib/components/dialog.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; final _key = GlobalKey(); diff --git a/apps/demo/lib/components/disclosure.dart b/apps/demo/lib/components/disclosure.dart index a5829cc10..1bcbd0db2 100644 --- a/apps/demo/lib/components/disclosure.dart +++ b/apps/demo/lib/components/disclosure.dart @@ -2,7 +2,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:demo/helpers/use_case_state.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/divider.dart b/apps/demo/lib/components/divider.dart index 69116a2b0..9159f0335 100644 --- a/apps/demo/lib/components/divider.dart +++ b/apps/demo/lib/components/divider.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/heading.dart b/apps/demo/lib/components/heading.dart index 68e4abf9f..0931310cc 100644 --- a/apps/demo/lib/components/heading.dart +++ b/apps/demo/lib/components/heading.dart @@ -1,6 +1,6 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Catalog', type: FortalHeading) diff --git a/apps/demo/lib/components/icon_button.dart b/apps/demo/lib/components/icon_button.dart index 9fdfe9c03..3cc5bbcf2 100644 --- a/apps/demo/lib/components/icon_button.dart +++ b/apps/demo/lib/components/icon_button.dart @@ -2,7 +2,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:demo/addons/icon_data_knob.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/kbd.dart b/apps/demo/lib/components/kbd.dart index a0636c1e1..23dade02a 100644 --- a/apps/demo/lib/components/kbd.dart +++ b/apps/demo/lib/components/kbd.dart @@ -1,6 +1,6 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Catalog', type: FortalKbd) diff --git a/apps/demo/lib/components/link.dart b/apps/demo/lib/components/link.dart index bb253592d..cede394fd 100644 --- a/apps/demo/lib/components/link.dart +++ b/apps/demo/lib/components/link.dart @@ -1,6 +1,6 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Catalog', type: FortalLink) diff --git a/apps/demo/lib/components/menu.dart b/apps/demo/lib/components/menu.dart index 1adf48d01..ee3300b8b 100644 --- a/apps/demo/lib/components/menu.dart +++ b/apps/demo/lib/components/menu.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/popover.dart b/apps/demo/lib/components/popover.dart index c792d482c..822c96687 100644 --- a/apps/demo/lib/components/popover.dart +++ b/apps/demo/lib/components/popover.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/progress.dart b/apps/demo/lib/components/progress.dart index 071963c47..c5f46c597 100644 --- a/apps/demo/lib/components/progress.dart +++ b/apps/demo/lib/components/progress.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/radio.dart b/apps/demo/lib/components/radio.dart index f9c6599dc..67ce8b8b6 100644 --- a/apps/demo/lib/components/radio.dart +++ b/apps/demo/lib/components/radio.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/segmented_control.dart b/apps/demo/lib/components/segmented_control.dart index a1d1d8aea..18b6adeb3 100644 --- a/apps/demo/lib/components/segmented_control.dart +++ b/apps/demo/lib/components/segmented_control.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Catalog', type: RemixSegmentedControl) diff --git a/apps/demo/lib/components/select.dart b/apps/demo/lib/components/select.dart index 48d36f952..676724fad 100644 --- a/apps/demo/lib/components/select.dart +++ b/apps/demo/lib/components/select.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/sidebar.dart b/apps/demo/lib/components/sidebar.dart index a6179addf..944c768ef 100644 --- a/apps/demo/lib/components/sidebar.dart +++ b/apps/demo/lib/components/sidebar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Workspace navigation', type: RemixSidebar) diff --git a/apps/demo/lib/components/skeleton.dart b/apps/demo/lib/components/skeleton.dart index bfa0ce40f..f79c31caf 100644 --- a/apps/demo/lib/components/skeleton.dart +++ b/apps/demo/lib/components/skeleton.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Loading content', type: RemixSkeleton) diff --git a/apps/demo/lib/components/slider.dart b/apps/demo/lib/components/slider.dart index 8d9e26e4f..15396cfd1 100644 --- a/apps/demo/lib/components/slider.dart +++ b/apps/demo/lib/components/slider.dart @@ -2,7 +2,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:demo/helpers/use_case_state.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/spinner.dart b/apps/demo/lib/components/spinner.dart index b38a31b43..40fcd3948 100644 --- a/apps/demo/lib/components/spinner.dart +++ b/apps/demo/lib/components/spinner.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/switch.dart b/apps/demo/lib/components/switch.dart index d8c77d8a7..e1c249039 100644 --- a/apps/demo/lib/components/switch.dart +++ b/apps/demo/lib/components/switch.dart @@ -2,7 +2,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:demo/helpers/use_case_state.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/tabs.dart b/apps/demo/lib/components/tabs.dart index ebd101b6a..ba4dbc5bb 100644 --- a/apps/demo/lib/components/tabs.dart +++ b/apps/demo/lib/components/tabs.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; final _key = GlobalKey(); diff --git a/apps/demo/lib/components/text.dart b/apps/demo/lib/components/text.dart index afd69848b..94241b283 100644 --- a/apps/demo/lib/components/text.dart +++ b/apps/demo/lib/components/text.dart @@ -1,6 +1,6 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Catalog', type: FortalText) diff --git a/apps/demo/lib/components/textarea.dart b/apps/demo/lib/components/textarea.dart index 691988a25..5655fab54 100644 --- a/apps/demo/lib/components/textarea.dart +++ b/apps/demo/lib/components/textarea.dart @@ -1,6 +1,6 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Catalog', type: FortalTextArea) diff --git a/apps/demo/lib/components/textfield.dart b/apps/demo/lib/components/textfield.dart index 4364ca598..6ae03647b 100644 --- a/apps/demo/lib/components/textfield.dart +++ b/apps/demo/lib/components/textfield.dart @@ -1,7 +1,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/toast.dart b/apps/demo/lib/components/toast.dart index c53ac47b2..802dfc812 100644 --- a/apps/demo/lib/components/toast.dart +++ b/apps/demo/lib/components/toast.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase(name: 'Stacked notifications', type: RemixToast) diff --git a/apps/demo/lib/components/toggle.dart b/apps/demo/lib/components/toggle.dart index 70c5c8374..8226ad936 100644 --- a/apps/demo/lib/components/toggle.dart +++ b/apps/demo/lib/components/toggle.dart @@ -2,7 +2,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:demo/helpers/use_case_state.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/toggle_group.dart b/apps/demo/lib/components/toggle_group.dart index 9afac591a..98034b88c 100644 --- a/apps/demo/lib/components/toggle_group.dart +++ b/apps/demo/lib/components/toggle_group.dart @@ -2,7 +2,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:demo/helpers/use_case_state.dart'; import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/components/tooltip.dart b/apps/demo/lib/components/tooltip.dart index 5e8ade472..b4102e897 100644 --- a/apps/demo/lib/components/tooltip.dart +++ b/apps/demo/lib/components/tooltip.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/main.dart b/apps/demo/lib/main.dart index e9e0852ec..e41b7e21c 100644 --- a/apps/demo/lib/main.dart +++ b/apps/demo/lib/main.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart' hide Scaffold; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; diff --git a/apps/demo/lib/previews/preview_helper.dart b/apps/demo/lib/previews/preview_helper.dart index c25e8f5e4..f8d2f1d0e 100644 --- a/apps/demo/lib/previews/preview_helper.dart +++ b/apps/demo/lib/previews/preview_helper.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; /// Helper function for creating consistent widget previews. /// diff --git a/packages/remix_fortal/lib/src/components/accordion.dart b/apps/demo/lib/ui/components/accordion.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/accordion.dart rename to apps/demo/lib/ui/components/accordion.dart diff --git a/packages/remix_fortal/lib/src/components/accordion.g.dart b/apps/demo/lib/ui/components/accordion.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/accordion.g.dart rename to apps/demo/lib/ui/components/accordion.g.dart diff --git a/packages/remix_fortal/lib/src/components/avatar.dart b/apps/demo/lib/ui/components/avatar.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/avatar.dart rename to apps/demo/lib/ui/components/avatar.dart diff --git a/packages/remix_fortal/lib/src/components/avatar.g.dart b/apps/demo/lib/ui/components/avatar.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/avatar.g.dart rename to apps/demo/lib/ui/components/avatar.g.dart diff --git a/packages/remix_fortal/lib/src/components/badge.dart b/apps/demo/lib/ui/components/badge.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/badge.dart rename to apps/demo/lib/ui/components/badge.dart diff --git a/packages/remix_fortal/lib/src/components/badge.g.dart b/apps/demo/lib/ui/components/badge.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/badge.g.dart rename to apps/demo/lib/ui/components/badge.g.dart diff --git a/packages/remix_fortal/lib/src/components/base_button.dart b/apps/demo/lib/ui/components/base_button.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/base_button.dart rename to apps/demo/lib/ui/components/base_button.dart diff --git a/packages/remix_fortal/lib/src/components/button.dart b/apps/demo/lib/ui/components/button.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/button.dart rename to apps/demo/lib/ui/components/button.dart diff --git a/packages/remix_fortal/lib/src/components/button.g.dart b/apps/demo/lib/ui/components/button.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/button.g.dart rename to apps/demo/lib/ui/components/button.g.dart diff --git a/packages/remix_fortal/lib/src/components/callout.dart b/apps/demo/lib/ui/components/callout.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/callout.dart rename to apps/demo/lib/ui/components/callout.dart diff --git a/packages/remix_fortal/lib/src/components/callout.g.dart b/apps/demo/lib/ui/components/callout.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/callout.g.dart rename to apps/demo/lib/ui/components/callout.g.dart diff --git a/packages/remix_fortal/lib/src/components/card.dart b/apps/demo/lib/ui/components/card.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/card.dart rename to apps/demo/lib/ui/components/card.dart diff --git a/packages/remix_fortal/lib/src/components/card.g.dart b/apps/demo/lib/ui/components/card.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/card.g.dart rename to apps/demo/lib/ui/components/card.g.dart diff --git a/packages/remix_fortal/lib/src/components/chart.dart b/apps/demo/lib/ui/components/chart.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/chart.dart rename to apps/demo/lib/ui/components/chart.dart diff --git a/packages/remix_fortal/lib/src/components/chart.g.dart b/apps/demo/lib/ui/components/chart.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/chart.g.dart rename to apps/demo/lib/ui/components/chart.g.dart diff --git a/packages/remix_fortal/lib/src/components/checkbox.dart b/apps/demo/lib/ui/components/checkbox.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/checkbox.dart rename to apps/demo/lib/ui/components/checkbox.dart diff --git a/packages/remix_fortal/lib/src/components/checkbox.g.dart b/apps/demo/lib/ui/components/checkbox.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/checkbox.g.dart rename to apps/demo/lib/ui/components/checkbox.g.dart diff --git a/packages/remix_fortal/lib/src/components/code.dart b/apps/demo/lib/ui/components/code.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/code.dart rename to apps/demo/lib/ui/components/code.dart diff --git a/packages/remix_fortal/lib/src/components/data_list.dart b/apps/demo/lib/ui/components/data_list.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/data_list.dart rename to apps/demo/lib/ui/components/data_list.dart diff --git a/packages/remix_fortal/lib/src/components/data_list.g.dart b/apps/demo/lib/ui/components/data_list.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/data_list.g.dart rename to apps/demo/lib/ui/components/data_list.g.dart diff --git a/packages/remix_fortal/lib/src/components/data_table.dart b/apps/demo/lib/ui/components/data_table.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/data_table.dart rename to apps/demo/lib/ui/components/data_table.dart diff --git a/packages/remix_fortal/lib/src/components/data_table.g.dart b/apps/demo/lib/ui/components/data_table.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/data_table.g.dart rename to apps/demo/lib/ui/components/data_table.g.dart diff --git a/packages/remix_fortal/lib/src/components/dialog.dart b/apps/demo/lib/ui/components/dialog.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/dialog.dart rename to apps/demo/lib/ui/components/dialog.dart diff --git a/packages/remix_fortal/lib/src/components/dialog.g.dart b/apps/demo/lib/ui/components/dialog.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/dialog.g.dart rename to apps/demo/lib/ui/components/dialog.g.dart diff --git a/packages/remix_fortal/lib/src/components/disclosure.dart b/apps/demo/lib/ui/components/disclosure.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/disclosure.dart rename to apps/demo/lib/ui/components/disclosure.dart diff --git a/packages/remix_fortal/lib/src/components/disclosure.g.dart b/apps/demo/lib/ui/components/disclosure.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/disclosure.g.dart rename to apps/demo/lib/ui/components/disclosure.g.dart diff --git a/packages/remix_fortal/lib/src/components/divider.dart b/apps/demo/lib/ui/components/divider.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/divider.dart rename to apps/demo/lib/ui/components/divider.dart diff --git a/packages/remix_fortal/lib/src/components/divider.g.dart b/apps/demo/lib/ui/components/divider.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/divider.g.dart rename to apps/demo/lib/ui/components/divider.g.dart diff --git a/packages/remix_fortal/lib/src/components/heading.dart b/apps/demo/lib/ui/components/heading.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/heading.dart rename to apps/demo/lib/ui/components/heading.dart diff --git a/packages/remix_fortal/lib/src/components/icon_button.dart b/apps/demo/lib/ui/components/icon_button.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/icon_button.dart rename to apps/demo/lib/ui/components/icon_button.dart diff --git a/packages/remix_fortal/lib/src/components/icon_button.g.dart b/apps/demo/lib/ui/components/icon_button.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/icon_button.g.dart rename to apps/demo/lib/ui/components/icon_button.g.dart diff --git a/packages/remix_fortal/lib/src/components/kbd.dart b/apps/demo/lib/ui/components/kbd.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/kbd.dart rename to apps/demo/lib/ui/components/kbd.dart diff --git a/packages/remix_fortal/lib/src/components/link.dart b/apps/demo/lib/ui/components/link.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/link.dart rename to apps/demo/lib/ui/components/link.dart diff --git a/packages/remix_fortal/lib/src/components/menu.dart b/apps/demo/lib/ui/components/menu.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/menu.dart rename to apps/demo/lib/ui/components/menu.dart diff --git a/packages/remix_fortal/lib/src/components/menu.g.dart b/apps/demo/lib/ui/components/menu.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/menu.g.dart rename to apps/demo/lib/ui/components/menu.g.dart diff --git a/packages/remix_fortal/lib/src/components/popover.dart b/apps/demo/lib/ui/components/popover.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/popover.dart rename to apps/demo/lib/ui/components/popover.dart diff --git a/packages/remix_fortal/lib/src/components/popover.g.dart b/apps/demo/lib/ui/components/popover.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/popover.g.dart rename to apps/demo/lib/ui/components/popover.g.dart diff --git a/packages/remix_fortal/lib/src/components/progress.dart b/apps/demo/lib/ui/components/progress.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/progress.dart rename to apps/demo/lib/ui/components/progress.dart diff --git a/packages/remix_fortal/lib/src/components/progress.g.dart b/apps/demo/lib/ui/components/progress.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/progress.g.dart rename to apps/demo/lib/ui/components/progress.g.dart diff --git a/packages/remix_fortal/lib/src/components/radio.dart b/apps/demo/lib/ui/components/radio.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/radio.dart rename to apps/demo/lib/ui/components/radio.dart diff --git a/packages/remix_fortal/lib/src/components/radio.g.dart b/apps/demo/lib/ui/components/radio.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/radio.g.dart rename to apps/demo/lib/ui/components/radio.g.dart diff --git a/packages/remix_fortal/lib/src/components/segmented_control.dart b/apps/demo/lib/ui/components/segmented_control.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/segmented_control.dart rename to apps/demo/lib/ui/components/segmented_control.dart diff --git a/packages/remix_fortal/lib/src/components/segmented_control.g.dart b/apps/demo/lib/ui/components/segmented_control.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/segmented_control.g.dart rename to apps/demo/lib/ui/components/segmented_control.g.dart diff --git a/packages/remix_fortal/lib/src/components/select.dart b/apps/demo/lib/ui/components/select.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/select.dart rename to apps/demo/lib/ui/components/select.dart diff --git a/packages/remix_fortal/lib/src/components/select.g.dart b/apps/demo/lib/ui/components/select.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/select.g.dart rename to apps/demo/lib/ui/components/select.g.dart diff --git a/packages/remix_fortal/lib/src/components/sidebar.dart b/apps/demo/lib/ui/components/sidebar.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/sidebar.dart rename to apps/demo/lib/ui/components/sidebar.dart diff --git a/packages/remix_fortal/lib/src/components/sidebar.g.dart b/apps/demo/lib/ui/components/sidebar.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/sidebar.g.dart rename to apps/demo/lib/ui/components/sidebar.g.dart diff --git a/packages/remix_fortal/lib/src/components/sidebar_layout.dart b/apps/demo/lib/ui/components/sidebar_layout.dart similarity index 98% rename from packages/remix_fortal/lib/src/components/sidebar_layout.dart rename to apps/demo/lib/ui/components/sidebar_layout.dart index da3cde32c..c8789a606 100644 --- a/packages/remix_fortal/lib/src/components/sidebar_layout.dart +++ b/apps/demo/lib/ui/components/sidebar_layout.dart @@ -310,7 +310,7 @@ class _FortalSidebarLayoutState extends State { /// destination's `onSelected` callback closing the sheet after navigating), /// since the layout re-provides this scope inside the sheet route. class FortalSidebarLayoutScope extends InheritedWidget { - // `remix_fortal` floors at Dart 3.11, one release before private named + // Fortal source floors at Dart 3.11, one release before private named // parameters, so this assigns the private fields explicitly instead of // naming the parameters after them. const FortalSidebarLayoutScope._({ @@ -319,8 +319,8 @@ class FortalSidebarLayoutScope extends InheritedWidget { required VoidCallback openCompact, required VoidCallback closeCompact, required super.child, - }) : _openCompact = openCompact, - _closeCompact = closeCompact; + }) : _openCompact = openCompact, // ignore: prefer_initializing_formals + _closeCompact = closeCompact; // ignore: prefer_initializing_formals /// Whether the layout is currently in its compact presentation. final bool isCompact; diff --git a/packages/remix_fortal/lib/src/components/skeleton.dart b/apps/demo/lib/ui/components/skeleton.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/skeleton.dart rename to apps/demo/lib/ui/components/skeleton.dart diff --git a/packages/remix_fortal/lib/src/components/skeleton.g.dart b/apps/demo/lib/ui/components/skeleton.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/skeleton.g.dart rename to apps/demo/lib/ui/components/skeleton.g.dart diff --git a/packages/remix_fortal/lib/src/components/slider.dart b/apps/demo/lib/ui/components/slider.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/slider.dart rename to apps/demo/lib/ui/components/slider.dart diff --git a/packages/remix_fortal/lib/src/components/slider.g.dart b/apps/demo/lib/ui/components/slider.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/slider.g.dart rename to apps/demo/lib/ui/components/slider.g.dart diff --git a/packages/remix_fortal/lib/src/components/spinner.dart b/apps/demo/lib/ui/components/spinner.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/spinner.dart rename to apps/demo/lib/ui/components/spinner.dart diff --git a/packages/remix_fortal/lib/src/components/spinner.g.dart b/apps/demo/lib/ui/components/spinner.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/spinner.g.dart rename to apps/demo/lib/ui/components/spinner.g.dart diff --git a/packages/remix_fortal/lib/src/components/switch.dart b/apps/demo/lib/ui/components/switch.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/switch.dart rename to apps/demo/lib/ui/components/switch.dart diff --git a/packages/remix_fortal/lib/src/components/switch.g.dart b/apps/demo/lib/ui/components/switch.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/switch.g.dart rename to apps/demo/lib/ui/components/switch.g.dart diff --git a/packages/remix_fortal/lib/src/components/tabs.dart b/apps/demo/lib/ui/components/tabs.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/tabs.dart rename to apps/demo/lib/ui/components/tabs.dart diff --git a/packages/remix_fortal/lib/src/components/tabs.g.dart b/apps/demo/lib/ui/components/tabs.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/tabs.g.dart rename to apps/demo/lib/ui/components/tabs.g.dart diff --git a/packages/remix_fortal/lib/src/components/text.dart b/apps/demo/lib/ui/components/text.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/text.dart rename to apps/demo/lib/ui/components/text.dart diff --git a/packages/remix_fortal/lib/src/components/text.g.dart b/apps/demo/lib/ui/components/text.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/text.g.dart rename to apps/demo/lib/ui/components/text.g.dart diff --git a/packages/remix_fortal/lib/src/components/textfield.dart b/apps/demo/lib/ui/components/textfield.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/textfield.dart rename to apps/demo/lib/ui/components/textfield.dart diff --git a/packages/remix_fortal/lib/src/components/textfield.g.dart b/apps/demo/lib/ui/components/textfield.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/textfield.g.dart rename to apps/demo/lib/ui/components/textfield.g.dart diff --git a/packages/remix_fortal/lib/src/components/toast.dart b/apps/demo/lib/ui/components/toast.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/toast.dart rename to apps/demo/lib/ui/components/toast.dart diff --git a/packages/remix_fortal/lib/src/components/toast.g.dart b/apps/demo/lib/ui/components/toast.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/toast.g.dart rename to apps/demo/lib/ui/components/toast.g.dart diff --git a/packages/remix_fortal/lib/src/components/toggle.dart b/apps/demo/lib/ui/components/toggle.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/toggle.dart rename to apps/demo/lib/ui/components/toggle.dart diff --git a/packages/remix_fortal/lib/src/components/toggle.g.dart b/apps/demo/lib/ui/components/toggle.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/toggle.g.dart rename to apps/demo/lib/ui/components/toggle.g.dart diff --git a/packages/remix_fortal/lib/src/components/toggle_group.dart b/apps/demo/lib/ui/components/toggle_group.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/toggle_group.dart rename to apps/demo/lib/ui/components/toggle_group.dart diff --git a/packages/remix_fortal/lib/src/components/toggle_group.g.dart b/apps/demo/lib/ui/components/toggle_group.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/toggle_group.g.dart rename to apps/demo/lib/ui/components/toggle_group.g.dart diff --git a/packages/remix_fortal/lib/src/components/tooltip.dart b/apps/demo/lib/ui/components/tooltip.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/tooltip.dart rename to apps/demo/lib/ui/components/tooltip.dart diff --git a/packages/remix_fortal/lib/src/components/tooltip.g.dart b/apps/demo/lib/ui/components/tooltip.g.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/tooltip.g.dart rename to apps/demo/lib/ui/components/tooltip.g.dart diff --git a/packages/remix_fortal/lib/src/components/typography.dart b/apps/demo/lib/ui/components/typography.dart similarity index 100% rename from packages/remix_fortal/lib/src/components/typography.dart rename to apps/demo/lib/ui/components/typography.dart diff --git a/apps/demo/lib/ui/icons.dart b/apps/demo/lib/ui/icons.dart new file mode 100644 index 000000000..6774e57ec --- /dev/null +++ b/apps/demo/lib/ui/icons.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix_ui_icons/remix_ui_icons.dart'; + +/// Application-owned aliases for the icons used by this UI layer. +/// +/// The complete 318-glyph catalog remains available through [RemixIcons]. +/// Add, rename, or remove aliases here as the application vocabulary evolves. +abstract final class FortalIcons { + /// Confirms a successful or selected action. + static const IconData check = RemixIcons.check; + + /// Dismisses, clears, or marks a failed action. + static const IconData cross = RemixIcons.cross2; + + /// Opens content positioned below the current control. + static const IconData chevronDown = RemixIcons.chevronDown; +} diff --git a/packages/remix_fortal/lib/src/theme/computed.dart b/apps/demo/lib/ui/theme/computed.dart similarity index 100% rename from packages/remix_fortal/lib/src/theme/computed.dart rename to apps/demo/lib/ui/theme/computed.dart diff --git a/packages/remix_fortal/lib/src/theme/control_styles.dart b/apps/demo/lib/ui/theme/control_styles.dart similarity index 100% rename from packages/remix_fortal/lib/src/theme/control_styles.dart rename to apps/demo/lib/ui/theme/control_styles.dart diff --git a/apps/demo/lib/ui/theme/radix_colors.dart b/apps/demo/lib/ui/theme/radix_colors.dart new file mode 100644 index 000000000..ae0d17020 --- /dev/null +++ b/apps/demo/lib/ui/theme/radix_colors.dart @@ -0,0 +1,2488 @@ +// GENERATED CODE - DO NOT EDIT +// Generated from: sRGB fallback tokens extracted from the pinned @radix-ui/themes npm artifact +// Radix Themes version: 3.3.0 +// Radix Colors version: bundled with Radix Themes 3.3.0 +// Source integrity: sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw== + +library; + +import 'package:flutter/painting.dart'; + +class RadixColor { + final RadixColorScale scale; + final Color surface; + final Color indicator; + final Color track; + final Color contrast; + + const RadixColor( + this.scale, + this.surface, + this.indicator, + this.track, + this.contrast, + ); +} + +class RadixColorTheme { + final RadixColor light; + final RadixColor dark; + + const RadixColorTheme(this.light, this.dark); +} + +class RadixColorScale { + final ColorSwatch solid; + final ColorSwatch alpha; + + const RadixColorScale(this.solid, this.alpha); + + /// The most subtle background color (step 1). + Color get appBackground => step(1); + + /// Subtle background with slightly more presence (step 2). + Color get subtleBackground => step(2); + + /// Default background for interactive components (step 3). + Color get componentBackground => step(3); + + /// Background color for components on hover (step 4). + Color get componentBackgroundHover => step(4); + + /// Background color for active/pressed components (step 5). + Color get componentBackgroundActive => step(5); + + /// Subtle border color for gentle separation (step 6). + Color get subtleBorder => step(6); + + /// Standard border color for components (step 7). + Color get componentBorder => step(7); + + /// Border color for hover and focus states (step 8). + Color get componentBorderHover => step(8); + + /// Primary solid background color (step 9). + Color get solidBackground => step(9); + + /// Solid background color on hover (step 10). + Color get solidBackgroundHover => step(10); + + /// Low contrast text color (step 11). + Color get lowContrastText => step(11); + + /// High contrast text color (step 12). + Color get highContrastText => step(12); + + /// Gets a solid color from the 12-step scale. + /// + /// Steps must be between 1 and 12. Falls back to step 9 if unavailable. + Color step(int n) { + assert(n >= 1 && n <= 12, 'Step must be between 1 and 12'); + + return solid[n] ?? solid[9]!; + } + + /// Gets a translucent color from the 12-step alpha scale. + /// + /// Alpha variants maintain saturation when composited. + /// Falls back to alpha step 9 if unavailable. + Color alphaStep(int n) { + assert(n >= 1 && n <= 12, 'Step must be between 1 and 12'); + + return alpha[n] ?? alpha[9]!; + } +} + +// gray color scale +const _grayLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8d8d8d, { + 1: Color(0xfffcfcfc), + 2: Color(0xfff9f9f9), + 3: Color(0xfff0f0f0), + 4: Color(0xffe8e8e8), + 5: Color(0xffe0e0e0), + 6: Color(0xffd9d9d9), + 7: Color(0xffcecece), + 8: Color(0xffbbbbbb), + 9: Color(0xff8d8d8d), + 10: Color(0xff838383), + 11: Color(0xff646464), + 12: Color(0xff202020), + }), + ColorSwatch(0x72000000, { + 1: Color(0x03000000), + 2: Color(0x06000000), + 3: Color(0x0f000000), + 4: Color(0x17000000), + 5: Color(0x1f000000), + 6: Color(0x26000000), + 7: Color(0x31000000), + 8: Color(0x44000000), + 9: Color(0x72000000), + 10: Color(0x7c000000), + 11: Color(0x9b000000), + 12: Color(0xdf000000), + }), + ), + Color(0xccffffff), + Color(0xff8d8d8d), + Color(0xff8d8d8d), + Color(0xffffffff), +); + +const _grayDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff6e6e6e, { + 1: Color(0xff111111), + 2: Color(0xff191919), + 3: Color(0xff222222), + 4: Color(0xff2a2a2a), + 5: Color(0xff313131), + 6: Color(0xff3a3a3a), + 7: Color(0xff484848), + 8: Color(0xff606060), + 9: Color(0xff6e6e6e), + 10: Color(0xff7b7b7b), + 11: Color(0xffb4b4b4), + 12: Color(0xffeeeeee), + }), + ColorSwatch(0x64ffffff, { + 1: Color(0x00000000), + 2: Color(0x09ffffff), + 3: Color(0x12ffffff), + 4: Color(0x1bffffff), + 5: Color(0x22ffffff), + 6: Color(0x2cffffff), + 7: Color(0x3bffffff), + 8: Color(0x55ffffff), + 9: Color(0x64ffffff), + 10: Color(0x72ffffff), + 11: Color(0xafffffff), + 12: Color(0xedffffff), + }), + ), + Color(0x80212121), + Color(0xff6e6e6e), + Color(0xff6e6e6e), + Color(0xffffffff), +); + +// mauve color scale +const _mauveLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8e8c99, { + 1: Color(0xfffdfcfd), + 2: Color(0xfffaf9fb), + 3: Color(0xfff2eff3), + 4: Color(0xffeae7ec), + 5: Color(0xffe3dfe6), + 6: Color(0xffdbd8e0), + 7: Color(0xffd0cdd7), + 8: Color(0xffbcbac7), + 9: Color(0xff8e8c99), + 10: Color(0xff84828e), + 11: Color(0xff65636d), + 12: Color(0xff211f26), + }), + ColorSwatch(0x7305001d, { + 1: Color(0x03550055), + 2: Color(0x062b0055), + 3: Color(0x10300040), + 4: Color(0x18200036), + 5: Color(0x20200038), + 6: Color(0x27140035), + 7: Color(0x32100033), + 8: Color(0x45080031), + 9: Color(0x7305001d), + 10: Color(0x7d050019), + 11: Color(0x9c040011), + 12: Color(0xe0020008), + }), + ), + Color(0xccffffff), + Color(0xff8e8c99), + Color(0xff8e8c99), + Color(0xffffffff), +); + +const _mauveDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff6f6d78, { + 1: Color(0xff121113), + 2: Color(0xff1a191b), + 3: Color(0xff232225), + 4: Color(0xff2b292d), + 5: Color(0xff323035), + 6: Color(0xff3c393f), + 7: Color(0xff49474e), + 8: Color(0xff625f69), + 9: Color(0xff6f6d78), + 10: Color(0xff7c7a85), + 11: Color(0xffb5b2bc), + 12: Color(0xffeeeef0), + }), + ColorSwatch(0x6eeae6fd, { + 1: Color(0x00000000), + 2: Color(0x09f5f4f6), + 3: Color(0x14ebeaf8), + 4: Color(0x1deee5f8), + 5: Color(0x25efe6fe), + 6: Color(0x30f1e6fd), + 7: Color(0x40eee9ff), + 8: Color(0x5deee7ff), + 9: Color(0x6eeae6fd), + 10: Color(0x7cece9fd), + 11: Color(0xb7f5f1ff), + 12: Color(0xeffdfdff), + }), + ), + Color(0x80222123), + Color(0xff6f6d78), + Color(0xff6f6d78), + Color(0xffffffff), +); + +// slate color scale +const _slateLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8b8d98, { + 1: Color(0xfffcfcfd), + 2: Color(0xfff9f9fb), + 3: Color(0xfff0f0f3), + 4: Color(0xffe8e8ec), + 5: Color(0xffe0e1e6), + 6: Color(0xffd9d9e0), + 7: Color(0xffcdced6), + 8: Color(0xffb9bbc6), + 9: Color(0xff8b8d98), + 10: Color(0xff80838d), + 11: Color(0xff60646c), + 12: Color(0xff1c2024), + }), + ColorSwatch(0x7400051d, { + 1: Color(0x03000055), + 2: Color(0x06000055), + 3: Color(0x0f000033), + 4: Color(0x1700002d), + 5: Color(0x1f000932), + 6: Color(0x2600002f), + 7: Color(0x3200062e), + 8: Color(0x46000830), + 9: Color(0x7400051d), + 10: Color(0x7f00071b), + 11: Color(0x9f000714), + 12: Color(0xe3000509), + }), + ), + Color(0xccffffff), + Color(0xff8b8d98), + Color(0xff8b8d98), + Color(0xffffffff), +); + +const _slateDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff696e77, { + 1: Color(0xff111113), + 2: Color(0xff18191b), + 3: Color(0xff212225), + 4: Color(0xff272a2d), + 5: Color(0xff2e3135), + 6: Color(0xff363a3f), + 7: Color(0xff43484e), + 8: Color(0xff5a6169), + 9: Color(0xff696e77), + 10: Color(0xff777b84), + 11: Color(0xffb0b4ba), + 12: Color(0xffedeef0), + }), + ColorSwatch(0x6ddfebfd, { + 1: Color(0x00000000), + 2: Color(0x09d8f4f6), + 3: Color(0x14ddeaf8), + 4: Color(0x1dd3edf8), + 5: Color(0x25d9edfe), + 6: Color(0x30d6ebfd), + 7: Color(0x40d9edff), + 8: Color(0x5dd9edff), + 9: Color(0x6ddfebfd), + 10: Color(0x7be5edfd), + 11: Color(0xb5f1f7fe), + 12: Color(0xeffcfdff), + }), + ), + Color(0x801f2123), + Color(0xff696e77), + Color(0xff696e77), + Color(0xffffffff), +); + +// sage color scale +const _sageLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff868e8b, { + 1: Color(0xfffbfdfc), + 2: Color(0xfff7f9f8), + 3: Color(0xffeef1f0), + 4: Color(0xffe6e9e8), + 5: Color(0xffdfe2e0), + 6: Color(0xffd7dad9), + 7: Color(0xffcbcfcd), + 8: Color(0xffb8bcba), + 9: Color(0xff868e8b), + 10: Color(0xff7c8481), + 11: Color(0xff5f6563), + 12: Color(0xff1a211e), + }), + ColorSwatch(0x7900110b, { + 1: Color(0x04008040), + 2: Color(0x08004020), + 3: Color(0x11002d1e), + 4: Color(0x19001f15), + 5: Color(0x20001808), + 6: Color(0x2800140d), + 7: Color(0x3400140a), + 8: Color(0x47000f08), + 9: Color(0x7900110b), + 10: Color(0x8300100a), + 11: Color(0xa0000a07), + 12: Color(0xe5000805), + }), + ), + Color(0xccffffff), + Color(0xff868e8b), + Color(0xff868e8b), + Color(0xffffffff), +); + +const _sageDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff63706b, { + 1: Color(0xff101211), + 2: Color(0xff171918), + 3: Color(0xff202221), + 4: Color(0xff272a29), + 5: Color(0xff2e3130), + 6: Color(0xff373b39), + 7: Color(0xff444947), + 8: Color(0xff5b625f), + 9: Color(0xff63706b), + 10: Color(0xff717d79), + 11: Color(0xffadb5b2), + 12: Color(0xffeceeed), + }), + ColorSwatch(0x66dffdf2, { + 1: Color(0x00000000), + 2: Color(0x08f0f2f1), + 3: Color(0x12f3f5f4), + 4: Color(0x1af2fefd), + 5: Color(0x22f1fbfa), + 6: Color(0x2dedfbf4), + 7: Color(0x3cedfcf7), + 8: Color(0x57ebfdf6), + 9: Color(0x66dffdf2), + 10: Color(0x74e5fdf6), + 11: Color(0xb0f4fefb), + 12: Color(0xedfdfffe), + }), + ), + Color(0x801e201f), + Color(0xff63706b), + Color(0xff63706b), + Color(0xffffffff), +); + +// olive color scale +const _oliveLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff898e87, { + 1: Color(0xfffcfdfc), + 2: Color(0xfff8faf8), + 3: Color(0xffeff1ef), + 4: Color(0xffe7e9e7), + 5: Color(0xffdfe2df), + 6: Color(0xffd7dad7), + 7: Color(0xffcccfcc), + 8: Color(0xffb9bcb8), + 9: Color(0xff898e87), + 10: Color(0xff7f847d), + 11: Color(0xff60655f), + 12: Color(0xff1d211c), + }), + ColorSwatch(0x78050f00, { + 1: Color(0x03005500), + 2: Color(0x07004900), + 3: Color(0x10002000), + 4: Color(0x18001600), + 5: Color(0x20001800), + 6: Color(0x28001400), + 7: Color(0x33000f00), + 8: Color(0x47040f00), + 9: Color(0x78050f00), + 10: Color(0x82040e00), + 11: Color(0xa0020a00), + 12: Color(0xe3010600), + }), + ), + Color(0xccffffff), + Color(0xff898e87), + Color(0xff898e87), + Color(0xffffffff), +); + +const _oliveDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff687066, { + 1: Color(0xff111210), + 2: Color(0xff181917), + 3: Color(0xff212220), + 4: Color(0xff282a27), + 5: Color(0xff2f312e), + 6: Color(0xff383a36), + 7: Color(0xff454843), + 8: Color(0xff5c625b), + 9: Color(0xff687066), + 10: Color(0xff767d74), + 11: Color(0xffafb5ad), + 12: Color(0xffeceeec), + }), + ColorSwatch(0x66ebfde7, { + 1: Color(0x00000000), + 2: Color(0x08f1f2f0), + 3: Color(0x12f4f5f3), + 4: Color(0x1af3fef2), + 5: Color(0x22f2fbf1), + 6: Color(0x2cf4faed), + 7: Color(0x3bf2fced), + 8: Color(0x57edfdeb), + 9: Color(0x66ebfde7), + 10: Color(0x74f0fdec), + 11: Color(0xb0f6fef4), + 12: Color(0xedfdfffd), + }), + ), + Color(0x801f201e), + Color(0xff687066), + Color(0xff687066), + Color(0xffffffff), +); + +// sand color scale +const _sandLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8d8d86, { + 1: Color(0xfffdfdfc), + 2: Color(0xfff9f9f8), + 3: Color(0xfff1f0ef), + 4: Color(0xffe9e8e6), + 5: Color(0xffe2e1de), + 6: Color(0xffdad9d6), + 7: Color(0xffcfceca), + 8: Color(0xffbcbbb5), + 9: Color(0xff8d8d86), + 10: Color(0xff82827c), + 11: Color(0xff63635e), + 12: Color(0xff21201c), + }), + ColorSwatch(0x790f0f00, { + 1: Color(0x03555500), + 2: Color(0x07252500), + 3: Color(0x10201000), + 4: Color(0x191f1500), + 5: Color(0x211f1800), + 6: Color(0x29191300), + 7: Color(0x35191400), + 8: Color(0x4a191501), + 9: Color(0x790f0f00), + 10: Color(0x830c0c00), + 11: Color(0xa1080800), + 12: Color(0xe3060500), + }), + ), + Color(0xccffffff), + Color(0xff8d8d86), + Color(0xff8d8d86), + Color(0xffffffff), +); + +const _sandDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff6f6d66, { + 1: Color(0xff111110), + 2: Color(0xff191918), + 3: Color(0xff222221), + 4: Color(0xff2a2a28), + 5: Color(0xff31312e), + 6: Color(0xff3b3a37), + 7: Color(0xff494844), + 8: Color(0xff62605b), + 9: Color(0xff6f6d66), + 10: Color(0xff7c7b74), + 11: Color(0xffb5b3ad), + 12: Color(0xffeeeeec), + }), + ColorSwatch(0x65fffae9, { + 1: Color(0x00000000), + 2: Color(0x09f4f4f3), + 3: Color(0x13f6f6f5), + 4: Color(0x1bfefef3), + 5: Color(0x23fbfbeb), + 6: Color(0x2dfffaed), + 7: Color(0x3cfffbed), + 8: Color(0x57fff9eb), + 9: Color(0x65fffae9), + 10: Color(0x73fffdee), + 11: Color(0xb0fffcf4), + 12: Color(0xedfffffd), + }), + ), + Color(0x80212120), + Color(0xff6f6d66), + Color(0xff6f6d66), + Color(0xffffffff), +); + +// tomato color scale +const _tomatoLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffe54d2e, { + 1: Color(0xfffffcfc), + 2: Color(0xfffff8f7), + 3: Color(0xfffeebe7), + 4: Color(0xffffdcd3), + 5: Color(0xffffcdc2), + 6: Color(0xfffdbdaf), + 7: Color(0xfff5a898), + 8: Color(0xffec8e7b), + 9: Color(0xffe54d2e), + 10: Color(0xffdd4425), + 11: Color(0xffd13415), + 12: Color(0xff5c271f), + }), + ColorSwatch(0xd1df2600, { + 1: Color(0x03ff0000), + 2: Color(0x08ff2000), + 3: Color(0x18f52b00), + 4: Color(0x2cff3500), + 5: Color(0x3dff2e00), + 6: Color(0x50f92d00), + 7: Color(0x67e72800), + 8: Color(0x84db2500), + 9: Color(0xd1df2600), + 10: Color(0xdad72400), + 11: Color(0xeacd2200), + 12: Color(0xe0460900), + }), + ), + Color(0xccfff6f5), + Color(0xffe54d2e), + Color(0xffe54d2e), + Color(0xffffffff), +); + +const _tomatoDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffe54d2e, { + 1: Color(0xff181111), + 2: Color(0xff1f1513), + 3: Color(0xff391714), + 4: Color(0xff4e1511), + 5: Color(0xff5e1c16), + 6: Color(0xff6e2920), + 7: Color(0xff853a2d), + 8: Color(0xffac4d39), + 9: Color(0xffe54d2e), + 10: Color(0xffec6142), + 11: Color(0xffff977d), + 12: Color(0xfffbd3cb), + }), + ColorSwatch(0xe4fe5431, { + 1: Color(0x08f11212), + 2: Color(0x0fff5533), + 3: Color(0x2bff3523), + 4: Color(0x42fd2011), + 5: Color(0x53fe3321), + 6: Color(0x64ff4f38), + 7: Color(0x7dfd644a), + 8: Color(0xa7fe6d4e), + 9: Color(0xe4fe5431), + 10: Color(0xebff6847), + 11: Color(0xffff977d), + 12: Color(0xfbffd6ce), + }), + ), + Color(0x802d1915), + Color(0xffe54d2e), + Color(0xffe54d2e), + Color(0xffffffff), +); + +// red color scale +const _redLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffe5484d, { + 1: Color(0xfffffcfc), + 2: Color(0xfffff7f7), + 3: Color(0xfffeebec), + 4: Color(0xffffdbdc), + 5: Color(0xffffcdce), + 6: Color(0xfffdbdbe), + 7: Color(0xfff4a9aa), + 8: Color(0xffeb8e90), + 9: Color(0xffe5484d), + 10: Color(0xffdc3e42), + 11: Color(0xffce2c31), + 12: Color(0xff641723), + }), + ColorSwatch(0xb7db0007, { + 1: Color(0x03ff0000), + 2: Color(0x08ff0000), + 3: Color(0x14f3000d), + 4: Color(0x24ff0008), + 5: Color(0x32ff0006), + 6: Color(0x42f80004), + 7: Color(0x56df0003), + 8: Color(0x71d20005), + 9: Color(0xb7db0007), + 10: Color(0xc1d10005), + 11: Color(0xd3c40006), + 12: Color(0xe855000d), + }), + ), + Color(0xccfff5f5), + Color(0xffe5484d), + Color(0xffe5484d), + Color(0xffffffff), +); + +const _redDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffe5484d, { + 1: Color(0xff191111), + 2: Color(0xff201314), + 3: Color(0xff3b1219), + 4: Color(0xff500f1c), + 5: Color(0xff611623), + 6: Color(0xff72232d), + 7: Color(0xff8c333a), + 8: Color(0xffb54548), + 9: Color(0xffe5484d), + 10: Color(0xffec5d5e), + 11: Color(0xffff9592), + 12: Color(0xffffd1d9), + }), + ColorSwatch(0xe4fe4e54, { + 1: Color(0x09f41212), + 2: Color(0x11f22f3e), + 3: Color(0x2dff173f), + 4: Color(0x44fe0a3b), + 5: Color(0x56ff2047), + 6: Color(0x68ff3e56), + 7: Color(0x84ff5361), + 8: Color(0xb0ff5d61), + 9: Color(0xe4fe4e54), + 10: Color(0xebff6465), + 11: Color(0xffff9592), + 12: Color(0xffffd1d9), + }), + ), + Color(0x802f1517), + Color(0xffe5484d), + Color(0xffe5484d), + Color(0xffffffff), +); + +// ruby color scale +const _rubyLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffe54666, { + 1: Color(0xfffffcfd), + 2: Color(0xfffff7f8), + 3: Color(0xfffeeaed), + 4: Color(0xffffdce1), + 5: Color(0xffffced6), + 6: Color(0xfff8bfc8), + 7: Color(0xffefacb8), + 8: Color(0xffe592a3), + 9: Color(0xffe54666), + 10: Color(0xffdc3b5d), + 11: Color(0xffca244d), + 12: Color(0xff64172b), + }), + ColorSwatch(0xb9db002c, { + 1: Color(0x03ff0055), + 2: Color(0x08ff0020), + 3: Color(0x15f30025), + 4: Color(0x23ff0025), + 5: Color(0x31ff002a), + 6: Color(0x40e40024), + 7: Color(0x53ce0025), + 8: Color(0x6dc30028), + 9: Color(0xb9db002c), + 10: Color(0xc4d2002c), + 11: Color(0xdbc10030), + 12: Color(0xe8550016), + }), + ), + Color(0xccfff5f6), + Color(0xffe54666), + Color(0xffe54666), + Color(0xffffffff), +); + +const _rubyDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffe54666, { + 1: Color(0xff191113), + 2: Color(0xff1e1517), + 3: Color(0xff3a141e), + 4: Color(0xff4e1325), + 5: Color(0xff5e1a2e), + 6: Color(0xff6f2539), + 7: Color(0xff883447), + 8: Color(0xffb3445a), + 9: Color(0xffe54666), + 10: Color(0xffec5a72), + 11: Color(0xffff949d), + 12: Color(0xfffed2e1), + }), + ColorSwatch(0xe4fe4c70, { + 1: Color(0x09f4124a), + 2: Color(0x0efe5a7f), + 3: Color(0x2cff235d), + 4: Color(0x42fd195e), + 5: Color(0x53fe2d6b), + 6: Color(0x65ff4476), + 7: Color(0x80ff577d), + 8: Color(0xaeff5c7c), + 9: Color(0xe4fe4c70), + 10: Color(0xebff617b), + 11: Color(0xffff949d), + 12: Color(0xfeffd3e2), + }), + ), + Color(0x802b191d), + Color(0xffe54666), + Color(0xffe54666), + Color(0xffffffff), +); + +// crimson color scale +const _crimsonLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffe93d82, { + 1: Color(0xfffffcfd), + 2: Color(0xfffef7f9), + 3: Color(0xffffe9f0), + 4: Color(0xfffedce7), + 5: Color(0xfffacedd), + 6: Color(0xfff3bed1), + 7: Color(0xffeaacc3), + 8: Color(0xffe093b2), + 9: Color(0xffe93d82), + 10: Color(0xffdf3478), + 11: Color(0xffcb1d63), + 12: Color(0xff621639), + }), + ColorSwatch(0xc2e2005b, { + 1: Color(0x03ff0055), + 2: Color(0x08e00040), + 3: Color(0x16ff0052), + 4: Color(0x23f80051), + 5: Color(0x31e5004f), + 6: Color(0x41d0004b), + 7: Color(0x53bf0047), + 8: Color(0x6cb6004a), + 9: Color(0xc2e2005b), + 10: Color(0xcbd70056), + 11: Color(0xe2c4004f), + 12: Color(0xe9530026), + }), + ), + Color(0xccfef5f8), + Color(0xffe93d82), + Color(0xffe93d82), + Color(0xffffffff), +); + +const _crimsonDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffe93d82, { + 1: Color(0xff191114), + 2: Color(0xff201318), + 3: Color(0xff381525), + 4: Color(0xff4d122f), + 5: Color(0xff5c1839), + 6: Color(0xff6d2545), + 7: Color(0xff873356), + 8: Color(0xffb0436e), + 9: Color(0xffe93d82), + 10: Color(0xffee518a), + 11: Color(0xffff92ad), + 12: Color(0xfffdd3e8), + }), + ColorSwatch(0xe8fe418d, { + 1: Color(0x09f41267), + 2: Color(0x11f22f7a), + 3: Color(0x2afe2a8b), + 4: Color(0x41fd1587), + 5: Color(0x51fd278f), + 6: Color(0x63fe4597), + 7: Color(0x7ffd559b), + 8: Color(0xabfe5b9b), + 9: Color(0xe8fe418d), + 10: Color(0xedff5693), + 11: Color(0xffff92ad), + 12: Color(0xfdffd5ea), + }), + ), + Color(0x802f151f), + Color(0xffe93d82), + Color(0xffe93d82), + Color(0xffffffff), +); + +// pink color scale +const _pinkLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffd6409f, { + 1: Color(0xfffffcfe), + 2: Color(0xfffef7fb), + 3: Color(0xfffee9f5), + 4: Color(0xfffbdcef), + 5: Color(0xfff6cee7), + 6: Color(0xffefbfdd), + 7: Color(0xffe7acd0), + 8: Color(0xffdd93c2), + 9: Color(0xffd6409f), + 10: Color(0xffcf3897), + 11: Color(0xffc2298a), + 12: Color(0xff651249), + }), + ColorSwatch(0xbfc8007f, { + 1: Color(0x03ff00aa), + 2: Color(0x08e00080), + 3: Color(0x16f4008c), + 4: Color(0x23e2008b), + 5: Color(0x31d10083), + 6: Color(0x40c00078), + 7: Color(0x53b6006f), + 8: Color(0x6caf006f), + 9: Color(0xbfc8007f), + 10: Color(0xc7c2007a), + 11: Color(0xd6b60074), + 12: Color(0xed59003b), + }), + ), + Color(0xccfef5fa), + Color(0xffd6409f), + Color(0xffd6409f), + Color(0xffffffff), +); + +const _pinkDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffd6409f, { + 1: Color(0xff191117), + 2: Color(0xff21121d), + 3: Color(0xff37172f), + 4: Color(0xff4b143d), + 5: Color(0xff591c47), + 6: Color(0xff692955), + 7: Color(0xff833869), + 8: Color(0xffa84885), + 9: Color(0xffd6409f), + 10: Color(0xffde51a8), + 11: Color(0xffff8dcc), + 12: Color(0xfffdd1ea), + }), + ColorSwatch(0xd4fe49bc, { + 1: Color(0x09f412bc), + 2: Color(0x12f420bb), + 3: Color(0x29fe37cc), + 4: Color(0x3ffc1ec4), + 5: Color(0x4efd35c2), + 6: Color(0x5ffd51c7), + 7: Color(0x7bfd62c8), + 8: Color(0xa2ff68c8), + 9: Color(0xd4fe49bc), + 10: Color(0xdcff5cc0), + 11: Color(0xffff8dcc), + 12: Color(0xfdffd3ec), + }), + ), + Color(0x80311329), + Color(0xffd6409f), + Color(0xffd6409f), + Color(0xffffffff), +); + +// plum color scale +const _plumLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffab4aba, { + 1: Color(0xfffefcff), + 2: Color(0xfffdf7fd), + 3: Color(0xfffbebfb), + 4: Color(0xfff7def8), + 5: Color(0xfff2d1f3), + 6: Color(0xffe9c2ec), + 7: Color(0xffdeade3), + 8: Color(0xffcf91d8), + 9: Color(0xffab4aba), + 10: Color(0xffa144af), + 11: Color(0xff953ea3), + 12: Color(0xff53195d), + }), + ColorSwatch(0xb589009e, { + 1: Color(0x03aa00ff), + 2: Color(0x08c000c0), + 3: Color(0x14cc00cc), + 4: Color(0x21c200c9), + 5: Color(0x2eb700bd), + 6: Color(0x3da400b0), + 7: Color(0x529900a8), + 8: Color(0x6e9000a5), + 9: Color(0xb589009e), + 10: Color(0xbb7f0092), + 11: Color(0xc1730086), + 12: Color(0xe640004b), + }), + ), + Color(0xccfdf5fd), + Color(0xffab4aba), + Color(0xffab4aba), + Color(0xffffffff), +); + +const _plumDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffab4aba, { + 1: Color(0xff181118), + 2: Color(0xff201320), + 3: Color(0xff351a35), + 4: Color(0xff451d47), + 5: Color(0xff512454), + 6: Color(0xff5e3061), + 7: Color(0xff734079), + 8: Color(0xff92549c), + 9: Color(0xffab4aba), + 10: Color(0xffb658c4), + 11: Color(0xffe796f3), + 12: Color(0xfff4d4f4), + }), + ColorSwatch(0xb6e961fe, { + 1: Color(0x08f112f1), + 2: Color(0x11f22ff2), + 3: Color(0x27fd4cfd), + 4: Color(0x3af646ff), + 5: Color(0x48f455ff), + 6: Color(0x56f66dff), + 7: Color(0x70f07cfd), + 8: Color(0x95ee84ff), + 9: Color(0xb6e961fe), + 10: Color(0xc0ed70ff), + 11: Color(0xf3f19cfe), + 12: Color(0xf4feddfe), + }), + ), + Color(0x802f152f), + Color(0xffab4aba), + Color(0xffab4aba), + Color(0xffffffff), +); + +// purple color scale +const _purpleLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8e4ec6, { + 1: Color(0xfffefcfe), + 2: Color(0xfffbf7fe), + 3: Color(0xfff7edfe), + 4: Color(0xfff2e2fc), + 5: Color(0xffead5f9), + 6: Color(0xffe0c4f4), + 7: Color(0xffd1afec), + 8: Color(0xffbe93e4), + 9: Color(0xff8e4ec6), + 10: Color(0xff8347b9), + 11: Color(0xff8145b5), + 12: Color(0xff402060), + }), + ColorSwatch(0xb15c00ad, { + 1: Color(0x03aa00aa), + 2: Color(0x088000e0), + 3: Color(0x128e00f1), + 4: Color(0x1d8d00e5), + 5: Color(0x2a8000db), + 6: Color(0x3b7a01d0), + 7: Color(0x506d00c3), + 8: Color(0x6c6600c0), + 9: Color(0xb15c00ad), + 10: Color(0xb853009e), + 11: Color(0xba52009a), + 12: Color(0xdf250049), + }), + ), + Color(0xccfaf5fe), + Color(0xff8e4ec6), + Color(0xff8e4ec6), + Color(0xffffffff), +); + +const _purpleDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff8e4ec6, { + 1: Color(0xff18111b), + 2: Color(0xff1e1523), + 3: Color(0xff301c3b), + 4: Color(0xff3d224e), + 5: Color(0xff48295c), + 6: Color(0xff54346b), + 7: Color(0xff664282), + 8: Color(0xff8457aa), + 9: Color(0xff8e4ec6), + 10: Color(0xff9a5cd0), + 11: Color(0xffd19dff), + 12: Color(0xffecd9fa), + }), + ColorSwatch(0xc2b661ff, { + 1: Color(0x0bb412f9), + 2: Color(0x14b744f7), + 3: Color(0x2dc150ff), + 4: Color(0x42bb53fd), + 5: Color(0x51be5cfd), + 6: Color(0x61c16dfd), + 7: Color(0x7ac378fd), + 8: Color(0xa4c47eff), + 9: Color(0xc2b661ff), + 10: Color(0xcdbc6fff), + 11: Color(0xffd19dff), + 12: Color(0xfaf1ddff), + }), + ), + Color(0x802b1735), + Color(0xff8e4ec6), + Color(0xff8e4ec6), + Color(0xffffffff), +); + +// violet color scale +const _violetLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff6e56cf, { + 1: Color(0xfffdfcfe), + 2: Color(0xfffaf8ff), + 3: Color(0xfff4f0fe), + 4: Color(0xffebe4ff), + 5: Color(0xffe1d9ff), + 6: Color(0xffd4cafe), + 7: Color(0xffc2b5f5), + 8: Color(0xffaa99ec), + 9: Color(0xff6e56cf), + 10: Color(0xff654dc4), + 11: Color(0xff6550b9), + 12: Color(0xff2f265f), + }), + ColorSwatch(0xa92400b7, { + 1: Color(0x035500aa), + 2: Color(0x074900ff), + 3: Color(0x0f4400ee), + 4: Color(0x1b4300ff), + 5: Color(0x263600ff), + 6: Color(0x353100fb), + 7: Color(0x4a2d01dd), + 8: Color(0x662b00d0), + 9: Color(0xa92400b7), + 10: Color(0xb22300ab), + 11: Color(0xaf1f0099), + 12: Color(0xd90b0043), + }), + ), + Color(0xccf9f6ff), + Color(0xff6e56cf), + Color(0xff6e56cf), + Color(0xffffffff), +); + +const _violetDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff6e56cf, { + 1: Color(0xff14121f), + 2: Color(0xff1b1525), + 3: Color(0xff291f43), + 4: Color(0xff33255b), + 5: Color(0xff3c2e69), + 6: Color(0xff473876), + 7: Color(0xff56468b), + 8: Color(0xff6958ad), + 9: Color(0xff6e56cf), + 10: Color(0xff7d66d9), + 11: Color(0xffbaa7ff), + 12: Color(0xffe2ddfe), + }), + ColorSwatch(0xcc8668ff, { + 1: Color(0x0f4422ff), + 2: Color(0x16853ff9), + 3: Color(0x368354fe), + 4: Color(0x507d51fd), + 5: Color(0x5f845ffd), + 6: Color(0x6d8f6cfd), + 7: Color(0x839879ff), + 8: Color(0xa8977dfe), + 9: Color(0xcc8668ff), + 10: Color(0xd79176fe), + 11: Color(0xffbaa7ff), + 12: Color(0xfee3deff), + }), + ), + Color(0x80251939), + Color(0xff6e56cf), + Color(0xff6e56cf), + Color(0xffffffff), +); + +// iris color scale +const _irisLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff5b5bd6, { + 1: Color(0xfffdfdff), + 2: Color(0xfff8f8ff), + 3: Color(0xfff0f1fe), + 4: Color(0xffe6e7ff), + 5: Color(0xffdadcff), + 6: Color(0xffcbcdff), + 7: Color(0xffb8baf8), + 8: Color(0xff9b9ef0), + 9: Color(0xff5b5bd6), + 10: Color(0xff5151cd), + 11: Color(0xff5753c6), + 12: Color(0xff272962), + }), + ColorSwatch(0xa40000c0, { + 1: Color(0x020000ff), + 2: Color(0x070000ff), + 3: Color(0x0f0011ee), + 4: Color(0x19000bff), + 5: Color(0x25000eff), + 6: Color(0x34000aff), + 7: Color(0x470008e6), + 8: Color(0x640008d9), + 9: Color(0xa40000c0), + 10: Color(0xae0000b6), + 11: Color(0xac0600ab), + 12: Color(0xd8000246), + }), + ), + Color(0xccf6f6ff), + Color(0xff5b5bd6), + Color(0xff5b5bd6), + Color(0xffffffff), +); + +const _irisDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff5b5bd6, { + 1: Color(0xff13131e), + 2: Color(0xff171625), + 3: Color(0xff202248), + 4: Color(0xff262a65), + 5: Color(0xff303374), + 6: Color(0xff3d3e82), + 7: Color(0xff4a4a95), + 8: Color(0xff5958b1), + 9: Color(0xff5b5bd6), + 10: Color(0xff6e6ade), + 11: Color(0xffb1a9ff), + 12: Color(0xffe0dffe), + }), + ColorSwatch(0xd46a6afe, { + 1: Color(0x0e3636fe), + 2: Color(0x16564bf9), + 3: Color(0x3b525bff), + 4: Color(0x5a4d58ff), + 5: Color(0x6b5b62fd), + 6: Color(0x7a6d6ffd), + 7: Color(0x8e7777fe), + 8: Color(0xac7b7afe), + 9: Color(0xd46a6afe), + 10: Color(0xdc7d79ff), + 11: Color(0xffb1a9ff), + 12: Color(0xfee1e0ff), + }), + ), + Color(0x801d1b39), + Color(0xff5b5bd6), + Color(0xff5b5bd6), + Color(0xffffffff), +); + +// indigo color scale +const _indigoLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff3e63dd, { + 1: Color(0xfffdfdfe), + 2: Color(0xfff7f9ff), + 3: Color(0xffedf2fe), + 4: Color(0xffe1e9ff), + 5: Color(0xffd2deff), + 6: Color(0xffc1d0ff), + 7: Color(0xffabbdf9), + 8: Color(0xff8da4ef), + 9: Color(0xff3e63dd), + 10: Color(0xff3358d4), + 11: Color(0xff3a5bc7), + 12: Color(0xff1f2d5c), + }), + ColorSwatch(0xc10031d2, { + 1: Color(0x02000080), + 2: Color(0x080040ff), + 3: Color(0x120047f1), + 4: Color(0x1e0044ff), + 5: Color(0x2d0044ff), + 6: Color(0x3e003eff), + 7: Color(0x540037ed), + 8: Color(0x720034dc), + 9: Color(0xc10031d2), + 10: Color(0xcc002ec9), + 11: Color(0xc5002bb7), + 12: Color(0xe0001046), + }), + ), + Color(0xccf5f8ff), + Color(0xff3e63dd), + Color(0xff3e63dd), + Color(0xffffffff), +); + +const _indigoDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff3e63dd, { + 1: Color(0xff11131f), + 2: Color(0xff141726), + 3: Color(0xff182449), + 4: Color(0xff1d2e62), + 5: Color(0xff253974), + 6: Color(0xff304384), + 7: Color(0xff3a4f97), + 8: Color(0xff435db1), + 9: Color(0xff3e63dd), + 10: Color(0xff5472e4), + 11: Color(0xff9eb1ff), + 12: Color(0xffd6e1ff), + }), + ColorSwatch(0xdb4671ff, { + 1: Color(0x0f1133ff), + 2: Color(0x173354fa), + 3: Color(0x3c2f62ff), + 4: Color(0x573566ff), + 5: Color(0x6b4171fd), + 6: Color(0x7c5178fd), + 7: Color(0x905a7fff), + 8: Color(0xac5b81fe), + 9: Color(0xdb4671ff), + 10: Color(0xe35c7efe), + 11: Color(0xff9eb1ff), + 12: Color(0xffd6e1ff), + }), + ), + Color(0x80171d3b), + Color(0xff3e63dd), + Color(0xff3e63dd), + Color(0xffffffff), +); + +// blue color scale +const _blueLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff0090ff, { + 1: Color(0xfffbfdff), + 2: Color(0xfff4faff), + 3: Color(0xffe6f4fe), + 4: Color(0xffd5efff), + 5: Color(0xffc2e5ff), + 6: Color(0xffacd8fc), + 7: Color(0xff8ec8f6), + 8: Color(0xff5eb1ef), + 9: Color(0xff0090ff), + 10: Color(0xff0588f0), + 11: Color(0xff0d74ce), + 12: Color(0xff113264), + }), + ColorSwatch(0xff0090ff, { + 1: Color(0x040080ff), + 2: Color(0x0b008cff), + 3: Color(0x19008ff5), + 4: Color(0x2a009eff), + 5: Color(0x3d0093ff), + 6: Color(0x530088f6), + 7: Color(0x710083eb), + 8: Color(0xa10084e6), + 9: Color(0xff0090ff), + 10: Color(0xfa0086f0), + 11: Color(0xf2006dcb), + 12: Color(0xee002359), + }), + ), + Color(0xccf1f9ff), + Color(0xff0090ff), + Color(0xff0090ff), + Color(0xffffffff), +); + +const _blueDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff0090ff, { + 1: Color(0xff0d1520), + 2: Color(0xff111927), + 3: Color(0xff0d2847), + 4: Color(0xff003362), + 5: Color(0xff004074), + 6: Color(0xff104d87), + 7: Color(0xff205d9e), + 8: Color(0xff2870bd), + 9: Color(0xff0090ff), + 10: Color(0xff3b9eff), + 11: Color(0xff70b8ff), + 12: Color(0xffc2e6ff), + }), + ColorSwatch(0xff0090ff, { + 1: Color(0x11004df2), + 2: Color(0x181166fb), + 3: Color(0x3a0077ff), + 4: Color(0x570075ff), + 5: Color(0x6b0081fd), + 6: Color(0x7f0f89fd), + 7: Color(0x982a91fe), + 8: Color(0xb93094fe), + 9: Color(0xff0090ff), + 10: Color(0xff3b9eff), + 11: Color(0xff70b8ff), + 12: Color(0xffc2e6ff), + }), + ), + Color(0x8011213d), + Color(0xff0090ff), + Color(0xff0090ff), + Color(0xffffffff), +); + +// cyan color scale +const _cyanLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff00a2c7, { + 1: Color(0xfffafdfe), + 2: Color(0xfff2fafb), + 3: Color(0xffdef7f9), + 4: Color(0xffcaf1f6), + 5: Color(0xffb5e9f0), + 6: Color(0xff9ddde7), + 7: Color(0xff7dcedc), + 8: Color(0xff3db9cf), + 9: Color(0xff00a2c7), + 10: Color(0xff0797b9), + 11: Color(0xff107d98), + 12: Color(0xff0d3c48), + }), + ColorSwatch(0xff00a2c7, { + 1: Color(0x050099cc), + 2: Color(0x0d009db1), + 3: Color(0x2100c2d1), + 4: Color(0x3500bcd4), + 5: Color(0x4a01b4cc), + 6: Color(0x6200a7c1), + 7: Color(0x82009fbb), + 8: Color(0xc200a3c0), + 9: Color(0xff00a2c7), + 10: Color(0xf80094b7), + 11: Color(0xef007491), + 12: Color(0xf200323e), + }), + ), + Color(0xcceff9fa), + Color(0xff00a2c7), + Color(0xff00a2c7), + Color(0xffffffff), +); + +const _cyanDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff00a2c7, { + 1: Color(0xff0b161a), + 2: Color(0xff101b20), + 3: Color(0xff082c36), + 4: Color(0xff003848), + 5: Color(0xff004558), + 6: Color(0xff045468), + 7: Color(0xff12677e), + 8: Color(0xff11809c), + 9: Color(0xff00a2c7), + 10: Color(0xff23afd0), + 11: Color(0xff4ccce6), + 12: Color(0xffb6ecf7), + }), + ColorSwatch(0xc300cfff, { + 1: Color(0x0a0091f7), + 2: Color(0x1102a7f2), + 3: Color(0x2800befd), + 4: Color(0x3b00baff), + 5: Color(0x4d00befd), + 6: Color(0x5e00c7fd), + 7: Color(0x7514cdff), + 8: Color(0x9511cfff), + 9: Color(0xc300cfff), + 10: Color(0xcd28d6ff), + 11: Color(0xe552e1fe), + 12: Color(0xf7bbf3fe), + }), + ), + Color(0x8011252d), + Color(0xff00a2c7), + Color(0xff00a2c7), + Color(0xffffffff), +); + +// teal color scale +const _tealLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff12a594, { + 1: Color(0xfffafefd), + 2: Color(0xfff3fbf9), + 3: Color(0xffe0f8f3), + 4: Color(0xffccf3ea), + 5: Color(0xffb8eae0), + 6: Color(0xffa1ded2), + 7: Color(0xff83cdc1), + 8: Color(0xff53b9ab), + 9: Color(0xff12a594), + 10: Color(0xff0d9b8a), + 11: Color(0xff008573), + 12: Color(0xff0d3d38), + }), + ColorSwatch(0xed009e8c, { + 1: Color(0x0500cc99), + 2: Color(0x0c00aa80), + 3: Color(0x1f00c69d), + 4: Color(0x3300c396), + 5: Color(0x4700b490), + 6: Color(0x5e00a685), + 7: Color(0x7c009980), + 8: Color(0xac009783), + 9: Color(0xed009e8c), + 10: Color(0xf2009684), + 11: Color(0xff008573), + 12: Color(0xf200332d), + }), + ), + Color(0xccf0faf8), + Color(0xff12a594), + Color(0xff12a594), + Color(0xffffffff), +); + +const _tealDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff12a594, { + 1: Color(0xff0d1514), + 2: Color(0xff111c1b), + 3: Color(0xff0d2d2a), + 4: Color(0xff023b37), + 5: Color(0xff084843), + 6: Color(0xff145750), + 7: Color(0xff1c6961), + 8: Color(0xff207e73), + 9: Color(0xff12a594), + 10: Color(0xff0eb39e), + 11: Color(0xff0bd8b6), + 12: Color(0xffadf0dd), + }), + ColorSwatch(0x9f13ffe4, { + 1: Color(0x0500deab), + 2: Color(0x0c12fbe6), + 3: Color(0x1e00ffe6), + 4: Color(0x2d00ffe9), + 5: Color(0x3b00ffea), + 6: Color(0x4b1cffe8), + 7: Color(0x5f2efde8), + 8: Color(0x7532ffe7), + 9: Color(0x9f13ffe4), + 10: Color(0xae0dffe0), + 11: Color(0xd60afed5), + 12: Color(0xefb8ffeb), + }), + ), + Color(0x80132725), + Color(0xff12a594), + Color(0xff12a594), + Color(0xffffffff), +); + +// jade color scale +const _jadeLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff29a383, { + 1: Color(0xfffbfefd), + 2: Color(0xfff4fbf7), + 3: Color(0xffe6f7ed), + 4: Color(0xffd6f1e3), + 5: Color(0xffc3e9d7), + 6: Color(0xffacdec8), + 7: Color(0xff8bceb6), + 8: Color(0xff56ba9f), + 9: Color(0xff29a383), + 10: Color(0xff26997b), + 11: Color(0xff208368), + 12: Color(0xff1d3b31), + }), + ColorSwatch(0xd600916b, { + 1: Color(0x0400c080), + 2: Color(0x0b00a346), + 3: Color(0x1900ae48), + 4: Color(0x2900a851), + 5: Color(0x3c00a255), + 6: Color(0x53009a57), + 7: Color(0x7400945f), + 8: Color(0xa900976e), + 9: Color(0xd600916b), + 10: Color(0xd9008764), + 11: Color(0xdf007152), + 12: Color(0xe2002217), + }), + ), + Color(0xccf1faf5), + Color(0xff29a383), + Color(0xff29a383), + Color(0xffffffff), +); + +const _jadeDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff29a383, { + 1: Color(0xff0d1512), + 2: Color(0xff121c18), + 3: Color(0xff0f2e22), + 4: Color(0xff0b3b2c), + 5: Color(0xff114837), + 6: Color(0xff1b5745), + 7: Color(0xff246854), + 8: Color(0xff2a7e68), + 9: Color(0xff29a383), + 10: Color(0xff27b08b), + 11: Color(0xff1fd8a4), + 12: Color(0xffadf0d4), + }), + ColorSwatch(0x9d38feca, { + 1: Color(0x0500de45), + 2: Color(0x0c27fba6), + 3: Color(0x2002f999), + 4: Color(0x2d00ffaa), + 5: Color(0x3b11ffb6), + 6: Color(0x4b34ffc2), + 7: Color(0x5e45fdc7), + 8: Color(0x7548ffcf), + 9: Color(0x9d38feca), + 10: Color(0xab31fec7), + 11: Color(0xd621fec0), + 12: Color(0xefb8ffe1), + }), + ), + Color(0x8013271f), + Color(0xff29a383), + Color(0xff29a383), + Color(0xffffffff), +); + +// green color scale +const _greenLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff30a46c, { + 1: Color(0xfffbfefc), + 2: Color(0xfff4fbf6), + 3: Color(0xffe6f6eb), + 4: Color(0xffd6f1df), + 5: Color(0xffc4e8d1), + 6: Color(0xffadddc0), + 7: Color(0xff8eceaa), + 8: Color(0xff5bb98b), + 9: Color(0xff30a46c), + 10: Color(0xff2b9a66), + 11: Color(0xff218358), + 12: Color(0xff193b2d), + }), + ColorSwatch(0xcf008f4a, { + 1: Color(0x0400c040), + 2: Color(0x0b00a32f), + 3: Color(0x1900a433), + 4: Color(0x2900a838), + 5: Color(0x3b019c39), + 6: Color(0x5200963c), + 7: Color(0x71009140), + 8: Color(0xa400924b), + 9: Color(0xcf008f4a), + 10: Color(0xd4008647), + 11: Color(0xde00713f), + 12: Color(0xe6002616), + }), + ), + Color(0xccf1faf4), + Color(0xff30a46c), + Color(0xff30a46c), + Color(0xffffffff), +); + +const _greenDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff30a46c, { + 1: Color(0xff0e1512), + 2: Color(0xff121b17), + 3: Color(0xff132d21), + 4: Color(0xff113b29), + 5: Color(0xff174933), + 6: Color(0xff20573e), + 7: Color(0xff28684a), + 8: Color(0xff2f7c57), + 9: Color(0xff30a46c), + 10: Color(0xff33b074), + 11: Color(0xff3dd68c), + 12: Color(0xffb1f1cb), + }), + ColorSwatch(0x9e44ffa4, { + 1: Color(0x0500de45), + 2: Color(0x0b29f99d), + 3: Color(0x1e22ff99), + 4: Color(0x2d11ff99), + 5: Color(0x3c2bffa2), + 6: Color(0x4b44ffaa), + 7: Color(0x5e50fdac), + 8: Color(0x7354ffad), + 9: Color(0x9e44ffa4), + 10: Color(0xab43fea4), + 11: Color(0xd446fea5), + 12: Color(0xf0bbffd7), + }), + ), + Color(0x8015251d), + Color(0xff30a46c), + Color(0xff30a46c), + Color(0xffffffff), +); + +// grass color scale +const _grassLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff46a758, { + 1: Color(0xfffbfefb), + 2: Color(0xfff5fbf5), + 3: Color(0xffe9f6e9), + 4: Color(0xffdaf1db), + 5: Color(0xffc9e8ca), + 6: Color(0xffb2ddb5), + 7: Color(0xff94ce9a), + 8: Color(0xff65ba74), + 9: Color(0xff46a758), + 10: Color(0xff3e9b4f), + 11: Color(0xff2a7e3b), + 12: Color(0xff203c25), + }), + ColorSwatch(0xb9008619, { + 1: Color(0x0400c000), + 2: Color(0x0a009900), + 3: Color(0x16009700), + 4: Color(0x25009f07), + 5: Color(0x36009305), + 6: Color(0x4d008f0a), + 7: Color(0x6b018b0f), + 8: Color(0x9a008d19), + 9: Color(0xb9008619), + 10: Color(0xc1007b17), + 11: Color(0xd5006514), + 12: Color(0xdf002006), + }), + ), + Color(0xccf3faf3), + Color(0xff46a758), + Color(0xff46a758), + Color(0xffffffff), +); + +const _grassDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff46a758, { + 1: Color(0xff0e1511), + 2: Color(0xff141a15), + 3: Color(0xff1b2a1e), + 4: Color(0xff1d3a24), + 5: Color(0xff25482d), + 6: Color(0xff2d5736), + 7: Color(0xff366740), + 8: Color(0xff3e7949), + 9: Color(0xff46a758), + 10: Color(0xff53b365), + 11: Color(0xff71d083), + 12: Color(0xffc2f0c2), + }), + ColorSwatch(0xa165ff82, { + 1: Color(0x0500de12), + 2: Color(0x0a5ef778), + 3: Color(0x1b70fe8c), + 4: Color(0x2c57ff80), + 5: Color(0x3b68ff8b), + 6: Color(0x4b71ff8f), + 7: Color(0x5d77fd92), + 8: Color(0x7077fd90), + 9: Color(0xa165ff82), + 10: Color(0xae72ff8d), + 11: Color(0xcd89ff9f), + 12: Color(0xefceffce), + }), + ), + Color(0x8019231b), + Color(0xff46a758), + Color(0xff46a758), + Color(0xffffffff), +); + +// bronze color scale +const _bronzeLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffa18072, { + 1: Color(0xfffdfcfc), + 2: Color(0xfffdf7f5), + 3: Color(0xfff6edea), + 4: Color(0xffefe4df), + 5: Color(0xffe7d9d3), + 6: Color(0xffdfcdc5), + 7: Color(0xffd3bcb3), + 8: Color(0xffc2a499), + 9: Color(0xffa18072), + 10: Color(0xff957468), + 11: Color(0xff7d5e54), + 12: Color(0xff43302b), + }), + ColorSwatch(0x8d551a00, { + 1: Color(0x03550000), + 2: Color(0x0acc3300), + 3: Color(0x15922500), + 4: Color(0x20802800), + 5: Color(0x2c742300), + 6: Color(0x3a732400), + 7: Color(0x4c6c1f00), + 8: Color(0x66671c00), + 9: Color(0x8d551a00), + 10: Color(0x974c1500), + 11: Color(0xab3d0f00), + 12: Color(0xd41d0600), + }), + ), + Color(0xccfdf5f3), + Color(0xffa18072), + Color(0xffa18072), + Color(0xffffffff), +); + +const _bronzeDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffa18072, { + 1: Color(0xff141110), + 2: Color(0xff1c1917), + 3: Color(0xff262220), + 4: Color(0xff302a27), + 5: Color(0xff3b3330), + 6: Color(0xff493e3a), + 7: Color(0xff5a4c47), + 8: Color(0xff6f5f58), + 9: Color(0xffa18072), + 10: Color(0xffae8c7e), + 11: Color(0xffd4b3a5), + 12: Color(0xffede0d9), + }), + ColorSwatch(0x9bfec7b0, { + 1: Color(0x04d11100), + 2: Color(0x0cfbbc91), + 3: Color(0x17faceb8), + 4: Color(0x22facdb6), + 5: Color(0x2dffd2c1), + 6: Color(0x3cffd1c0), + 7: Color(0x4ffdd0c0), + 8: Color(0x65ffd6c5), + 9: Color(0x9bfec7b0), + 10: Color(0xa9fecab5), + 11: Color(0xd1ffd7c6), + 12: Color(0xecfff1e9), + }), + ), + Color(0x8027211d), + Color(0xffa18072), + Color(0xffa18072), + Color(0xffffffff), +); + +// gold color scale +const _goldLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff978365, { + 1: Color(0xfffdfdfc), + 2: Color(0xfffaf9f2), + 3: Color(0xfff2f0e7), + 4: Color(0xffeae6db), + 5: Color(0xffe1dccf), + 6: Color(0xffd8d0bf), + 7: Color(0xffcbc0aa), + 8: Color(0xffb9a88d), + 9: Color(0xff978365), + 10: Color(0xff8c7a5e), + 11: Color(0xff71624b), + 12: Color(0xff3b352b), + }), + ColorSwatch(0x9a533200, { + 1: Color(0x03555500), + 2: Color(0x0d9d8a00), + 3: Color(0x18756000), + 4: Color(0x246b4e00), + 5: Color(0x30604600), + 6: Color(0x40644400), + 7: Color(0x55634200), + 8: Color(0x72633d00), + 9: Color(0x9a533200), + 10: Color(0xa1492d00), + 11: Color(0xb4362100), + 12: Color(0xd4130c00), + }), + ), + Color(0xccf9f8ef), + Color(0xff978365), + Color(0xff978365), + Color(0xffffffff), +); + +const _goldDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff978365, { + 1: Color(0xff121211), + 2: Color(0xff1b1a17), + 3: Color(0xff24231f), + 4: Color(0xff2d2b26), + 5: Color(0xff38352e), + 6: Color(0xff444039), + 7: Color(0xff544f46), + 8: Color(0xff696256), + 9: Color(0xff978365), + 10: Color(0xffa39073), + 11: Color(0xffcbb99f), + 12: Color(0xffe8e2d9), + }), + ColorSwatch(0x90ffdba6, { + 1: Color(0x02919111), + 2: Color(0x0bf9e29d), + 3: Color(0x15f8ecbb), + 4: Color(0x1effeec4), + 5: Color(0x2afeecc2), + 6: Color(0x37feebcb), + 7: Color(0x48ffedcd), + 8: Color(0x5ffdeaca), + 9: Color(0x90ffdba6), + 10: Color(0x9dfedfb0), + 11: Color(0xc8fee7c6), + 12: Color(0xe7fef7ed), + }), + ), + Color(0x8025231d), + Color(0xff978365), + Color(0xff978365), + Color(0xffffffff), +); + +// brown color scale +const _brownLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffad7f58, { + 1: Color(0xfffefdfc), + 2: Color(0xfffcf9f6), + 3: Color(0xfff6eee7), + 4: Color(0xfff0e4d9), + 5: Color(0xffebdaca), + 6: Color(0xffe4cdb7), + 7: Color(0xffdcbc9f), + 8: Color(0xffcea37e), + 9: Color(0xffad7f58), + 10: Color(0xffa07553), + 11: Color(0xff815e46), + 12: Color(0xff3e332e), + }), + ColorSwatch(0xa7823c00, { + 1: Color(0x03aa5500), + 2: Color(0x09aa5500), + 3: Color(0x18a04b00), + 4: Color(0x269b4a00), + 5: Color(0x359f4d00), + 6: Color(0x48a04e00), + 7: Color(0x60a34e00), + 8: Color(0x819f4a00), + 9: Color(0xa7823c00), + 10: Color(0xac723300), + 11: Color(0xb9522100), + 12: Color(0xd1140600), + }), + ), + Color(0xccfbf8f4), + Color(0xffad7f58), + Color(0xffad7f58), + Color(0xffffffff), +); + +const _brownDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffad7f58, { + 1: Color(0xff12110f), + 2: Color(0xff1c1816), + 3: Color(0xff28211d), + 4: Color(0xff322922), + 5: Color(0xff3e3128), + 6: Color(0xff4d3c2f), + 7: Color(0xff614a39), + 8: Color(0xff7c5f46), + 9: Color(0xffad7f58), + 10: Color(0xffb88c67), + 11: Color(0xffdbb594), + 12: Color(0xfff2e1ca), + }), + ColorSwatch(0xa8feb87d, { + 1: Color(0x02911100), + 2: Color(0x0cfba67c), + 3: Color(0x19fcb58c), + 4: Color(0x24fbbb8a), + 5: Color(0x31fcb889), + 6: Color(0x41fdba87), + 7: Color(0x56ffbb88), + 8: Color(0x73ffbe87), + 9: Color(0xa8feb87d), + 10: Color(0xb3ffc18c), + 11: Color(0xd9fed1aa), + 12: Color(0xf2feecd4), + }), + ), + Color(0x80271f1b), + Color(0xffad7f58), + Color(0xffad7f58), + Color(0xffffffff), +); + +// orange color scale +const _orangeLight = RadixColor( + RadixColorScale( + ColorSwatch(0xfff76b15, { + 1: Color(0xfffefcfb), + 2: Color(0xfffff7ed), + 3: Color(0xffffefd6), + 4: Color(0xffffdfb5), + 5: Color(0xffffd19a), + 6: Color(0xffffc182), + 7: Color(0xfff5ae73), + 8: Color(0xffec9455), + 9: Color(0xfff76b15), + 10: Color(0xffef5f00), + 11: Color(0xffcc4e00), + 12: Color(0xff582d1d), + }), + ColorSwatch(0xeaf65e00, { + 1: Color(0x04c04000), + 2: Color(0x12ff8e00), + 3: Color(0x29ff9c00), + 4: Color(0x4aff9101), + 5: Color(0x65ff8b00), + 6: Color(0x7dff8100), + 7: Color(0x8ced6c00), + 8: Color(0xaae35f00), + 9: Color(0xeaf65e00), + 10: Color(0xffef5f00), + 11: Color(0xffcc4e00), + 12: Color(0xe2431200), + }), + ), + Color(0xccfff5e9), + Color(0xfff76b15), + Color(0xfff76b15), + Color(0xffffffff), +); + +const _orangeDark = RadixColor( + RadixColorScale( + ColorSwatch(0xfff76b15, { + 1: Color(0xff17120e), + 2: Color(0xff1e160f), + 3: Color(0xff331e0b), + 4: Color(0xff462100), + 5: Color(0xff562800), + 6: Color(0xff66350c), + 7: Color(0xff7e451d), + 8: Color(0xffa35829), + 9: Color(0xfff76b15), + 10: Color(0xffff801f), + 11: Color(0xffffa057), + 12: Color(0xffffe0c2), + }), + ColorSwatch(0xf7fe6d15, { + 1: Color(0x07ec3600), + 2: Color(0x0efe6d00), + 3: Color(0x25fb6a00), + 4: Color(0x39ff5900), + 5: Color(0x4aff6100), + 6: Color(0x5cfd7504), + 7: Color(0x75ff832c), + 8: Color(0x9dfe8438), + 9: Color(0xf7fe6d15), + 10: Color(0xffff801f), + 11: Color(0xffffa057), + 12: Color(0xffffe0c2), + }), + ), + Color(0x80271d13), + Color(0xfff76b15), + Color(0xfff76b15), + Color(0xffffffff), +); + +// amber color scale +const _amberLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffffc53d, { + 1: Color(0xfffefdfb), + 2: Color(0xfffefbe9), + 3: Color(0xfffff7c2), + 4: Color(0xffffee9c), + 5: Color(0xfffbe577), + 6: Color(0xfff3d673), + 7: Color(0xffe9c162), + 8: Color(0xffe2a336), + 9: Color(0xffffc53d), + 10: Color(0xffffba18), + 11: Color(0xffab6400), + 12: Color(0xff4f3422), + }), + ColorSwatch(0xc2ffb300, { + 1: Color(0x04c08000), + 2: Color(0x16f4d100), + 3: Color(0x3dffde00), + 4: Color(0x63ffd400), + 5: Color(0x88f8cf00), + 6: Color(0x8ceab500), + 7: Color(0x9ddc9b00), + 8: Color(0xc9da8a00), + 9: Color(0xc2ffb300), + 10: Color(0xe7ffb300), + 11: Color(0xffab6400), + 12: Color(0xdd341500), + }), + ), + Color(0xccfefae4), + Color(0xffffc53d), + Color(0xffffc53d), + Color(0xff21201c), +); + +const _amberDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffffc53d, { + 1: Color(0xff16120c), + 2: Color(0xff1d180f), + 3: Color(0xff302008), + 4: Color(0xff3f2700), + 5: Color(0xff4d3000), + 6: Color(0xff5c3d05), + 7: Color(0xff714f19), + 8: Color(0xff8f6424), + 9: Color(0xffffc53d), + 10: Color(0xffffd60a), + 11: Color(0xffffca16), + 12: Color(0xffffe7b3), + }), + ColorSwatch(0xffffc53d, { + 1: Color(0x06e63c00), + 2: Color(0x0dfd9b00), + 3: Color(0x22fa8200), + 4: Color(0x32fc8200), + 5: Color(0x41fd8b00), + 6: Color(0x51fd9b00), + 7: Color(0x67ffab25), + 8: Color(0x87ffae35), + 9: Color(0xffffc53d), + 10: Color(0xffffd60a), + 11: Color(0xffffca16), + 12: Color(0xffffe7b3), + }), + ), + Color(0x80271f13), + Color(0xffffc53d), + Color(0xffe2ac37), + Color(0xff21201c), +); + +// yellow color scale +const _yellowLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffffe629, { + 1: Color(0xfffdfdf9), + 2: Color(0xfffefce9), + 3: Color(0xfffffab8), + 4: Color(0xfffff394), + 5: Color(0xffffe770), + 6: Color(0xfff3d768), + 7: Color(0xffe4c767), + 8: Color(0xffd5ae39), + 9: Color(0xffffe629), + 10: Color(0xffffdc00), + 11: Color(0xff9e6c00), + 12: Color(0xff473b1f), + }), + ColorSwatch(0xd6ffe100, { + 1: Color(0x06aaaa00), + 2: Color(0x16f4dd00), + 3: Color(0x47ffee00), + 4: Color(0x6bffe301), + 5: Color(0x8fffd500), + 6: Color(0x97ebbc00), + 7: Color(0x98d2a100), + 8: Color(0xc6c99700), + 9: Color(0xd6ffe100), + 10: Color(0xffffdc00), + 11: Color(0xff9e6c00), + 12: Color(0xe02e2000), + }), + ), + Color(0xccfefbe4), + Color(0xffffe629), + Color(0xffffe629), + Color(0xff21201c), +); + +const _yellowDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffffe629, { + 1: Color(0xff14120b), + 2: Color(0xff1b180f), + 3: Color(0xff2d2305), + 4: Color(0xff362b00), + 5: Color(0xff433500), + 6: Color(0xff524202), + 7: Color(0xff665417), + 8: Color(0xff836a21), + 9: Color(0xffffe629), + 10: Color(0xffffff57), + 11: Color(0xfff5e147), + 12: Color(0xfff6eeb4), + }), + ColorSwatch(0xffffe629, { + 1: Color(0x04d15100), + 2: Color(0x0bf9b400), + 3: Color(0x1effaa00), + 4: Color(0x28fdb700), + 5: Color(0x36febb00), + 6: Color(0x46fec400), + 7: Color(0x5cfdcb22), + 8: Color(0x7bfdca32), + 9: Color(0xffffe629), + 10: Color(0xffffff57), + 11: Color(0xf5fee949), + 12: Color(0xf6fef6ba), + }), + ), + Color(0x80231f13), + Color(0xffffe629), + Color(0xffd2b929), + Color(0xff21201c), +); + +// lime color scale +const _limeLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffbdee63, { + 1: Color(0xfffcfdfa), + 2: Color(0xfff8faf3), + 3: Color(0xffeef6d6), + 4: Color(0xffe2f0bd), + 5: Color(0xffd3e7a6), + 6: Color(0xffc2da91), + 7: Color(0xffabc978), + 8: Color(0xff8db654), + 9: Color(0xffbdee63), + 10: Color(0xffb0e64c), + 11: Color(0xff5c7c2f), + 12: Color(0xff37401c), + }), + ColorSwatch(0x9c93e400, { + 1: Color(0x05669900), + 2: Color(0x0c6b9500), + 3: Color(0x2996c800), + 4: Color(0x428fc600), + 5: Color(0x5981bb00), + 6: Color(0x6e72aa00), + 7: Color(0x87619900), + 8: Color(0xab559200), + 9: Color(0x9c93e400), + 10: Color(0xb38fdc00), + 11: Color(0xd0375f00), + 12: Color(0xe31e2900), + }), + ), + Color(0xccf6f9f0), + Color(0xffbdee63), + Color(0xffbdee63), + Color(0xff1d211c), +); + +const _limeDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffbdee63, { + 1: Color(0xff11130c), + 2: Color(0xff151a10), + 3: Color(0xff1f2917), + 4: Color(0xff29371d), + 5: Color(0xff334423), + 6: Color(0xff3d522a), + 7: Color(0xff496231), + 8: Color(0xff577538), + 9: Color(0xffbdee63), + 10: Color(0xffd4ff70), + 11: Color(0xffbde56c), + 12: Color(0xffe3f7ba), + }), + ColorSwatch(0xedcaff69, { + 1: Color(0x0311bb00), + 2: Color(0x0a78f700), + 3: Color(0x1a9bfd4c), + 4: Color(0x29a7fe5c), + 5: Color(0x37affe65), + 6: Color(0x46b2fe6d), + 7: Color(0x57b6ff6f), + 8: Color(0x6cb6fd6d), + 9: Color(0xedcaff69), + 10: Color(0xffd4ff70), + 11: Color(0xe4d1fe77), + 12: Color(0xf7e9febf), + }), + ), + Color(0x801b2115), + Color(0xffbdee63), + Color(0xff98c254), + Color(0xff1d211c), +); + +// mint color scale +const _mintLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff86ead4, { + 1: Color(0xfff9fefd), + 2: Color(0xfff2fbf9), + 3: Color(0xffddf9f2), + 4: Color(0xffc8f4e9), + 5: Color(0xffb3ecde), + 6: Color(0xff9ce0d0), + 7: Color(0xff7ecfbd), + 8: Color(0xff4cbba5), + 9: Color(0xff86ead4), + 10: Color(0xff7de0cb), + 11: Color(0xff027864), + 12: Color(0xff16433c), + }), + ColorSwatch(0x7900d3a5, { + 1: Color(0x0600d5aa), + 2: Color(0x0d00b18a), + 3: Color(0x2200d29e), + 4: Color(0x3700cc99), + 5: Color(0x4c00c091), + 6: Color(0x6300b086), + 7: Color(0x8100a17d), + 8: Color(0xb3009e7f), + 9: Color(0x7900d3a5), + 10: Color(0x8200c399), + 11: Color(0xfd007763), + 12: Color(0xe900312a), + }), + ), + Color(0xcceffaf8), + Color(0xff86ead4), + Color(0xff86ead4), + Color(0xff1a211e), +); + +const _mintDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff86ead4, { + 1: Color(0xff0e1515), + 2: Color(0xff0f1b1b), + 3: Color(0xff092c2b), + 4: Color(0xff003a38), + 5: Color(0xff004744), + 6: Color(0xff105650), + 7: Color(0xff1e685f), + 8: Color(0xff277f70), + 9: Color(0xff86ead4), + 10: Color(0xffa8f5e5), + 11: Color(0xff58d5ba), + 12: Color(0xffc4f5e1), + }), + ColorSwatch(0xe992ffe7, { + 1: Color(0x0500dede), + 2: Color(0x0b00f9f9), + 3: Color(0x1d00fff6), + 4: Color(0x2c00fff4), + 5: Color(0x3a00fff2), + 6: Color(0x4a0effeb), + 7: Color(0x5e34fde5), + 8: Color(0x7641ffdf), + 9: Color(0xe992ffe7), + 10: Color(0xf5aefeed), + 11: Color(0xd267ffde), + 12: Color(0xf5cbfee9), + }), + ), + Color(0x80152727), + Color(0xff86ead4), + Color(0xff65c3b0), + Color(0xff1a211e), +); + +// sky color scale +const _skyLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff7ce2fe, { + 1: Color(0xfff9feff), + 2: Color(0xfff1fafd), + 3: Color(0xffe1f6fd), + 4: Color(0xffd1f0fa), + 5: Color(0xffbee7f5), + 6: Color(0xffa9daed), + 7: Color(0xff8dcae3), + 8: Color(0xff60b3d7), + 9: Color(0xff7ce2fe), + 10: Color(0xff74daf8), + 11: Color(0xff00749e), + 12: Color(0xff1d3e56), + }), + ColorSwatch(0x8300c7fe, { + 1: Color(0x0600d5ff), + 2: Color(0x0e00a4db), + 3: Color(0x1e00b3ee), + 4: Color(0x2e00ace4), + 5: Color(0x4100a1d8), + 6: Color(0x560092ca), + 7: Color(0x720089c1), + 8: Color(0x9f0085bf), + 9: Color(0x8300c7fe), + 10: Color(0x8b00bcf3), + 11: Color(0xff00749e), + 12: Color(0xe2002540), + }), + ), + Color(0xcceef9fd), + Color(0xff7ce2fe), + Color(0xff7ce2fe), + Color(0xff1c2024), +); + +const _skyDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff7ce2fe, { + 1: Color(0xff0d141f), + 2: Color(0xff111a27), + 3: Color(0xff112840), + 4: Color(0xff113555), + 5: Color(0xff154467), + 6: Color(0xff1b537b), + 7: Color(0xff1f6692), + 8: Color(0xff197cae), + 9: Color(0xff7ce2fe), + 10: Color(0xffa8eeff), + 11: Color(0xff75c7f0), + 12: Color(0xffc2f3ff), + }), + ColorSwatch(0xfe7ce3ff, { + 1: Color(0x0f0044ff), + 2: Color(0x181171fb), + 3: Color(0x331184fc), + 4: Color(0x49128fff), + 5: Color(0x5d1c9dfd), + 6: Color(0x7228a5ff), + 7: Color(0x8b2badfe), + 8: Color(0xa91db2fe), + 9: Color(0xfe7ce3ff), + 10: Color(0xffa8eeff), + 11: Color(0xef7cd3ff), + 12: Color(0xffc2f3ff), + }), + ), + Color(0x8013233b), + Color(0xff7ce2fe), + Color(0xff5bbde2), + Color(0xff1c2024), +); + +// blackA neutral +const _blackAlphaAlpha = ColorSwatch(0xb3000000, { + 1: Color(0x0d000000), + 2: Color(0x1a000000), + 3: Color(0x26000000), + 4: Color(0x33000000), + 5: Color(0x4d000000), + 6: Color(0x66000000), + 7: Color(0x80000000), + 8: Color(0x99000000), + 9: Color(0xb3000000), + 10: Color(0xcc000000), + 11: Color(0xe6000000), + 12: Color(0xf2000000), +}); + +// whiteA neutral +const _whiteAlphaAlpha = ColorSwatch(0xb3ffffff, { + 1: Color(0x0dffffff), + 2: Color(0x1affffff), + 3: Color(0x26ffffff), + 4: Color(0x33ffffff), + 5: Color(0x4dffffff), + 6: Color(0x66ffffff), + 7: Color(0x80ffffff), + 8: Color(0x99ffffff), + 9: Color(0xb3ffffff), + 10: Color(0xccffffff), + 11: Color(0xe6ffffff), + 12: Color(0xf2ffffff), +}); + +// Color theme instances +const gray = RadixColorTheme(_grayLight, _grayDark); +const mauve = RadixColorTheme(_mauveLight, _mauveDark); +const slate = RadixColorTheme(_slateLight, _slateDark); +const sage = RadixColorTheme(_sageLight, _sageDark); +const olive = RadixColorTheme(_oliveLight, _oliveDark); +const sand = RadixColorTheme(_sandLight, _sandDark); +const tomato = RadixColorTheme(_tomatoLight, _tomatoDark); +const red = RadixColorTheme(_redLight, _redDark); +const ruby = RadixColorTheme(_rubyLight, _rubyDark); +const crimson = RadixColorTheme(_crimsonLight, _crimsonDark); +const pink = RadixColorTheme(_pinkLight, _pinkDark); +const plum = RadixColorTheme(_plumLight, _plumDark); +const purple = RadixColorTheme(_purpleLight, _purpleDark); +const violet = RadixColorTheme(_violetLight, _violetDark); +const iris = RadixColorTheme(_irisLight, _irisDark); +const indigo = RadixColorTheme(_indigoLight, _indigoDark); +const blue = RadixColorTheme(_blueLight, _blueDark); +const cyan = RadixColorTheme(_cyanLight, _cyanDark); +const teal = RadixColorTheme(_tealLight, _tealDark); +const jade = RadixColorTheme(_jadeLight, _jadeDark); +const green = RadixColorTheme(_greenLight, _greenDark); +const grass = RadixColorTheme(_grassLight, _grassDark); +const bronze = RadixColorTheme(_bronzeLight, _bronzeDark); +const gold = RadixColorTheme(_goldLight, _goldDark); +const brown = RadixColorTheme(_brownLight, _brownDark); +const orange = RadixColorTheme(_orangeLight, _orangeDark); +const amber = RadixColorTheme(_amberLight, _amberDark); +const yellow = RadixColorTheme(_yellowLight, _yellowDark); +const lime = RadixColorTheme(_limeLight, _limeDark); +const mint = RadixColorTheme(_mintLight, _mintDark); +const sky = RadixColorTheme(_skyLight, _skyDark); + +// Neutral instances +const blackAlpha = _blackAlphaAlpha; +const whiteAlpha = _whiteAlphaAlpha; diff --git a/packages/remix_fortal/lib/src/theme/surface_frame.dart b/apps/demo/lib/ui/theme/surface_frame.dart similarity index 100% rename from packages/remix_fortal/lib/src/theme/surface_frame.dart rename to apps/demo/lib/ui/theme/surface_frame.dart diff --git a/packages/remix_fortal/lib/src/theme/theme.dart b/apps/demo/lib/ui/theme/theme.dart similarity index 100% rename from packages/remix_fortal/lib/src/theme/theme.dart rename to apps/demo/lib/ui/theme/theme.dart diff --git a/packages/remix_fortal/lib/src/theme/theme_data.dart b/apps/demo/lib/ui/theme/theme_data.dart similarity index 100% rename from packages/remix_fortal/lib/src/theme/theme_data.dart rename to apps/demo/lib/ui/theme/theme_data.dart diff --git a/packages/remix_fortal/lib/src/theme/theme_scope.dart b/apps/demo/lib/ui/theme/theme_scope.dart similarity index 100% rename from packages/remix_fortal/lib/src/theme/theme_scope.dart rename to apps/demo/lib/ui/theme/theme_scope.dart diff --git a/packages/remix_fortal/lib/src/theme/tokens.dart b/apps/demo/lib/ui/theme/tokens.dart similarity index 100% rename from packages/remix_fortal/lib/src/theme/tokens.dart rename to apps/demo/lib/ui/theme/tokens.dart diff --git a/apps/demo/lib/ui/ui.dart b/apps/demo/lib/ui/ui.dart new file mode 100644 index 000000000..e07d5fb27 --- /dev/null +++ b/apps/demo/lib/ui/ui.dart @@ -0,0 +1,46 @@ +library; + +// remix_cli:exports:start +export 'components/accordion.dart'; +export 'components/avatar.dart'; +export 'components/badge.dart'; +export 'components/base_button.dart'; +export 'components/button.dart'; +export 'components/callout.dart'; +export 'components/card.dart'; +export 'components/chart.dart'; +export 'components/checkbox.dart'; +export 'components/code.dart'; +export 'components/data_list.dart'; +export 'components/data_table.dart'; +export 'components/dialog.dart'; +export 'components/disclosure.dart'; +export 'components/divider.dart'; +export 'components/heading.dart'; +export 'components/icon_button.dart'; +export 'components/kbd.dart'; +export 'components/link.dart'; +export 'components/menu.dart'; +export 'components/popover.dart'; +export 'components/progress.dart'; +export 'components/radio.dart'; +export 'components/segmented_control.dart'; +export 'components/select.dart'; +export 'components/sidebar.dart'; +export 'components/sidebar_layout.dart'; +export 'components/skeleton.dart'; +export 'components/slider.dart'; +export 'components/spinner.dart'; +export 'components/switch.dart'; +export 'components/tabs.dart'; +export 'components/text.dart'; +export 'components/textfield.dart'; +export 'components/toast.dart'; +export 'components/toggle.dart'; +export 'components/toggle_group.dart'; +export 'components/tooltip.dart'; +export 'components/typography.dart'; +export 'icons.dart'; +export 'theme/theme.dart'; + +// remix_cli:exports:end diff --git a/apps/demo/pubspec.yaml b/apps/demo/pubspec.yaml index 4b3a94386..3e6a6754c 100644 --- a/apps/demo/pubspec.yaml +++ b/apps/demo/pubspec.yaml @@ -21,11 +21,15 @@ dependencies: # Workspace resolution uses the local packages during development; these # hosted constraints keep the app manifest deployable outside the workspace. remix: ^1.0.0-beta.10 - remix_fortal: ^1.0.0-beta.9 + remix_ui_icons: ^0.1.0 + mix_annotations: ^2.2.0-beta.1 + mix_chart: ^0.0.1-beta.1 dev_dependencies: + build_runner: ^2.10.1 flutter_test: sdk: flutter + mix_generator: ^2.2.0-beta.3 widgetbook_generator: ^3.24.0 flutter: diff --git a/apps/demo/remix.yaml b/apps/demo/remix.yaml new file mode 100644 index 000000000..d66b7df97 --- /dev/null +++ b/apps/demo/remix.yaml @@ -0,0 +1,5 @@ +schema: 2 +prefix: Fortal +preset: fortal +paths: + ui: lib/ui diff --git a/apps/demo/test/accordion_test.dart b/apps/demo/test/accordion_test.dart index 4429397fc..ba19d5030 100644 --- a/apps/demo/test/accordion_test.dart +++ b/apps/demo/test/accordion_test.dart @@ -2,7 +2,7 @@ import 'package:demo/components/accordion.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; void main() { testWidgets('catalog presents each accordion item as a separate panel', ( diff --git a/apps/demo/test/catalog_test.dart b/apps/demo/test/catalog_test.dart index f66555caf..324e45c2c 100644 --- a/apps/demo/test/catalog_test.dart +++ b/apps/demo/test/catalog_test.dart @@ -31,7 +31,7 @@ import 'package:demo/components/toggle_group.dart' as toggle_group; import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; /// Every catalog use case, by the component it reviews. /// diff --git a/apps/demo/test/disclosure_test.dart b/apps/demo/test/disclosure_test.dart index d2363475e..238f8aa87 100644 --- a/apps/demo/test/disclosure_test.dart +++ b/apps/demo/test/disclosure_test.dart @@ -3,7 +3,7 @@ import 'package:demo/helpers/catalog.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; void main() { testWidgets('Remix use case opens and closes its content', (tester) async { diff --git a/apps/demo/test/docs_preview_test.dart b/apps/demo/test/docs_preview_test.dart index 3619375d5..df46580a2 100644 --- a/apps/demo/test/docs_preview_test.dart +++ b/apps/demo/test/docs_preview_test.dart @@ -9,7 +9,7 @@ import 'package:demo/main.directories.g.dart'; import 'package:demo/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:demo/ui/ui.dart'; import 'package:widgetbook/widgetbook.dart'; void main() { diff --git a/apps/naked_ui_example/integration_test/components/naked_accordion_integration.dart b/apps/naked_ui_example/integration_test/components/naked_accordion_integration.dart index 2d284d1de..dcb81804d 100644 --- a/apps/naked_ui_example/integration_test/components/naked_accordion_integration.dart +++ b/apps/naked_ui_example/integration_test/components/naked_accordion_integration.dart @@ -1,4 +1,5 @@ -import 'package:naked_ui_example/api/naked_accordion.0.dart' as accordion_example; +import 'package:naked_ui_example/api/naked_accordion.0.dart' + as accordion_example; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/apps/naked_ui_example/integration_test/components/naked_textfield_integration.dart b/apps/naked_ui_example/integration_test/components/naked_textfield_integration.dart index b1e945aad..09c507018 100644 --- a/apps/naked_ui_example/integration_test/components/naked_textfield_integration.dart +++ b/apps/naked_ui_example/integration_test/components/naked_textfield_integration.dart @@ -1,4 +1,5 @@ -import 'package:naked_ui_example/api/naked_textfield.0.dart' as textfield_example; +import 'package:naked_ui_example/api/naked_textfield.0.dart' + as textfield_example; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/apps/playground/build.yaml b/apps/playground/build.yaml new file mode 100644 index 000000000..8c76f7e50 --- /dev/null +++ b/apps/playground/build.yaml @@ -0,0 +1,14 @@ +targets: + $default: + builders: + mix_generator:spec_styler_generator: + enabled: true + generate_for: + - "lib/ui/components/activity.dart" + - "lib/ui/components/answer.dart" + - "lib/ui/components/composer.dart" + - "lib/ui/components/execution.dart" + - "lib/ui/components/message.dart" + - "lib/ui/components/permission.dart" + - "lib/ui/components/plan.dart" + - "lib/ui/components/transcript.dart" diff --git a/apps/playground/lib/preview_shell/controls_bar.dart b/apps/playground/lib/preview_shell/controls_bar.dart index 893b4b55a..089ec11c1 100644 --- a/apps/playground/lib/preview_shell/controls_bar.dart +++ b/apps/playground/lib/preview_shell/controls_bar.dart @@ -19,60 +19,80 @@ class ControlsBar extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; + final previewControls = [ + SegmentedButton( + segments: const [ + ButtonSegment(value: .light, label: Text('Light')), + ButtonSegment(value: .dark, label: Text('Dark')), + ], + selected: {brightness}, + onSelectionChanged: (selection) { + if (selection.isNotEmpty) { + onChange(brightness: selection.first); + } + }, + ), + const SizedBox(width: 16), + _PresetChip( + label: 'Mobile', + onTap: () => onChange(size: ViewportPresets.mobile), + ), + const SizedBox(width: 8), + _PresetChip( + label: 'Tablet', + onTap: () => onChange(size: ViewportPresets.tablet), + ), + const SizedBox(width: 8), + _PresetChip( + label: 'Desktop', + onTap: () => onChange(size: ViewportPresets.desktop), + ), + ]; + final sizeControls = [ + Text('W', style: textTheme.labelMedium), + const SizedBox(width: 6), + _SizeField( + initial: size.width.round(), + onSubmitted: (w) => + onChange(size: Size(w.toDouble().clamp(200, 3000), size.height)), + ), + const SizedBox(width: 12), + Text('H', style: textTheme.labelMedium), + const SizedBox(width: 6), + _SizeField( + initial: size.height.round(), + onSubmitted: (h) => + onChange(size: Size(size.width, h.toDouble().clamp(200, 3000))), + ), + ]; return Material( elevation: 1, color: Theme.of(context).colorScheme.surface, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Row( - children: [ - SegmentedButton( - segments: const [ - ButtonSegment(value: .light, label: Text('Light')), - ButtonSegment(value: .dark, label: Text('Dark')), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= + 900 * MediaQuery.textScalerOf(context).scale(1)) { + return Row( + children: [...previewControls, const Spacer(), ...sizeControls], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: previewControls), + ), + const SizedBox(height: 8), + Row(children: sizeControls), ], - selected: {brightness}, - onSelectionChanged: (selection) { - if (selection.isNotEmpty) { - onChange(brightness: selection.first); - } - }, - ), - const SizedBox(width: 16), - _PresetChip( - label: 'Mobile', - onTap: () => onChange(size: ViewportPresets.mobile), - ), - const SizedBox(width: 8), - _PresetChip( - label: 'Tablet', - onTap: () => onChange(size: ViewportPresets.tablet), - ), - const SizedBox(width: 8), - _PresetChip( - label: 'Desktop', - onTap: () => onChange(size: ViewportPresets.desktop), - ), - const Spacer(), - Text('W', style: textTheme.labelMedium), - const SizedBox(width: 6), - _SizeField( - initial: size.width.round(), - onSubmitted: (w) => onChange( - size: Size(w.toDouble().clamp(200, 3000), size.height), - ), - ), - const SizedBox(width: 12), - Text('H', style: textTheme.labelMedium), - const SizedBox(width: 6), - _SizeField( - initial: size.height.round(), - onSubmitted: (h) => onChange( - size: Size(size.width, h.toDouble().clamp(200, 3000)), - ), - ), - ], + ); + }, ), ), ); @@ -110,6 +130,14 @@ class _SizeFieldState extends State<_SizeField> { text: widget.initial.toString(), ); + @override + void didUpdateWidget(_SizeField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initial != widget.initial) { + controller.text = widget.initial.toString(); + } + } + @override void dispose() { controller.dispose(); diff --git a/apps/playground/lib/preview_shell/preview_shell.dart b/apps/playground/lib/preview_shell/preview_shell.dart index 3691d43bb..2c44d2d18 100644 --- a/apps/playground/lib/preview_shell/preview_shell.dart +++ b/apps/playground/lib/preview_shell/preview_shell.dart @@ -93,7 +93,7 @@ class _ViewportFrame extends StatelessWidget { ), ); - // Note: FortalScope is applied by the component registry wrapper. + // Note: the theme scope is applied by the component registry wrapper. return Stack( children: [ frame, diff --git a/apps/playground/lib/registry/component_registry.dart b/apps/playground/lib/registry/component_registry.dart index 9f53d5e14..d102d0ed0 100644 --- a/apps/playground/lib/registry/component_registry.dart +++ b/apps/playground/lib/registry/component_registry.dart @@ -1,169 +1,99 @@ import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; import '../preview_shell/preview_shell.dart'; +import '../ui/ui.dart'; import '../routes/all_components.dart'; import 'entries/avatar_entry.dart'; +import 'entries/agent_entries.dart'; import 'entries/badge_entry.dart'; import 'entries/button_entry.dart'; import 'entries/callout_entry.dart'; import 'entries/card_entry.dart'; import 'entries/checkbox_entry.dart'; import 'entries/checkbox_group_entry.dart'; -import 'entries/data_list_entry.dart'; -import 'entries/data_table_entry.dart'; import 'entries/divider_entry.dart'; -import 'entries/menu_entry.dart'; import 'entries/progress_entry.dart'; import 'entries/radio_entry.dart'; -import 'entries/segmented_control_entry.dart'; import 'entries/select_entry.dart'; import 'entries/skeleton_entry.dart'; import 'entries/slider_entry.dart'; import 'entries/spinner_entry.dart'; import 'entries/switch_entry.dart'; import 'entries/textfield_entry.dart'; -import 'entries/textarea_entry.dart'; import 'entries/tooltip_entry.dart'; -import 'entries/typography_entry.dart'; -// Map component slugs to a builder that returns the component inside FortalScope. +// Map component slugs to a builder that returns the component inside the +// installed theme scope, resolved for the preview's current brightness. + +Widget _scope(BuildContext context, Widget child) => PlaygroundThemeScope( + data: Theme.of(context).brightness == Brightness.dark + ? const PlaygroundThemeData.dark() + : const PlaygroundThemeData.light(), + child: child, +); + final Map components = { - 'button': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildButtonExample()), - ), - 'textfield': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildTextFieldExample()), - ), - 'textarea': (context) => PreviewShell( - child: Builder( - builder: (context) => FortalScope( - brightness: Theme.of(context).brightness, - hasBackground: false, - child: buildTextAreaExample(), - ), - ), - ), - 'checkbox': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildCheckboxExample()), - ), + 'agent-activity': (context) => + PreviewShell(child: Builder(builder: buildAgentActivity)), + 'agent-answer': (context) => + PreviewShell(child: Builder(builder: buildAgentAnswer)), + 'agent-composer': (context) => + PreviewShell(child: Builder(builder: buildAgentComposer)), + 'agent-execution': (context) => + PreviewShell(child: Builder(builder: buildAgentExecution)), + 'agent-message': (context) => + PreviewShell(child: Builder(builder: buildAgentMessage)), + 'agent-permission': (context) => + PreviewShell(child: Builder(builder: buildAgentPermission)), + 'agent-plan': (context) => + PreviewShell(child: Builder(builder: buildAgentPlan)), + 'agent-transcript': (context) => + PreviewShell(child: Builder(builder: buildAgentTranscript)), + 'chat': (context) => PreviewShell( + initialSize: const Size(900, 720), + child: Builder(builder: buildAgentChat), + ), + 'button': (context) => + _scope(context, PreviewShell(child: buildButtonExample())), + 'textfield': (context) => + _scope(context, PreviewShell(child: buildTextFieldExample())), + 'checkbox': (context) => + _scope(context, PreviewShell(child: buildCheckboxExample())), 'checkbox_group': (context) => PreviewShell( child: Builder( - builder: (context) => FortalScope( - brightness: Theme.of(context).brightness, - hasBackground: false, - child: buildCheckboxGroupExample(), - ), - ), - ), - 'radio': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildRadioExample()), - ), - 'select': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildSelectExample()), - ), - 'segmented-control': (context) => PreviewShell( - child: Builder( - builder: (context) => FortalScope( - brightness: Theme.of(context).brightness, - hasBackground: false, - child: buildSegmentedControlExample(), - ), - ), - ), - 'switch': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildSwitchExample()), - ), - 'slider': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildSliderExample()), - ), - // Resolve Fortal inside PreviewShell so its light/dark control owns tokens. - 'menu': (context) => PreviewShell( - child: Builder( - builder: (context) => FortalScope( - brightness: Theme.of(context).brightness, - hasBackground: false, - child: buildMenuExample(), - ), + builder: (context) => _scope(context, buildCheckboxGroupExample()), ), ), - 'all': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: const PreviewShell(child: AllComponentsPage()), - ), - 'avatar': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildAvatarExample()), - ), - 'badge': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildBadgeExample()), - ), - // Resolve Fortal *inside* PreviewShell so the shell's light/dark control owns - // the tokens; reading Theme.of above the shell leaves them stuck on light. - 'typography': (context) => PreviewShell( - initialSize: const Size(900, 1180), - child: Builder( - builder: (context) => FortalScope( - brightness: Theme.of(context).brightness, - hasBackground: false, - child: buildTypographyExample(), - ), - ), - ), - 'card': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildCardExample()), - ), - 'callout': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildCalloutExample()), - ), - 'data_list': (context) => PreviewShell( - child: Builder( - builder: (context) => FortalScope( - brightness: Theme.of(context).brightness, - hasBackground: false, - child: buildDataListExample(), - ), - ), - ), - 'data_table': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildDataTableExample()), - ), - 'divider': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildDividerExample()), - ), - 'progress': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildProgressExample()), - ), + 'radio': (context) => + _scope(context, PreviewShell(child: buildRadioExample())), + 'select': (context) => + _scope(context, PreviewShell(child: buildSelectExample())), + 'switch': (context) => + _scope(context, PreviewShell(child: buildSwitchExample())), + 'slider': (context) => + _scope(context, PreviewShell(child: buildSliderExample())), + 'all': (context) => + _scope(context, const PreviewShell(child: AllComponentsPage())), + 'avatar': (context) => + _scope(context, PreviewShell(child: buildAvatarExample())), + 'badge': (context) => + _scope(context, PreviewShell(child: buildBadgeExample())), + 'card': (context) => _scope(context, PreviewShell(child: buildCardExample())), + 'callout': (context) => + _scope(context, PreviewShell(child: buildCalloutExample())), + 'divider': (context) => + _scope(context, PreviewShell(child: buildDividerExample())), + 'progress': (context) => + _scope(context, PreviewShell(child: buildProgressExample())), 'skeleton': (context) => PreviewShell( child: Builder( - builder: (context) => FortalScope( - brightness: Theme.of(context).brightness, - hasBackground: false, - child: buildSkeletonExample(), - ), + builder: (context) => _scope(context, buildSkeletonExample()), ), ), - 'spinner': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildSpinnerExample()), - ), - 'tooltip': (context) => FortalScope( - brightness: Theme.of(context).brightness, - child: PreviewShell(child: buildTooltipExample()), - ), + 'spinner': (context) => + _scope(context, PreviewShell(child: buildSpinnerExample())), + 'tooltip': (context) => + _scope(context, PreviewShell(child: buildTooltipExample())), }; List get availableComponents => components.keys.toList()..sort(); diff --git a/apps/playground/lib/registry/entries/agent_entries.dart b/apps/playground/lib/registry/entries/agent_entries.dart new file mode 100644 index 000000000..d34063389 --- /dev/null +++ b/apps/playground/lib/registry/entries/agent_entries.dart @@ -0,0 +1,394 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:remix/remix.dart'; + +import '../../ui/ui.dart'; + +Widget _scope(BuildContext context, Widget child) => PlaygroundThemeScope( + data: Theme.of(context).brightness == Brightness.dark + ? const PlaygroundThemeData.dark() + : const PlaygroundThemeData.light(), + child: DefaultTextStyle.merge( + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + child: child, + ), +); + +Widget buildAgentComposer(BuildContext context) { + final recipe = playgroundAgentComposerRecipe(); + return _scope( + context, + PlaygroundComposer( + onSubmit: (_) {}, + style: recipe.style, + surfaceStyle: recipe.surfaceStyle, + fieldStyle: recipe.fieldStyle, + submitStyle: recipe.submitStyle, + stopStyle: recipe.stopStyle, + ), + ); +} + +Widget buildAgentMessage(BuildContext context) { + final recipe = playgroundAgentMessageRecipe(); + return _scope( + context, + PlaygroundMessageGroup( + spacing: 12, + children: [ + PlaygroundMessage( + role: .user, + style: recipe.style, + surfaceStyle: recipe.surfaceStyle, + child: const Text('Review the checkout flow.'), + ), + PlaygroundMessage( + role: .assistant, + style: recipe.style, + surfaceStyle: recipe.surfaceStyle, + child: const Text('I will inspect it and report the focused checks.'), + ), + ], + ), + ); +} + +Widget buildAgentTranscript(BuildContext context) { + final recipe = playgroundAgentTranscriptRecipe(); + return _scope( + context, + SizedBox( + height: 280, + child: PlaygroundTranscript( + style: recipe.style, + children: List.generate( + 8, + (index) => Text('Transcript event ${index + 1}'), + ), + ), + ), + ); +} + +Widget buildAgentPlan(BuildContext context) { + final recipe = playgroundAgentPlanRecipe(); + return _scope( + context, + PlaygroundPlan( + style: recipe.style, + disclosureStyle: recipe.disclosureStyle, + items: const [ + PlaygroundPlanItem( + id: 'one', + title: 'Inspect request', + status: .completed, + ), + PlaygroundPlanItem( + id: 'two', + title: 'Run focused checks', + status: .inProgress, + ), + PlaygroundPlanItem(id: 'three', title: 'Report result'), + ], + ), + ); +} + +Widget buildAgentActivity(BuildContext context) { + final recipe = playgroundAgentActivityRecipe(); + return _scope( + context, + PlaygroundActivity( + style: recipe.style, + disclosureStyle: recipe.disclosureStyle, + items: const [ + PlaygroundActivityItem( + id: 'one', + title: 'Read files', + status: .complete, + ), + PlaygroundActivityItem( + id: 'two', + title: 'Checking behavior', + status: .active, + ), + ], + ), + ); +} + +Widget buildAgentAnswer(BuildContext context) { + final recipe = playgroundAgentAnswerRecipe(); + return _scope( + context, + PlaygroundAnswer( + status: .complete, + style: recipe.style, + surfaceStyle: recipe.surfaceStyle, + sourcesStyle: recipe.sourcesStyle, + copyStyle: recipe.copyStyle, + retryStyle: recipe.retryStyle, + onCopy: () {}, + onRetry: () {}, + sourcesContent: const Text('Local deterministic fixture'), + child: const Text('The checkout flow is ready for review.'), + ), + ); +} + +Widget buildAgentExecution(BuildContext context) { + final recipe = playgroundAgentExecutionRecipe(); + return _scope( + context, + PlaygroundExecution( + tool: 'terminal.run', + title: 'Focused checks', + status: .running, + style: recipe.style, + surfaceStyle: recipe.surfaceStyle, + disclosureStyle: recipe.disclosureStyle, + copyStyle: recipe.copyStyle, + retryStyle: recipe.retryStyle, + child: const Text('\$ flutter test\n00:01 +12: running'), + ), + ); +} + +Widget buildAgentPermission(BuildContext context) => + _scope(context, const _PermissionPreview()); + +class _PermissionPreview extends StatefulWidget { + const _PermissionPreview(); + @override + State<_PermissionPreview> createState() => _PermissionPreviewState(); +} + +class _PermissionPreviewState extends State<_PermissionPreview> { + var status = PlaygroundPermissionStatus.pending; + @override + Widget build(BuildContext context) { + final recipe = playgroundAgentPermissionRecipe(); + return PlaygroundPermission( + requestId: 1, + tool: 'terminal.run', + description: 'Run deterministic focused checks.', + status: status, + parameters: const [ + RemixDataListItem(label: 'Command', value: 'flutter test'), + ], + style: recipe.style, + surfaceStyle: recipe.surfaceStyle, + detailsStyle: recipe.detailsStyle, + parametersStyle: recipe.parametersStyle, + allowOnceStyle: recipe.allowOnceStyle, + alwaysAllowStyle: recipe.alwaysAllowStyle, + denyStyle: recipe.denyStyle, + onAllowOnce: () => setState(() => status = .running), + onAlwaysAllow: () => setState(() => status = .running), + onDeny: () => setState(() => status = .denied), + ); + } +} + +Widget buildAgentChat(BuildContext context) => + _scope(context, const _CompactChat()); + +enum _ChatStage { + ready, + permission, + running, + failed, + complete, + stopped, + denied, +} + +class _CompactChat extends StatefulWidget { + const _CompactChat(); + @override + State<_CompactChat> createState() => _CompactChatState(); +} + +class _CompactChatState extends State<_CompactChat> { + var stage = _ChatStage.ready; + var prompt = 'Run the focused checks.'; + var attempt = 0; + var alwaysAllow = false; + var ranTool = false; + + void _begin(String value) { + if (stage == .permission || stage == .running) return; + setState(() { + attempt++; + prompt = value; + ranTool = alwaysAllow; + stage = alwaysAllow ? .running : .permission; + }); + } + + void _allow(int id, {bool always = false}) { + if (id != attempt || stage != .permission) return; + setState(() { + alwaysAllow = alwaysAllow || always; + ranTool = true; + stage = .running; + }); + } + + void _reset() => setState(() { + attempt++; + alwaysAllow = false; + ranTool = false; + stage = .ready; + }); + + @override + Widget build(BuildContext context) { + final message = playgroundAgentMessageRecipe(); + final transcript = playgroundAgentTranscriptRecipe( + style: PlaygroundTranscriptStyler(viewport: BoxStyler().padding(.all(0))), + ); + final permission = playgroundAgentPermissionRecipe(); + final execution = playgroundAgentExecutionRecipe(); + final answer = playgroundAgentAnswerRecipe(); + final composer = playgroundAgentComposerRecipe(); + final active = stage == .permission || stage == .running; + final requestId = attempt; + return Box( + style: BoxStyler().height(600).maxWidth(800).padding(.all(16)), + child: Column( + crossAxisAlignment: .stretch, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Interactive chat demo', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + TextButton(onPressed: _reset, child: const Text('New chat')), + ], + ), + const SizedBox(height: 4), + const Text('Simulated locally · no backend'), + const SizedBox(height: 20), + Expanded( + child: PlaygroundTranscript( + busy: active, + style: transcript.style, + children: [ + if (stage != .ready) + PlaygroundMessage( + role: .user, + style: message.style, + surfaceStyle: message.surfaceStyle, + child: Text(prompt), + ), + if (stage == .permission) + PlaygroundPermission( + requestId: requestId, + tool: 'terminal.run', + description: 'Run a simulated command.', + status: .pending, + style: permission.style, + surfaceStyle: permission.surfaceStyle, + detailsStyle: permission.detailsStyle, + parametersStyle: permission.parametersStyle, + allowOnceStyle: permission.allowOnceStyle, + alwaysAllowStyle: permission.alwaysAllowStyle, + denyStyle: permission.denyStyle, + onAllowOnce: () => _allow(requestId), + onAlwaysAllow: () => _allow(requestId, always: true), + onDeny: () { + if (requestId != attempt || stage != .permission) return; + setState(() => stage = .denied); + }, + ), + if (ranTool && + { + _ChatStage.running, + _ChatStage.failed, + _ChatStage.complete, + _ChatStage.stopped, + }.contains(stage)) + PlaygroundExecution( + tool: 'terminal.run', + title: 'Focused checks', + status: stage == .running + ? .running + : stage == .failed + ? .error + : stage == .complete + ? .success + : .cancelled, + style: execution.style, + surfaceStyle: execution.surfaceStyle, + disclosureStyle: execution.disclosureStyle, + copyStyle: execution.copyStyle, + retryStyle: execution.retryStyle, + onCopy: () => Clipboard.setData( + const ClipboardData(text: 'simulated output'), + ), + onRetry: () => _begin(prompt), + child: const Text('\$ flutter test\nSimulated output'), + ), + if ({ + _ChatStage.denied, + _ChatStage.failed, + _ChatStage.complete, + _ChatStage.stopped, + }.contains(stage)) + PlaygroundAnswer( + status: stage == .failed ? .error : .complete, + style: answer.style, + surfaceStyle: answer.surfaceStyle, + sourcesStyle: answer.sourcesStyle, + copyStyle: answer.copyStyle, + retryStyle: answer.retryStyle, + onRetry: () => _begin(prompt), + child: Text( + stage == .complete + ? 'All checks passed.' + : stage == .failed + ? 'The command failed. Retry is available.' + : stage == .denied + ? 'Permission denied. No command was run.' + : ranTool + ? 'The run was stopped.' + : 'Stopped before running the command.', + ), + ), + ], + ), + ), + if (stage == .running) + Wrap( + spacing: 8, + children: [ + TextButton( + onPressed: () => setState(() => stage = .complete), + child: const Text('Finish'), + ), + TextButton( + onPressed: () => setState(() => stage = .failed), + child: const Text('Simulate failure'), + ), + ], + ), + const SizedBox(height: 16), + PlaygroundComposer( + running: active, + onSubmit: _begin, + onStop: () => setState(() => stage = .stopped), + style: composer.style, + surfaceStyle: composer.surfaceStyle, + fieldStyle: composer.fieldStyle, + submitStyle: composer.submitStyle, + stopStyle: composer.stopStyle, + ), + ], + ), + ); + } +} diff --git a/apps/playground/lib/registry/entries/checkbox_group_entry.dart b/apps/playground/lib/registry/entries/checkbox_group_entry.dart index 3b8c32b0c..c43e54c0b 100644 --- a/apps/playground/lib/registry/entries/checkbox_group_entry.dart +++ b/apps/playground/lib/registry/entries/checkbox_group_entry.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../../ui/ui.dart'; import '../../widgets/comparison_view.dart'; @@ -25,7 +25,7 @@ extension on _Interest { } /// The group itself is unstyled — every visual comes from the item's checkbox -/// recipe, so `fortalCheckboxStyle()` applies with no group-level style. +/// recipe, so `playgroundCheckboxStyle()` applies with no group-level style. class _RemixCheckboxGroupPreview extends StatefulWidget { const _RemixCheckboxGroupPreview(); @@ -117,7 +117,7 @@ class _LabeledOption extends StatelessWidget { value: value, label: label, enabled: enabled, - style: fortalCheckboxStyle(), + style: playgroundCheckboxStyle(), ); } } diff --git a/apps/playground/lib/registry/entries/data_list_entry.dart b/apps/playground/lib/registry/entries/data_list_entry.dart deleted file mode 100644 index 7c0a3c165..000000000 --- a/apps/playground/lib/registry/entries/data_list_entry.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; - -import '../../widgets/comparison_view.dart'; - -Widget buildDataListExample() { - const metadata = [ - RemixDataListItem(label: 'Name', value: 'Leo Farias'), - RemixDataListItem(label: 'Email', value: 'leo@example.com'), - RemixDataListItem( - label: 'Bio', - value: 'Building composable Flutter design systems with Mix and Remix.', - ), - ]; - - Widget materialRow(String label, String value) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: .start, - children: [ - SizedBox( - width: 96, - child: Text(label, style: const TextStyle(color: Colors.grey)), - ), - const SizedBox(width: 24), - Expanded(child: Text(value)), - ], - ), - ); - - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SizedBox( - width: 640, - child: ComparisonView( - remix: [ - // Horizontal: one shared label column, wrapping values, and custom - // value children (a display-only badge and an interactive button). - SizedBox( - width: 280, - child: FortalDataList( - semanticLabel: 'Account details', - items: [ - ...metadata, - const RemixDataListItem( - label: 'Status', - semanticValue: 'Authorized', - alignment: RemixDataListItemAlignment.center, - child: FortalBadge(label: 'Authorized'), - ), - RemixDataListItem( - label: 'API key', - alignment: RemixDataListItemAlignment.center, - child: FortalButton.soft( - size: .size1, - label: 'Reveal', - onPressed: () {}, - ), - ), - ], - ), - ), - // Vertical: the caller-owned fallback for narrow widths. - SizedBox( - width: 200, - child: FortalDataList(orientation: Axis.vertical, items: metadata), - ), - ], - material: [ - SizedBox( - width: 280, - child: Column( - mainAxisSize: .min, - children: [ - materialRow('Name', 'Leo Farias'), - materialRow('Email', 'leo@example.com'), - materialRow( - 'Bio', - 'Building composable Flutter design systems with Mix and ' - 'Remix.', - ), - ], - ), - ), - ], - ), - ), - ); -} diff --git a/apps/playground/lib/registry/entries/data_table_entry.dart b/apps/playground/lib/registry/entries/data_table_entry.dart deleted file mode 100644 index 509145b28..000000000 --- a/apps/playground/lib/registry/entries/data_table_entry.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; - -import '../../widgets/comparison_view.dart'; - -class _Member { - const _Member(this.id, this.name, this.role, this.seats); - - final String id; - final String name; - final String role; - final int seats; -} - -const _members = [ - _Member('m1', 'Leo Farias', 'Owner', 12), - _Member('m2', 'Ada Lovelace', 'Engineer', 4), - _Member('m3', 'Grace Hopper', 'Engineer', 7), -]; - -Widget buildDataTableExample() => const _DataTableExample(); - -class _DataTableExample extends StatefulWidget { - const _DataTableExample(); - - @override - State<_DataTableExample> createState() => _DataTableExampleState(); -} - -class _DataTableExampleState extends State<_DataTableExample> { - RemixDataTableSort _sort = const RemixDataTableSort( - columnId: 'name', - direction: RemixDataTableSortDirection.ascending, - ); - Set _selected = {'m2'}; - int _pageIndex = 0; - int _pageSize = 10; - - List<_Member> get _sorted { - final rows = List<_Member>.of(_members); - rows.sort((a, b) { - final result = switch (_sort.columnId) { - 'seats' => a.seats.compareTo(b.seats), - _ => a.name.compareTo(b.name), - }; - - return _sort.direction == RemixDataTableSortDirection.ascending - ? result - : -result; - }); - - return rows; - } - - List> get _columns => [ - RemixDataTableColumn( - id: 'name', - label: 'Member', - sortable: true, - width: const FlexColumnWidth(2), - cellBuilder: (context, row) => Text(row.name), - ), - RemixDataTableColumn( - id: 'role', - label: 'Role', - width: const FixedColumnWidth(120), - cellBuilder: (context, row) => FortalBadge(label: row.role), - ), - RemixDataTableColumn( - id: 'seats', - label: 'Seats', - sortable: true, - width: const FixedColumnWidth(96), - alignment: RemixDataTableCellAlignment.end, - cellBuilder: (context, row) => Text('${row.seats}'), - ), - ]; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 900, - child: ComparisonView( - remix: [ - // Sortable, selectable, and paginated: every operation stays a - // controlled signal owned by this widget's state. - SizedBox( - width: 460, - child: FortalDataTable<_Member>.surface( - semanticLabel: 'Workspace members', - rows: _sorted, - columns: _columns, - sort: _sort, - onSortChanged: (sort) => setState(() => _sort = sort), - rowId: (row) => row.id, - selectedRowIds: _selected, - onSelectionChanged: (ids) => setState(() => _selected = ids), - totalRows: _members.length, - pageIndex: _pageIndex, - pageSize: _pageSize, - pageSizeOptions: const [5, 10, 20], - onPageChanged: (index) => setState(() => _pageIndex = index), - onPageSizeChanged: (size) => setState(() { - _pageSize = size; - _pageIndex = 0; - }), - ), - ), - // Ghost keeps every divider and drops the panel surface. - SizedBox( - width: 460, - child: FortalDataTable<_Member>.ghost( - size: .size1, - semanticLabel: 'Compact members', - rows: _sorted, - columns: _columns, - ), - ), - // The empty state replaces body rows and keeps the header. - SizedBox( - width: 460, - child: FortalDataTable<_Member>.surface( - semanticLabel: 'No members', - rows: const [], - columns: _columns, - emptyBuilder: (context) => const Padding( - padding: EdgeInsets.all(24), - child: Text('No members match this filter'), - ), - ), - ), - // Directional alignment follows Directionality, not a locale guess. - SizedBox( - width: 460, - child: Directionality( - textDirection: TextDirection.rtl, - child: FortalDataTable<_Member>.surface( - semanticLabel: 'أعضاء', - rows: _sorted, - columns: _columns, - ), - ), - ), - ], - material: [ - SizedBox( - width: 380, - child: DataTable( - columns: const [ - DataColumn(label: Text('Member')), - DataColumn(label: Text('Role')), - DataColumn(label: Text('Seats'), numeric: true), - ], - rows: [ - for (final member in _members) - DataRow( - cells: [ - DataCell(Text(member.name)), - DataCell(Text(member.role)), - DataCell(Text('${member.seats}')), - ], - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/apps/playground/lib/registry/entries/menu_entry.dart b/apps/playground/lib/registry/entries/menu_entry.dart deleted file mode 100644 index 8d0c5e799..000000000 --- a/apps/playground/lib/registry/entries/menu_entry.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; - -Widget buildMenuExample() => const _MenuExample(); - -class _MenuExample extends StatefulWidget { - const _MenuExample(); - - @override - State<_MenuExample> createState() => _MenuExampleState(); -} - -class _MenuExampleState extends State<_MenuExample> { - bool _showStatus = true; - String _density = 'comfortable'; - String _lastSelection = 'None'; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 320, - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text('Compound menu', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text( - 'Open the menu and use the arrow keys to see focus and nested navigation.', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 16), - FortalMenu.soft( - trigger: const RemixMenuTrigger( - label: 'View options', - icon: Icons.tune, - ), - onSelected: (value) { - setState(() => _lastSelection = value); - }, - items: [ - const RemixMenuItem( - value: 'rename', - label: 'Rename', - leadingIcon: Icons.edit_outlined, - trailingIcon: Icons.keyboard_command_key, - ), - const RemixMenuItem( - value: 'locked', - label: 'Locked action', - leadingIcon: Icons.lock_outline, - enabled: false, - ), - const RemixMenuDivider(), - RemixMenuCheckboxItem( - value: 'show-status', - label: 'Show status', - checked: _showStatus, - closeOnActivate: false, - onChanged: (next) => setState(() => _showStatus = next), - ), - RemixMenuRadioGroup( - value: _density, - onChanged: (next) => setState(() => _density = next), - items: const [ - RemixMenuRadioItem( - value: 'compact', - label: 'Compact', - closeOnActivate: false, - ), - RemixMenuRadioItem( - value: 'comfortable', - label: 'Comfortable', - closeOnActivate: false, - ), - ], - ), - const RemixMenuDivider(), - const RemixMenuSubmenu( - label: 'Share', - leadingIcon: Icons.ios_share_outlined, - items: [ - RemixMenuItem(value: 'copy-link', label: 'Copy link'), - RemixMenuSubmenu( - label: 'Send with', - items: [ - RemixMenuItem(value: 'email', label: 'Email'), - RemixMenuItem(value: 'messages', label: 'Messages'), - ], - ), - ], - ), - ], - ), - const SizedBox(height: 16), - Text('Last selection: $_lastSelection'), - Text('Status: ${_showStatus ? 'shown' : 'hidden'}'), - Text('Density: $_density'), - ], - ), - ); - } -} diff --git a/apps/playground/lib/registry/entries/segmented_control_entry.dart b/apps/playground/lib/registry/entries/segmented_control_entry.dart deleted file mode 100644 index 642df465e..000000000 --- a/apps/playground/lib/registry/entries/segmented_control_entry.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; - -Widget buildSegmentedControlExample() { - return const SizedBox(width: 340, child: _RemixSegmentedControlPreview()); -} - -class _RemixSegmentedControlPreview extends StatefulWidget { - const _RemixSegmentedControlPreview(); - - @override - State<_RemixSegmentedControlPreview> createState() => - _RemixSegmentedControlPreviewState(); -} - -class _RemixSegmentedControlPreviewState - extends State<_RemixSegmentedControlPreview> { - String _period = 'week'; - String _view = 'grid'; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - FortalSegmentedControl.surface( - semanticLabel: 'Reporting period', - items: const [ - RemixSegmentedControlItem(value: 'day', label: 'Day'), - RemixSegmentedControlItem(value: 'week', label: 'This week'), - RemixSegmentedControlItem(value: 'month', label: 'Month'), - RemixSegmentedControlItem( - value: 'year', - label: 'Year', - enabled: false, - ), - ], - selectedValue: _period, - onChanged: (value) => setState(() => _period = value), - ), - const SizedBox(height: 8), - Text('Selected: $_period'), - const SizedBox(height: 20), - FortalSegmentedControl.classic( - semanticLabel: 'Layout', - items: const [ - RemixSegmentedControlItem( - value: 'list', - icon: Icons.view_list, - semanticLabel: 'List view', - ), - RemixSegmentedControlItem( - value: 'grid', - icon: Icons.grid_view, - semanticLabel: 'Grid view', - ), - RemixSegmentedControlItem( - value: 'board', - icon: Icons.view_kanban, - semanticLabel: 'Board view', - ), - ], - selectedValue: _view, - onChanged: (value) => setState(() => _view = value), - ), - ], - ); - } -} diff --git a/apps/playground/lib/registry/entries/skeleton_entry.dart b/apps/playground/lib/registry/entries/skeleton_entry.dart index 2fc53b9a8..03232cbcf 100644 --- a/apps/playground/lib/registry/entries/skeleton_entry.dart +++ b/apps/playground/lib/registry/entries/skeleton_entry.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import '../../ui/ui.dart'; import '../../widgets/spaced_column.dart'; @@ -21,7 +21,7 @@ class _SkeletonPreviewState extends State<_SkeletonPreview> { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final base = fortalSkeletonStyle(); + final base = playgroundSkeletonStyle(); return SpacedColumn( spacing: 24, @@ -45,11 +45,11 @@ class _SkeletonPreviewState extends State<_SkeletonPreview> { _Section( title: 'Child-sized — no layout shift', children: [ - FortalSkeleton( + PlaygroundSkeleton( loading: _loading, child: const Text('Jane Appleseed — jane@example.com'), ), - FortalSkeleton( + PlaygroundSkeleton( loading: _loading, child: RemixButton(label: 'Open profile', onPressed: () {}), ), diff --git a/apps/playground/lib/registry/entries/textarea_entry.dart b/apps/playground/lib/registry/entries/textarea_entry.dart deleted file mode 100644 index 572ccb90c..000000000 --- a/apps/playground/lib/registry/entries/textarea_entry.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; - -import '../../widgets/comparison_view.dart'; - -Widget buildTextAreaExample() => const _TextAreaExample(); - -class _TextAreaExample extends StatefulWidget { - const _TextAreaExample(); - - @override - State<_TextAreaExample> createState() => _TextAreaExampleState(); -} - -class _TextAreaExampleState extends State<_TextAreaExample> { - final _remixController = TextEditingController( - text: 'First paragraph.\nSecond paragraph.', - ); - final _materialController = TextEditingController( - text: 'First paragraph.\nSecond paragraph.', - ); - final _remixReadOnlyController = TextEditingController( - text: 'This content can be selected but not changed.', - ); - final _materialReadOnlyController = TextEditingController( - text: 'This content can be selected but not changed.', - ); - - @override - void dispose() { - _remixController.dispose(); - _materialController.dispose(); - _remixReadOnlyController.dispose(); - _materialReadOnlyController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final surface = isDark ? const Color(0xFF202124) : const Color(0xFFF8FAFC); - final foreground = isDark ? Colors.white : const Color(0xFF172033); - final muted = isDark ? const Color(0xFFB8BDC7) : const Color(0xFF667085); - - final customStyle = TextFieldStyler() - .color(surface) - .textColor(foreground) - .hintColor(muted) - .padding(.all(14)) - .borderRadius(.all(const Radius.circular(12))) - .border( - BoxBorderMix.all( - BorderSideMix(color: const Color(0xFF7C3AED), width: 1.5), - ), - ) - .label(TextStyler().color(foreground).fontWeight(FontWeight.w600)) - .helperText(TextStyler().color(muted)) - .onFocused( - TextFieldStyler().border( - BoxBorderMix.all( - BorderSideMix(color: const Color(0xFF8B5CF6), width: 2.5), - ), - ), - ); - - Widget field(Widget child) => SizedBox(width: 320, child: child); - - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ComparisonView( - remix: [ - field( - const FortalTextArea.surface( - key: ValueKey('textarea-empty'), - label: 'Empty', - hintText: 'Start typing at the top…', - ), - ), - field( - FortalTextArea.classic( - key: const ValueKey('textarea-filled'), - controller: _remixController, - label: 'Controlled value', - helperText: 'Line breaks are preserved', - ), - ), - field( - const FortalTextArea.surface( - key: ValueKey('textarea-error'), - label: 'Project summary', - hintText: 'Describe the project', - helperText: 'A summary is required', - error: true, - ), - ), - field( - const FortalTextArea.soft( - label: 'Limited feedback', - hintText: 'Up to 120 characters', - maxLength: 120, - maxLines: 4, - ), - ), - field( - const FortalTextArea.surface( - key: ValueKey('textarea-disabled'), - label: 'Disabled', - hintText: 'Editing unavailable', - enabled: false, - ), - ), - field( - FortalTextArea.classic( - controller: _remixReadOnlyController, - label: 'Read only', - readOnly: true, - ), - ), - field( - RemixTextArea( - label: 'Custom Remix styling', - hintText: 'Same TextFieldStyler anatomy', - helperText: 'Grows within its parent constraints', - style: customStyle, - ), - ), - ], - material: [ - field( - const TextField( - minLines: 2, - maxLines: null, - decoration: InputDecoration( - labelText: 'Empty', - hintText: 'Start typing at the top…', - border: OutlineInputBorder(), - ), - ), - ), - field( - TextField( - controller: _materialController, - minLines: 2, - maxLines: null, - decoration: const InputDecoration( - labelText: 'Controlled value', - helperText: 'Line breaks are preserved', - border: OutlineInputBorder(), - ), - ), - ), - field( - const TextField( - minLines: 2, - maxLines: null, - decoration: InputDecoration( - labelText: 'Project summary', - hintText: 'Describe the project', - errorText: 'A summary is required', - border: OutlineInputBorder(), - ), - ), - ), - field( - const TextField( - minLines: 2, - maxLines: 4, - maxLength: 120, - decoration: InputDecoration( - labelText: 'Limited feedback', - hintText: 'Up to 120 characters', - border: OutlineInputBorder(), - ), - ), - ), - field( - const TextField( - minLines: 2, - maxLines: null, - enabled: false, - decoration: InputDecoration( - labelText: 'Disabled', - hintText: 'Editing unavailable', - border: OutlineInputBorder(), - ), - ), - ), - field( - TextField( - controller: _materialReadOnlyController, - minLines: 2, - maxLines: null, - readOnly: true, - decoration: const InputDecoration( - labelText: 'Read only', - border: OutlineInputBorder(), - ), - ), - ), - field( - const TextField( - minLines: 2, - maxLines: null, - decoration: InputDecoration( - labelText: 'Custom styling', - hintText: 'Material comparison', - helperText: 'Grows within its parent constraints', - border: OutlineInputBorder(), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/apps/playground/lib/registry/entries/typography_entry.dart b/apps/playground/lib/registry/entries/typography_entry.dart deleted file mode 100644 index 94293978f..000000000 --- a/apps/playground/lib/registry/entries/typography_entry.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; - -Widget buildTypographyExample() { - return SizedBox( - width: 820, - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const FortalHeading('Fortal typography', size: FortalTextSize.size8), - const SizedBox(height: 8), - const FortalText( - 'Text, headings, code, keys, and links on one shared scale.', - size: FortalTextSize.size3, - ), - const SizedBox(height: 28), - const _SectionLabel('Nine-step text scale'), - const SizedBox(height: 12), - for (final (index, size) in FortalTextSize.values.indexed) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - SizedBox(width: 52, child: FortalCode.ghost('${index + 1}')), - FortalText('The quick brown fox', size: size), - ], - ), - ), - const SizedBox(height: 24), - const _SectionLabel('Semantic level is independent from size'), - const SizedBox(height: 12), - const FortalHeading( - 'Visual size 5, semantic heading level 2', - headingLevel: 2, - size: FortalTextSize.size5, - ), - const SizedBox(height: 24), - const _SectionLabel('Code and keyboard variants'), - const SizedBox(height: 12), - const Wrap( - spacing: 12, - runSpacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - FortalCode.solid('solid'), - FortalCode.soft('soft'), - FortalCode.outline('outline'), - FortalCode.ghost('ghost'), - FortalKbd.classic('⌘K', semanticLabel: 'Command K'), - FortalKbd.soft('Esc', semanticLabel: 'Escape'), - ], - ), - const SizedBox(height: 24), - const _SectionLabel('Link underline and action states'), - const SizedBox(height: 12), - Wrap( - spacing: 18, - runSpacing: 14, - children: [ - FortalLink('Auto', onPressed: () {}), - FortalLink( - 'Always', - underline: FortalLinkUnderline.always, - onPressed: () {}, - ), - FortalLink( - 'Hover', - underline: FortalLinkUnderline.hover, - onPressed: () {}, - ), - FortalLink( - 'None', - underline: FortalLinkUnderline.none, - onPressed: () {}, - ), - // Two spellings of the same state: a null callback disables the - // link exactly as `enabled: false` does. - FortalLink('Disabled', enabled: false, onPressed: () {}), - const FortalLink('Disabled (no callback)'), - ], - ), - const SizedBox(height: 24), - const _SectionLabel('Accent and high contrast'), - const SizedBox(height: 12), - Wrap( - spacing: 18, - runSpacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - const FortalText('Accent text', accent: true), - const FortalText( - 'Accent high contrast', - accent: true, - highContrast: true, - ), - const FortalCode.soft('accent code', highContrast: true), - // Actionable on purpose: this row is about accent colour, and a - // callback-less link would show the disabled treatment instead. - FortalLink('Accent link', highContrast: true, onPressed: () {}), - ], - ), - const SizedBox(height: 24), - const _SectionLabel('Wrapping and truncation'), - const SizedBox(height: 12), - const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 240, - child: FortalText( - 'Wrap keeps the complete sentence in narrow space.', - ), - ), - SizedBox(width: 24), - SizedBox( - width: 240, - child: FortalText( - 'Truncate keeps exactly one line in narrow space.', - truncate: true, - ), - ), - ], - ), - ], - ), - ), - ); -} - -class _SectionLabel extends StatelessWidget { - const _SectionLabel(this.text); - - final String text; - - @override - Widget build(BuildContext context) => FortalText( - text, - size: FortalTextSize.size2, - weight: FortalTextWeight.medium, - accent: true, - highContrast: true, - ); -} diff --git a/apps/playground/lib/routes/all_components.dart b/apps/playground/lib/routes/all_components.dart index d83757a04..9a3f120ef 100644 --- a/apps/playground/lib/routes/all_components.dart +++ b/apps/playground/lib/routes/all_components.dart @@ -6,19 +6,14 @@ import '../registry/entries/button_entry.dart'; import '../registry/entries/card_entry.dart'; import '../registry/entries/callout_entry.dart'; import '../registry/entries/checkbox_entry.dart'; -import '../registry/entries/data_list_entry.dart'; -import '../registry/entries/data_table_entry.dart'; import '../registry/entries/divider_entry.dart'; import '../registry/entries/progress_entry.dart'; import '../registry/entries/radio_entry.dart'; -import '../registry/entries/segmented_control_entry.dart'; import '../registry/entries/select_entry.dart'; import '../registry/entries/slider_entry.dart'; import '../registry/entries/spinner_entry.dart'; import '../registry/entries/switch_entry.dart'; -import '../registry/entries/textarea_entry.dart'; import '../registry/entries/tooltip_entry.dart'; -import '../registry/entries/typography_entry.dart'; class AllComponentsPage extends StatelessWidget { const AllComponentsPage({super.key}); @@ -51,19 +46,14 @@ class AllComponentsPage extends StatelessWidget { _section('Card', buildCardExample()), _section('Callout', buildCalloutExample()), _section('Checkbox', buildCheckboxExample()), - _section('Data List', buildDataListExample()), - _section('Data Table', buildDataTableExample()), _section('Divider', buildDividerExample()), _section('Progress', buildProgressExample()), _section('Radio', buildRadioExample()), - _section('Segmented Control', buildSegmentedControlExample()), _section('Select', buildSelectExample()), _section('Slider', buildSliderExample()), _section('Spinner', buildSpinnerExample()), _section('Switch', buildSwitchExample()), - _section('TextArea', buildTextAreaExample()), _section('Tooltip', buildTooltipExample()), - _section('Typography', buildTypographyExample()), ], ); } diff --git a/apps/playground/lib/ui/components/activity.dart b/apps/playground/lib/ui/components/activity.dart new file mode 100644 index 000000000..bfb308aae --- /dev/null +++ b/apps/playground/lib/ui/components/activity.dart @@ -0,0 +1,313 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/activity_item.dart'; +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'activity.g.dart'; + +typedef PlaygroundActivityStatusBuilder = + Widget Function(BuildContext context, PlaygroundActivityItem item); +typedef PlaygroundActivityStatusLabelBuilder = + String Function(PlaygroundActivityItem item); +typedef PlaygroundActivityIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Activity ledger that is forced open and non-toggleable only while working. +class PlaygroundActivity extends StatefulWidget { + const PlaygroundActivity({ + super.key, + required this.items, + this.status = PlaygroundRunStatus.working, + this.title = 'Activity', + this.semanticLabel = 'Activity', + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.statusBuilder, + this.statusLabelBuilder, + this.indicatorBuilder, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + this.disclosureStyle = const DisclosureStyler.create(), + this.style = const PlaygroundActivityStyler.create(), + this.styleSpec, + }); + + final List items; + final PlaygroundRunStatus status; + final String title; + final String semanticLabel; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final PlaygroundActivityStatusBuilder? statusBuilder; + final PlaygroundActivityStatusLabelBuilder? statusLabelBuilder; + final PlaygroundActivityIndicatorBuilder? indicatorBuilder; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + final DisclosureStyler disclosureStyle; + final PlaygroundActivityStyler style; + final PlaygroundActivitySpec? styleSpec; + + bool get isWorking => status == PlaygroundRunStatus.working; + + /// Number of completed rows in the activity ledger. + int get settledCount => items + .where((item) => item.status == PlaygroundActivityItemStatus.complete) + .length; + + @override + State createState() => _PlaygroundActivityState(); +} + +class _PlaygroundActivityState extends State { + late final PlaygroundDisclosureEngine _disclosure; + + bool get _expanded => widget.isWorking ? true : (_disclosure.value); + + @override + void initState() { + super.initState(); + _disclosure = PlaygroundDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget(PlaygroundActivity oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + if (!oldWidget.isWorking && widget.isWorking) { + _request(true, lifecycle: true); + } else if (oldWidget.isWorking && + !widget.isWorking && + widget.collapseOnComplete) { + _request(false, lifecycle: true); + } + } + + void _request(bool next, {bool lifecycle = false}) { + if (widget.isWorking && !lifecycle) return; + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String _statusLabel(PlaygroundActivityItem item) => + widget.statusLabelBuilder?.call(item) ?? + switch (item.status) { + PlaygroundActivityItemStatus.pending => 'Pending', + PlaygroundActivityItemStatus.active => 'Active', + PlaygroundActivityItemStatus.complete => 'Complete', + }; + + PlaygroundFunctionalGlyphKind _statusGlyph( + PlaygroundActivityItemStatus status, + ) => switch (status) { + PlaygroundActivityItemStatus.pending => .pending, + PlaygroundActivityItemStatus.active => .active, + PlaygroundActivityItemStatus.complete => .completed, + }; + + StyleSpec _statusContainer( + PlaygroundActivitySpec spec, + PlaygroundActivityItemStatus status, + ) => switch (status) { + PlaygroundActivityItemStatus.pending => spec.pendingItem, + PlaygroundActivityItemStatus.active => spec.activeItem, + PlaygroundActivityItemStatus.complete => spec.completedItem, + }; + + StyleSpec _statusStyle( + PlaygroundActivitySpec spec, + PlaygroundActivityItemStatus status, + ) => switch (status) { + PlaygroundActivityItemStatus.pending => spec.pendingStatus, + PlaygroundActivityItemStatus.active => spec.activeStatus, + PlaygroundActivityItemStatus.complete => spec.completedStatus, + }; + + Widget _defaultStatus( + BuildContext context, + PlaygroundActivitySpec spec, + PlaygroundActivityItem item, + ) => StyleSpecBuilder( + styleSpec: _statusStyle(spec, item.status), + builder: (context, iconSpec) => PlaygroundFunctionalGlyph( + kind: _statusGlyph(item.status), + spec: iconSpec, + ), + ); + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixDisclosure( + expanded: _expanded, + enabled: !widget.isWorking, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + // Preserve the count alignment and expansion cue while working. + // RemixDisclosure keeps the forced-open header non-toggleable. + PlaygroundDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: Row( + children: [ + Expanded( + child: StyledText(widget.title, styleSpec: spec.summaryTitle), + ), + StyledText( + '${widget.settledCount}/${widget.items.length}', + styleSpec: spec.count, + ), + ], + ), + content: Box( + styleSpec: spec.viewport, + child: PlaygroundLiveEdgeScrollView( + followOutput: widget.followOutput, + followThreshold: widget.followThreshold, + onFollowChanged: widget.onFollowChanged, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in widget.items) + Semantics( + container: true, + explicitChildNodes: true, + label: [ + item.title, + if (item.detail != null) item.detail!, + _statusLabel(item), + ].join(', '), + child: Box( + styleSpec: _statusContainer(spec, item.status), + child: RowBox( + key: ValueKey('playground-activity-item-${item.id}'), + styleSpec: spec.item, + children: [ + ExcludeSemantics( + child: + widget.statusBuilder?.call(context, item) ?? + _defaultStatus(context, spec, item), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: StyledText( + item.title, + styleSpec: spec.itemTitle, + ), + ), + if (item.detail != null) + ExcludeSemantics( + child: StyledText( + item.detail!, + styleSpec: spec.itemDetail, + ), + ), + if (item.child != null) item.child!, + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: PlaygroundActivity.new) +@immutable +final class PlaygroundActivitySpec with _$PlaygroundActivitySpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final StyleSpec summaryTitle; + @override + final StyleSpec itemTitle; + @override + final StyleSpec itemDetail; + @override + final StyleSpec count; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingItem; + @override + final StyleSpec activeItem; + @override + final StyleSpec completedItem; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec activeStatus; + @override + final StyleSpec completedStatus; + + const PlaygroundActivitySpec({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: FlexBoxSpec()), + summaryTitle = summaryTitle ?? const StyleSpec(spec: TextSpec()), + itemTitle = itemTitle ?? const StyleSpec(spec: TextSpec()), + itemDetail = itemDetail ?? const StyleSpec(spec: TextSpec()), + count = count ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingItem = pendingItem ?? const StyleSpec(spec: BoxSpec()), + activeItem = activeItem ?? const StyleSpec(spec: BoxSpec()), + completedItem = completedItem ?? const StyleSpec(spec: BoxSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: IconSpec()), + activeStatus = activeStatus ?? const StyleSpec(spec: IconSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: IconSpec()); +} diff --git a/apps/playground/lib/ui/components/activity.g.dart b/apps/playground/lib/ui/components/activity.g.dart new file mode 100644 index 000000000..4dde8e408 --- /dev/null +++ b/apps/playground/lib/ui/components/activity.g.dart @@ -0,0 +1,509 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$PlaygroundActivitySpec + implements Spec, Diagnosticable { + StyleSpec get viewport; + StyleSpec get item; + StyleSpec get summaryTitle; + StyleSpec get itemTitle; + StyleSpec get itemDetail; + StyleSpec get count; + StyleSpec get indicator; + StyleSpec get pendingItem; + StyleSpec get activeItem; + StyleSpec get completedItem; + StyleSpec get pendingStatus; + StyleSpec get activeStatus; + StyleSpec get completedStatus; + + @override + Type get type => PlaygroundActivitySpec; + + @override + PlaygroundActivitySpec copyWith({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + }) { + return PlaygroundActivitySpec( + viewport: viewport ?? this.viewport, + item: item ?? this.item, + summaryTitle: summaryTitle ?? this.summaryTitle, + itemTitle: itemTitle ?? this.itemTitle, + itemDetail: itemDetail ?? this.itemDetail, + count: count ?? this.count, + indicator: indicator ?? this.indicator, + pendingItem: pendingItem ?? this.pendingItem, + activeItem: activeItem ?? this.activeItem, + completedItem: completedItem ?? this.completedItem, + pendingStatus: pendingStatus ?? this.pendingStatus, + activeStatus: activeStatus ?? this.activeStatus, + completedStatus: completedStatus ?? this.completedStatus, + ); + } + + @override + PlaygroundActivitySpec lerp(PlaygroundActivitySpec? other, double t) { + return PlaygroundActivitySpec( + viewport: viewport.lerp(other?.viewport, t), + item: item.lerp(other?.item, t), + summaryTitle: summaryTitle.lerp(other?.summaryTitle, t), + itemTitle: itemTitle.lerp(other?.itemTitle, t), + itemDetail: itemDetail.lerp(other?.itemDetail, t), + count: count.lerp(other?.count, t), + indicator: indicator.lerp(other?.indicator, t), + pendingItem: pendingItem.lerp(other?.pendingItem, t), + activeItem: activeItem.lerp(other?.activeItem, t), + completedItem: completedItem.lerp(other?.completedItem, t), + pendingStatus: pendingStatus.lerp(other?.pendingStatus, t), + activeStatus: activeStatus.lerp(other?.activeStatus, t), + completedStatus: completedStatus.lerp(other?.completedStatus, t), + ); + } + + @override + List get props => [ + viewport, + item, + summaryTitle, + itemTitle, + itemDetail, + count, + indicator, + pendingItem, + activeItem, + completedItem, + pendingStatus, + activeStatus, + completedStatus, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is PlaygroundActivitySpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('viewport', viewport)) + ..add(DiagnosticsProperty('item', item)) + ..add(DiagnosticsProperty('summaryTitle', summaryTitle)) + ..add(DiagnosticsProperty('itemTitle', itemTitle)) + ..add(DiagnosticsProperty('itemDetail', itemDetail)) + ..add(DiagnosticsProperty('count', count)) + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('pendingItem', pendingItem)) + ..add(DiagnosticsProperty('activeItem', activeItem)) + ..add(DiagnosticsProperty('completedItem', completedItem)) + ..add(DiagnosticsProperty('pendingStatus', pendingStatus)) + ..add(DiagnosticsProperty('activeStatus', activeStatus)) + ..add(DiagnosticsProperty('completedStatus', completedStatus)); + } +} + +@Deprecated( + 'Rename to `_\$PlaygroundActivitySpec` and migrate the class declaration to `class PlaygroundActivitySpec with _\$PlaygroundActivitySpec`. The `_\$PlaygroundActivitySpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$PlaygroundActivitySpecMethods = _$PlaygroundActivitySpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class PlaygroundActivityStyler + extends MixStyler + implements StylerFieldMetadata { + final Prop>? $viewport; + final Prop>? $item; + final Prop>? $summaryTitle; + final Prop>? $itemTitle; + final Prop>? $itemDetail; + final Prop>? $count; + final Prop>? $indicator; + final Prop>? $pendingItem; + final Prop>? $activeItem; + final Prop>? $completedItem; + final Prop>? $pendingStatus; + final Prop>? $activeStatus; + final Prop>? $completedStatus; + + const PlaygroundActivityStyler.create({ + Prop>? viewport, + Prop>? item, + Prop>? summaryTitle, + Prop>? itemTitle, + Prop>? itemDetail, + Prop>? count, + Prop>? indicator, + Prop>? pendingItem, + Prop>? activeItem, + Prop>? completedItem, + Prop>? pendingStatus, + Prop>? activeStatus, + Prop>? completedStatus, + super.variants, + super.modifier, + super.animation, + }) : $viewport = viewport, + $item = item, + $summaryTitle = summaryTitle, + $itemTitle = itemTitle, + $itemDetail = itemDetail, + $count = count, + $indicator = indicator, + $pendingItem = pendingItem, + $activeItem = activeItem, + $completedItem = completedItem, + $pendingStatus = pendingStatus, + $activeStatus = activeStatus, + $completedStatus = completedStatus; + + PlaygroundActivityStyler({ + BoxStyler? viewport, + FlexBoxStyler? item, + TextStyler? summaryTitle, + TextStyler? itemTitle, + TextStyler? itemDetail, + TextStyler? count, + IconStyler? indicator, + BoxStyler? pendingItem, + BoxStyler? activeItem, + BoxStyler? completedItem, + IconStyler? pendingStatus, + IconStyler? activeStatus, + IconStyler? completedStatus, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + viewport: Prop.maybeMix(viewport), + item: Prop.maybeMix(item), + summaryTitle: Prop.maybeMix(summaryTitle), + itemTitle: Prop.maybeMix(itemTitle), + itemDetail: Prop.maybeMix(itemDetail), + count: Prop.maybeMix(count), + indicator: Prop.maybeMix(indicator), + pendingItem: Prop.maybeMix(pendingItem), + activeItem: Prop.maybeMix(activeItem), + completedItem: Prop.maybeMix(completedItem), + pendingStatus: Prop.maybeMix(pendingStatus), + activeStatus: Prop.maybeMix(activeStatus), + completedStatus: Prop.maybeMix(completedStatus), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory PlaygroundActivityStyler.viewport(BoxStyler value) => + PlaygroundActivityStyler().viewport(value); + factory PlaygroundActivityStyler.item(FlexBoxStyler value) => + PlaygroundActivityStyler().item(value); + factory PlaygroundActivityStyler.summaryTitle(TextStyler value) => + PlaygroundActivityStyler().summaryTitle(value); + factory PlaygroundActivityStyler.itemTitle(TextStyler value) => + PlaygroundActivityStyler().itemTitle(value); + factory PlaygroundActivityStyler.itemDetail(TextStyler value) => + PlaygroundActivityStyler().itemDetail(value); + factory PlaygroundActivityStyler.count(TextStyler value) => + PlaygroundActivityStyler().count(value); + factory PlaygroundActivityStyler.indicator(IconStyler value) => + PlaygroundActivityStyler().indicator(value); + factory PlaygroundActivityStyler.pendingItem(BoxStyler value) => + PlaygroundActivityStyler().pendingItem(value); + factory PlaygroundActivityStyler.activeItem(BoxStyler value) => + PlaygroundActivityStyler().activeItem(value); + factory PlaygroundActivityStyler.completedItem(BoxStyler value) => + PlaygroundActivityStyler().completedItem(value); + factory PlaygroundActivityStyler.pendingStatus(IconStyler value) => + PlaygroundActivityStyler().pendingStatus(value); + factory PlaygroundActivityStyler.activeStatus(IconStyler value) => + PlaygroundActivityStyler().activeStatus(value); + factory PlaygroundActivityStyler.completedStatus(IconStyler value) => + PlaygroundActivityStyler().completedStatus(value); + + @override + Set get $stylerFieldNames => const { + 'viewport', + 'item', + 'summaryTitle', + 'itemTitle', + 'itemDetail', + 'count', + 'indicator', + 'pendingItem', + 'activeItem', + 'completedItem', + 'pendingStatus', + 'activeStatus', + 'completedStatus', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the viewport. + PlaygroundActivityStyler viewport(BoxStyler value) { + return merge(PlaygroundActivityStyler(viewport: value)); + } + + /// Sets the item. + PlaygroundActivityStyler item(FlexBoxStyler value) { + return merge(PlaygroundActivityStyler(item: value)); + } + + /// Sets the summaryTitle. + PlaygroundActivityStyler summaryTitle(TextStyler value) { + return merge(PlaygroundActivityStyler(summaryTitle: value)); + } + + /// Sets the itemTitle. + PlaygroundActivityStyler itemTitle(TextStyler value) { + return merge(PlaygroundActivityStyler(itemTitle: value)); + } + + /// Sets the itemDetail. + PlaygroundActivityStyler itemDetail(TextStyler value) { + return merge(PlaygroundActivityStyler(itemDetail: value)); + } + + /// Sets the count. + PlaygroundActivityStyler count(TextStyler value) { + return merge(PlaygroundActivityStyler(count: value)); + } + + /// Sets the indicator. + PlaygroundActivityStyler indicator(IconStyler value) { + return merge(PlaygroundActivityStyler(indicator: value)); + } + + /// Sets the pendingItem. + PlaygroundActivityStyler pendingItem(BoxStyler value) { + return merge(PlaygroundActivityStyler(pendingItem: value)); + } + + /// Sets the activeItem. + PlaygroundActivityStyler activeItem(BoxStyler value) { + return merge(PlaygroundActivityStyler(activeItem: value)); + } + + /// Sets the completedItem. + PlaygroundActivityStyler completedItem(BoxStyler value) { + return merge(PlaygroundActivityStyler(completedItem: value)); + } + + /// Sets the pendingStatus. + PlaygroundActivityStyler pendingStatus(IconStyler value) { + return merge(PlaygroundActivityStyler(pendingStatus: value)); + } + + /// Sets the activeStatus. + PlaygroundActivityStyler activeStatus(IconStyler value) { + return merge(PlaygroundActivityStyler(activeStatus: value)); + } + + /// Sets the completedStatus. + PlaygroundActivityStyler completedStatus(IconStyler value) { + return merge(PlaygroundActivityStyler(completedStatus: value)); + } + + /// Sets the animation configuration. + @override + PlaygroundActivityStyler animate(AnimationConfig value) { + return merge(PlaygroundActivityStyler(animation: value)); + } + + /// Sets the style variants. + @override + PlaygroundActivityStyler variants( + List> value, + ) { + return merge(PlaygroundActivityStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + PlaygroundActivityStyler wrap(WidgetModifierConfig value) { + return merge(PlaygroundActivityStyler(modifier: value)); + } + + /// Sets the widget modifier. + PlaygroundActivityStyler modifier(WidgetModifierConfig value) { + return merge(PlaygroundActivityStyler(modifier: value)); + } + + PlaygroundActivity call({ + Key? key, + required List items, + PlaygroundRunStatus status = PlaygroundRunStatus.working, + String title = 'Activity', + String semanticLabel = 'Activity', + bool collapseOnComplete = true, + bool? expanded, + bool defaultExpanded = true, + ValueChanged? onExpandedChanged, + PlaygroundActivityStatusBuilder? statusBuilder, + PlaygroundActivityStatusLabelBuilder? statusLabelBuilder, + PlaygroundActivityIndicatorBuilder? indicatorBuilder, + bool followOutput = true, + double followThreshold = 48, + ValueChanged? onFollowChanged, + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + }) { + return PlaygroundActivity( + key: key, + style: this, + items: items, + status: status, + title: title, + semanticLabel: semanticLabel, + collapseOnComplete: collapseOnComplete, + expanded: expanded, + defaultExpanded: defaultExpanded, + onExpandedChanged: onExpandedChanged, + statusBuilder: statusBuilder, + statusLabelBuilder: statusLabelBuilder, + indicatorBuilder: indicatorBuilder, + followOutput: followOutput, + followThreshold: followThreshold, + onFollowChanged: onFollowChanged, + disclosureStyle: disclosureStyle, + ); + } + + /// Merges with another [PlaygroundActivityStyler]. + @override + PlaygroundActivityStyler merge(PlaygroundActivityStyler? other) { + return PlaygroundActivityStyler.create( + viewport: MixOps.merge($viewport, other?.$viewport), + item: MixOps.merge($item, other?.$item), + summaryTitle: MixOps.merge($summaryTitle, other?.$summaryTitle), + itemTitle: MixOps.merge($itemTitle, other?.$itemTitle), + itemDetail: MixOps.merge($itemDetail, other?.$itemDetail), + count: MixOps.merge($count, other?.$count), + indicator: MixOps.merge($indicator, other?.$indicator), + pendingItem: MixOps.merge($pendingItem, other?.$pendingItem), + activeItem: MixOps.merge($activeItem, other?.$activeItem), + completedItem: MixOps.merge($completedItem, other?.$completedItem), + pendingStatus: MixOps.merge($pendingStatus, other?.$pendingStatus), + activeStatus: MixOps.merge($activeStatus, other?.$activeStatus), + completedStatus: MixOps.merge($completedStatus, other?.$completedStatus), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = PlaygroundActivitySpec( + viewport: MixOps.resolve(context, $viewport), + item: MixOps.resolve(context, $item), + summaryTitle: MixOps.resolve(context, $summaryTitle), + itemTitle: MixOps.resolve(context, $itemTitle), + itemDetail: MixOps.resolve(context, $itemDetail), + count: MixOps.resolve(context, $count), + indicator: MixOps.resolve(context, $indicator), + pendingItem: MixOps.resolve(context, $pendingItem), + activeItem: MixOps.resolve(context, $activeItem), + completedItem: MixOps.resolve(context, $completedItem), + pendingStatus: MixOps.resolve(context, $pendingStatus), + activeStatus: MixOps.resolve(context, $activeStatus), + completedStatus: MixOps.resolve(context, $completedStatus), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('viewport', $viewport)) + ..add(DiagnosticsProperty('item', $item)) + ..add(DiagnosticsProperty('summaryTitle', $summaryTitle)) + ..add(DiagnosticsProperty('itemTitle', $itemTitle)) + ..add(DiagnosticsProperty('itemDetail', $itemDetail)) + ..add(DiagnosticsProperty('count', $count)) + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('pendingItem', $pendingItem)) + ..add(DiagnosticsProperty('activeItem', $activeItem)) + ..add(DiagnosticsProperty('completedItem', $completedItem)) + ..add(DiagnosticsProperty('pendingStatus', $pendingStatus)) + ..add(DiagnosticsProperty('activeStatus', $activeStatus)) + ..add(DiagnosticsProperty('completedStatus', $completedStatus)); + } + + @override + List get props => [ + $viewport, + $item, + $summaryTitle, + $itemTitle, + $itemDetail, + $count, + $indicator, + $pendingItem, + $activeItem, + $completedItem, + $pendingStatus, + $activeStatus, + $completedStatus, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/playground/lib/ui/components/answer.dart b/apps/playground/lib/ui/components/answer.dart new file mode 100644 index 000000000..8e58ed08d --- /dev/null +++ b/apps/playground/lib/ui/components/answer.dart @@ -0,0 +1,218 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; + +part 'answer.g.dart'; + +typedef PlaygroundAnswerSourcesIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Streaming answer surface with host-owned content and feedback. +class PlaygroundAnswer extends StatefulWidget { + const PlaygroundAnswer({ + super.key, + required this.child, + this.streamId, + this.status = PlaygroundAnswerStatus.streaming, + this.onCopy, + this.onRetry, + this.copyIconBuilder, + this.retryIconBuilder, + this.sourcesIndicatorBuilder, + this.copyLabel = 'Copy answer', + this.retryLabel = 'Retry answer', + this.showActions, + this.feedback, + this.sourcesContent, + this.sourcesExpanded, + this.defaultSourcesExpanded = false, + this.onSourcesExpandedChanged, + this.sourcesLabel = 'Sources', + this.semanticLabel = 'Answer', + this.surfaceStyle = const CardStyler.create(), + this.sourcesStyle = const DisclosureStyler.create(), + this.copyStyle = const IconButtonStyler.create(), + this.retryStyle = const IconButtonStyler.create(), + this.style = const PlaygroundAnswerStyler.create(), + this.styleSpec, + }); + + final Widget child; + final Object? streamId; + final PlaygroundAnswerStatus status; + final VoidCallback? onCopy; + final VoidCallback? onRetry; + final RemixIconButtonIconBuilder? copyIconBuilder; + final RemixIconButtonIconBuilder? retryIconBuilder; + final PlaygroundAnswerSourcesIndicatorBuilder? sourcesIndicatorBuilder; + final String copyLabel; + final String retryLabel; + final bool? showActions; + final Widget? feedback; + final Widget? sourcesContent; + final bool? sourcesExpanded; + final bool defaultSourcesExpanded; + final ValueChanged? onSourcesExpandedChanged; + final String sourcesLabel; + final String semanticLabel; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; + final PlaygroundAnswerStyler style; + final PlaygroundAnswerSpec? styleSpec; + + @override + State createState() => _PlaygroundAnswerState(); +} + +class _PlaygroundAnswerState extends State { + late final PlaygroundDisclosureEngine _disclosure; + + bool get _sourcesExpanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = PlaygroundDisclosureEngine( + value: widget.sourcesExpanded, + defaultValue: widget.defaultSourcesExpanded, + ); + } + + @override + void didUpdateWidget(PlaygroundAnswer oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.sourcesExpanded); + final beganStreaming = + !oldWidget.status.isStreaming && widget.status.isStreaming; + final newStreamingIdentity = + oldWidget.streamId != widget.streamId && widget.status.isStreaming; + if (beganStreaming || newStreamingIdentity) _requestSources(false); + } + + void _requestSources(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onSourcesExpandedChanged?.call(next); + } + + @override + Widget build(BuildContext context) { + final revealActions = + !widget.status.isStreaming && + (widget.showActions ?? widget.status.showsActions); + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixCard( + style: widget.surfaceStyle, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + liveRegion: widget.status.isStreaming, + child: Box(styleSpec: spec.body, child: widget.child), + ), + if (widget.sourcesContent != null) + RemixDisclosure( + expanded: _sourcesExpanded, + onExpandedChanged: _requestSources, + semanticLabel: widget.sourcesLabel, + style: widget.sourcesStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + PlaygroundDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.sourcesIndicatorBuilder, + ), + ], + ), + trigger: StyledText( + widget.sourcesLabel, + styleSpec: spec.sourcesLabel, + ), + content: widget.sourcesContent!, + ), + if (revealActions) + RowBox( + styleSpec: spec.actions, + children: [ + if (widget.onCopy != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.copyIconBuilder ?? + (context, iconSpec, icon) => + PlaygroundFunctionalGlyph( + kind: .copy, + spec: iconSpec, + ), + semanticLabel: widget.copyLabel, + onPressed: widget.onCopy, + style: widget.copyStyle, + ), + if (widget.onRetry != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.retryIconBuilder ?? + (context, iconSpec, icon) => + PlaygroundFunctionalGlyph( + kind: .retry, + spec: iconSpec, + ), + semanticLabel: widget.retryLabel, + onPressed: widget.onRetry, + style: widget.retryStyle, + ), + if (widget.status == PlaygroundAnswerStatus.complete && + widget.feedback != null) + Box(styleSpec: spec.feedback, child: widget.feedback), + ], + ), + ], + ), + ), + ), + ); + } +} + +@MixableSpec(target: PlaygroundAnswer.new) +@immutable +final class PlaygroundAnswerSpec with _$PlaygroundAnswerSpec { + @override + final StyleSpec body; + @override + final StyleSpec actions; + @override + final StyleSpec feedback; + @override + final StyleSpec sourcesLabel; + @override + final StyleSpec indicator; + + const PlaygroundAnswerSpec({ + StyleSpec? body, + StyleSpec? actions, + StyleSpec? feedback, + StyleSpec? sourcesLabel, + StyleSpec? indicator, + }) : body = body ?? const StyleSpec(spec: BoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + feedback = feedback ?? const StyleSpec(spec: BoxSpec()), + sourcesLabel = sourcesLabel ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()); +} diff --git a/apps/playground/lib/ui/components/answer.g.dart b/apps/playground/lib/ui/components/answer.g.dart new file mode 100644 index 000000000..da2cac277 --- /dev/null +++ b/apps/playground/lib/ui/components/answer.g.dart @@ -0,0 +1,333 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'answer.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$PlaygroundAnswerSpec + implements Spec, Diagnosticable { + StyleSpec get body; + StyleSpec get actions; + StyleSpec get feedback; + StyleSpec get sourcesLabel; + StyleSpec get indicator; + + @override + Type get type => PlaygroundAnswerSpec; + + @override + PlaygroundAnswerSpec copyWith({ + StyleSpec? body, + StyleSpec? actions, + StyleSpec? feedback, + StyleSpec? sourcesLabel, + StyleSpec? indicator, + }) { + return PlaygroundAnswerSpec( + body: body ?? this.body, + actions: actions ?? this.actions, + feedback: feedback ?? this.feedback, + sourcesLabel: sourcesLabel ?? this.sourcesLabel, + indicator: indicator ?? this.indicator, + ); + } + + @override + PlaygroundAnswerSpec lerp(PlaygroundAnswerSpec? other, double t) { + return PlaygroundAnswerSpec( + body: body.lerp(other?.body, t), + actions: actions.lerp(other?.actions, t), + feedback: feedback.lerp(other?.feedback, t), + sourcesLabel: sourcesLabel.lerp(other?.sourcesLabel, t), + indicator: indicator.lerp(other?.indicator, t), + ); + } + + @override + List get props => [body, actions, feedback, sourcesLabel, indicator]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is PlaygroundAnswerSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('body', body)) + ..add(DiagnosticsProperty('actions', actions)) + ..add(DiagnosticsProperty('feedback', feedback)) + ..add(DiagnosticsProperty('sourcesLabel', sourcesLabel)) + ..add(DiagnosticsProperty('indicator', indicator)); + } +} + +@Deprecated( + 'Rename to `_\$PlaygroundAnswerSpec` and migrate the class declaration to `class PlaygroundAnswerSpec with _\$PlaygroundAnswerSpec`. The `_\$PlaygroundAnswerSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$PlaygroundAnswerSpecMethods = _$PlaygroundAnswerSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class PlaygroundAnswerStyler + extends MixStyler + implements StylerFieldMetadata { + final Prop>? $body; + final Prop>? $actions; + final Prop>? $feedback; + final Prop>? $sourcesLabel; + final Prop>? $indicator; + + const PlaygroundAnswerStyler.create({ + Prop>? body, + Prop>? actions, + Prop>? feedback, + Prop>? sourcesLabel, + Prop>? indicator, + super.variants, + super.modifier, + super.animation, + }) : $body = body, + $actions = actions, + $feedback = feedback, + $sourcesLabel = sourcesLabel, + $indicator = indicator; + + PlaygroundAnswerStyler({ + BoxStyler? body, + FlexBoxStyler? actions, + BoxStyler? feedback, + TextStyler? sourcesLabel, + IconStyler? indicator, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + body: Prop.maybeMix(body), + actions: Prop.maybeMix(actions), + feedback: Prop.maybeMix(feedback), + sourcesLabel: Prop.maybeMix(sourcesLabel), + indicator: Prop.maybeMix(indicator), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory PlaygroundAnswerStyler.body(BoxStyler value) => + PlaygroundAnswerStyler().body(value); + factory PlaygroundAnswerStyler.actions(FlexBoxStyler value) => + PlaygroundAnswerStyler().actions(value); + factory PlaygroundAnswerStyler.feedback(BoxStyler value) => + PlaygroundAnswerStyler().feedback(value); + factory PlaygroundAnswerStyler.sourcesLabel(TextStyler value) => + PlaygroundAnswerStyler().sourcesLabel(value); + factory PlaygroundAnswerStyler.indicator(IconStyler value) => + PlaygroundAnswerStyler().indicator(value); + + @override + Set get $stylerFieldNames => const { + 'body', + 'actions', + 'feedback', + 'sourcesLabel', + 'indicator', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the body. + PlaygroundAnswerStyler body(BoxStyler value) { + return merge(PlaygroundAnswerStyler(body: value)); + } + + /// Sets the actions. + PlaygroundAnswerStyler actions(FlexBoxStyler value) { + return merge(PlaygroundAnswerStyler(actions: value)); + } + + /// Sets the feedback. + PlaygroundAnswerStyler feedback(BoxStyler value) { + return merge(PlaygroundAnswerStyler(feedback: value)); + } + + /// Sets the sourcesLabel. + PlaygroundAnswerStyler sourcesLabel(TextStyler value) { + return merge(PlaygroundAnswerStyler(sourcesLabel: value)); + } + + /// Sets the indicator. + PlaygroundAnswerStyler indicator(IconStyler value) { + return merge(PlaygroundAnswerStyler(indicator: value)); + } + + /// Sets the animation configuration. + @override + PlaygroundAnswerStyler animate(AnimationConfig value) { + return merge(PlaygroundAnswerStyler(animation: value)); + } + + /// Sets the style variants. + @override + PlaygroundAnswerStyler variants( + List> value, + ) { + return merge(PlaygroundAnswerStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + PlaygroundAnswerStyler wrap(WidgetModifierConfig value) { + return merge(PlaygroundAnswerStyler(modifier: value)); + } + + /// Sets the widget modifier. + PlaygroundAnswerStyler modifier(WidgetModifierConfig value) { + return merge(PlaygroundAnswerStyler(modifier: value)); + } + + PlaygroundAnswer call({ + Key? key, + required Widget child, + Object? streamId, + PlaygroundAnswerStatus status = PlaygroundAnswerStatus.streaming, + VoidCallback? onCopy, + VoidCallback? onRetry, + RemixIconButtonIconBuilder? copyIconBuilder, + RemixIconButtonIconBuilder? retryIconBuilder, + PlaygroundAnswerSourcesIndicatorBuilder? sourcesIndicatorBuilder, + String copyLabel = 'Copy answer', + String retryLabel = 'Retry answer', + bool? showActions, + Widget? feedback, + Widget? sourcesContent, + bool? sourcesExpanded, + bool defaultSourcesExpanded = false, + ValueChanged? onSourcesExpandedChanged, + String sourcesLabel = 'Sources', + String semanticLabel = 'Answer', + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler sourcesStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), + }) { + return PlaygroundAnswer( + key: key, + style: this, + child: child, + streamId: streamId, + status: status, + onCopy: onCopy, + onRetry: onRetry, + copyIconBuilder: copyIconBuilder, + retryIconBuilder: retryIconBuilder, + sourcesIndicatorBuilder: sourcesIndicatorBuilder, + copyLabel: copyLabel, + retryLabel: retryLabel, + showActions: showActions, + feedback: feedback, + sourcesContent: sourcesContent, + sourcesExpanded: sourcesExpanded, + defaultSourcesExpanded: defaultSourcesExpanded, + onSourcesExpandedChanged: onSourcesExpandedChanged, + sourcesLabel: sourcesLabel, + semanticLabel: semanticLabel, + surfaceStyle: surfaceStyle, + sourcesStyle: sourcesStyle, + copyStyle: copyStyle, + retryStyle: retryStyle, + ); + } + + /// Merges with another [PlaygroundAnswerStyler]. + @override + PlaygroundAnswerStyler merge(PlaygroundAnswerStyler? other) { + return PlaygroundAnswerStyler.create( + body: MixOps.merge($body, other?.$body), + actions: MixOps.merge($actions, other?.$actions), + feedback: MixOps.merge($feedback, other?.$feedback), + sourcesLabel: MixOps.merge($sourcesLabel, other?.$sourcesLabel), + indicator: MixOps.merge($indicator, other?.$indicator), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = PlaygroundAnswerSpec( + body: MixOps.resolve(context, $body), + actions: MixOps.resolve(context, $actions), + feedback: MixOps.resolve(context, $feedback), + sourcesLabel: MixOps.resolve(context, $sourcesLabel), + indicator: MixOps.resolve(context, $indicator), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('body', $body)) + ..add(DiagnosticsProperty('actions', $actions)) + ..add(DiagnosticsProperty('feedback', $feedback)) + ..add(DiagnosticsProperty('sourcesLabel', $sourcesLabel)) + ..add(DiagnosticsProperty('indicator', $indicator)); + } + + @override + List get props => [ + $body, + $actions, + $feedback, + $sourcesLabel, + $indicator, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/playground/lib/ui/components/card.dart b/apps/playground/lib/ui/components/card.dart index f6dc02474..8023478ed 100644 --- a/apps/playground/lib/ui/components/card.dart +++ b/apps/playground/lib/ui/components/card.dart @@ -18,7 +18,7 @@ part 'card.g.dart'; /// /// The fill is `background`, the same token the page uses, so a card is told /// apart by its outline rather than by a second surface color. That is -/// deliberate: it keeps the token vocabulary at fifteen names, and a theme +/// deliberate: it keeps the token vocabulary at twenty names, and a theme /// that wants a distinct card surface changes this one line. /// /// [style] is merged **last**, so a single call site can override any part of diff --git a/apps/playground/lib/ui/components/card.g.dart b/apps/playground/lib/ui/components/card.g.dart index 9bb4ff8c1..d1a4dd399 100644 --- a/apps/playground/lib/ui/components/card.g.dart +++ b/apps/playground/lib/ui/components/card.g.dart @@ -18,7 +18,7 @@ part of 'card.dart'; /// /// The fill is `background`, the same token the page uses, so a card is told /// apart by its outline rather than by a second surface color. That is -/// deliberate: it keeps the token vocabulary at fifteen names, and a theme +/// deliberate: it keeps the token vocabulary at twenty names, and a theme /// that wants a distinct card surface changes this one line. /// /// [style] is merged **last**, so a single call site can override any part of diff --git a/apps/playground/lib/ui/components/composer.dart b/apps/playground/lib/ui/components/composer.dart new file mode 100644 index 000000000..fda754a42 --- /dev/null +++ b/apps/playground/lib/ui/components/composer.dart @@ -0,0 +1,276 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../support/functional_glyph.dart'; + +part 'composer.g.dart'; + +/// Growable prompt input composed from Remix text-area and icon-button controls. +class PlaygroundComposer extends StatefulWidget { + const PlaygroundComposer({ + super.key, + this.controller, + this.initialValue, + this.focusNode, + this.onChanged, + this.onSubmit, + this.onStop, + this.running = false, + this.enabled = true, + this.canSubmit, + this.clearOnSubmit = true, + this.autofocus = false, + this.hintText = 'Message', + this.semanticLabel = 'Message', + this.minLines = 2, + this.maxLines = 8, + this.leading, + this.trailing, + this.submitIconBuilder, + this.stopIconBuilder, + this.submitLabel = 'Send', + this.stopLabel = 'Stop', + this.surfaceStyle = const CardStyler.create(), + this.fieldStyle = const TextFieldStyler.create(), + this.submitStyle = const IconButtonStyler.create(), + this.stopStyle = const IconButtonStyler.create(), + this.style = const PlaygroundComposerStyler.create(), + this.styleSpec, + }) : assert( + controller == null || initialValue == null, + 'initialValue cannot be used with an external controller.', + ); + + final TextEditingController? controller; + final String? initialValue; + final FocusNode? focusNode; + final ValueChanged? onChanged; + final ValueChanged? onSubmit; + final VoidCallback? onStop; + final bool running; + final bool enabled; + final bool? canSubmit; + final bool clearOnSubmit; + final bool autofocus; + final String hintText; + final String semanticLabel; + final int minLines; + final int maxLines; + final Widget? leading; + final Widget? trailing; + final RemixIconButtonIconBuilder? submitIconBuilder; + final RemixIconButtonIconBuilder? stopIconBuilder; + final String submitLabel; + final String stopLabel; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; + final PlaygroundComposerStyler style; + final PlaygroundComposerSpec? styleSpec; + + @override + State createState() => _PlaygroundComposerState(); +} + +class _PlaygroundComposerState extends State { + TextEditingController? _ownedController; + FocusNode? _ownedFocusNode; + late TextEditingController _controller; + late String _text; + + FocusNode get _focusNode => + widget.focusNode ?? (_ownedFocusNode ??= FocusNode()); + + bool get _isComposing { + final composing = _controller.value.composing; + return composing.isValid && !composing.isCollapsed; + } + + bool get _canSubmit => + widget.enabled && + !widget.running && + _text.trim().isNotEmpty && + widget.onSubmit != null && + (widget.canSubmit ?? true); + + @override + void initState() { + super.initState(); + _controller = + widget.controller ?? + (_ownedController = TextEditingController(text: widget.initialValue)); + _text = _controller.text; + _controller.addListener(_handleControllerChanged); + } + + void _handleControllerChanged() { + final next = _controller.text; + if (next == _text) return; + setState(() => _text = next); + widget.onChanged?.call(next); + } + + @override + void didUpdateWidget(PlaygroundComposer oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + final seed = _controller.text; + _controller.removeListener(_handleControllerChanged); + final oldOwnedController = _ownedController; + _ownedController = null; + _controller = + widget.controller ?? + (_ownedController = TextEditingController(text: seed)); + _text = _controller.text; + _controller.addListener(_handleControllerChanged); + _disposeAfterFrame(oldOwnedController); + } + if (!identical(oldWidget.focusNode, widget.focusNode)) { + final oldOwnedFocusNode = _ownedFocusNode; + _ownedFocusNode = null; + _disposeAfterFrame(oldOwnedFocusNode); + } + } + + /// Releases a superseded owned object once the child has let go of it. + /// + /// The same deferral the transcript uses for its scroll controller: the child + /// RemixTextArea still holds the old controller and focus node until this + /// frame's rebuild detaches them, and detaching touches a disposed object. + void _disposeAfterFrame(ChangeNotifier? superseded) { + if (superseded == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) => superseded.dispose()); + } + + void _submit() { + if (!_canSubmit || _isComposing) return; + final prompt = _text.trim(); + widget.onSubmit?.call(prompt); + if (widget.clearOnSubmit) _controller.clear(); + _focusNode.requestFocus(); + } + + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final isEnter = + event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter; + if (!isEnter || HardwareKeyboard.instance.isShiftPressed || _isComposing) { + return KeyEventResult.ignored; + } + if (!_canSubmit) return KeyEventResult.ignored; + _submit(); + return KeyEventResult.handled; + } + + Widget _defaultSubmitIcon( + BuildContext context, + IconSpec spec, + IconData? icon, + ) => PlaygroundFunctionalGlyph(kind: .send, spec: spec); + + Widget _defaultStopIcon( + BuildContext context, + IconSpec spec, + IconData? icon, + ) => PlaygroundFunctionalGlyph(kind: .stop, spec: spec); + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + // Keep the field and action in separate accessibility nodes. + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _handleKey, + child: RemixCard( + style: widget.surfaceStyle, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ScrollConfiguration( + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false), + child: RemixTextArea( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + autofocus: widget.autofocus, + hintText: widget.hintText, + semanticLabel: widget.semanticLabel, + minLines: widget.minLines, + maxLines: widget.maxLines, + textInputAction: TextInputAction.newline, + style: widget.fieldStyle, + ), + ), + RowBox( + styleSpec: spec.toolbar, + children: [ + if (widget.leading != null) widget.leading!, + const Spacer(), + if (widget.trailing != null) widget.trailing!, + Semantics( + container: true, + child: RemixIconButton( + key: ValueKey( + widget.running + ? 'playground-composer-stop' + : 'playground-composer-send', + ), + icon: null, + iconBuilder: widget.running + ? (widget.stopIconBuilder ?? _defaultStopIcon) + : (widget.submitIconBuilder ?? _defaultSubmitIcon), + semanticLabel: widget.running + ? widget.stopLabel + : widget.submitLabel, + enabled: widget.running + ? widget.enabled && widget.onStop != null + : _canSubmit, + onPressed: widget.running ? widget.onStop : _submit, + style: widget.running + ? widget.stopStyle + : widget.submitStyle, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _controller.removeListener(_handleControllerChanged); + _ownedController?.dispose(); + _ownedFocusNode?.dispose(); + super.dispose(); + } +} + +@MixableSpec(target: PlaygroundComposer.new) +@immutable +final class PlaygroundComposerSpec with _$PlaygroundComposerSpec { + @override + final StyleSpec toolbar; + + const PlaygroundComposerSpec({StyleSpec? toolbar}) + : toolbar = toolbar ?? const StyleSpec(spec: FlexBoxSpec()); +} diff --git a/apps/playground/lib/ui/components/composer.g.dart b/apps/playground/lib/ui/components/composer.g.dart new file mode 100644 index 000000000..a24f30555 --- /dev/null +++ b/apps/playground/lib/ui/components/composer.g.dart @@ -0,0 +1,238 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'composer.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$PlaygroundComposerSpec + implements Spec, Diagnosticable { + StyleSpec get toolbar; + + @override + Type get type => PlaygroundComposerSpec; + + @override + PlaygroundComposerSpec copyWith({StyleSpec? toolbar}) { + return PlaygroundComposerSpec(toolbar: toolbar ?? this.toolbar); + } + + @override + PlaygroundComposerSpec lerp(PlaygroundComposerSpec? other, double t) { + return PlaygroundComposerSpec(toolbar: toolbar.lerp(other?.toolbar, t)); + } + + @override + List get props => [toolbar]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is PlaygroundComposerSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties.add(DiagnosticsProperty('toolbar', toolbar)); + } +} + +@Deprecated( + 'Rename to `_\$PlaygroundComposerSpec` and migrate the class declaration to `class PlaygroundComposerSpec with _\$PlaygroundComposerSpec`. The `_\$PlaygroundComposerSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$PlaygroundComposerSpecMethods = _$PlaygroundComposerSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class PlaygroundComposerStyler + extends MixStyler + implements StylerFieldMetadata { + final Prop>? $toolbar; + + const PlaygroundComposerStyler.create({ + Prop>? toolbar, + super.variants, + super.modifier, + super.animation, + }) : $toolbar = toolbar; + + PlaygroundComposerStyler({ + FlexBoxStyler? toolbar, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + toolbar: Prop.maybeMix(toolbar), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory PlaygroundComposerStyler.toolbar(FlexBoxStyler value) => + PlaygroundComposerStyler().toolbar(value); + + @override + Set get $stylerFieldNames => const { + 'toolbar', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the toolbar. + PlaygroundComposerStyler toolbar(FlexBoxStyler value) { + return merge(PlaygroundComposerStyler(toolbar: value)); + } + + /// Sets the animation configuration. + @override + PlaygroundComposerStyler animate(AnimationConfig value) { + return merge(PlaygroundComposerStyler(animation: value)); + } + + /// Sets the style variants. + @override + PlaygroundComposerStyler variants( + List> value, + ) { + return merge(PlaygroundComposerStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + PlaygroundComposerStyler wrap(WidgetModifierConfig value) { + return merge(PlaygroundComposerStyler(modifier: value)); + } + + /// Sets the widget modifier. + PlaygroundComposerStyler modifier(WidgetModifierConfig value) { + return merge(PlaygroundComposerStyler(modifier: value)); + } + + PlaygroundComposer call({ + Key? key, + TextEditingController? controller, + String? initialValue, + FocusNode? focusNode, + ValueChanged? onChanged, + ValueChanged? onSubmit, + VoidCallback? onStop, + bool running = false, + bool enabled = true, + bool? canSubmit, + bool clearOnSubmit = true, + bool autofocus = false, + String hintText = 'Message', + String semanticLabel = 'Message', + int minLines = 2, + int maxLines = 8, + Widget? leading, + Widget? trailing, + RemixIconButtonIconBuilder? submitIconBuilder, + RemixIconButtonIconBuilder? stopIconBuilder, + String submitLabel = 'Send', + String stopLabel = 'Stop', + CardStyler surfaceStyle = const CardStyler.create(), + TextFieldStyler fieldStyle = const TextFieldStyler.create(), + IconButtonStyler submitStyle = const IconButtonStyler.create(), + IconButtonStyler stopStyle = const IconButtonStyler.create(), + }) { + return PlaygroundComposer( + key: key, + style: this, + controller: controller, + initialValue: initialValue, + focusNode: focusNode, + onChanged: onChanged, + onSubmit: onSubmit, + onStop: onStop, + running: running, + enabled: enabled, + canSubmit: canSubmit, + clearOnSubmit: clearOnSubmit, + autofocus: autofocus, + hintText: hintText, + semanticLabel: semanticLabel, + minLines: minLines, + maxLines: maxLines, + leading: leading, + trailing: trailing, + submitIconBuilder: submitIconBuilder, + stopIconBuilder: stopIconBuilder, + submitLabel: submitLabel, + stopLabel: stopLabel, + surfaceStyle: surfaceStyle, + fieldStyle: fieldStyle, + submitStyle: submitStyle, + stopStyle: stopStyle, + ); + } + + /// Merges with another [PlaygroundComposerStyler]. + @override + PlaygroundComposerStyler merge(PlaygroundComposerStyler? other) { + return PlaygroundComposerStyler.create( + toolbar: MixOps.merge($toolbar, other?.$toolbar), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = PlaygroundComposerSpec( + toolbar: MixOps.resolve(context, $toolbar), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('toolbar', $toolbar)); + } + + @override + List get props => [$toolbar, $animation, $modifier, $variants]; +} diff --git a/apps/playground/lib/ui/components/execution.dart b/apps/playground/lib/ui/components/execution.dart new file mode 100644 index 000000000..1b0768da9 --- /dev/null +++ b/apps/playground/lib/ui/components/execution.dart @@ -0,0 +1,342 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'execution.g.dart'; + +typedef PlaygroundExecutionStatusLabelBuilder = + String Function(PlaygroundExecutionStatus status); +typedef PlaygroundExecutionStatusBuilder = + Widget Function(BuildContext context, PlaygroundExecutionStatus status); +typedef PlaygroundExecutionIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Toggleable tool execution output with lifecycle-driven open requests. +class PlaygroundExecution extends StatefulWidget { + const PlaygroundExecution({ + super.key, + required this.tool, + required this.title, + required this.child, + this.status = PlaygroundExecutionStatus.running, + this.meta, + this.icon, + this.onCopy, + this.onRetry, + this.copyIconBuilder, + this.retryIconBuilder, + this.indicatorBuilder, + this.statusBuilder, + this.statusLabelBuilder, + this.copyLabel = 'Copy output', + this.retryLabel = 'Retry execution', + this.outputLabel = 'Tool output', + this.showActions = true, + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.semanticLabel = 'Tool execution', + this.surfaceStyle = const CardStyler.create(), + this.disclosureStyle = const DisclosureStyler.create(), + this.copyStyle = const IconButtonStyler.create(), + this.retryStyle = const IconButtonStyler.create(), + this.style = const PlaygroundExecutionStyler.create(), + this.styleSpec, + }); + + final String tool; + final String title; + final Widget child; + final PlaygroundExecutionStatus status; + final String? meta; + final Widget? icon; + final VoidCallback? onCopy; + final VoidCallback? onRetry; + final RemixIconButtonIconBuilder? copyIconBuilder; + final RemixIconButtonIconBuilder? retryIconBuilder; + final PlaygroundExecutionIndicatorBuilder? indicatorBuilder; + final PlaygroundExecutionStatusBuilder? statusBuilder; + final PlaygroundExecutionStatusLabelBuilder? statusLabelBuilder; + final String copyLabel; + final String retryLabel; + final String outputLabel; + final bool showActions; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final String semanticLabel; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; + final PlaygroundExecutionStyler style; + final PlaygroundExecutionSpec? styleSpec; + + @override + State createState() => _PlaygroundExecutionState(); +} + +class _PlaygroundExecutionState extends State { + late final PlaygroundDisclosureEngine _disclosure; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = PlaygroundDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget(PlaygroundExecution oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + if (!oldWidget.status.isWorking && widget.status.isWorking) { + _request(true); + } else if (oldWidget.status.isWorking && + !widget.status.isWorking && + widget.collapseOnComplete) { + _request(false); + } + } + + void _request(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String get _statusLabel => + widget.statusLabelBuilder?.call(widget.status) ?? + switch (widget.status) { + PlaygroundExecutionStatus.running => 'Running', + PlaygroundExecutionStatus.success => 'Completed', + PlaygroundExecutionStatus.error => 'Failed', + PlaygroundExecutionStatus.cancelled => 'Cancelled', + }; + + StyleSpec _statusContainer(PlaygroundExecutionSpec spec) => + switch (widget.status) { + PlaygroundExecutionStatus.running => spec.runningStatus, + PlaygroundExecutionStatus.success => spec.successStatus, + PlaygroundExecutionStatus.error => spec.errorStatus, + PlaygroundExecutionStatus.cancelled => spec.cancelledStatus, + }; + + PlaygroundFunctionalGlyphKind get _statusGlyph => switch (widget.status) { + PlaygroundExecutionStatus.running => .loading, + PlaygroundExecutionStatus.success => .completedCircle, + PlaygroundExecutionStatus.error => .errorCircle, + PlaygroundExecutionStatus.cancelled => .cancelledCircle, + }; + + Widget _toolIcon(PlaygroundExecutionSpec spec) { + final icon = widget.icon; + if (icon != null) return icon; + return StyleSpecBuilder( + styleSpec: spec.toolIcon, + builder: (context, iconSpec) => + PlaygroundFunctionalGlyph(kind: .tool, spec: iconSpec), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + value: '${widget.tool}, $_statusLabel', + child: RemixCard( + style: widget.surfaceStyle, + child: RemixDisclosure( + expanded: _expanded, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + PlaygroundDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: RowBox( + styleSpec: spec.header, + children: [ + _toolIcon(spec), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StyledText(widget.title, styleSpec: spec.title), + StyledText(widget.tool, styleSpec: spec.tool), + ], + ), + ), + if (widget.meta != null) + StyledText(widget.meta!, styleSpec: spec.meta), + Box( + styleSpec: _statusContainer(spec), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.statusBuilder?.call(context, widget.status) ?? + StyleSpecBuilder( + styleSpec: spec.statusIcon, + builder: (context, iconSpec) => + PlaygroundFunctionalGlyph( + kind: _statusGlyph, + spec: iconSpec, + ), + ), + StyledText(_statusLabel, styleSpec: spec.status), + ], + ), + ), + ], + ), + content: Box( + styleSpec: spec.output, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Deliberately not an PlaygroundTranscript. That installs + // Arrow/Page/Home/End shortcuts and its own Semantics + // container, and an execution card is normally nested inside + // a host transcript: the inner list shrink-wraps to a zero + // scroll extent but its action still consumes those intents, + // so focus landing here stopped the outer transcript from + // scrolling, and its `busy` value announced the status a + // second time. This is the primitive plan and activity use. + Semantics( + label: widget.outputLabel, + child: PlaygroundLiveEdgeScrollView( + followOutput: widget.status.isWorking, + child: widget.child, + ), + ), + if (widget.showActions && widget.status.isSettled) + RowBox( + styleSpec: spec.actions, + children: [ + if (widget.onCopy != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.copyIconBuilder ?? + (context, iconSpec, icon) => + PlaygroundFunctionalGlyph( + kind: .copy, + spec: iconSpec, + ), + semanticLabel: widget.copyLabel, + onPressed: widget.onCopy, + style: widget.copyStyle, + ), + if (widget.onRetry != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.retryIconBuilder ?? + (context, iconSpec, icon) => + PlaygroundFunctionalGlyph( + kind: .retry, + spec: iconSpec, + ), + semanticLabel: widget.retryLabel, + onPressed: widget.onRetry, + style: widget.retryStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: PlaygroundExecution.new) +@immutable +final class PlaygroundExecutionSpec with _$PlaygroundExecutionSpec { + @override + final StyleSpec header; + @override + final StyleSpec output; + @override + final StyleSpec actions; + @override + final StyleSpec tool; + @override + final StyleSpec title; + @override + final StyleSpec meta; + @override + final StyleSpec status; + @override + final StyleSpec toolIcon; + @override + final StyleSpec statusIcon; + @override + final StyleSpec indicator; + @override + final StyleSpec runningStatus; + @override + final StyleSpec successStatus; + @override + final StyleSpec errorStatus; + @override + final StyleSpec cancelledStatus; + + const PlaygroundExecutionSpec({ + StyleSpec? header, + StyleSpec? output, + StyleSpec? actions, + StyleSpec? tool, + StyleSpec? title, + StyleSpec? meta, + StyleSpec? status, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? runningStatus, + StyleSpec? successStatus, + StyleSpec? errorStatus, + StyleSpec? cancelledStatus, + }) : header = header ?? const StyleSpec(spec: FlexBoxSpec()), + output = output ?? const StyleSpec(spec: BoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + tool = tool ?? const StyleSpec(spec: TextSpec()), + title = title ?? const StyleSpec(spec: TextSpec()), + meta = meta ?? const StyleSpec(spec: TextSpec()), + status = status ?? const StyleSpec(spec: TextSpec()), + toolIcon = toolIcon ?? const StyleSpec(spec: IconSpec()), + statusIcon = statusIcon ?? const StyleSpec(spec: IconSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + runningStatus = runningStatus ?? const StyleSpec(spec: BoxSpec()), + successStatus = successStatus ?? const StyleSpec(spec: BoxSpec()), + errorStatus = errorStatus ?? const StyleSpec(spec: BoxSpec()), + cancelledStatus = cancelledStatus ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/apps/playground/lib/ui/components/execution.g.dart b/apps/playground/lib/ui/components/execution.g.dart new file mode 100644 index 000000000..35ab3773e --- /dev/null +++ b/apps/playground/lib/ui/components/execution.g.dart @@ -0,0 +1,554 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'execution.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$PlaygroundExecutionSpec + implements Spec, Diagnosticable { + StyleSpec get header; + StyleSpec get output; + StyleSpec get actions; + StyleSpec get tool; + StyleSpec get title; + StyleSpec get meta; + StyleSpec get status; + StyleSpec get toolIcon; + StyleSpec get statusIcon; + StyleSpec get indicator; + StyleSpec get runningStatus; + StyleSpec get successStatus; + StyleSpec get errorStatus; + StyleSpec get cancelledStatus; + + @override + Type get type => PlaygroundExecutionSpec; + + @override + PlaygroundExecutionSpec copyWith({ + StyleSpec? header, + StyleSpec? output, + StyleSpec? actions, + StyleSpec? tool, + StyleSpec? title, + StyleSpec? meta, + StyleSpec? status, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? runningStatus, + StyleSpec? successStatus, + StyleSpec? errorStatus, + StyleSpec? cancelledStatus, + }) { + return PlaygroundExecutionSpec( + header: header ?? this.header, + output: output ?? this.output, + actions: actions ?? this.actions, + tool: tool ?? this.tool, + title: title ?? this.title, + meta: meta ?? this.meta, + status: status ?? this.status, + toolIcon: toolIcon ?? this.toolIcon, + statusIcon: statusIcon ?? this.statusIcon, + indicator: indicator ?? this.indicator, + runningStatus: runningStatus ?? this.runningStatus, + successStatus: successStatus ?? this.successStatus, + errorStatus: errorStatus ?? this.errorStatus, + cancelledStatus: cancelledStatus ?? this.cancelledStatus, + ); + } + + @override + PlaygroundExecutionSpec lerp(PlaygroundExecutionSpec? other, double t) { + return PlaygroundExecutionSpec( + header: header.lerp(other?.header, t), + output: output.lerp(other?.output, t), + actions: actions.lerp(other?.actions, t), + tool: tool.lerp(other?.tool, t), + title: title.lerp(other?.title, t), + meta: meta.lerp(other?.meta, t), + status: status.lerp(other?.status, t), + toolIcon: toolIcon.lerp(other?.toolIcon, t), + statusIcon: statusIcon.lerp(other?.statusIcon, t), + indicator: indicator.lerp(other?.indicator, t), + runningStatus: runningStatus.lerp(other?.runningStatus, t), + successStatus: successStatus.lerp(other?.successStatus, t), + errorStatus: errorStatus.lerp(other?.errorStatus, t), + cancelledStatus: cancelledStatus.lerp(other?.cancelledStatus, t), + ); + } + + @override + List get props => [ + header, + output, + actions, + tool, + title, + meta, + status, + toolIcon, + statusIcon, + indicator, + runningStatus, + successStatus, + errorStatus, + cancelledStatus, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is PlaygroundExecutionSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('header', header)) + ..add(DiagnosticsProperty('output', output)) + ..add(DiagnosticsProperty('actions', actions)) + ..add(DiagnosticsProperty('tool', tool)) + ..add(DiagnosticsProperty('title', title)) + ..add(DiagnosticsProperty('meta', meta)) + ..add(DiagnosticsProperty('status', status)) + ..add(DiagnosticsProperty('toolIcon', toolIcon)) + ..add(DiagnosticsProperty('statusIcon', statusIcon)) + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('runningStatus', runningStatus)) + ..add(DiagnosticsProperty('successStatus', successStatus)) + ..add(DiagnosticsProperty('errorStatus', errorStatus)) + ..add(DiagnosticsProperty('cancelledStatus', cancelledStatus)); + } +} + +@Deprecated( + 'Rename to `_\$PlaygroundExecutionSpec` and migrate the class declaration to `class PlaygroundExecutionSpec with _\$PlaygroundExecutionSpec`. The `_\$PlaygroundExecutionSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$PlaygroundExecutionSpecMethods = _$PlaygroundExecutionSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class PlaygroundExecutionStyler + extends MixStyler + implements StylerFieldMetadata { + final Prop>? $header; + final Prop>? $output; + final Prop>? $actions; + final Prop>? $tool; + final Prop>? $title; + final Prop>? $meta; + final Prop>? $status; + final Prop>? $toolIcon; + final Prop>? $statusIcon; + final Prop>? $indicator; + final Prop>? $runningStatus; + final Prop>? $successStatus; + final Prop>? $errorStatus; + final Prop>? $cancelledStatus; + + const PlaygroundExecutionStyler.create({ + Prop>? header, + Prop>? output, + Prop>? actions, + Prop>? tool, + Prop>? title, + Prop>? meta, + Prop>? status, + Prop>? toolIcon, + Prop>? statusIcon, + Prop>? indicator, + Prop>? runningStatus, + Prop>? successStatus, + Prop>? errorStatus, + Prop>? cancelledStatus, + super.variants, + super.modifier, + super.animation, + }) : $header = header, + $output = output, + $actions = actions, + $tool = tool, + $title = title, + $meta = meta, + $status = status, + $toolIcon = toolIcon, + $statusIcon = statusIcon, + $indicator = indicator, + $runningStatus = runningStatus, + $successStatus = successStatus, + $errorStatus = errorStatus, + $cancelledStatus = cancelledStatus; + + PlaygroundExecutionStyler({ + FlexBoxStyler? header, + BoxStyler? output, + FlexBoxStyler? actions, + TextStyler? tool, + TextStyler? title, + TextStyler? meta, + TextStyler? status, + IconStyler? toolIcon, + IconStyler? statusIcon, + IconStyler? indicator, + BoxStyler? runningStatus, + BoxStyler? successStatus, + BoxStyler? errorStatus, + BoxStyler? cancelledStatus, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + header: Prop.maybeMix(header), + output: Prop.maybeMix(output), + actions: Prop.maybeMix(actions), + tool: Prop.maybeMix(tool), + title: Prop.maybeMix(title), + meta: Prop.maybeMix(meta), + status: Prop.maybeMix(status), + toolIcon: Prop.maybeMix(toolIcon), + statusIcon: Prop.maybeMix(statusIcon), + indicator: Prop.maybeMix(indicator), + runningStatus: Prop.maybeMix(runningStatus), + successStatus: Prop.maybeMix(successStatus), + errorStatus: Prop.maybeMix(errorStatus), + cancelledStatus: Prop.maybeMix(cancelledStatus), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory PlaygroundExecutionStyler.header(FlexBoxStyler value) => + PlaygroundExecutionStyler().header(value); + factory PlaygroundExecutionStyler.output(BoxStyler value) => + PlaygroundExecutionStyler().output(value); + factory PlaygroundExecutionStyler.actions(FlexBoxStyler value) => + PlaygroundExecutionStyler().actions(value); + factory PlaygroundExecutionStyler.tool(TextStyler value) => + PlaygroundExecutionStyler().tool(value); + factory PlaygroundExecutionStyler.title(TextStyler value) => + PlaygroundExecutionStyler().title(value); + factory PlaygroundExecutionStyler.meta(TextStyler value) => + PlaygroundExecutionStyler().meta(value); + factory PlaygroundExecutionStyler.status(TextStyler value) => + PlaygroundExecutionStyler().status(value); + factory PlaygroundExecutionStyler.toolIcon(IconStyler value) => + PlaygroundExecutionStyler().toolIcon(value); + factory PlaygroundExecutionStyler.statusIcon(IconStyler value) => + PlaygroundExecutionStyler().statusIcon(value); + factory PlaygroundExecutionStyler.indicator(IconStyler value) => + PlaygroundExecutionStyler().indicator(value); + factory PlaygroundExecutionStyler.runningStatus(BoxStyler value) => + PlaygroundExecutionStyler().runningStatus(value); + factory PlaygroundExecutionStyler.successStatus(BoxStyler value) => + PlaygroundExecutionStyler().successStatus(value); + factory PlaygroundExecutionStyler.errorStatus(BoxStyler value) => + PlaygroundExecutionStyler().errorStatus(value); + factory PlaygroundExecutionStyler.cancelledStatus(BoxStyler value) => + PlaygroundExecutionStyler().cancelledStatus(value); + + @override + Set get $stylerFieldNames => const { + 'header', + 'output', + 'actions', + 'tool', + 'title', + 'meta', + 'status', + 'toolIcon', + 'statusIcon', + 'indicator', + 'runningStatus', + 'successStatus', + 'errorStatus', + 'cancelledStatus', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the header. + PlaygroundExecutionStyler header(FlexBoxStyler value) { + return merge(PlaygroundExecutionStyler(header: value)); + } + + /// Sets the output. + PlaygroundExecutionStyler output(BoxStyler value) { + return merge(PlaygroundExecutionStyler(output: value)); + } + + /// Sets the actions. + PlaygroundExecutionStyler actions(FlexBoxStyler value) { + return merge(PlaygroundExecutionStyler(actions: value)); + } + + /// Sets the tool. + PlaygroundExecutionStyler tool(TextStyler value) { + return merge(PlaygroundExecutionStyler(tool: value)); + } + + /// Sets the title. + PlaygroundExecutionStyler title(TextStyler value) { + return merge(PlaygroundExecutionStyler(title: value)); + } + + /// Sets the meta. + PlaygroundExecutionStyler meta(TextStyler value) { + return merge(PlaygroundExecutionStyler(meta: value)); + } + + /// Sets the status. + PlaygroundExecutionStyler status(TextStyler value) { + return merge(PlaygroundExecutionStyler(status: value)); + } + + /// Sets the toolIcon. + PlaygroundExecutionStyler toolIcon(IconStyler value) { + return merge(PlaygroundExecutionStyler(toolIcon: value)); + } + + /// Sets the statusIcon. + PlaygroundExecutionStyler statusIcon(IconStyler value) { + return merge(PlaygroundExecutionStyler(statusIcon: value)); + } + + /// Sets the indicator. + PlaygroundExecutionStyler indicator(IconStyler value) { + return merge(PlaygroundExecutionStyler(indicator: value)); + } + + /// Sets the runningStatus. + PlaygroundExecutionStyler runningStatus(BoxStyler value) { + return merge(PlaygroundExecutionStyler(runningStatus: value)); + } + + /// Sets the successStatus. + PlaygroundExecutionStyler successStatus(BoxStyler value) { + return merge(PlaygroundExecutionStyler(successStatus: value)); + } + + /// Sets the errorStatus. + PlaygroundExecutionStyler errorStatus(BoxStyler value) { + return merge(PlaygroundExecutionStyler(errorStatus: value)); + } + + /// Sets the cancelledStatus. + PlaygroundExecutionStyler cancelledStatus(BoxStyler value) { + return merge(PlaygroundExecutionStyler(cancelledStatus: value)); + } + + /// Sets the animation configuration. + @override + PlaygroundExecutionStyler animate(AnimationConfig value) { + return merge(PlaygroundExecutionStyler(animation: value)); + } + + /// Sets the style variants. + @override + PlaygroundExecutionStyler variants( + List> value, + ) { + return merge(PlaygroundExecutionStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + PlaygroundExecutionStyler wrap(WidgetModifierConfig value) { + return merge(PlaygroundExecutionStyler(modifier: value)); + } + + /// Sets the widget modifier. + PlaygroundExecutionStyler modifier(WidgetModifierConfig value) { + return merge(PlaygroundExecutionStyler(modifier: value)); + } + + PlaygroundExecution call({ + Key? key, + required String tool, + required String title, + required Widget child, + PlaygroundExecutionStatus status = PlaygroundExecutionStatus.running, + String? meta, + Widget? icon, + VoidCallback? onCopy, + VoidCallback? onRetry, + RemixIconButtonIconBuilder? copyIconBuilder, + RemixIconButtonIconBuilder? retryIconBuilder, + PlaygroundExecutionIndicatorBuilder? indicatorBuilder, + PlaygroundExecutionStatusBuilder? statusBuilder, + PlaygroundExecutionStatusLabelBuilder? statusLabelBuilder, + String copyLabel = 'Copy output', + String retryLabel = 'Retry execution', + String outputLabel = 'Tool output', + bool showActions = true, + bool collapseOnComplete = true, + bool? expanded, + bool defaultExpanded = true, + ValueChanged? onExpandedChanged, + String semanticLabel = 'Tool execution', + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), + }) { + return PlaygroundExecution( + key: key, + style: this, + tool: tool, + title: title, + child: child, + status: status, + meta: meta, + icon: icon, + onCopy: onCopy, + onRetry: onRetry, + copyIconBuilder: copyIconBuilder, + retryIconBuilder: retryIconBuilder, + indicatorBuilder: indicatorBuilder, + statusBuilder: statusBuilder, + statusLabelBuilder: statusLabelBuilder, + copyLabel: copyLabel, + retryLabel: retryLabel, + outputLabel: outputLabel, + showActions: showActions, + collapseOnComplete: collapseOnComplete, + expanded: expanded, + defaultExpanded: defaultExpanded, + onExpandedChanged: onExpandedChanged, + semanticLabel: semanticLabel, + surfaceStyle: surfaceStyle, + disclosureStyle: disclosureStyle, + copyStyle: copyStyle, + retryStyle: retryStyle, + ); + } + + /// Merges with another [PlaygroundExecutionStyler]. + @override + PlaygroundExecutionStyler merge(PlaygroundExecutionStyler? other) { + return PlaygroundExecutionStyler.create( + header: MixOps.merge($header, other?.$header), + output: MixOps.merge($output, other?.$output), + actions: MixOps.merge($actions, other?.$actions), + tool: MixOps.merge($tool, other?.$tool), + title: MixOps.merge($title, other?.$title), + meta: MixOps.merge($meta, other?.$meta), + status: MixOps.merge($status, other?.$status), + toolIcon: MixOps.merge($toolIcon, other?.$toolIcon), + statusIcon: MixOps.merge($statusIcon, other?.$statusIcon), + indicator: MixOps.merge($indicator, other?.$indicator), + runningStatus: MixOps.merge($runningStatus, other?.$runningStatus), + successStatus: MixOps.merge($successStatus, other?.$successStatus), + errorStatus: MixOps.merge($errorStatus, other?.$errorStatus), + cancelledStatus: MixOps.merge($cancelledStatus, other?.$cancelledStatus), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = PlaygroundExecutionSpec( + header: MixOps.resolve(context, $header), + output: MixOps.resolve(context, $output), + actions: MixOps.resolve(context, $actions), + tool: MixOps.resolve(context, $tool), + title: MixOps.resolve(context, $title), + meta: MixOps.resolve(context, $meta), + status: MixOps.resolve(context, $status), + toolIcon: MixOps.resolve(context, $toolIcon), + statusIcon: MixOps.resolve(context, $statusIcon), + indicator: MixOps.resolve(context, $indicator), + runningStatus: MixOps.resolve(context, $runningStatus), + successStatus: MixOps.resolve(context, $successStatus), + errorStatus: MixOps.resolve(context, $errorStatus), + cancelledStatus: MixOps.resolve(context, $cancelledStatus), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('header', $header)) + ..add(DiagnosticsProperty('output', $output)) + ..add(DiagnosticsProperty('actions', $actions)) + ..add(DiagnosticsProperty('tool', $tool)) + ..add(DiagnosticsProperty('title', $title)) + ..add(DiagnosticsProperty('meta', $meta)) + ..add(DiagnosticsProperty('status', $status)) + ..add(DiagnosticsProperty('toolIcon', $toolIcon)) + ..add(DiagnosticsProperty('statusIcon', $statusIcon)) + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('runningStatus', $runningStatus)) + ..add(DiagnosticsProperty('successStatus', $successStatus)) + ..add(DiagnosticsProperty('errorStatus', $errorStatus)) + ..add(DiagnosticsProperty('cancelledStatus', $cancelledStatus)); + } + + @override + List get props => [ + $header, + $output, + $actions, + $tool, + $title, + $meta, + $status, + $toolIcon, + $statusIcon, + $indicator, + $runningStatus, + $successStatus, + $errorStatus, + $cancelledStatus, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/playground/lib/ui/components/message.dart b/apps/playground/lib/ui/components/message.dart new file mode 100644 index 000000000..a1e40b41c --- /dev/null +++ b/apps/playground/lib/ui/components/message.dart @@ -0,0 +1,393 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; + +part 'message.g.dart'; + +enum PlaygroundMessageAlign { start, end } + +/// Groups chronological message rows without imposing visual chrome. +class PlaygroundMessageGroup extends StatelessWidget { + const PlaygroundMessageGroup({ + super.key, + required this.children, + this.spacing = 0, + }); + + final List children; + final double spacing; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + spacing: spacing, + children: children, + ); +} + +/// Sender-aware message row. Message bodies are never clamped automatically. +class PlaygroundMessage extends StatelessWidget { + const PlaygroundMessage({ + super.key, + required this.role, + required this.child, + this.align, + this.avatar, + this.showAvatar = false, + this.placeholderAvatar = false, + this.maxWidth, + this.header, + this.footer, + this.semanticLabel, + this.surfaceStyle = const CardStyler.create(), + this.style = const PlaygroundMessageStyler.create(), + this.styleSpec, + }); + + final PlaygroundRole role; + final Widget child; + final PlaygroundMessageAlign? align; + final Widget? avatar; + final bool showAvatar; + final bool placeholderAvatar; + final double? maxWidth; + final Widget? header; + final Widget? footer; + final String? semanticLabel; + final CardStyler surfaceStyle; + final PlaygroundMessageStyler style; + final PlaygroundMessageSpec? styleSpec; + + bool get _alignEnd => + (align ?? + (role == PlaygroundRole.user + ? PlaygroundMessageAlign.end + : PlaygroundMessageAlign.start)) == + PlaygroundMessageAlign.end; + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: style, + styleSpec: styleSpec, + builder: (context, spec) { + final body = RemixCard( + style: surfaceStyle, + child: Box(styleSpec: spec.body, child: child), + ); + final cap = maxWidth ?? spec.maxWidth; + final constrained = cap == null + ? body + : ConstrainedBox( + constraints: BoxConstraints(maxWidth: cap), + child: body, + ); + final stack = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _alignEnd + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (header != null) Box(styleSpec: spec.header, child: header), + constrained, + if (footer != null) Box(styleSpec: spec.footer, child: footer), + ], + ); + final avatarSlot = _avatarSlot(spec); + final row = RowBox( + styleSpec: spec.row, + children: [ + if (!_alignEnd && avatarSlot != null) avatarSlot, + Expanded( + child: Align( + alignment: _alignEnd + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: stack, + ), + ), + if (_alignEnd && avatarSlot != null) avatarSlot, + ], + ); + return Semantics( + container: true, + explicitChildNodes: true, + label: + semanticLabel ?? + (role == PlaygroundRole.user + ? 'User message' + : 'Assistant message'), + child: row, + ); + }, + ); + } + + Widget? _avatarSlot(PlaygroundMessageSpec spec) { + if (placeholderAvatar) return Box(styleSpec: spec.avatar); + if (!showAvatar || avatar == null) return null; + return Box(styleSpec: spec.avatar, child: avatar); + } +} + +/// Explicit, opt-in clipping for noninteractive message copy. +/// +/// Do not place buttons, links, or other interactive descendants in [child]. +/// While collapsed, the whole child remains readable to assistive technology +/// but is removed from pointer input, focus, and traversal. +class PlaygroundMessageCollapsible extends StatefulWidget { + const PlaygroundMessageCollapsible({ + super.key, + required this.child, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.showMoreLabel = 'Show more', + this.showLessLabel = 'Show less', + this.toggleStyle = const ButtonStyler.create(), + this.style = const PlaygroundMessageCollapsibleStyler.create(), + this.styleSpec, + }); + + final Widget child; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final String showMoreLabel; + final String showLessLabel; + final ButtonStyler toggleStyle; + final PlaygroundMessageCollapsibleStyler style; + final PlaygroundMessageCollapsibleSpec? styleSpec; + + @override + State createState() => + _PlaygroundMessageCollapsibleState(); +} + +class _PlaygroundMessageCollapsibleState + extends State { + late final PlaygroundDisclosureEngine _disclosure; + bool _overflows = false; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = PlaygroundDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget(PlaygroundMessageCollapsible oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + } + + void _toggle() { + final next = !_expanded; + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + void _handleOverflow(bool value) { + if (value == _overflows) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && value != _overflows) setState(() => _overflows = value); + }); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) { + final height = spec.collapsedHeight; + final collapsed = !_expanded && height != null; + Widget content = _OverflowClip( + maxHeight: height, + clip: collapsed, + onOverflowChanged: _handleOverflow, + child: Box(styleSpec: spec.clipped, child: widget.child), + ); + if (collapsed) { + content = IgnorePointer( + child: Focus( + canRequestFocus: false, + skipTraversal: true, + descendantsAreFocusable: false, + descendantsAreTraversable: false, + child: content, + ), + ); + } + return Box( + styleSpec: spec.container, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + if (_overflows) + RemixButton( + label: _expanded + ? widget.showLessLabel + : widget.showMoreLabel, + semanticLabel: _expanded + ? widget.showLessLabel + : widget.showMoreLabel, + onPressed: _toggle, + style: widget.toggleStyle, + ), + ], + ), + ); + }, + ); + } +} + +class _OverflowClip extends SingleChildRenderObjectWidget { + const _OverflowClip({ + required this.maxHeight, + required this.clip, + required this.onOverflowChanged, + required super.child, + }); + + final double? maxHeight; + final bool clip; + final ValueChanged onOverflowChanged; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderOverflowClip(maxHeight, clip, onOverflowChanged); + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderOverflowClip renderObject, + ) { + renderObject + ..maxHeight = maxHeight + ..clip = clip + ..onOverflowChanged = onOverflowChanged; + } +} + +class _RenderOverflowClip extends RenderProxyBox { + _RenderOverflowClip(this._maxHeight, this._clip, this.onOverflowChanged); + + double? _maxHeight; + bool _clip; + ValueChanged onOverflowChanged; + bool _reportedOverflow = false; + + set maxHeight(double? value) { + if (value == _maxHeight) return; + _maxHeight = value; + markNeedsLayout(); + } + + set clip(bool value) { + if (value == _clip) return; + _clip = value; + markNeedsLayout(); + } + + @override + void performLayout() { + final current = child; + if (current == null) { + size = constraints.smallest; + return; + } + current.layout( + constraints.copyWith(minHeight: 0, maxHeight: double.infinity), + parentUsesSize: true, + ); + final limit = _maxHeight; + final overflow = limit != null && current.size.height > limit; + size = constraints.constrain( + Size(current.size.width, _clip && overflow ? limit : current.size.height), + ); + if (overflow != _reportedOverflow) { + _reportedOverflow = overflow; + onOverflowChanged(overflow); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) return; + if (!_clip) { + super.paint(context, offset); + return; + } + // pushClipRect applies the paint offset to this local rectangle. + context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + super.paint, + ); + } +} + +@MixableSpec(target: PlaygroundMessage.new) +@immutable +final class PlaygroundMessageSpec with _$PlaygroundMessageSpec { + @override + final double? maxWidth; + @override + final StyleSpec row; + @override + final StyleSpec avatar; + @override + final StyleSpec header; + @override + final StyleSpec body; + @override + final StyleSpec footer; + + const PlaygroundMessageSpec({ + this.maxWidth, + StyleSpec? row, + StyleSpec? avatar, + StyleSpec? header, + StyleSpec? body, + StyleSpec? footer, + }) : row = row ?? const StyleSpec(spec: FlexBoxSpec()), + avatar = avatar ?? const StyleSpec(spec: BoxSpec()), + header = header ?? const StyleSpec(spec: BoxSpec()), + body = body ?? const StyleSpec(spec: BoxSpec()), + footer = footer ?? const StyleSpec(spec: BoxSpec()); +} + +@MixableSpec(target: PlaygroundMessageCollapsible.new) +@immutable +final class PlaygroundMessageCollapsibleSpec + with _$PlaygroundMessageCollapsibleSpec { + @override + final double? collapsedHeight; + @override + final StyleSpec container; + @override + final StyleSpec clipped; + + const PlaygroundMessageCollapsibleSpec({ + this.collapsedHeight, + StyleSpec? container, + StyleSpec? clipped, + }) : container = container ?? const StyleSpec(spec: BoxSpec()), + clipped = clipped ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/apps/playground/lib/ui/components/message.g.dart b/apps/playground/lib/ui/components/message.g.dart new file mode 100644 index 000000000..95a805956 --- /dev/null +++ b/apps/playground/lib/ui/components/message.g.dart @@ -0,0 +1,591 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$PlaygroundMessageSpec + implements Spec, Diagnosticable { + double? get maxWidth; + StyleSpec get row; + StyleSpec get avatar; + StyleSpec get header; + StyleSpec get body; + StyleSpec get footer; + + @override + Type get type => PlaygroundMessageSpec; + + @override + PlaygroundMessageSpec copyWith({ + double? maxWidth, + StyleSpec? row, + StyleSpec? avatar, + StyleSpec? header, + StyleSpec? body, + StyleSpec? footer, + }) { + return PlaygroundMessageSpec( + maxWidth: maxWidth ?? this.maxWidth, + row: row ?? this.row, + avatar: avatar ?? this.avatar, + header: header ?? this.header, + body: body ?? this.body, + footer: footer ?? this.footer, + ); + } + + @override + PlaygroundMessageSpec lerp(PlaygroundMessageSpec? other, double t) { + return PlaygroundMessageSpec( + maxWidth: MixOps.lerp(maxWidth, other?.maxWidth, t), + row: row.lerp(other?.row, t), + avatar: avatar.lerp(other?.avatar, t), + header: header.lerp(other?.header, t), + body: body.lerp(other?.body, t), + footer: footer.lerp(other?.footer, t), + ); + } + + @override + List get props => [maxWidth, row, avatar, header, body, footer]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is PlaygroundMessageSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DoubleProperty('maxWidth', maxWidth)) + ..add(DiagnosticsProperty('row', row)) + ..add(DiagnosticsProperty('avatar', avatar)) + ..add(DiagnosticsProperty('header', header)) + ..add(DiagnosticsProperty('body', body)) + ..add(DiagnosticsProperty('footer', footer)); + } +} + +@Deprecated( + 'Rename to `_\$PlaygroundMessageSpec` and migrate the class declaration to `class PlaygroundMessageSpec with _\$PlaygroundMessageSpec`. The `_\$PlaygroundMessageSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$PlaygroundMessageSpecMethods = _$PlaygroundMessageSpec; // ignore: unused_element + +mixin _$PlaygroundMessageCollapsibleSpec + implements Spec, Diagnosticable { + double? get collapsedHeight; + StyleSpec get container; + StyleSpec get clipped; + + @override + Type get type => PlaygroundMessageCollapsibleSpec; + + @override + PlaygroundMessageCollapsibleSpec copyWith({ + double? collapsedHeight, + StyleSpec? container, + StyleSpec? clipped, + }) { + return PlaygroundMessageCollapsibleSpec( + collapsedHeight: collapsedHeight ?? this.collapsedHeight, + container: container ?? this.container, + clipped: clipped ?? this.clipped, + ); + } + + @override + PlaygroundMessageCollapsibleSpec lerp( + PlaygroundMessageCollapsibleSpec? other, + double t, + ) { + return PlaygroundMessageCollapsibleSpec( + collapsedHeight: MixOps.lerp(collapsedHeight, other?.collapsedHeight, t), + container: container.lerp(other?.container, t), + clipped: clipped.lerp(other?.clipped, t), + ); + } + + @override + List get props => [collapsedHeight, container, clipped]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is PlaygroundMessageCollapsibleSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DoubleProperty('collapsedHeight', collapsedHeight)) + ..add(DiagnosticsProperty('container', container)) + ..add(DiagnosticsProperty('clipped', clipped)); + } +} + +@Deprecated( + 'Rename to `_\$PlaygroundMessageCollapsibleSpec` and migrate the class declaration to `class PlaygroundMessageCollapsibleSpec with _\$PlaygroundMessageCollapsibleSpec`. The `_\$PlaygroundMessageCollapsibleSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$PlaygroundMessageCollapsibleSpecMethods = + _$PlaygroundMessageCollapsibleSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class PlaygroundMessageStyler + extends MixStyler + implements StylerFieldMetadata { + final Prop? $maxWidth; + final Prop>? $row; + final Prop>? $avatar; + final Prop>? $header; + final Prop>? $body; + final Prop>? $footer; + + const PlaygroundMessageStyler.create({ + Prop? maxWidth, + Prop>? row, + Prop>? avatar, + Prop>? header, + Prop>? body, + Prop>? footer, + super.variants, + super.modifier, + super.animation, + }) : $maxWidth = maxWidth, + $row = row, + $avatar = avatar, + $header = header, + $body = body, + $footer = footer; + + PlaygroundMessageStyler({ + double? maxWidth, + FlexBoxStyler? row, + BoxStyler? avatar, + BoxStyler? header, + BoxStyler? body, + BoxStyler? footer, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + maxWidth: Prop.maybe(maxWidth), + row: Prop.maybeMix(row), + avatar: Prop.maybeMix(avatar), + header: Prop.maybeMix(header), + body: Prop.maybeMix(body), + footer: Prop.maybeMix(footer), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory PlaygroundMessageStyler.maxWidth(double value) => + PlaygroundMessageStyler().maxWidth(value); + factory PlaygroundMessageStyler.row(FlexBoxStyler value) => + PlaygroundMessageStyler().row(value); + factory PlaygroundMessageStyler.avatar(BoxStyler value) => + PlaygroundMessageStyler().avatar(value); + factory PlaygroundMessageStyler.header(BoxStyler value) => + PlaygroundMessageStyler().header(value); + factory PlaygroundMessageStyler.body(BoxStyler value) => + PlaygroundMessageStyler().body(value); + factory PlaygroundMessageStyler.footer(BoxStyler value) => + PlaygroundMessageStyler().footer(value); + + @override + Set get $stylerFieldNames => const { + 'maxWidth', + 'row', + 'avatar', + 'header', + 'body', + 'footer', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the maxWidth. + PlaygroundMessageStyler maxWidth(double value) { + return merge(PlaygroundMessageStyler(maxWidth: value)); + } + + /// Sets the row. + PlaygroundMessageStyler row(FlexBoxStyler value) { + return merge(PlaygroundMessageStyler(row: value)); + } + + /// Sets the avatar. + PlaygroundMessageStyler avatar(BoxStyler value) { + return merge(PlaygroundMessageStyler(avatar: value)); + } + + /// Sets the header. + PlaygroundMessageStyler header(BoxStyler value) { + return merge(PlaygroundMessageStyler(header: value)); + } + + /// Sets the body. + PlaygroundMessageStyler body(BoxStyler value) { + return merge(PlaygroundMessageStyler(body: value)); + } + + /// Sets the footer. + PlaygroundMessageStyler footer(BoxStyler value) { + return merge(PlaygroundMessageStyler(footer: value)); + } + + /// Sets the animation configuration. + @override + PlaygroundMessageStyler animate(AnimationConfig value) { + return merge(PlaygroundMessageStyler(animation: value)); + } + + /// Sets the style variants. + @override + PlaygroundMessageStyler variants( + List> value, + ) { + return merge(PlaygroundMessageStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + PlaygroundMessageStyler wrap(WidgetModifierConfig value) { + return merge(PlaygroundMessageStyler(modifier: value)); + } + + /// Sets the widget modifier. + PlaygroundMessageStyler modifier(WidgetModifierConfig value) { + return merge(PlaygroundMessageStyler(modifier: value)); + } + + PlaygroundMessage call({ + Key? key, + required PlaygroundRole role, + required Widget child, + PlaygroundMessageAlign? align, + Widget? avatar, + bool showAvatar = false, + bool placeholderAvatar = false, + double? maxWidth, + Widget? header, + Widget? footer, + String? semanticLabel, + CardStyler surfaceStyle = const CardStyler.create(), + }) { + return PlaygroundMessage( + key: key, + style: this, + role: role, + child: child, + align: align, + avatar: avatar, + showAvatar: showAvatar, + placeholderAvatar: placeholderAvatar, + maxWidth: maxWidth, + header: header, + footer: footer, + semanticLabel: semanticLabel, + surfaceStyle: surfaceStyle, + ); + } + + /// Merges with another [PlaygroundMessageStyler]. + @override + PlaygroundMessageStyler merge(PlaygroundMessageStyler? other) { + return PlaygroundMessageStyler.create( + maxWidth: MixOps.merge($maxWidth, other?.$maxWidth), + row: MixOps.merge($row, other?.$row), + avatar: MixOps.merge($avatar, other?.$avatar), + header: MixOps.merge($header, other?.$header), + body: MixOps.merge($body, other?.$body), + footer: MixOps.merge($footer, other?.$footer), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = PlaygroundMessageSpec( + maxWidth: MixOps.resolve(context, $maxWidth), + row: MixOps.resolve(context, $row), + avatar: MixOps.resolve(context, $avatar), + header: MixOps.resolve(context, $header), + body: MixOps.resolve(context, $body), + footer: MixOps.resolve(context, $footer), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('maxWidth', $maxWidth)) + ..add(DiagnosticsProperty('row', $row)) + ..add(DiagnosticsProperty('avatar', $avatar)) + ..add(DiagnosticsProperty('header', $header)) + ..add(DiagnosticsProperty('body', $body)) + ..add(DiagnosticsProperty('footer', $footer)); + } + + @override + List get props => [ + $maxWidth, + $row, + $avatar, + $header, + $body, + $footer, + $animation, + $modifier, + $variants, + ]; +} + +class PlaygroundMessageCollapsibleStyler + extends + MixStyler< + PlaygroundMessageCollapsibleStyler, + PlaygroundMessageCollapsibleSpec + > + implements StylerFieldMetadata { + final Prop? $collapsedHeight; + final Prop>? $container; + final Prop>? $clipped; + + const PlaygroundMessageCollapsibleStyler.create({ + Prop? collapsedHeight, + Prop>? container, + Prop>? clipped, + super.variants, + super.modifier, + super.animation, + }) : $collapsedHeight = collapsedHeight, + $container = container, + $clipped = clipped; + + PlaygroundMessageCollapsibleStyler({ + double? collapsedHeight, + BoxStyler? container, + BoxStyler? clipped, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + collapsedHeight: Prop.maybe(collapsedHeight), + container: Prop.maybeMix(container), + clipped: Prop.maybeMix(clipped), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory PlaygroundMessageCollapsibleStyler.collapsedHeight(double value) => + PlaygroundMessageCollapsibleStyler().collapsedHeight(value); + factory PlaygroundMessageCollapsibleStyler.container(BoxStyler value) => + PlaygroundMessageCollapsibleStyler().container(value); + factory PlaygroundMessageCollapsibleStyler.clipped(BoxStyler value) => + PlaygroundMessageCollapsibleStyler().clipped(value); + + @override + Set get $stylerFieldNames => const { + 'collapsedHeight', + 'container', + 'clipped', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the collapsedHeight. + PlaygroundMessageCollapsibleStyler collapsedHeight(double value) { + return merge(PlaygroundMessageCollapsibleStyler(collapsedHeight: value)); + } + + /// Sets the container. + PlaygroundMessageCollapsibleStyler container(BoxStyler value) { + return merge(PlaygroundMessageCollapsibleStyler(container: value)); + } + + /// Sets the clipped. + PlaygroundMessageCollapsibleStyler clipped(BoxStyler value) { + return merge(PlaygroundMessageCollapsibleStyler(clipped: value)); + } + + /// Sets the animation configuration. + @override + PlaygroundMessageCollapsibleStyler animate(AnimationConfig value) { + return merge(PlaygroundMessageCollapsibleStyler(animation: value)); + } + + /// Sets the style variants. + @override + PlaygroundMessageCollapsibleStyler variants( + List> value, + ) { + return merge(PlaygroundMessageCollapsibleStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + PlaygroundMessageCollapsibleStyler wrap(WidgetModifierConfig value) { + return merge(PlaygroundMessageCollapsibleStyler(modifier: value)); + } + + /// Sets the widget modifier. + PlaygroundMessageCollapsibleStyler modifier(WidgetModifierConfig value) { + return merge(PlaygroundMessageCollapsibleStyler(modifier: value)); + } + + PlaygroundMessageCollapsible call({ + Key? key, + required Widget child, + bool? expanded, + bool defaultExpanded = false, + ValueChanged? onExpandedChanged, + String showMoreLabel = 'Show more', + String showLessLabel = 'Show less', + ButtonStyler toggleStyle = const ButtonStyler.create(), + }) { + return PlaygroundMessageCollapsible( + key: key, + style: this, + child: child, + expanded: expanded, + defaultExpanded: defaultExpanded, + onExpandedChanged: onExpandedChanged, + showMoreLabel: showMoreLabel, + showLessLabel: showLessLabel, + toggleStyle: toggleStyle, + ); + } + + /// Merges with another [PlaygroundMessageCollapsibleStyler]. + @override + PlaygroundMessageCollapsibleStyler merge( + PlaygroundMessageCollapsibleStyler? other, + ) { + return PlaygroundMessageCollapsibleStyler.create( + collapsedHeight: MixOps.merge($collapsedHeight, other?.$collapsedHeight), + container: MixOps.merge($container, other?.$container), + clipped: MixOps.merge($clipped, other?.$clipped), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = PlaygroundMessageCollapsibleSpec( + collapsedHeight: MixOps.resolve(context, $collapsedHeight), + container: MixOps.resolve(context, $container), + clipped: MixOps.resolve(context, $clipped), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('collapsedHeight', $collapsedHeight)) + ..add(DiagnosticsProperty('container', $container)) + ..add(DiagnosticsProperty('clipped', $clipped)); + } + + @override + List get props => [ + $collapsedHeight, + $container, + $clipped, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/playground/lib/ui/components/permission.dart b/apps/playground/lib/ui/components/permission.dart new file mode 100644 index 000000000..c846c6425 --- /dev/null +++ b/apps/playground/lib/ui/components/permission.dart @@ -0,0 +1,390 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; + +part 'permission.g.dart'; + +typedef PlaygroundPermissionStatusLabelBuilder = + String Function(PlaygroundPermissionStatus status); +typedef PlaygroundPermissionStatusBuilder = + Widget Function(BuildContext context, PlaygroundPermissionStatus status); +typedef PlaygroundPermissionIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// In-transcript permission request composed from Remix controls. +class PlaygroundPermission extends StatefulWidget { + const PlaygroundPermission({ + super.key, + required this.tool, + this.requestId, + this.title = 'Allow this tool to run?', + this.description, + this.status = PlaygroundPermissionStatus.pending, + this.parameters = const [], + this.showParameters = true, + this.detailsExpanded, + this.defaultDetailsExpanded = false, + this.onDetailsExpandedChanged, + this.onAllowOnce, + this.onAlwaysAllow, + this.onDeny, + this.statusLabelBuilder, + this.statusBuilder, + this.indicatorBuilder, + this.allowOnceLabel = 'Allow once', + this.alwaysAllowLabel = 'Always allow', + this.denyLabel = 'Deny', + this.detailsLabel = 'View details', + this.semanticLabel = 'Tool permission', + this.parameterOrientation = Axis.horizontal, + this.surfaceStyle = const CardStyler.create(), + this.detailsStyle = const DisclosureStyler.create(), + this.parametersStyle = const DataListStyler.create(), + this.allowOnceStyle = const ButtonStyler.create(), + this.alwaysAllowStyle = const ButtonStyler.create(), + this.denyStyle = const ButtonStyler.create(), + this.style = const PlaygroundPermissionStyler.create(), + this.styleSpec, + }); + + final Object? requestId; + final String tool; + final String title; + final String? description; + final PlaygroundPermissionStatus status; + final List parameters; + final bool showParameters; + final bool? detailsExpanded; + final bool defaultDetailsExpanded; + final ValueChanged? onDetailsExpandedChanged; + final VoidCallback? onAllowOnce; + final VoidCallback? onAlwaysAllow; + final VoidCallback? onDeny; + final PlaygroundPermissionStatusLabelBuilder? statusLabelBuilder; + final PlaygroundPermissionStatusBuilder? statusBuilder; + final PlaygroundPermissionIndicatorBuilder? indicatorBuilder; + final String allowOnceLabel; + final String alwaysAllowLabel; + final String denyLabel; + final String detailsLabel; + final String semanticLabel; + final Axis parameterOrientation; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; + final PlaygroundPermissionStyler style; + final PlaygroundPermissionSpec? styleSpec; + + @override + State createState() => _PlaygroundPermissionState(); +} + +class _PlaygroundPermissionState extends State { + late final PlaygroundDisclosureEngine _disclosure; + bool _decisionSubmitted = false; + + bool get _detailsExpanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = PlaygroundDisclosureEngine( + value: widget.detailsExpanded, + defaultValue: + widget.status.keepsDetailsOpen || widget.defaultDetailsExpanded, + ); + } + + @override + void didUpdateWidget(PlaygroundPermission oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.detailsExpanded); + final returnedToPending = + oldWidget.status != PlaygroundPermissionStatus.pending && + widget.status == PlaygroundPermissionStatus.pending; + final newPendingRequest = + oldWidget.requestId != widget.requestId && + widget.status == PlaygroundPermissionStatus.pending; + if (returnedToPending || newPendingRequest) _decisionSubmitted = false; + + if (!oldWidget.status.keepsDetailsOpen && widget.status.keepsDetailsOpen) { + _requestDetails(true); + } else if (!oldWidget.status.isSettled && widget.status.isSettled) { + _requestDetails(false); + } + } + + void _requestDetails(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onDetailsExpandedChanged?.call(next); + } + + void _submit(VoidCallback? callback) { + if (_decisionSubmitted || + widget.status != PlaygroundPermissionStatus.pending || + callback == null) { + return; + } + setState(() => _decisionSubmitted = true); + callback(); + } + + String get _statusLabel => + widget.statusLabelBuilder?.call(widget.status) ?? + switch (widget.status) { + PlaygroundPermissionStatus.pending => 'Permission required', + PlaygroundPermissionStatus.deciding => 'Recording', + PlaygroundPermissionStatus.allowed => 'Allowed', + PlaygroundPermissionStatus.running => 'Running', + PlaygroundPermissionStatus.complete => 'Complete', + PlaygroundPermissionStatus.denied => 'Denied', + PlaygroundPermissionStatus.error => 'Error', + }; + + PlaygroundFunctionalGlyphKind get _statusGlyph => switch (widget.status) { + PlaygroundPermissionStatus.pending => .permission, + PlaygroundPermissionStatus.deciding => .loading, + PlaygroundPermissionStatus.allowed => .completed, + PlaygroundPermissionStatus.running => .loading, + PlaygroundPermissionStatus.complete => .completed, + PlaygroundPermissionStatus.denied => .cancelled, + PlaygroundPermissionStatus.error => .error, + }; + + StyleSpec _statusContainer(PlaygroundPermissionSpec spec) => + switch (widget.status) { + PlaygroundPermissionStatus.pending => spec.pendingStatus, + PlaygroundPermissionStatus.deciding => spec.decidingStatus, + PlaygroundPermissionStatus.allowed => spec.allowedStatus, + PlaygroundPermissionStatus.running => spec.runningStatus, + PlaygroundPermissionStatus.complete => spec.completedStatus, + PlaygroundPermissionStatus.denied => spec.deniedStatus, + PlaygroundPermissionStatus.error => spec.errorStatus, + }; + + // Horizontal by default; callers may stack actions without losing the + // action slot's box, modifiers, or nested style resolution. + StyleSpec _actionsStyle(PlaygroundPermissionSpec spec) { + final actions = spec.actions.spec; + final flex = actions.flex ?? const StyleSpec(spec: FlexSpec()); + return spec.actions.copyWith( + spec: actions.copyWith( + flex: flex.copyWith( + spec: flex.spec.copyWith( + direction: flex.spec.direction ?? Axis.horizontal, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixCard( + style: widget.surfaceStyle, + child: Box( + styleSpec: spec.content, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RowBox( + styleSpec: spec.header, + children: [ + StyleSpecBuilder( + styleSpec: spec.toolIcon, + builder: (context, iconSpec) => PlaygroundFunctionalGlyph( + kind: .tool, + spec: iconSpec, + ), + ), + Expanded( + child: StyledText(widget.title, styleSpec: spec.title), + ), + ], + ), + StyledText(widget.tool, styleSpec: spec.tool), + if (widget.description != null) + StyledText(widget.description!, styleSpec: spec.description), + Box( + styleSpec: _statusContainer(spec), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.statusBuilder?.call(context, widget.status) ?? + StyleSpecBuilder( + styleSpec: spec.statusIcon, + builder: (context, iconSpec) => + PlaygroundFunctionalGlyph( + kind: _statusGlyph, + spec: iconSpec, + ), + ), + Flexible( + child: StyledText(_statusLabel, styleSpec: spec.status), + ), + ], + ), + ), + if (widget.showParameters && widget.parameters.isNotEmpty) + RemixDisclosure( + expanded: _detailsExpanded, + onExpandedChanged: _requestDetails, + semanticLabel: widget.detailsLabel, + style: widget.detailsStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + PlaygroundDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: StyledText( + widget.detailsLabel, + styleSpec: spec.detailsLabel, + ), + content: RemixDataList( + items: widget.parameters, + orientation: widget.parameterOrientation, + style: widget.parametersStyle, + ), + ), + if (widget.status == PlaygroundPermissionStatus.pending) + FlexBox( + styleSpec: _actionsStyle(spec), + children: [ + RemixButton( + key: const ValueKey('playground-permission-allow-once'), + label: widget.allowOnceLabel, + enabled: !_decisionSubmitted, + onPressed: widget.onAllowOnce == null + ? null + : () => _submit(widget.onAllowOnce), + style: widget.allowOnceStyle, + ), + if (widget.onAlwaysAllow != null) + RemixButton( + key: const ValueKey( + 'playground-permission-always-allow', + ), + label: widget.alwaysAllowLabel, + enabled: !_decisionSubmitted, + onPressed: () => _submit(widget.onAlwaysAllow), + style: widget.alwaysAllowStyle, + ), + RemixButton( + key: const ValueKey('playground-permission-deny'), + label: widget.denyLabel, + enabled: !_decisionSubmitted, + onPressed: widget.onDeny == null + ? null + : () => _submit(widget.onDeny), + style: widget.denyStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: PlaygroundPermission.new) +@immutable +final class PlaygroundPermissionSpec with _$PlaygroundPermissionSpec { + @override + final StyleSpec content; + @override + final StyleSpec header; + @override + final StyleSpec actions; + @override + final StyleSpec title; + @override + final StyleSpec tool; + @override + final StyleSpec description; + @override + final StyleSpec status; + @override + final StyleSpec detailsLabel; + @override + final StyleSpec toolIcon; + @override + final StyleSpec statusIcon; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec decidingStatus; + @override + final StyleSpec allowedStatus; + @override + final StyleSpec runningStatus; + @override + final StyleSpec completedStatus; + @override + final StyleSpec deniedStatus; + @override + final StyleSpec errorStatus; + + const PlaygroundPermissionSpec({ + StyleSpec? content, + StyleSpec? header, + StyleSpec? actions, + StyleSpec? title, + StyleSpec? tool, + StyleSpec? description, + StyleSpec? status, + StyleSpec? detailsLabel, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? pendingStatus, + StyleSpec? decidingStatus, + StyleSpec? allowedStatus, + StyleSpec? runningStatus, + StyleSpec? completedStatus, + StyleSpec? deniedStatus, + StyleSpec? errorStatus, + }) : content = content ?? const StyleSpec(spec: BoxSpec()), + header = header ?? const StyleSpec(spec: FlexBoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + title = title ?? const StyleSpec(spec: TextSpec()), + tool = tool ?? const StyleSpec(spec: TextSpec()), + description = description ?? const StyleSpec(spec: TextSpec()), + status = status ?? const StyleSpec(spec: TextSpec()), + detailsLabel = detailsLabel ?? const StyleSpec(spec: TextSpec()), + toolIcon = toolIcon ?? const StyleSpec(spec: IconSpec()), + statusIcon = statusIcon ?? const StyleSpec(spec: IconSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: BoxSpec()), + decidingStatus = decidingStatus ?? const StyleSpec(spec: BoxSpec()), + allowedStatus = allowedStatus ?? const StyleSpec(spec: BoxSpec()), + runningStatus = runningStatus ?? const StyleSpec(spec: BoxSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: BoxSpec()), + deniedStatus = deniedStatus ?? const StyleSpec(spec: BoxSpec()), + errorStatus = errorStatus ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/apps/playground/lib/ui/components/permission.g.dart b/apps/playground/lib/ui/components/permission.g.dart new file mode 100644 index 000000000..f635d3670 --- /dev/null +++ b/apps/playground/lib/ui/components/permission.g.dart @@ -0,0 +1,650 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'permission.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$PlaygroundPermissionSpec + implements Spec, Diagnosticable { + StyleSpec get content; + StyleSpec get header; + StyleSpec get actions; + StyleSpec get title; + StyleSpec get tool; + StyleSpec get description; + StyleSpec get status; + StyleSpec get detailsLabel; + StyleSpec get toolIcon; + StyleSpec get statusIcon; + StyleSpec get indicator; + StyleSpec get pendingStatus; + StyleSpec get decidingStatus; + StyleSpec get allowedStatus; + StyleSpec get runningStatus; + StyleSpec get completedStatus; + StyleSpec get deniedStatus; + StyleSpec get errorStatus; + + @override + Type get type => PlaygroundPermissionSpec; + + @override + PlaygroundPermissionSpec copyWith({ + StyleSpec? content, + StyleSpec? header, + StyleSpec? actions, + StyleSpec? title, + StyleSpec? tool, + StyleSpec? description, + StyleSpec? status, + StyleSpec? detailsLabel, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? pendingStatus, + StyleSpec? decidingStatus, + StyleSpec? allowedStatus, + StyleSpec? runningStatus, + StyleSpec? completedStatus, + StyleSpec? deniedStatus, + StyleSpec? errorStatus, + }) { + return PlaygroundPermissionSpec( + content: content ?? this.content, + header: header ?? this.header, + actions: actions ?? this.actions, + title: title ?? this.title, + tool: tool ?? this.tool, + description: description ?? this.description, + status: status ?? this.status, + detailsLabel: detailsLabel ?? this.detailsLabel, + toolIcon: toolIcon ?? this.toolIcon, + statusIcon: statusIcon ?? this.statusIcon, + indicator: indicator ?? this.indicator, + pendingStatus: pendingStatus ?? this.pendingStatus, + decidingStatus: decidingStatus ?? this.decidingStatus, + allowedStatus: allowedStatus ?? this.allowedStatus, + runningStatus: runningStatus ?? this.runningStatus, + completedStatus: completedStatus ?? this.completedStatus, + deniedStatus: deniedStatus ?? this.deniedStatus, + errorStatus: errorStatus ?? this.errorStatus, + ); + } + + @override + PlaygroundPermissionSpec lerp(PlaygroundPermissionSpec? other, double t) { + return PlaygroundPermissionSpec( + content: content.lerp(other?.content, t), + header: header.lerp(other?.header, t), + actions: actions.lerp(other?.actions, t), + title: title.lerp(other?.title, t), + tool: tool.lerp(other?.tool, t), + description: description.lerp(other?.description, t), + status: status.lerp(other?.status, t), + detailsLabel: detailsLabel.lerp(other?.detailsLabel, t), + toolIcon: toolIcon.lerp(other?.toolIcon, t), + statusIcon: statusIcon.lerp(other?.statusIcon, t), + indicator: indicator.lerp(other?.indicator, t), + pendingStatus: pendingStatus.lerp(other?.pendingStatus, t), + decidingStatus: decidingStatus.lerp(other?.decidingStatus, t), + allowedStatus: allowedStatus.lerp(other?.allowedStatus, t), + runningStatus: runningStatus.lerp(other?.runningStatus, t), + completedStatus: completedStatus.lerp(other?.completedStatus, t), + deniedStatus: deniedStatus.lerp(other?.deniedStatus, t), + errorStatus: errorStatus.lerp(other?.errorStatus, t), + ); + } + + @override + List get props => [ + content, + header, + actions, + title, + tool, + description, + status, + detailsLabel, + toolIcon, + statusIcon, + indicator, + pendingStatus, + decidingStatus, + allowedStatus, + runningStatus, + completedStatus, + deniedStatus, + errorStatus, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is PlaygroundPermissionSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('content', content)) + ..add(DiagnosticsProperty('header', header)) + ..add(DiagnosticsProperty('actions', actions)) + ..add(DiagnosticsProperty('title', title)) + ..add(DiagnosticsProperty('tool', tool)) + ..add(DiagnosticsProperty('description', description)) + ..add(DiagnosticsProperty('status', status)) + ..add(DiagnosticsProperty('detailsLabel', detailsLabel)) + ..add(DiagnosticsProperty('toolIcon', toolIcon)) + ..add(DiagnosticsProperty('statusIcon', statusIcon)) + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('pendingStatus', pendingStatus)) + ..add(DiagnosticsProperty('decidingStatus', decidingStatus)) + ..add(DiagnosticsProperty('allowedStatus', allowedStatus)) + ..add(DiagnosticsProperty('runningStatus', runningStatus)) + ..add(DiagnosticsProperty('completedStatus', completedStatus)) + ..add(DiagnosticsProperty('deniedStatus', deniedStatus)) + ..add(DiagnosticsProperty('errorStatus', errorStatus)); + } +} + +@Deprecated( + 'Rename to `_\$PlaygroundPermissionSpec` and migrate the class declaration to `class PlaygroundPermissionSpec with _\$PlaygroundPermissionSpec`. The `_\$PlaygroundPermissionSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$PlaygroundPermissionSpecMethods = _$PlaygroundPermissionSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class PlaygroundPermissionStyler + extends MixStyler + implements StylerFieldMetadata { + final Prop>? $content; + final Prop>? $header; + final Prop>? $actions; + final Prop>? $title; + final Prop>? $tool; + final Prop>? $description; + final Prop>? $status; + final Prop>? $detailsLabel; + final Prop>? $toolIcon; + final Prop>? $statusIcon; + final Prop>? $indicator; + final Prop>? $pendingStatus; + final Prop>? $decidingStatus; + final Prop>? $allowedStatus; + final Prop>? $runningStatus; + final Prop>? $completedStatus; + final Prop>? $deniedStatus; + final Prop>? $errorStatus; + + const PlaygroundPermissionStyler.create({ + Prop>? content, + Prop>? header, + Prop>? actions, + Prop>? title, + Prop>? tool, + Prop>? description, + Prop>? status, + Prop>? detailsLabel, + Prop>? toolIcon, + Prop>? statusIcon, + Prop>? indicator, + Prop>? pendingStatus, + Prop>? decidingStatus, + Prop>? allowedStatus, + Prop>? runningStatus, + Prop>? completedStatus, + Prop>? deniedStatus, + Prop>? errorStatus, + super.variants, + super.modifier, + super.animation, + }) : $content = content, + $header = header, + $actions = actions, + $title = title, + $tool = tool, + $description = description, + $status = status, + $detailsLabel = detailsLabel, + $toolIcon = toolIcon, + $statusIcon = statusIcon, + $indicator = indicator, + $pendingStatus = pendingStatus, + $decidingStatus = decidingStatus, + $allowedStatus = allowedStatus, + $runningStatus = runningStatus, + $completedStatus = completedStatus, + $deniedStatus = deniedStatus, + $errorStatus = errorStatus; + + PlaygroundPermissionStyler({ + BoxStyler? content, + FlexBoxStyler? header, + FlexBoxStyler? actions, + TextStyler? title, + TextStyler? tool, + TextStyler? description, + TextStyler? status, + TextStyler? detailsLabel, + IconStyler? toolIcon, + IconStyler? statusIcon, + IconStyler? indicator, + BoxStyler? pendingStatus, + BoxStyler? decidingStatus, + BoxStyler? allowedStatus, + BoxStyler? runningStatus, + BoxStyler? completedStatus, + BoxStyler? deniedStatus, + BoxStyler? errorStatus, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + content: Prop.maybeMix(content), + header: Prop.maybeMix(header), + actions: Prop.maybeMix(actions), + title: Prop.maybeMix(title), + tool: Prop.maybeMix(tool), + description: Prop.maybeMix(description), + status: Prop.maybeMix(status), + detailsLabel: Prop.maybeMix(detailsLabel), + toolIcon: Prop.maybeMix(toolIcon), + statusIcon: Prop.maybeMix(statusIcon), + indicator: Prop.maybeMix(indicator), + pendingStatus: Prop.maybeMix(pendingStatus), + decidingStatus: Prop.maybeMix(decidingStatus), + allowedStatus: Prop.maybeMix(allowedStatus), + runningStatus: Prop.maybeMix(runningStatus), + completedStatus: Prop.maybeMix(completedStatus), + deniedStatus: Prop.maybeMix(deniedStatus), + errorStatus: Prop.maybeMix(errorStatus), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory PlaygroundPermissionStyler.content(BoxStyler value) => + PlaygroundPermissionStyler().content(value); + factory PlaygroundPermissionStyler.header(FlexBoxStyler value) => + PlaygroundPermissionStyler().header(value); + factory PlaygroundPermissionStyler.actions(FlexBoxStyler value) => + PlaygroundPermissionStyler().actions(value); + factory PlaygroundPermissionStyler.title(TextStyler value) => + PlaygroundPermissionStyler().title(value); + factory PlaygroundPermissionStyler.tool(TextStyler value) => + PlaygroundPermissionStyler().tool(value); + factory PlaygroundPermissionStyler.description(TextStyler value) => + PlaygroundPermissionStyler().description(value); + factory PlaygroundPermissionStyler.status(TextStyler value) => + PlaygroundPermissionStyler().status(value); + factory PlaygroundPermissionStyler.detailsLabel(TextStyler value) => + PlaygroundPermissionStyler().detailsLabel(value); + factory PlaygroundPermissionStyler.toolIcon(IconStyler value) => + PlaygroundPermissionStyler().toolIcon(value); + factory PlaygroundPermissionStyler.statusIcon(IconStyler value) => + PlaygroundPermissionStyler().statusIcon(value); + factory PlaygroundPermissionStyler.indicator(IconStyler value) => + PlaygroundPermissionStyler().indicator(value); + factory PlaygroundPermissionStyler.pendingStatus(BoxStyler value) => + PlaygroundPermissionStyler().pendingStatus(value); + factory PlaygroundPermissionStyler.decidingStatus(BoxStyler value) => + PlaygroundPermissionStyler().decidingStatus(value); + factory PlaygroundPermissionStyler.allowedStatus(BoxStyler value) => + PlaygroundPermissionStyler().allowedStatus(value); + factory PlaygroundPermissionStyler.runningStatus(BoxStyler value) => + PlaygroundPermissionStyler().runningStatus(value); + factory PlaygroundPermissionStyler.completedStatus(BoxStyler value) => + PlaygroundPermissionStyler().completedStatus(value); + factory PlaygroundPermissionStyler.deniedStatus(BoxStyler value) => + PlaygroundPermissionStyler().deniedStatus(value); + factory PlaygroundPermissionStyler.errorStatus(BoxStyler value) => + PlaygroundPermissionStyler().errorStatus(value); + + @override + Set get $stylerFieldNames => const { + 'content', + 'header', + 'actions', + 'title', + 'tool', + 'description', + 'status', + 'detailsLabel', + 'toolIcon', + 'statusIcon', + 'indicator', + 'pendingStatus', + 'decidingStatus', + 'allowedStatus', + 'runningStatus', + 'completedStatus', + 'deniedStatus', + 'errorStatus', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the content. + PlaygroundPermissionStyler content(BoxStyler value) { + return merge(PlaygroundPermissionStyler(content: value)); + } + + /// Sets the header. + PlaygroundPermissionStyler header(FlexBoxStyler value) { + return merge(PlaygroundPermissionStyler(header: value)); + } + + /// Sets the actions. + PlaygroundPermissionStyler actions(FlexBoxStyler value) { + return merge(PlaygroundPermissionStyler(actions: value)); + } + + /// Sets the title. + PlaygroundPermissionStyler title(TextStyler value) { + return merge(PlaygroundPermissionStyler(title: value)); + } + + /// Sets the tool. + PlaygroundPermissionStyler tool(TextStyler value) { + return merge(PlaygroundPermissionStyler(tool: value)); + } + + /// Sets the description. + PlaygroundPermissionStyler description(TextStyler value) { + return merge(PlaygroundPermissionStyler(description: value)); + } + + /// Sets the status. + PlaygroundPermissionStyler status(TextStyler value) { + return merge(PlaygroundPermissionStyler(status: value)); + } + + /// Sets the detailsLabel. + PlaygroundPermissionStyler detailsLabel(TextStyler value) { + return merge(PlaygroundPermissionStyler(detailsLabel: value)); + } + + /// Sets the toolIcon. + PlaygroundPermissionStyler toolIcon(IconStyler value) { + return merge(PlaygroundPermissionStyler(toolIcon: value)); + } + + /// Sets the statusIcon. + PlaygroundPermissionStyler statusIcon(IconStyler value) { + return merge(PlaygroundPermissionStyler(statusIcon: value)); + } + + /// Sets the indicator. + PlaygroundPermissionStyler indicator(IconStyler value) { + return merge(PlaygroundPermissionStyler(indicator: value)); + } + + /// Sets the pendingStatus. + PlaygroundPermissionStyler pendingStatus(BoxStyler value) { + return merge(PlaygroundPermissionStyler(pendingStatus: value)); + } + + /// Sets the decidingStatus. + PlaygroundPermissionStyler decidingStatus(BoxStyler value) { + return merge(PlaygroundPermissionStyler(decidingStatus: value)); + } + + /// Sets the allowedStatus. + PlaygroundPermissionStyler allowedStatus(BoxStyler value) { + return merge(PlaygroundPermissionStyler(allowedStatus: value)); + } + + /// Sets the runningStatus. + PlaygroundPermissionStyler runningStatus(BoxStyler value) { + return merge(PlaygroundPermissionStyler(runningStatus: value)); + } + + /// Sets the completedStatus. + PlaygroundPermissionStyler completedStatus(BoxStyler value) { + return merge(PlaygroundPermissionStyler(completedStatus: value)); + } + + /// Sets the deniedStatus. + PlaygroundPermissionStyler deniedStatus(BoxStyler value) { + return merge(PlaygroundPermissionStyler(deniedStatus: value)); + } + + /// Sets the errorStatus. + PlaygroundPermissionStyler errorStatus(BoxStyler value) { + return merge(PlaygroundPermissionStyler(errorStatus: value)); + } + + /// Sets the animation configuration. + @override + PlaygroundPermissionStyler animate(AnimationConfig value) { + return merge(PlaygroundPermissionStyler(animation: value)); + } + + /// Sets the style variants. + @override + PlaygroundPermissionStyler variants( + List> value, + ) { + return merge(PlaygroundPermissionStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + PlaygroundPermissionStyler wrap(WidgetModifierConfig value) { + return merge(PlaygroundPermissionStyler(modifier: value)); + } + + /// Sets the widget modifier. + PlaygroundPermissionStyler modifier(WidgetModifierConfig value) { + return merge(PlaygroundPermissionStyler(modifier: value)); + } + + PlaygroundPermission call({ + Key? key, + required String tool, + Object? requestId, + String title = 'Allow this tool to run?', + String? description, + PlaygroundPermissionStatus status = PlaygroundPermissionStatus.pending, + List parameters = const [], + bool showParameters = true, + bool? detailsExpanded, + bool defaultDetailsExpanded = false, + ValueChanged? onDetailsExpandedChanged, + VoidCallback? onAllowOnce, + VoidCallback? onAlwaysAllow, + VoidCallback? onDeny, + PlaygroundPermissionStatusLabelBuilder? statusLabelBuilder, + PlaygroundPermissionStatusBuilder? statusBuilder, + PlaygroundPermissionIndicatorBuilder? indicatorBuilder, + String allowOnceLabel = 'Allow once', + String alwaysAllowLabel = 'Always allow', + String denyLabel = 'Deny', + String detailsLabel = 'View details', + String semanticLabel = 'Tool permission', + Axis parameterOrientation = Axis.horizontal, + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler detailsStyle = const DisclosureStyler.create(), + DataListStyler parametersStyle = const DataListStyler.create(), + ButtonStyler allowOnceStyle = const ButtonStyler.create(), + ButtonStyler alwaysAllowStyle = const ButtonStyler.create(), + ButtonStyler denyStyle = const ButtonStyler.create(), + }) { + return PlaygroundPermission( + key: key, + style: this, + tool: tool, + requestId: requestId, + title: title, + description: description, + status: status, + parameters: parameters, + showParameters: showParameters, + detailsExpanded: detailsExpanded, + defaultDetailsExpanded: defaultDetailsExpanded, + onDetailsExpandedChanged: onDetailsExpandedChanged, + onAllowOnce: onAllowOnce, + onAlwaysAllow: onAlwaysAllow, + onDeny: onDeny, + statusLabelBuilder: statusLabelBuilder, + statusBuilder: statusBuilder, + indicatorBuilder: indicatorBuilder, + allowOnceLabel: allowOnceLabel, + alwaysAllowLabel: alwaysAllowLabel, + denyLabel: denyLabel, + detailsLabel: detailsLabel, + semanticLabel: semanticLabel, + parameterOrientation: parameterOrientation, + surfaceStyle: surfaceStyle, + detailsStyle: detailsStyle, + parametersStyle: parametersStyle, + allowOnceStyle: allowOnceStyle, + alwaysAllowStyle: alwaysAllowStyle, + denyStyle: denyStyle, + ); + } + + /// Merges with another [PlaygroundPermissionStyler]. + @override + PlaygroundPermissionStyler merge(PlaygroundPermissionStyler? other) { + return PlaygroundPermissionStyler.create( + content: MixOps.merge($content, other?.$content), + header: MixOps.merge($header, other?.$header), + actions: MixOps.merge($actions, other?.$actions), + title: MixOps.merge($title, other?.$title), + tool: MixOps.merge($tool, other?.$tool), + description: MixOps.merge($description, other?.$description), + status: MixOps.merge($status, other?.$status), + detailsLabel: MixOps.merge($detailsLabel, other?.$detailsLabel), + toolIcon: MixOps.merge($toolIcon, other?.$toolIcon), + statusIcon: MixOps.merge($statusIcon, other?.$statusIcon), + indicator: MixOps.merge($indicator, other?.$indicator), + pendingStatus: MixOps.merge($pendingStatus, other?.$pendingStatus), + decidingStatus: MixOps.merge($decidingStatus, other?.$decidingStatus), + allowedStatus: MixOps.merge($allowedStatus, other?.$allowedStatus), + runningStatus: MixOps.merge($runningStatus, other?.$runningStatus), + completedStatus: MixOps.merge($completedStatus, other?.$completedStatus), + deniedStatus: MixOps.merge($deniedStatus, other?.$deniedStatus), + errorStatus: MixOps.merge($errorStatus, other?.$errorStatus), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = PlaygroundPermissionSpec( + content: MixOps.resolve(context, $content), + header: MixOps.resolve(context, $header), + actions: MixOps.resolve(context, $actions), + title: MixOps.resolve(context, $title), + tool: MixOps.resolve(context, $tool), + description: MixOps.resolve(context, $description), + status: MixOps.resolve(context, $status), + detailsLabel: MixOps.resolve(context, $detailsLabel), + toolIcon: MixOps.resolve(context, $toolIcon), + statusIcon: MixOps.resolve(context, $statusIcon), + indicator: MixOps.resolve(context, $indicator), + pendingStatus: MixOps.resolve(context, $pendingStatus), + decidingStatus: MixOps.resolve(context, $decidingStatus), + allowedStatus: MixOps.resolve(context, $allowedStatus), + runningStatus: MixOps.resolve(context, $runningStatus), + completedStatus: MixOps.resolve(context, $completedStatus), + deniedStatus: MixOps.resolve(context, $deniedStatus), + errorStatus: MixOps.resolve(context, $errorStatus), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('content', $content)) + ..add(DiagnosticsProperty('header', $header)) + ..add(DiagnosticsProperty('actions', $actions)) + ..add(DiagnosticsProperty('title', $title)) + ..add(DiagnosticsProperty('tool', $tool)) + ..add(DiagnosticsProperty('description', $description)) + ..add(DiagnosticsProperty('status', $status)) + ..add(DiagnosticsProperty('detailsLabel', $detailsLabel)) + ..add(DiagnosticsProperty('toolIcon', $toolIcon)) + ..add(DiagnosticsProperty('statusIcon', $statusIcon)) + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('pendingStatus', $pendingStatus)) + ..add(DiagnosticsProperty('decidingStatus', $decidingStatus)) + ..add(DiagnosticsProperty('allowedStatus', $allowedStatus)) + ..add(DiagnosticsProperty('runningStatus', $runningStatus)) + ..add(DiagnosticsProperty('completedStatus', $completedStatus)) + ..add(DiagnosticsProperty('deniedStatus', $deniedStatus)) + ..add(DiagnosticsProperty('errorStatus', $errorStatus)); + } + + @override + List get props => [ + $content, + $header, + $actions, + $title, + $tool, + $description, + $status, + $detailsLabel, + $toolIcon, + $statusIcon, + $indicator, + $pendingStatus, + $decidingStatus, + $allowedStatus, + $runningStatus, + $completedStatus, + $deniedStatus, + $errorStatus, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/playground/lib/ui/components/plan.dart b/apps/playground/lib/ui/components/plan.dart new file mode 100644 index 000000000..cecba8da3 --- /dev/null +++ b/apps/playground/lib/ui/components/plan.dart @@ -0,0 +1,308 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/plan_item.dart'; +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'plan.g.dart'; + +typedef PlaygroundPlanStatusBuilder = + Widget Function(BuildContext context, PlaygroundPlanItem item); +typedef PlaygroundPlanStatusLabelBuilder = + String Function(PlaygroundPlanItem item); +typedef PlaygroundPlanIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Toggleable task plan with lifecycle-aware uncontrolled disclosure state. +class PlaygroundPlan extends StatefulWidget { + const PlaygroundPlan({ + super.key, + required this.items, + this.title = 'Plan', + this.emptyLabel = 'No tasks yet', + this.semanticLabel = 'Task plan', + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.statusBuilder, + this.statusLabelBuilder, + this.indicatorBuilder, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + this.disclosureStyle = const DisclosureStyler.create(), + this.style = const PlaygroundPlanStyler.create(), + this.styleSpec, + }); + + final List items; + final String title; + final String emptyLabel; + final String semanticLabel; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final PlaygroundPlanStatusBuilder? statusBuilder; + final PlaygroundPlanStatusLabelBuilder? statusLabelBuilder; + final PlaygroundPlanIndicatorBuilder? indicatorBuilder; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + final DisclosureStyler disclosureStyle; + final PlaygroundPlanStyler style; + final PlaygroundPlanSpec? styleSpec; + + int get settledCount => items.where((item) => item.status.isDone).length; + bool get isWorking => items.any((item) => !item.status.isDone); + + @override + State createState() => _PlaygroundPlanState(); +} + +class _PlaygroundPlanState extends State { + late final PlaygroundDisclosureEngine _disclosure; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = PlaygroundDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget(PlaygroundPlan oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + final wasWorking = oldWidget.isWorking; + final working = widget.isWorking; + if (wasWorking && !working && widget.collapseOnComplete) { + _request(false); + } else if (!wasWorking && working) { + _request(true); + } + } + + void _request(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String _statusLabel(PlaygroundPlanItem item) => + widget.statusLabelBuilder?.call(item) ?? + switch (item.status) { + PlaygroundPlanItemStatus.pending => 'Pending', + PlaygroundPlanItemStatus.inProgress => 'In progress', + PlaygroundPlanItemStatus.completed => 'Completed', + PlaygroundPlanItemStatus.cancelled => 'Cancelled', + }; + + PlaygroundFunctionalGlyphKind _statusGlyph(PlaygroundPlanItemStatus status) => + switch (status) { + PlaygroundPlanItemStatus.pending => .pending, + PlaygroundPlanItemStatus.inProgress => .active, + PlaygroundPlanItemStatus.completed => .completed, + PlaygroundPlanItemStatus.cancelled => .cancelled, + }; + + StyleSpec _statusContainer( + PlaygroundPlanSpec spec, + PlaygroundPlanItemStatus status, + ) => switch (status) { + PlaygroundPlanItemStatus.pending => spec.pendingItem, + PlaygroundPlanItemStatus.inProgress => spec.activeItem, + PlaygroundPlanItemStatus.completed => spec.completedItem, + PlaygroundPlanItemStatus.cancelled => spec.cancelledItem, + }; + + StyleSpec _statusStyle( + PlaygroundPlanSpec spec, + PlaygroundPlanItemStatus status, + ) => switch (status) { + PlaygroundPlanItemStatus.pending => spec.pendingStatus, + PlaygroundPlanItemStatus.inProgress => spec.activeStatus, + PlaygroundPlanItemStatus.completed => spec.completedStatus, + PlaygroundPlanItemStatus.cancelled => spec.cancelledStatus, + }; + + Widget _defaultStatus( + BuildContext context, + PlaygroundPlanSpec spec, + PlaygroundPlanItem item, + ) { + return StyleSpecBuilder( + styleSpec: _statusStyle(spec, item.status), + builder: (context, iconSpec) => PlaygroundFunctionalGlyph( + kind: _statusGlyph(item.status), + spec: iconSpec, + ), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixDisclosure( + expanded: _expanded, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + PlaygroundDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: Row( + children: [ + Expanded( + child: StyledText(widget.title, styleSpec: spec.summaryTitle), + ), + StyledText( + '${widget.settledCount}/${widget.items.length}', + styleSpec: spec.count, + ), + ], + ), + content: Box( + styleSpec: spec.viewport, + child: widget.items.isEmpty + ? StyledText(widget.emptyLabel, styleSpec: spec.itemDetail) + : PlaygroundLiveEdgeScrollView( + followOutput: widget.followOutput, + followThreshold: widget.followThreshold, + onFollowChanged: widget.onFollowChanged, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in widget.items) + Semantics( + container: true, + excludeSemantics: true, + label: [ + item.title, + if (item.detail != null) item.detail!, + _statusLabel(item), + ].join(', '), + child: Box( + styleSpec: _statusContainer(spec, item.status), + child: RowBox( + key: ValueKey( + 'playground-plan-item-${item.id}', + ), + styleSpec: spec.item, + children: [ + widget.statusBuilder?.call(context, item) ?? + _defaultStatus(context, spec, item), + Expanded( + child: StyledText( + item.title, + styleSpec: spec.itemTitle, + ), + ), + if (item.detail != null) + StyledText( + item.detail!, + styleSpec: spec.itemDetail, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: PlaygroundPlan.new) +@immutable +final class PlaygroundPlanSpec with _$PlaygroundPlanSpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final StyleSpec summaryTitle; + @override + final StyleSpec itemTitle; + @override + final StyleSpec itemDetail; + @override + final StyleSpec count; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingItem; + @override + final StyleSpec activeItem; + @override + final StyleSpec completedItem; + @override + final StyleSpec cancelledItem; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec activeStatus; + @override + final StyleSpec completedStatus; + @override + final StyleSpec cancelledStatus; + + const PlaygroundPlanSpec({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? cancelledItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + StyleSpec? cancelledStatus, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: FlexBoxSpec()), + summaryTitle = summaryTitle ?? const StyleSpec(spec: TextSpec()), + itemTitle = itemTitle ?? const StyleSpec(spec: TextSpec()), + itemDetail = itemDetail ?? const StyleSpec(spec: TextSpec()), + count = count ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingItem = pendingItem ?? const StyleSpec(spec: BoxSpec()), + activeItem = activeItem ?? const StyleSpec(spec: BoxSpec()), + completedItem = completedItem ?? const StyleSpec(spec: BoxSpec()), + cancelledItem = cancelledItem ?? const StyleSpec(spec: BoxSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: IconSpec()), + activeStatus = activeStatus ?? const StyleSpec(spec: IconSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: IconSpec()), + cancelledStatus = cancelledStatus ?? const StyleSpec(spec: IconSpec()); +} diff --git a/apps/playground/lib/ui/components/plan.g.dart b/apps/playground/lib/ui/components/plan.g.dart new file mode 100644 index 000000000..b32b4f154 --- /dev/null +++ b/apps/playground/lib/ui/components/plan.g.dart @@ -0,0 +1,552 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plan.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$PlaygroundPlanSpec implements Spec, Diagnosticable { + StyleSpec get viewport; + StyleSpec get item; + StyleSpec get summaryTitle; + StyleSpec get itemTitle; + StyleSpec get itemDetail; + StyleSpec get count; + StyleSpec get indicator; + StyleSpec get pendingItem; + StyleSpec get activeItem; + StyleSpec get completedItem; + StyleSpec get cancelledItem; + StyleSpec get pendingStatus; + StyleSpec get activeStatus; + StyleSpec get completedStatus; + StyleSpec get cancelledStatus; + + @override + Type get type => PlaygroundPlanSpec; + + @override + PlaygroundPlanSpec copyWith({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? cancelledItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + StyleSpec? cancelledStatus, + }) { + return PlaygroundPlanSpec( + viewport: viewport ?? this.viewport, + item: item ?? this.item, + summaryTitle: summaryTitle ?? this.summaryTitle, + itemTitle: itemTitle ?? this.itemTitle, + itemDetail: itemDetail ?? this.itemDetail, + count: count ?? this.count, + indicator: indicator ?? this.indicator, + pendingItem: pendingItem ?? this.pendingItem, + activeItem: activeItem ?? this.activeItem, + completedItem: completedItem ?? this.completedItem, + cancelledItem: cancelledItem ?? this.cancelledItem, + pendingStatus: pendingStatus ?? this.pendingStatus, + activeStatus: activeStatus ?? this.activeStatus, + completedStatus: completedStatus ?? this.completedStatus, + cancelledStatus: cancelledStatus ?? this.cancelledStatus, + ); + } + + @override + PlaygroundPlanSpec lerp(PlaygroundPlanSpec? other, double t) { + return PlaygroundPlanSpec( + viewport: viewport.lerp(other?.viewport, t), + item: item.lerp(other?.item, t), + summaryTitle: summaryTitle.lerp(other?.summaryTitle, t), + itemTitle: itemTitle.lerp(other?.itemTitle, t), + itemDetail: itemDetail.lerp(other?.itemDetail, t), + count: count.lerp(other?.count, t), + indicator: indicator.lerp(other?.indicator, t), + pendingItem: pendingItem.lerp(other?.pendingItem, t), + activeItem: activeItem.lerp(other?.activeItem, t), + completedItem: completedItem.lerp(other?.completedItem, t), + cancelledItem: cancelledItem.lerp(other?.cancelledItem, t), + pendingStatus: pendingStatus.lerp(other?.pendingStatus, t), + activeStatus: activeStatus.lerp(other?.activeStatus, t), + completedStatus: completedStatus.lerp(other?.completedStatus, t), + cancelledStatus: cancelledStatus.lerp(other?.cancelledStatus, t), + ); + } + + @override + List get props => [ + viewport, + item, + summaryTitle, + itemTitle, + itemDetail, + count, + indicator, + pendingItem, + activeItem, + completedItem, + cancelledItem, + pendingStatus, + activeStatus, + completedStatus, + cancelledStatus, + ]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is PlaygroundPlanSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('viewport', viewport)) + ..add(DiagnosticsProperty('item', item)) + ..add(DiagnosticsProperty('summaryTitle', summaryTitle)) + ..add(DiagnosticsProperty('itemTitle', itemTitle)) + ..add(DiagnosticsProperty('itemDetail', itemDetail)) + ..add(DiagnosticsProperty('count', count)) + ..add(DiagnosticsProperty('indicator', indicator)) + ..add(DiagnosticsProperty('pendingItem', pendingItem)) + ..add(DiagnosticsProperty('activeItem', activeItem)) + ..add(DiagnosticsProperty('completedItem', completedItem)) + ..add(DiagnosticsProperty('cancelledItem', cancelledItem)) + ..add(DiagnosticsProperty('pendingStatus', pendingStatus)) + ..add(DiagnosticsProperty('activeStatus', activeStatus)) + ..add(DiagnosticsProperty('completedStatus', completedStatus)) + ..add(DiagnosticsProperty('cancelledStatus', cancelledStatus)); + } +} + +@Deprecated( + 'Rename to `_\$PlaygroundPlanSpec` and migrate the class declaration to `class PlaygroundPlanSpec with _\$PlaygroundPlanSpec`. The `_\$PlaygroundPlanSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$PlaygroundPlanSpecMethods = _$PlaygroundPlanSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class PlaygroundPlanStyler + extends MixStyler + implements StylerFieldMetadata { + final Prop>? $viewport; + final Prop>? $item; + final Prop>? $summaryTitle; + final Prop>? $itemTitle; + final Prop>? $itemDetail; + final Prop>? $count; + final Prop>? $indicator; + final Prop>? $pendingItem; + final Prop>? $activeItem; + final Prop>? $completedItem; + final Prop>? $cancelledItem; + final Prop>? $pendingStatus; + final Prop>? $activeStatus; + final Prop>? $completedStatus; + final Prop>? $cancelledStatus; + + const PlaygroundPlanStyler.create({ + Prop>? viewport, + Prop>? item, + Prop>? summaryTitle, + Prop>? itemTitle, + Prop>? itemDetail, + Prop>? count, + Prop>? indicator, + Prop>? pendingItem, + Prop>? activeItem, + Prop>? completedItem, + Prop>? cancelledItem, + Prop>? pendingStatus, + Prop>? activeStatus, + Prop>? completedStatus, + Prop>? cancelledStatus, + super.variants, + super.modifier, + super.animation, + }) : $viewport = viewport, + $item = item, + $summaryTitle = summaryTitle, + $itemTitle = itemTitle, + $itemDetail = itemDetail, + $count = count, + $indicator = indicator, + $pendingItem = pendingItem, + $activeItem = activeItem, + $completedItem = completedItem, + $cancelledItem = cancelledItem, + $pendingStatus = pendingStatus, + $activeStatus = activeStatus, + $completedStatus = completedStatus, + $cancelledStatus = cancelledStatus; + + PlaygroundPlanStyler({ + BoxStyler? viewport, + FlexBoxStyler? item, + TextStyler? summaryTitle, + TextStyler? itemTitle, + TextStyler? itemDetail, + TextStyler? count, + IconStyler? indicator, + BoxStyler? pendingItem, + BoxStyler? activeItem, + BoxStyler? completedItem, + BoxStyler? cancelledItem, + IconStyler? pendingStatus, + IconStyler? activeStatus, + IconStyler? completedStatus, + IconStyler? cancelledStatus, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + viewport: Prop.maybeMix(viewport), + item: Prop.maybeMix(item), + summaryTitle: Prop.maybeMix(summaryTitle), + itemTitle: Prop.maybeMix(itemTitle), + itemDetail: Prop.maybeMix(itemDetail), + count: Prop.maybeMix(count), + indicator: Prop.maybeMix(indicator), + pendingItem: Prop.maybeMix(pendingItem), + activeItem: Prop.maybeMix(activeItem), + completedItem: Prop.maybeMix(completedItem), + cancelledItem: Prop.maybeMix(cancelledItem), + pendingStatus: Prop.maybeMix(pendingStatus), + activeStatus: Prop.maybeMix(activeStatus), + completedStatus: Prop.maybeMix(completedStatus), + cancelledStatus: Prop.maybeMix(cancelledStatus), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory PlaygroundPlanStyler.viewport(BoxStyler value) => + PlaygroundPlanStyler().viewport(value); + factory PlaygroundPlanStyler.item(FlexBoxStyler value) => + PlaygroundPlanStyler().item(value); + factory PlaygroundPlanStyler.summaryTitle(TextStyler value) => + PlaygroundPlanStyler().summaryTitle(value); + factory PlaygroundPlanStyler.itemTitle(TextStyler value) => + PlaygroundPlanStyler().itemTitle(value); + factory PlaygroundPlanStyler.itemDetail(TextStyler value) => + PlaygroundPlanStyler().itemDetail(value); + factory PlaygroundPlanStyler.count(TextStyler value) => + PlaygroundPlanStyler().count(value); + factory PlaygroundPlanStyler.indicator(IconStyler value) => + PlaygroundPlanStyler().indicator(value); + factory PlaygroundPlanStyler.pendingItem(BoxStyler value) => + PlaygroundPlanStyler().pendingItem(value); + factory PlaygroundPlanStyler.activeItem(BoxStyler value) => + PlaygroundPlanStyler().activeItem(value); + factory PlaygroundPlanStyler.completedItem(BoxStyler value) => + PlaygroundPlanStyler().completedItem(value); + factory PlaygroundPlanStyler.cancelledItem(BoxStyler value) => + PlaygroundPlanStyler().cancelledItem(value); + factory PlaygroundPlanStyler.pendingStatus(IconStyler value) => + PlaygroundPlanStyler().pendingStatus(value); + factory PlaygroundPlanStyler.activeStatus(IconStyler value) => + PlaygroundPlanStyler().activeStatus(value); + factory PlaygroundPlanStyler.completedStatus(IconStyler value) => + PlaygroundPlanStyler().completedStatus(value); + factory PlaygroundPlanStyler.cancelledStatus(IconStyler value) => + PlaygroundPlanStyler().cancelledStatus(value); + + @override + Set get $stylerFieldNames => const { + 'viewport', + 'item', + 'summaryTitle', + 'itemTitle', + 'itemDetail', + 'count', + 'indicator', + 'pendingItem', + 'activeItem', + 'completedItem', + 'cancelledItem', + 'pendingStatus', + 'activeStatus', + 'completedStatus', + 'cancelledStatus', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the viewport. + PlaygroundPlanStyler viewport(BoxStyler value) { + return merge(PlaygroundPlanStyler(viewport: value)); + } + + /// Sets the item. + PlaygroundPlanStyler item(FlexBoxStyler value) { + return merge(PlaygroundPlanStyler(item: value)); + } + + /// Sets the summaryTitle. + PlaygroundPlanStyler summaryTitle(TextStyler value) { + return merge(PlaygroundPlanStyler(summaryTitle: value)); + } + + /// Sets the itemTitle. + PlaygroundPlanStyler itemTitle(TextStyler value) { + return merge(PlaygroundPlanStyler(itemTitle: value)); + } + + /// Sets the itemDetail. + PlaygroundPlanStyler itemDetail(TextStyler value) { + return merge(PlaygroundPlanStyler(itemDetail: value)); + } + + /// Sets the count. + PlaygroundPlanStyler count(TextStyler value) { + return merge(PlaygroundPlanStyler(count: value)); + } + + /// Sets the indicator. + PlaygroundPlanStyler indicator(IconStyler value) { + return merge(PlaygroundPlanStyler(indicator: value)); + } + + /// Sets the pendingItem. + PlaygroundPlanStyler pendingItem(BoxStyler value) { + return merge(PlaygroundPlanStyler(pendingItem: value)); + } + + /// Sets the activeItem. + PlaygroundPlanStyler activeItem(BoxStyler value) { + return merge(PlaygroundPlanStyler(activeItem: value)); + } + + /// Sets the completedItem. + PlaygroundPlanStyler completedItem(BoxStyler value) { + return merge(PlaygroundPlanStyler(completedItem: value)); + } + + /// Sets the cancelledItem. + PlaygroundPlanStyler cancelledItem(BoxStyler value) { + return merge(PlaygroundPlanStyler(cancelledItem: value)); + } + + /// Sets the pendingStatus. + PlaygroundPlanStyler pendingStatus(IconStyler value) { + return merge(PlaygroundPlanStyler(pendingStatus: value)); + } + + /// Sets the activeStatus. + PlaygroundPlanStyler activeStatus(IconStyler value) { + return merge(PlaygroundPlanStyler(activeStatus: value)); + } + + /// Sets the completedStatus. + PlaygroundPlanStyler completedStatus(IconStyler value) { + return merge(PlaygroundPlanStyler(completedStatus: value)); + } + + /// Sets the cancelledStatus. + PlaygroundPlanStyler cancelledStatus(IconStyler value) { + return merge(PlaygroundPlanStyler(cancelledStatus: value)); + } + + /// Sets the animation configuration. + @override + PlaygroundPlanStyler animate(AnimationConfig value) { + return merge(PlaygroundPlanStyler(animation: value)); + } + + /// Sets the style variants. + @override + PlaygroundPlanStyler variants(List> value) { + return merge(PlaygroundPlanStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + PlaygroundPlanStyler wrap(WidgetModifierConfig value) { + return merge(PlaygroundPlanStyler(modifier: value)); + } + + /// Sets the widget modifier. + PlaygroundPlanStyler modifier(WidgetModifierConfig value) { + return merge(PlaygroundPlanStyler(modifier: value)); + } + + PlaygroundPlan call({ + Key? key, + required List items, + String title = 'Plan', + String emptyLabel = 'No tasks yet', + String semanticLabel = 'Task plan', + bool collapseOnComplete = true, + bool? expanded, + bool defaultExpanded = true, + ValueChanged? onExpandedChanged, + PlaygroundPlanStatusBuilder? statusBuilder, + PlaygroundPlanStatusLabelBuilder? statusLabelBuilder, + PlaygroundPlanIndicatorBuilder? indicatorBuilder, + bool followOutput = true, + double followThreshold = 48, + ValueChanged? onFollowChanged, + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + }) { + return PlaygroundPlan( + key: key, + style: this, + items: items, + title: title, + emptyLabel: emptyLabel, + semanticLabel: semanticLabel, + collapseOnComplete: collapseOnComplete, + expanded: expanded, + defaultExpanded: defaultExpanded, + onExpandedChanged: onExpandedChanged, + statusBuilder: statusBuilder, + statusLabelBuilder: statusLabelBuilder, + indicatorBuilder: indicatorBuilder, + followOutput: followOutput, + followThreshold: followThreshold, + onFollowChanged: onFollowChanged, + disclosureStyle: disclosureStyle, + ); + } + + /// Merges with another [PlaygroundPlanStyler]. + @override + PlaygroundPlanStyler merge(PlaygroundPlanStyler? other) { + return PlaygroundPlanStyler.create( + viewport: MixOps.merge($viewport, other?.$viewport), + item: MixOps.merge($item, other?.$item), + summaryTitle: MixOps.merge($summaryTitle, other?.$summaryTitle), + itemTitle: MixOps.merge($itemTitle, other?.$itemTitle), + itemDetail: MixOps.merge($itemDetail, other?.$itemDetail), + count: MixOps.merge($count, other?.$count), + indicator: MixOps.merge($indicator, other?.$indicator), + pendingItem: MixOps.merge($pendingItem, other?.$pendingItem), + activeItem: MixOps.merge($activeItem, other?.$activeItem), + completedItem: MixOps.merge($completedItem, other?.$completedItem), + cancelledItem: MixOps.merge($cancelledItem, other?.$cancelledItem), + pendingStatus: MixOps.merge($pendingStatus, other?.$pendingStatus), + activeStatus: MixOps.merge($activeStatus, other?.$activeStatus), + completedStatus: MixOps.merge($completedStatus, other?.$completedStatus), + cancelledStatus: MixOps.merge($cancelledStatus, other?.$cancelledStatus), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = PlaygroundPlanSpec( + viewport: MixOps.resolve(context, $viewport), + item: MixOps.resolve(context, $item), + summaryTitle: MixOps.resolve(context, $summaryTitle), + itemTitle: MixOps.resolve(context, $itemTitle), + itemDetail: MixOps.resolve(context, $itemDetail), + count: MixOps.resolve(context, $count), + indicator: MixOps.resolve(context, $indicator), + pendingItem: MixOps.resolve(context, $pendingItem), + activeItem: MixOps.resolve(context, $activeItem), + completedItem: MixOps.resolve(context, $completedItem), + cancelledItem: MixOps.resolve(context, $cancelledItem), + pendingStatus: MixOps.resolve(context, $pendingStatus), + activeStatus: MixOps.resolve(context, $activeStatus), + completedStatus: MixOps.resolve(context, $completedStatus), + cancelledStatus: MixOps.resolve(context, $cancelledStatus), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('viewport', $viewport)) + ..add(DiagnosticsProperty('item', $item)) + ..add(DiagnosticsProperty('summaryTitle', $summaryTitle)) + ..add(DiagnosticsProperty('itemTitle', $itemTitle)) + ..add(DiagnosticsProperty('itemDetail', $itemDetail)) + ..add(DiagnosticsProperty('count', $count)) + ..add(DiagnosticsProperty('indicator', $indicator)) + ..add(DiagnosticsProperty('pendingItem', $pendingItem)) + ..add(DiagnosticsProperty('activeItem', $activeItem)) + ..add(DiagnosticsProperty('completedItem', $completedItem)) + ..add(DiagnosticsProperty('cancelledItem', $cancelledItem)) + ..add(DiagnosticsProperty('pendingStatus', $pendingStatus)) + ..add(DiagnosticsProperty('activeStatus', $activeStatus)) + ..add(DiagnosticsProperty('completedStatus', $completedStatus)) + ..add(DiagnosticsProperty('cancelledStatus', $cancelledStatus)); + } + + @override + List get props => [ + $viewport, + $item, + $summaryTitle, + $itemTitle, + $itemDetail, + $count, + $indicator, + $pendingItem, + $activeItem, + $completedItem, + $cancelledItem, + $pendingStatus, + $activeStatus, + $completedStatus, + $cancelledStatus, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/playground/lib/ui/components/textfield.dart b/apps/playground/lib/ui/components/textfield.dart index 72f533258..369e375be 100644 --- a/apps/playground/lib/ui/components/textfield.dart +++ b/apps/playground/lib/ui/components/textfield.dart @@ -168,7 +168,7 @@ TextFieldStyler _focusVisibleStyle() => TextFieldStyler().containerEffects( /// assistive technology either way. /// /// A theme with a dedicated danger *text* step would put it on the helper -/// line here; this vocabulary has fifteen tokens and no such step. +/// line here; this vocabulary has twenty tokens and no such step. TextFieldStyler _errorStyle() => TextFieldStyler().variant( ContextVariant.widgetState(.error), TextFieldStyler() diff --git a/apps/playground/lib/ui/components/transcript.dart b/apps/playground/lib/ui/components/transcript.dart new file mode 100644 index 000000000..239d31690 --- /dev/null +++ b/apps/playground/lib/ui/components/transcript.dart @@ -0,0 +1,273 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../support/live_edge.dart'; + +part 'transcript.g.dart'; + +/// Chronological transcript with reader-aware live-edge following. +class PlaygroundTranscript extends StatefulWidget { + const PlaygroundTranscript({ + super.key, + required List this.children, + this.followOutput = true, + this.followThreshold = 48.0, + this.busy = false, + this.busyLabel = 'Busy', + this.label = 'Conversation', + this.onFollowChanged, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.style = const PlaygroundTranscriptStyler.create(), + this.styleSpec, + }) : itemCount = null, + itemBuilder = null; + + const PlaygroundTranscript.builder({ + super.key, + required int this.itemCount, + required IndexedWidgetBuilder this.itemBuilder, + this.followOutput = true, + this.followThreshold = 48.0, + this.busy = false, + this.busyLabel = 'Busy', + this.label = 'Conversation', + this.onFollowChanged, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.style = const PlaygroundTranscriptStyler.create(), + this.styleSpec, + }) : children = null; + + final List? children; + final int? itemCount; + final IndexedWidgetBuilder? itemBuilder; + final bool followOutput; + final double followThreshold; + final bool busy; + final String busyLabel; + final String label; + final ValueChanged? onFollowChanged; + final ScrollController? controller; + final Clip clipBehavior; + final PlaygroundTranscriptStyler style; + final PlaygroundTranscriptSpec? styleSpec; + + @override + State createState() => _PlaygroundTranscriptState(); +} + +class _PlaygroundTranscriptState extends State { + ScrollController? _ownedController; + late ScrollController _controller; + late final PlaygroundLiveEdgeEngine _liveEdge; + + /// Publishes this surface's focus to the styles resolved above it. + /// + /// `focused` has no other source here: Playground's slots resolve above any Naked + /// control, so without this the `focus-visible` state the transcript + /// worksheet documents could never activate. + /// + /// Only `focused`. The pointer-driven states do not resolve on this slot, and + /// did not before this controller existed either — a host's `onHovered` on + /// [PlaygroundTranscriptSpec.viewport] has never had an effect. Passing a + /// controller also means Mix will not mount its own pointer detector, so + /// restoring hover would be this object's job; nothing asks for it yet. + final WidgetStatesController _statesController = WidgetStatesController(); + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? (_ownedController = ScrollController()); + _liveEdge = PlaygroundLiveEdgeEngine( + enabled: widget.followOutput, + threshold: widget.followThreshold, + onChanged: widget.onFollowChanged, + ); + _scheduleFollow(); + } + + @override + void didUpdateWidget(PlaygroundTranscript oldWidget) { + super.didUpdateWidget(oldWidget); + _liveEdge + ..enabled = widget.followOutput + ..threshold = widget.followThreshold + ..onChanged = widget.onFollowChanged; + if (!identical(oldWidget.controller, widget.controller)) { + final offset = _controller.hasClients ? _controller.offset : 0.0; + final oldOwned = _ownedController; + _ownedController = null; + _controller = + widget.controller ?? + (_ownedController = ScrollController(initialScrollOffset: offset)); + if (oldOwned != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => oldOwned.dispose()); + } + } + _scheduleFollow(); + } + + void _scheduleFollow() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _liveEdge.follow(_controller); + }); + } + + bool _handleScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + _liveEdge.handleScroll(notification, _controller); + return notification is OverscrollNotification; + } + + void _handleIntent(_TranscriptScrollIntent intent) { + if (!_controller.hasClients) return; + final position = _controller.position; + final target = switch (intent.kind) { + _TranscriptScrollKind.lineUp => position.pixels - 50, + _TranscriptScrollKind.lineDown => position.pixels + 50, + _TranscriptScrollKind.pageUp => + position.pixels - position.viewportDimension * 0.8, + _TranscriptScrollKind.pageDown => + position.pixels + position.viewportDimension * 0.8, + _TranscriptScrollKind.home => position.minScrollExtent, + _TranscriptScrollKind.end => position.maxScrollExtent, + }; + position.jumpTo( + target + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(), + ); + _liveEdge.handlePosition(position); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder( + style: widget.style, + styleSpec: widget.styleSpec, + controller: _statesController, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.label, + value: widget.busy ? widget.busyLabel : null, + child: FocusableActionDetector( + onFocusChange: (focused) => + _statesController.update(WidgetState.focused, focused), + shortcuts: _transcriptShortcuts, + actions: >{ + _TranscriptScrollIntent: CallbackAction<_TranscriptScrollIntent>( + onInvoke: (intent) { + _handleIntent(intent); + return null; + }, + ), + }, + child: Box( + styleSpec: spec.viewport, + child: LayoutBuilder( + builder: (context, constraints) => + NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && _liveEdge.following) { + _scheduleFollow(); + } + return false; + }, + child: NotificationListener( + onNotification: _handleScroll, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + overscroll: false, + physics: const ClampingScrollPhysics(), + ), + child: _buildList( + spec, + shrinkWrap: !constraints.hasBoundedHeight, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildList(PlaygroundTranscriptSpec spec, {required bool shrinkWrap}) { + final children = widget.children; + final count = children?.length ?? widget.itemCount!; + final spacing = spec.spacing ?? 0; + assert(spacing >= 0, 'PlaygroundTranscript spacing must be non-negative.'); + return ListView.separated( + controller: _controller, + shrinkWrap: shrinkWrap, + physics: const ClampingScrollPhysics(), + clipBehavior: widget.clipBehavior, + itemCount: count, + itemBuilder: (context, index) => Box( + styleSpec: spec.item, + child: children?[index] ?? widget.itemBuilder!(context, index), + ), + separatorBuilder: (context, index) => SizedBox(height: spacing), + ); + } + + @override + void dispose() { + _ownedController?.dispose(); + _statesController.dispose(); + super.dispose(); + } +} + +enum _TranscriptScrollKind { lineUp, lineDown, pageUp, pageDown, home, end } + +class _TranscriptScrollIntent extends Intent { + const _TranscriptScrollIntent(this.kind); + final _TranscriptScrollKind kind; +} + +const _transcriptShortcuts = { + SingleActivator(LogicalKeyboardKey.arrowUp): _TranscriptScrollIntent( + _TranscriptScrollKind.lineUp, + ), + SingleActivator(LogicalKeyboardKey.arrowDown): _TranscriptScrollIntent( + _TranscriptScrollKind.lineDown, + ), + SingleActivator(LogicalKeyboardKey.pageUp): _TranscriptScrollIntent( + _TranscriptScrollKind.pageUp, + ), + SingleActivator(LogicalKeyboardKey.pageDown): _TranscriptScrollIntent( + _TranscriptScrollKind.pageDown, + ), + SingleActivator(LogicalKeyboardKey.home): _TranscriptScrollIntent( + _TranscriptScrollKind.home, + ), + SingleActivator(LogicalKeyboardKey.end): _TranscriptScrollIntent( + _TranscriptScrollKind.end, + ), +}; + +@MixableSpec(target: PlaygroundTranscript.new) +@immutable +final class PlaygroundTranscriptSpec with _$PlaygroundTranscriptSpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final double? spacing; + + const PlaygroundTranscriptSpec({ + StyleSpec? viewport, + StyleSpec? item, + this.spacing, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/apps/playground/lib/ui/components/transcript.g.dart b/apps/playground/lib/ui/components/transcript.g.dart new file mode 100644 index 000000000..0eb93461e --- /dev/null +++ b/apps/playground/lib/ui/components/transcript.g.dart @@ -0,0 +1,263 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'transcript.dart'; + +// ************************************************************************** +// SpecGenerator +// ************************************************************************** + +mixin _$PlaygroundTranscriptSpec + implements Spec, Diagnosticable { + StyleSpec get viewport; + StyleSpec get item; + double? get spacing; + + @override + Type get type => PlaygroundTranscriptSpec; + + @override + PlaygroundTranscriptSpec copyWith({ + StyleSpec? viewport, + StyleSpec? item, + double? spacing, + }) { + return PlaygroundTranscriptSpec( + viewport: viewport ?? this.viewport, + item: item ?? this.item, + spacing: spacing ?? this.spacing, + ); + } + + @override + PlaygroundTranscriptSpec lerp(PlaygroundTranscriptSpec? other, double t) { + return PlaygroundTranscriptSpec( + viewport: viewport.lerp(other?.viewport, t), + item: item.lerp(other?.item, t), + spacing: MixOps.lerp(spacing, other?.spacing, t), + ); + } + + @override + List get props => [viewport, item, spacing]; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is PlaygroundTranscriptSpec && + runtimeType == other.runtimeType && + propsEquals(props, other.props); + } + + @override + int get hashCode => propsHash(runtimeType, props); + + @override + bool get stringify => true; + + @override + Map getDiff(Equatable other) { + if (this == other) return const {}; + + return propsDiff(props, other.props); + } + + @override + String toStringShort() => '$runtimeType'; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + toDiagnosticsNode( + style: DiagnosticsTreeStyle.singleLine, + ).toString(minLevel: minLevel); + + @override + DiagnosticsNode toDiagnosticsNode({ + String? name, + DiagnosticsTreeStyle? style, + }) => + DiagnosticableNode(name: name, value: this, style: style); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('viewport', viewport)) + ..add(DiagnosticsProperty('item', item)) + ..add(DoubleProperty('spacing', spacing)); + } +} + +@Deprecated( + 'Rename to `_\$PlaygroundTranscriptSpec` and migrate the class declaration to `class PlaygroundTranscriptSpec with _\$PlaygroundTranscriptSpec`. The `_\$PlaygroundTranscriptSpecMethods` alias will be removed in mix_generator 3.0.', +) +typedef _$PlaygroundTranscriptSpecMethods = _$PlaygroundTranscriptSpec; // ignore: unused_element + +// ************************************************************************** +// SpecStylerGenerator +// ************************************************************************** + +class PlaygroundTranscriptStyler + extends MixStyler + implements StylerFieldMetadata { + final Prop>? $viewport; + final Prop>? $item; + final Prop? $spacing; + + const PlaygroundTranscriptStyler.create({ + Prop>? viewport, + Prop>? item, + Prop? spacing, + super.variants, + super.modifier, + super.animation, + }) : $viewport = viewport, + $item = item, + $spacing = spacing; + + PlaygroundTranscriptStyler({ + BoxStyler? viewport, + BoxStyler? item, + double? spacing, + AnimationConfig? animation, + WidgetModifierConfig? modifier, + List>? variants, + }) : this.create( + viewport: Prop.maybeMix(viewport), + item: Prop.maybeMix(item), + spacing: Prop.maybe(spacing), + variants: variants, + modifier: modifier, + animation: animation, + ); + + factory PlaygroundTranscriptStyler.viewport(BoxStyler value) => + PlaygroundTranscriptStyler().viewport(value); + factory PlaygroundTranscriptStyler.item(BoxStyler value) => + PlaygroundTranscriptStyler().item(value); + factory PlaygroundTranscriptStyler.spacing(double value) => + PlaygroundTranscriptStyler().spacing(value); + + @override + Set get $stylerFieldNames => const { + 'viewport', + 'item', + 'spacing', + 'animation', + 'modifier', + 'variants', + }; + + /// Sets the viewport. + PlaygroundTranscriptStyler viewport(BoxStyler value) { + return merge(PlaygroundTranscriptStyler(viewport: value)); + } + + /// Sets the item. + PlaygroundTranscriptStyler item(BoxStyler value) { + return merge(PlaygroundTranscriptStyler(item: value)); + } + + /// Sets the spacing. + PlaygroundTranscriptStyler spacing(double value) { + return merge(PlaygroundTranscriptStyler(spacing: value)); + } + + /// Sets the animation configuration. + @override + PlaygroundTranscriptStyler animate(AnimationConfig value) { + return merge(PlaygroundTranscriptStyler(animation: value)); + } + + /// Sets the style variants. + @override + PlaygroundTranscriptStyler variants( + List> value, + ) { + return merge(PlaygroundTranscriptStyler(variants: value)); + } + + /// Wraps with a widget modifier. + @override + PlaygroundTranscriptStyler wrap(WidgetModifierConfig value) { + return merge(PlaygroundTranscriptStyler(modifier: value)); + } + + /// Sets the widget modifier. + PlaygroundTranscriptStyler modifier(WidgetModifierConfig value) { + return merge(PlaygroundTranscriptStyler(modifier: value)); + } + + PlaygroundTranscript call({ + Key? key, + required List children, + bool followOutput = true, + double followThreshold = 48.0, + bool busy = false, + String busyLabel = 'Busy', + String label = 'Conversation', + ValueChanged? onFollowChanged, + ScrollController? controller, + Clip clipBehavior = Clip.hardEdge, + }) { + return PlaygroundTranscript( + key: key, + style: this, + children: children, + followOutput: followOutput, + followThreshold: followThreshold, + busy: busy, + busyLabel: busyLabel, + label: label, + onFollowChanged: onFollowChanged, + controller: controller, + clipBehavior: clipBehavior, + ); + } + + /// Merges with another [PlaygroundTranscriptStyler]. + @override + PlaygroundTranscriptStyler merge(PlaygroundTranscriptStyler? other) { + return PlaygroundTranscriptStyler.create( + viewport: MixOps.merge($viewport, other?.$viewport), + item: MixOps.merge($item, other?.$item), + spacing: MixOps.merge($spacing, other?.$spacing), + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + ); + } + + /// Resolves to [StyleSpec] using [context]. + @override + StyleSpec resolve(BuildContext context) { + final spec = PlaygroundTranscriptSpec( + viewport: MixOps.resolve(context, $viewport), + item: MixOps.resolve(context, $item), + spacing: MixOps.resolve(context, $spacing), + ); + + return StyleSpec( + spec: spec, + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('viewport', $viewport)) + ..add(DiagnosticsProperty('item', $item)) + ..add(DiagnosticsProperty('spacing', $spacing)); + } + + @override + List get props => [ + $viewport, + $item, + $spacing, + $animation, + $modifier, + $variants, + ]; +} diff --git a/apps/playground/lib/ui/models/activity_item.dart b/apps/playground/lib/ui/models/activity_item.dart new file mode 100644 index 000000000..c44daef35 --- /dev/null +++ b/apps/playground/lib/ui/models/activity_item.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; + +import 'statuses.dart'; + +/// One row in an [PlaygroundActivity] ledger. +@immutable +class PlaygroundActivityItem { + /// Creates an activity row. + const PlaygroundActivityItem({ + required this.id, + required this.title, + this.status = PlaygroundActivityItemStatus.pending, + this.detail, + this.child, + }); + + /// Stable identity across list updates. + final String id; + + /// Visible title. + final String title; + + /// Current status. + final PlaygroundActivityItemStatus status; + + /// Optional compact detail rendered with the activity detail style slot. + final String? detail; + + /// Optional host-rendered detail. The catalog does not parse this child. + final Widget? child; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlaygroundActivityItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail && + identical(other.child, child); + + @override + int get hashCode => Object.hash( + runtimeType, + id, + title, + status, + detail, + identityHashCode(child), + ); + + @override + String toString() => + 'PlaygroundActivityItem(id: $id, title: $title, status: $status, detail: $detail, child: $child)'; +} diff --git a/apps/playground/lib/ui/models/plan_item.dart b/apps/playground/lib/ui/models/plan_item.dart new file mode 100644 index 000000000..0bbb792da --- /dev/null +++ b/apps/playground/lib/ui/models/plan_item.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; + +import 'statuses.dart'; + +/// One row in an [PlaygroundPlan]. +@immutable +class PlaygroundPlanItem { + /// Creates a plan item. + const PlaygroundPlanItem({ + required this.id, + required this.title, + this.status = PlaygroundPlanItemStatus.pending, + this.detail, + }); + + /// Stable identity across list updates. + final String id; + + /// Visible title. + final String title; + + /// Current status. + final PlaygroundPlanItemStatus status; + + /// Optional compact metadata (elapsed time, percent, path). + final String? detail; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlaygroundPlanItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail; + + @override + int get hashCode => Object.hash(runtimeType, id, title, status, detail); + + @override + String toString() => + 'PlaygroundPlanItem(id: $id, title: $title, status: $status, detail: $detail)'; +} diff --git a/apps/playground/lib/ui/models/statuses.dart b/apps/playground/lib/ui/models/statuses.dart new file mode 100644 index 000000000..9dc49de9d --- /dev/null +++ b/apps/playground/lib/ui/models/statuses.dart @@ -0,0 +1,147 @@ +/// Status of a long-running turn or activity ledger. +enum PlaygroundRunStatus { + /// Work is in progress. Disclosures stay open. + working, + + /// Work finished. Disclosures may collapse. + complete, +} + +/// Status of a streamed answer. +enum PlaygroundAnswerStatus { + /// Tokens are still arriving. + streaming, + + /// The answer finished successfully. + complete, + + /// The answer failed. + error, +} + +/// Status of an in-transcript tool permission. +/// +/// This is a machine, not a boolean loading flag. Actions are offered only +/// while [pending]. +enum PlaygroundPermissionStatus { + /// Waiting for a human decision. + pending, + + /// A decision was submitted and is being recorded. + deciding, + + /// The host accepted this invocation. + allowed, + + /// The approved tool is executing. + running, + + /// The approved tool finished. + complete, + + /// The host refused this invocation. + denied, + + /// Permission or execution failed. + error, +} + +/// Status of a tool execution disclosure. +enum PlaygroundExecutionStatus { + /// Output is still arriving. + running, + + /// The tool finished successfully. + success, + + /// The tool failed. + error, + + /// The host or runtime cancelled the tool. + cancelled, +} + +/// Status of one item in a task plan. +enum PlaygroundPlanItemStatus { + /// Not started. + pending, + + /// Currently underway. + inProgress, + + /// Finished successfully. + completed, + + /// Abandoned or skipped. + cancelled, +} + +/// Status of one row in an activity ledger. +enum PlaygroundActivityItemStatus { + /// Not yet started. + pending, + + /// The current step. + active, + + /// Finished. + complete, +} + +/// Who authored a transcript row. +enum PlaygroundRole { + /// The human operator. + user, + + /// The assistant replying to the operator. + assistant, +} + +/// Whether a permission or execution is still occupying the operator. +extension PlaygroundPermissionStatusX on PlaygroundPermissionStatus { + /// True until a terminal outcome. [pending] is working (HITL in flight) + /// but does not keep parameter details open. + bool get isWorking => !isSettled; + + /// True after a terminal decision or outcome. + bool get isSettled => + this == PlaygroundPermissionStatus.complete || + this == PlaygroundPermissionStatus.denied || + this == PlaygroundPermissionStatus.error; + + /// True while parameter details stay open without a user toggle. + /// Pending starts closed. + bool get keepsDetailsOpen => + this == PlaygroundPermissionStatus.deciding || + this == PlaygroundPermissionStatus.allowed || + this == PlaygroundPermissionStatus.running; +} + +/// Working vs settled for an execution disclosure. +extension PlaygroundExecutionStatusX on PlaygroundExecutionStatus { + /// True while output should stay expanded. + bool get isWorking => this == PlaygroundExecutionStatus.running; + + /// True after a terminal outcome. + bool get isSettled => !isWorking; +} + +/// Working vs settled for a streamed answer. +extension PlaygroundAnswerStatusX on PlaygroundAnswerStatus { + /// True while tokens are still arriving. + bool get isStreaming => this == PlaygroundAnswerStatus.streaming; + + /// True when completion actions may appear. + bool get showsActions => + this == PlaygroundAnswerStatus.complete || + this == PlaygroundAnswerStatus.error; +} + +/// Working vs settled for a plan item. +extension PlaygroundPlanItemStatusX on PlaygroundPlanItemStatus { + bool get isActive => this == PlaygroundPlanItemStatus.inProgress; + + bool get isDone => + this == PlaygroundPlanItemStatus.completed || + this == PlaygroundPlanItemStatus.cancelled; +} diff --git a/apps/playground/lib/ui/recipes/activity_recipe.dart b/apps/playground/lib/ui/recipes/activity_recipe.dart new file mode 100644 index 000000000..90896396c --- /dev/null +++ b/apps/playground/lib/ui/recipes/activity_recipe.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/activity.dart'; +import '../components/disclosure.dart'; +import '../theme/tokens.dart'; + +@immutable +final class PlaygroundAgentActivityRecipe { + const PlaygroundAgentActivityRecipe({ + required this.style, + required this.disclosureStyle, + }); + final PlaygroundActivityStyler style; + final DisclosureStyler disclosureStyle; +} + +PlaygroundAgentActivityRecipe playgroundAgentActivityRecipe({ + PlaygroundActivityStyler style = const PlaygroundActivityStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => PlaygroundAgentActivityRecipe( + style: PlaygroundActivityStyler( + viewport: BoxStyler().maxHeight(200), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color(PlaygroundTokens.foreground()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color(PlaygroundTokens.foreground()).fontSize(14), + itemDetail: TextStyler() + .color(PlaygroundTokens.mutedForeground()) + .fontSize(12), + count: TextStyler() + .color(PlaygroundTokens.mutedForeground()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color(PlaygroundTokens.foreground()).size(16), + pendingStatus: IconStyler() + .color(PlaygroundTokens.mutedForeground()) + .size(12), + activeStatus: IconStyler().color(PlaygroundTokens.primary()).size(12), + completedStatus: IconStyler().color(PlaygroundTokens.primary()).size(12), + ).merge(style), + disclosureStyle: playgroundDisclosureStyle( + style: DisclosureStyler() + .content(BoxStyler().padding(.all(0))) + .merge(disclosureStyle), + ), +); diff --git a/apps/playground/lib/ui/recipes/answer_recipe.dart b/apps/playground/lib/ui/recipes/answer_recipe.dart new file mode 100644 index 000000000..2aafe440b --- /dev/null +++ b/apps/playground/lib/ui/recipes/answer_recipe.dart @@ -0,0 +1,54 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/answer.dart'; +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/icon_button.dart'; +import '../theme/tokens.dart'; + +@immutable +final class PlaygroundAgentAnswerRecipe { + const PlaygroundAgentAnswerRecipe({ + required this.style, + required this.surfaceStyle, + required this.sourcesStyle, + required this.copyStyle, + required this.retryStyle, + }); + final PlaygroundAnswerStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +PlaygroundAgentAnswerRecipe playgroundAgentAnswerRecipe({ + PlaygroundAnswerStyler style = const PlaygroundAnswerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler sourcesStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => PlaygroundAgentAnswerRecipe( + style: PlaygroundAnswerStyler( + body: BoxStyler(), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + feedback: BoxStyler().padding(.only(top: 6)), + sourcesLabel: TextStyler() + .color(PlaygroundTokens.foreground()) + .fontSize(13), + indicator: IconStyler().color(PlaygroundTokens.foreground()).size(16), + ).merge(style), + surfaceStyle: playgroundCardStyle(style: surfaceStyle), + sourcesStyle: playgroundDisclosureStyle(style: sourcesStyle), + copyStyle: playgroundIconButtonStyle( + variant: .ghost, + size: .small, + style: copyStyle, + ), + retryStyle: playgroundIconButtonStyle( + variant: .ghost, + size: .small, + style: retryStyle, + ), +); diff --git a/apps/playground/lib/ui/recipes/composer_recipe.dart b/apps/playground/lib/ui/recipes/composer_recipe.dart new file mode 100644 index 000000000..278bc7fb2 --- /dev/null +++ b/apps/playground/lib/ui/recipes/composer_recipe.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/card.dart'; +import '../components/composer.dart'; +import '../components/icon_button.dart'; +import '../components/textfield.dart'; + +@immutable +final class PlaygroundAgentComposerRecipe { + const PlaygroundAgentComposerRecipe({ + required this.style, + required this.surfaceStyle, + required this.fieldStyle, + required this.submitStyle, + required this.stopStyle, + }); + final PlaygroundComposerStyler style; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; +} + +PlaygroundAgentComposerRecipe playgroundAgentComposerRecipe({ + PlaygroundComposerStyler style = const PlaygroundComposerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + TextFieldStyler fieldStyle = const TextFieldStyler.create(), + IconButtonStyler submitStyle = const IconButtonStyler.create(), + IconButtonStyler stopStyle = const IconButtonStyler.create(), +}) => PlaygroundAgentComposerRecipe( + style: PlaygroundComposerStyler( + toolbar: FlexBoxStyler() + .direction(.horizontal) + .mainAxisSize(.max) + .crossAxisAlignment(.center) + .spacing(8) + .padding(.only(top: 8)), + ).merge(style), + surfaceStyle: playgroundCardStyle( + style: CardStyler().padding(.all(12)).merge(surfaceStyle), + ), + fieldStyle: playgroundTextAreaStyle( + style: TextFieldStyler() + .color(const Color(0x00000000)) + .border(.style(.none)) + .minHeight(56) + .padding(.all(4)) + .merge(fieldStyle), + ), + submitStyle: playgroundIconButtonStyle( + size: .small, + style: IconButtonStyler().size(48, 48).merge(submitStyle), + ), + stopStyle: playgroundIconButtonStyle( + variant: .destructive, + size: .small, + style: IconButtonStyler().size(48, 48).merge(stopStyle), + ), +); diff --git a/apps/playground/lib/ui/recipes/execution_recipe.dart b/apps/playground/lib/ui/recipes/execution_recipe.dart new file mode 100644 index 000000000..351de8cdb --- /dev/null +++ b/apps/playground/lib/ui/recipes/execution_recipe.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/execution.dart'; +import '../components/icon_button.dart'; +import '../theme/tokens.dart'; + +@immutable +final class PlaygroundAgentExecutionRecipe { + const PlaygroundAgentExecutionRecipe({ + required this.style, + required this.surfaceStyle, + required this.disclosureStyle, + required this.copyStyle, + required this.retryStyle, + }); + final PlaygroundExecutionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +PlaygroundAgentExecutionRecipe playgroundAgentExecutionRecipe({ + PlaygroundExecutionStyler style = const PlaygroundExecutionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => PlaygroundAgentExecutionRecipe( + style: PlaygroundExecutionStyler( + header: FlexBoxStyler().spacing(8), + output: BoxStyler() + .color(PlaygroundTokens.muted()) + .borderRadius(.circular(6)) + .padding(.all(12)), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + tool: TextStyler().color(PlaygroundTokens.mutedForeground()).fontSize(12), + title: TextStyler() + .color(PlaygroundTokens.foreground()) + .fontWeight(FontWeight.w600), + meta: TextStyler().color(PlaygroundTokens.mutedForeground()).fontSize(12), + status: TextStyler().color(PlaygroundTokens.mutedForeground()).fontSize(12), + toolIcon: IconStyler().color(PlaygroundTokens.foreground()).size(16), + statusIcon: IconStyler().color(PlaygroundTokens.primary()).size(12), + indicator: IconStyler().color(PlaygroundTokens.foreground()).size(16), + ).merge(style), + surfaceStyle: playgroundCardStyle(style: surfaceStyle), + disclosureStyle: playgroundDisclosureStyle(style: disclosureStyle), + copyStyle: playgroundIconButtonStyle( + variant: .ghost, + size: .small, + style: copyStyle, + ), + retryStyle: playgroundIconButtonStyle( + variant: .ghost, + size: .small, + style: retryStyle, + ), +); diff --git a/apps/playground/lib/ui/recipes/message_recipe.dart b/apps/playground/lib/ui/recipes/message_recipe.dart new file mode 100644 index 000000000..b21452634 --- /dev/null +++ b/apps/playground/lib/ui/recipes/message_recipe.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/message.dart'; + +@immutable +final class PlaygroundAgentMessageRecipe { + const PlaygroundAgentMessageRecipe({ + required this.style, + required this.surfaceStyle, + required this.collapsibleStyle, + required this.toggleStyle, + }); + final PlaygroundMessageStyler style; + final CardStyler surfaceStyle; + final PlaygroundMessageCollapsibleStyler collapsibleStyle; + final ButtonStyler toggleStyle; +} + +PlaygroundAgentMessageRecipe playgroundAgentMessageRecipe({ + PlaygroundMessageStyler style = const PlaygroundMessageStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + PlaygroundMessageCollapsibleStyler collapsibleStyle = + const PlaygroundMessageCollapsibleStyler.create(), + ButtonStyler toggleStyle = const ButtonStyler.create(), +}) => PlaygroundAgentMessageRecipe( + style: PlaygroundMessageStyler( + row: FlexBoxStyler().mainAxisSize(.max).spacing(8), + avatar: BoxStyler().size(28, 28), + header: BoxStyler().padding(.only(bottom: 6)), + body: BoxStyler(), + footer: BoxStyler().padding(.only(top: 4)), + maxWidth: 640, + ).merge(style), + surfaceStyle: playgroundCardStyle(style: surfaceStyle), + collapsibleStyle: PlaygroundMessageCollapsibleStyler( + collapsedHeight: 72, + container: BoxStyler(), + clipped: BoxStyler(), + ).merge(collapsibleStyle), + toggleStyle: playgroundButtonStyle( + variant: .ghost, + size: .small, + style: toggleStyle, + ), +); diff --git a/apps/playground/lib/ui/recipes/permission_recipe.dart b/apps/playground/lib/ui/recipes/permission_recipe.dart new file mode 100644 index 000000000..a93148bf6 --- /dev/null +++ b/apps/playground/lib/ui/recipes/permission_recipe.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/data_list.dart'; +import '../components/disclosure.dart'; +import '../components/permission.dart'; +import '../theme/tokens.dart'; + +@immutable +final class PlaygroundAgentPermissionRecipe { + const PlaygroundAgentPermissionRecipe({ + required this.style, + required this.surfaceStyle, + required this.detailsStyle, + required this.parametersStyle, + required this.allowOnceStyle, + required this.alwaysAllowStyle, + required this.denyStyle, + }); + final PlaygroundPermissionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; +} + +PlaygroundAgentPermissionRecipe playgroundAgentPermissionRecipe({ + PlaygroundPermissionStyler style = const PlaygroundPermissionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler detailsStyle = const DisclosureStyler.create(), + DataListStyler parametersStyle = const DataListStyler.create(), + ButtonStyler allowOnceStyle = const ButtonStyler.create(), + ButtonStyler alwaysAllowStyle = const ButtonStyler.create(), + ButtonStyler denyStyle = const ButtonStyler.create(), +}) => PlaygroundAgentPermissionRecipe( + style: PlaygroundPermissionStyler( + header: FlexBoxStyler().spacing(8), + actions: FlexBoxStyler().spacing(8).padding(.only(top: 8)), + title: TextStyler() + .color(PlaygroundTokens.foreground()) + .fontWeight(FontWeight.w600), + tool: TextStyler().color(PlaygroundTokens.mutedForeground()).fontSize(12), + description: TextStyler() + .color(PlaygroundTokens.mutedForeground()) + .wrap(.padding(.symmetric(vertical: 8))), + status: TextStyler().color(PlaygroundTokens.mutedForeground()).fontSize(12), + detailsLabel: TextStyler() + .color(PlaygroundTokens.foreground()) + .fontSize(13), + toolIcon: IconStyler().color(PlaygroundTokens.foreground()).size(16), + statusIcon: IconStyler().color(PlaygroundTokens.primary()).size(12), + indicator: IconStyler().color(PlaygroundTokens.foreground()).size(16), + ).merge(style), + surfaceStyle: playgroundCardStyle(style: surfaceStyle), + detailsStyle: playgroundDisclosureStyle(style: detailsStyle), + parametersStyle: playgroundDataListStyle(style: parametersStyle), + allowOnceStyle: playgroundButtonStyle(style: allowOnceStyle), + alwaysAllowStyle: playgroundButtonStyle( + variant: .outline, + style: alwaysAllowStyle, + ), + denyStyle: playgroundButtonStyle(variant: .ghost, style: denyStyle), +); diff --git a/apps/playground/lib/ui/recipes/plan_recipe.dart b/apps/playground/lib/ui/recipes/plan_recipe.dart new file mode 100644 index 000000000..d9c4a2066 --- /dev/null +++ b/apps/playground/lib/ui/recipes/plan_recipe.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/disclosure.dart'; +import '../components/plan.dart'; +import '../theme/tokens.dart'; + +@immutable +final class PlaygroundAgentPlanRecipe { + const PlaygroundAgentPlanRecipe({ + required this.style, + required this.disclosureStyle, + }); + final PlaygroundPlanStyler style; + final DisclosureStyler disclosureStyle; +} + +PlaygroundAgentPlanRecipe playgroundAgentPlanRecipe({ + PlaygroundPlanStyler style = const PlaygroundPlanStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => PlaygroundAgentPlanRecipe( + style: PlaygroundPlanStyler( + viewport: BoxStyler().maxHeight(220), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color(PlaygroundTokens.foreground()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color(PlaygroundTokens.foreground()).fontSize(14), + itemDetail: TextStyler() + .color(PlaygroundTokens.mutedForeground()) + .fontSize(12), + count: TextStyler() + .color(PlaygroundTokens.mutedForeground()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color(PlaygroundTokens.foreground()).size(16), + pendingStatus: IconStyler() + .color(PlaygroundTokens.mutedForeground()) + .size(18), + activeStatus: IconStyler().color(PlaygroundTokens.primary()).size(18), + completedStatus: IconStyler().color(PlaygroundTokens.primary()).size(18), + cancelledStatus: IconStyler() + .color(PlaygroundTokens.mutedForeground()) + .size(18), + ).merge(style), + disclosureStyle: playgroundDisclosureStyle(style: disclosureStyle), +); diff --git a/apps/playground/lib/ui/recipes/transcript_recipe.dart b/apps/playground/lib/ui/recipes/transcript_recipe.dart new file mode 100644 index 000000000..cd45c6101 --- /dev/null +++ b/apps/playground/lib/ui/recipes/transcript_recipe.dart @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/transcript.dart'; + +@immutable +final class PlaygroundAgentTranscriptRecipe { + const PlaygroundAgentTranscriptRecipe({required this.style}); + final PlaygroundTranscriptStyler style; +} + +PlaygroundAgentTranscriptRecipe playgroundAgentTranscriptRecipe({ + PlaygroundTranscriptStyler style = const PlaygroundTranscriptStyler.create(), +}) => PlaygroundAgentTranscriptRecipe( + style: PlaygroundTranscriptStyler( + viewport: BoxStyler().padding(.only(right: 12)), + item: BoxStyler(), + spacing: 16, + ).merge(style), +); diff --git a/apps/playground/lib/ui/support/disclosure.dart b/apps/playground/lib/ui/support/disclosure.dart new file mode 100644 index 000000000..37cd300f5 --- /dev/null +++ b/apps/playground/lib/ui/support/disclosure.dart @@ -0,0 +1,29 @@ +/// Controlled/uncontrolled storage shared by collapsible surfaces. +/// +/// Widgets own lifecycle policy, rebuilding, and request callbacks. In +/// particular, a request that does not change storage may still notify a host. +class PlaygroundDisclosureEngine { + PlaygroundDisclosureEngine({required bool? value, required bool defaultValue}) + : _controlled = value, + _uncontrolled = value ?? defaultValue; + + bool? _controlled; + bool _uncontrolled; + + bool get value => _controlled ?? _uncontrolled; + + /// Adopt the last controlled value when the host releases control. + void reconcile(bool? value) { + if (_controlled != null && value == null) { + _uncontrolled = _controlled!; + } + _controlled = value; + } + + /// Returns whether local storage changed and the widget needs a rebuild. + bool request(bool next) { + if (_controlled != null || next == _uncontrolled) return false; + _uncontrolled = next; + return true; + } +} diff --git a/apps/playground/lib/ui/support/functional_glyph.dart b/apps/playground/lib/ui/support/functional_glyph.dart new file mode 100644 index 000000000..9c019819d --- /dev/null +++ b/apps/playground/lib/ui/support/functional_glyph.dart @@ -0,0 +1,182 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import 'package:remix_ui_icons/remix_ui_icons.dart'; + +abstract final class _Glyphs { + static const arrowUp = RemixIcons.arrowUp; + static const square = RemixIcons.square; + static const copy = RemixIcons.copy; + static const rotateCcw = RemixIcons.reload; + static const chevronUp = RemixIcons.chevronUp; + static const chevronDown = RemixIcons.chevronDown; + static const circle = RemixIcons.circle; + static const circleDot = RemixIcons.dotFilled; + static const check = RemixIcons.check; + static const x = RemixIcons.cross2; + static const circleAlert = RemixIcons.exclamationTriangle; + static const squareTerminal = RemixIcons.code; + static const loaderCircle = RemixIcons.update; + static const circleCheck = RemixIcons.checkCircled; + static const ban = RemixIcons.circleBackslash; + static const circleX = RemixIcons.crossCircled; + static const shieldCheck = RemixIcons.lockClosed; +} + +/// Builds the chevron that reports a collapsible surface's state. +/// +/// Every collapsible Playground surface offers the host the same escape hatch — a +/// builder that replaces the glyph outright — over the same default. Each takes +/// that builder under its own name, because a permission card discloses +/// *details* and an answer discloses *sources*, so the shared part is this +/// body and not the parameter. +class PlaygroundDisclosureIndicator extends StatelessWidget { + const PlaygroundDisclosureIndicator({ + super.key, + required this.styleSpec, + required this.expanded, + this.builder, + }); + + final StyleSpec styleSpec; + final bool expanded; + final Widget Function(BuildContext context, bool expanded)? builder; + + @override + Widget build(BuildContext context) => + builder?.call(context, expanded) ?? + StyleSpecBuilder( + styleSpec: styleSpec, + builder: (context, iconSpec) => PlaygroundFunctionalGlyph( + kind: .chevron, + spec: iconSpec, + expanded: expanded, + ), + ); +} + +/// Internal Material-free glyph set used by Playground's functional defaults. +/// +/// The types are intentionally not exported from the package barrel. Public +/// icon/status builders remain the replacement mechanism. +enum PlaygroundFunctionalGlyphKind { + send, + stop, + copy, + retry, + chevron, + pending, + active, + completed, + cancelled, + error, + tool, + loading, + completedCircle, + cancelledCircle, + errorCircle, + permission, +} + +class PlaygroundFunctionalGlyph extends StatelessWidget { + const PlaygroundFunctionalGlyph({ + super.key, + required this.kind, + required this.spec, + this.expanded = false, + }); + + final PlaygroundFunctionalGlyphKind kind; + final IconSpec spec; + final bool expanded; + + IconData get _icon => switch (kind) { + .send => _Glyphs.arrowUp, + .stop => _Glyphs.square, + .copy => _Glyphs.copy, + .retry => _Glyphs.rotateCcw, + .chevron => expanded ? _Glyphs.chevronUp : _Glyphs.chevronDown, + .pending => _Glyphs.circle, + .active => _Glyphs.circleDot, + .completed => _Glyphs.check, + .cancelled => _Glyphs.x, + .error => _Glyphs.circleAlert, + .tool => _Glyphs.squareTerminal, + .loading => _Glyphs.loaderCircle, + .completedCircle => _Glyphs.circleCheck, + .cancelledCircle => _Glyphs.ban, + .errorCircle => _Glyphs.circleX, + .permission => _Glyphs.shieldCheck, + }; + + @override + Widget build(BuildContext context) { + final theme = IconTheme.of(context); + final opacity = spec.opacity ?? theme.opacity; + final baseColor = spec.color ?? theme.color; + final color = opacity == null || baseColor == null + ? baseColor + : baseColor.withValues(alpha: baseColor.a * opacity.clamp(0, 1)); + + final icon = Icon( + _icon, + size: spec.size ?? theme.size, + fill: spec.fill ?? theme.fill, + weight: spec.weight ?? theme.weight, + grade: spec.grade ?? theme.grade, + opticalSize: spec.opticalSize ?? theme.opticalSize, + color: color, + shadows: spec.shadows ?? theme.shadows, + textDirection: spec.textDirection, + applyTextScaling: + spec.applyTextScaling ?? theme.applyTextScaling ?? false, + blendMode: spec.blendMode ?? BlendMode.srcOver, + ); + return ExcludeSemantics( + child: kind == PlaygroundFunctionalGlyphKind.loading + ? _LoadingGlyph(child: icon) + : icon, + ); + } +} + +/// Animate only indeterminate loading; status labels own the semantics. +class _LoadingGlyph extends StatefulWidget { + const _LoadingGlyph({required this.child}); + + final Widget child; + + @override + State<_LoadingGlyph> createState() => _LoadingGlyphState(); +} + +class _LoadingGlyphState extends State<_LoadingGlyph> + with SingleTickerProviderStateMixin { + late final _turns = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final animate = + !(MediaQuery.maybeOf(context)?.disableAnimations ?? false) && + TickerMode.valuesOf(context).enabled; + if (animate) { + if (!_turns.isAnimating) _turns.repeat(); + } else { + _turns.stop(); + _turns.value = 0; + } + } + + @override + Widget build(BuildContext context) => + RotationTransition(turns: _turns, child: widget.child); + + @override + void dispose() { + _turns.dispose(); + super.dispose(); + } +} diff --git a/apps/playground/lib/ui/support/live_edge.dart b/apps/playground/lib/ui/support/live_edge.dart new file mode 100644 index 000000000..a5a3b977c --- /dev/null +++ b/apps/playground/lib/ui/support/live_edge.dart @@ -0,0 +1,144 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Shared private-package live-edge state machine. +class PlaygroundLiveEdgeEngine { + PlaygroundLiveEdgeEngine({ + required this._enabled, + required this.threshold, + this.onChanged, + }); + + bool _enabled; + bool get enabled => _enabled; + + set enabled(bool value) { + // An explicit false-to-true transition is the host's resume action. + // Ordinary rebuilds with follow enabled must preserve a reader's release. + if (value && !_enabled) _following = true; + _enabled = value; + } + + double threshold; + ValueChanged? onChanged; + bool _following = true; + bool _programmatic = false; + + bool get following => _following; + + void handleScroll( + ScrollNotification notification, + ScrollController controller, + ) { + if (_programmatic || !controller.hasClients) return; + final fromDrag = + notification is ScrollUpdateNotification && + notification.dragDetails != null; + final fromUserDirection = + notification is UserScrollNotification && + notification.direction != ScrollDirection.idle; + if (fromDrag || fromUserDirection) handlePosition(controller.position); + } + + void handlePosition(ScrollPosition position) { + final distance = position.maxScrollExtent - position.pixels; + _setFollowing(distance <= threshold); + } + + void follow(ScrollController controller) { + if (!enabled || !following || !controller.hasClients) return; + final position = controller.position; + if (!position.hasContentDimensions) return; + _programmatic = true; + position.jumpTo(position.maxScrollExtent); + _programmatic = false; + } + + void _setFollowing(bool next) { + if (following == next) return; + _following = next; + onChanged?.call(next); + } +} + +/// Small non-lazy scroll view used by plan and activity ledgers. +class PlaygroundLiveEdgeScrollView extends StatefulWidget { + const PlaygroundLiveEdgeScrollView({ + super.key, + required this.child, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + }); + + final Widget child; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + + @override + State createState() => + _PlaygroundLiveEdgeScrollViewState(); +} + +class _PlaygroundLiveEdgeScrollViewState + extends State { + late final ScrollController _controller; + late final PlaygroundLiveEdgeEngine _liveEdge; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + _liveEdge = PlaygroundLiveEdgeEngine( + enabled: widget.followOutput, + threshold: widget.followThreshold, + onChanged: widget.onFollowChanged, + ); + _scheduleFollow(); + } + + @override + void didUpdateWidget(PlaygroundLiveEdgeScrollView oldWidget) { + super.didUpdateWidget(oldWidget); + _liveEdge + ..enabled = widget.followOutput + ..threshold = widget.followThreshold + ..onChanged = widget.onFollowChanged; + _scheduleFollow(); + } + + void _scheduleFollow() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _liveEdge.follow(_controller); + }); + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && _liveEdge.following) _scheduleFollow(); + return false; + }, + child: NotificationListener( + onNotification: (notification) { + if (notification.depth == 0) { + _liveEdge.handleScroll(notification, _controller); + } + return false; + }, + child: SingleChildScrollView( + controller: _controller, + child: widget.child, + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/apps/playground/lib/ui/theme/tokens.dart b/apps/playground/lib/ui/theme/tokens.dart index 8d044f232..d54653ea1 100644 --- a/apps/playground/lib/ui/theme/tokens.dart +++ b/apps/playground/lib/ui/theme/tokens.dart @@ -13,27 +13,29 @@ import 'package:remix/remix.dart'; /// ``` abstract final class PlaygroundTokens { /// Page background the application paints behind its content. - static const background = ColorToken('ui.color.background'); + static const background = ColorToken('playground.color.background'); /// Default content color used on top of [background]. - static const foreground = ColorToken('ui.color.foreground'); + static const foreground = ColorToken('playground.color.foreground'); /// Highest-emphasis fill. - static const primary = ColorToken('ui.color.primary'); + static const primary = ColorToken('playground.color.primary'); /// Content color used on top of [primary]. - static const primaryForeground = ColorToken('ui.color.primary-foreground'); + static const primaryForeground = ColorToken( + 'playground.color.primary-foreground', + ); /// Medium-emphasis fill. - static const secondary = ColorToken('ui.color.secondary'); + static const secondary = ColorToken('playground.color.secondary'); /// Content color used on top of [secondary]. static const secondaryForeground = ColorToken( - 'ui.color.secondary-foreground', + 'playground.color.secondary-foreground', ); /// De-emphasized surface. - static const muted = ColorToken('ui.color.muted'); + static const muted = ColorToken('playground.color.muted'); /// De-emphasized content color. /// @@ -42,24 +44,28 @@ abstract final class PlaygroundTokens { /// it for text on [background], and for glyphs and other non-text marks /// anywhere; text that lands on a `muted` surface takes [foreground]. Raise /// this value here and that restriction goes away everywhere at once. - static const mutedForeground = ColorToken('ui.color.muted-foreground'); + static const mutedForeground = ColorToken( + 'playground.color.muted-foreground', + ); /// Interaction surface for otherwise transparent controls. - static const accent = ColorToken('ui.color.accent'); + static const accent = ColorToken('playground.color.accent'); /// Content color used on top of [accent]. - static const accentForeground = ColorToken('ui.color.accent-foreground'); + static const accentForeground = ColorToken( + 'playground.color.accent-foreground', + ); /// Destructive fill for irreversible actions. - static const destructive = ColorToken('ui.color.destructive'); + static const destructive = ColorToken('playground.color.destructive'); /// Content color used on top of [destructive]. static const destructiveForeground = ColorToken( - 'ui.color.destructive-foreground', + 'playground.color.destructive-foreground', ); /// Hairline separator and control outline color. - static const border = ColorToken('ui.color.border'); + static const border = ColorToken('playground.color.border'); /// Focus ring color drawn for keyboard focus. /// @@ -68,7 +74,7 @@ abstract final class PlaygroundTokens { /// talking rather than the brand, and it clears the 3:1 non-text floor on /// both pages. Give it a brand color here and every control's focus ring /// follows; nothing else reads this token. - static const focusRing = ColorToken('ui.color.focus-ring'); + static const focusRing = ColorToken('playground.color.focus-ring'); /// First categorical chart series color. /// @@ -76,22 +82,22 @@ abstract final class PlaygroundTokens { /// The shipped themes keep one hue per series in both brightnesses, and /// every value clears 4.5:1 against [background]. That also keeps pie labels, /// which are drawn in [background], readable on their slice. - static const chart1 = ColorToken('ui.color.chart-1'); + static const chart1 = ColorToken('playground.color.chart-1'); /// Second categorical chart series color. See [chart1]. - static const chart2 = ColorToken('ui.color.chart-2'); + static const chart2 = ColorToken('playground.color.chart-2'); /// Third categorical chart series color. See [chart1]. - static const chart3 = ColorToken('ui.color.chart-3'); + static const chart3 = ColorToken('playground.color.chart-3'); /// Fourth categorical chart series color. See [chart1]. - static const chart4 = ColorToken('ui.color.chart-4'); + static const chart4 = ColorToken('playground.color.chart-4'); /// Fifth categorical chart series color. See [chart1]. - static const chart5 = ColorToken('ui.color.chart-5'); + static const chart5 = ColorToken('playground.color.chart-5'); /// Corner radius shared by the application's controls. - static const radius = RadiusToken('ui.radius'); + static const radius = RadiusToken('playground.radius'); /// The chart series colors in the order charts assign them. static const chart = [chart1, chart2, chart3, chart4, chart5]; diff --git a/apps/playground/lib/ui/ui.dart b/apps/playground/lib/ui/ui.dart index 26f0a4da1..4032e7a93 100644 --- a/apps/playground/lib/ui/ui.dart +++ b/apps/playground/lib/ui/ui.dart @@ -2,6 +2,8 @@ library; // remix_cli:exports:start export 'components/accordion.dart'; +export 'components/activity.dart'; +export 'components/answer.dart'; export 'components/avatar.dart'; export 'components/badge.dart'; export 'components/button.dart'; @@ -9,14 +11,19 @@ export 'components/callout.dart'; export 'components/card.dart'; export 'components/chart.dart'; export 'components/checkbox.dart'; +export 'components/composer.dart'; export 'components/data_list.dart'; export 'components/data_table.dart'; export 'components/dialog.dart'; export 'components/disclosure.dart'; export 'components/divider.dart'; +export 'components/execution.dart'; export 'components/icon_button.dart'; export 'components/link.dart'; export 'components/menu.dart'; +export 'components/message.dart'; +export 'components/permission.dart'; +export 'components/plan.dart'; export 'components/popover.dart'; export 'components/progress.dart'; export 'components/radio.dart'; @@ -34,7 +41,19 @@ export 'components/toast.dart'; export 'components/toggle.dart'; export 'components/toggle_group.dart'; export 'components/tooltip.dart'; +export 'components/transcript.dart'; export 'icons.dart'; +export 'models/activity_item.dart'; +export 'models/plan_item.dart'; +export 'models/statuses.dart'; +export 'recipes/activity_recipe.dart'; +export 'recipes/answer_recipe.dart'; +export 'recipes/composer_recipe.dart'; +export 'recipes/execution_recipe.dart'; +export 'recipes/message_recipe.dart'; +export 'recipes/permission_recipe.dart'; +export 'recipes/plan_recipe.dart'; +export 'recipes/transcript_recipe.dart'; export 'theme/theme_data.dart'; export 'theme/theme_scope.dart'; export 'theme/tokens.dart'; diff --git a/apps/playground/pubspec.yaml b/apps/playground/pubspec.yaml index 8f75190a7..f7ae72bf9 100644 --- a/apps/playground/pubspec.yaml +++ b/apps/playground/pubspec.yaml @@ -15,7 +15,6 @@ dependencies: # Workspace resolution uses the local packages during development; these # hosted constraints keep the app manifest deployable outside the workspace. remix: ^1.0.0-beta.10 - remix_fortal: ^1.0.0-beta.9 mix_annotations: ^2.2.0-beta.1 remix_ui_icons: ^0.1.0 mix_chart: ^0.0.1-beta.1 diff --git a/apps/playground/test/agent_entries_test.dart b/apps/playground/test/agent_entries_test.dart new file mode 100644 index 000000000..a6e21493e --- /dev/null +++ b/apps/playground/test/agent_entries_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/registry/component_registry.dart'; +import 'package:playground/ui/ui.dart'; + +void main() { + testWidgets('denial does not run a tool and retry requests permission', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp(home: Builder(builder: components['chat']!)), + ); + await tester.enterText( + find.descendant( + of: find.byType(PlaygroundComposer), + matching: find.byType(EditableText), + ), + 'Run checks', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await pumpChatLayout(tester); + await tester.tap(find.text('Deny')); + await pumpChatLayout(tester); + expect(find.byType(PlaygroundExecution), findsNothing); + expect(find.text('Permission denied. No command was run.'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Retry answer')); + await pumpChatLayout(tester); + expect(find.text('Allow once'), findsOneWidget); + await tester.tap(find.text('Always allow')); + await pumpChatLayout(tester); + await tester.tap(find.text('Finish')); + await pumpChatLayout(tester); + await tester.tap(find.bySemanticsLabel('Retry answer')); + await pumpChatLayout(tester); + expect(find.text('Finish'), findsOneWidget); + expect(find.byType(PlaygroundPermission), findsNothing); + await tester.tap(find.text('New chat')); + await pumpChatLayout(tester); + await tester.enterText( + find.descendant( + of: find.byType(PlaygroundComposer), + matching: find.byType(EditableText), + ), + 'Run checks again', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await pumpChatLayout(tester); + expect(find.text('Allow once'), findsOneWidget); + }); + + test('Agent surfaces and chat are discoverable', () { + expect( + components.keys, + containsAll({ + 'agent-activity', + 'agent-answer', + 'agent-composer', + 'agent-execution', + 'agent-message', + 'agent-permission', + 'agent-plan', + 'agent-transcript', + 'chat', + }), + ); + }); + + testWidgets('compact chat submits, permits, stops, and exposes retry', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: components['chat']!)), + ); + await tester.enterText( + find.descendant( + of: find.byType(PlaygroundComposer), + matching: find.byType(EditableText), + ), + 'Run checks', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await pumpChatLayout(tester); + expect(find.text('Allow once'), findsOneWidget); + await tester.tap(find.text('Allow once')); + await pumpChatLayout(tester); + expect(find.text('Simulate failure'), findsOneWidget); + await tester.tap(find.text('Simulate failure')); + await pumpChatLayout(tester); + expect(find.bySemanticsLabel('Retry answer'), findsOneWidget); + }); +} + +Future pumpChatLayout(WidgetTester tester) async { + // Lay out lazy transcript children, then apply the post-frame live-edge scroll. + // Running tool animations intentionally never settle. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(); +} diff --git a/apps/playground/test/sidebar_layout_test.dart b/apps/playground/test/sidebar_layout_test.dart new file mode 100644 index 000000000..f7a73ac02 --- /dev/null +++ b/apps/playground/test/sidebar_layout_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/ui/ui.dart'; + +/// Covers the compact behavior of the application-owned sidebar layout. +/// +/// This is the only default-preset component that opens a route, owns a +/// breakpoint, and has a controlled/uncontrolled contract, so it is the one +/// whose behavior an analyzer cannot vouch for. Nothing outside `lib/ui/` +/// imports the installed mirror, so before this it was never built. +/// The compact sheet's own barrier, matched by the label the layout gives it. +/// +/// `ModalBarrier` alone will not do: the host route MaterialApp pushes for +/// `home` always has one, so a bare type finder reports a sheet that is not +/// there. +final _sheetBarrier = find.byWidgetPredicate( + (widget) => + widget is ModalBarrier && widget.semanticsLabel == 'Close navigation', +); + +Future _pump( + WidgetTester tester, + Widget child, { + Size size = const Size(1000, 800), +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // MaterialApp, not WidgetsApp: the compact sheet is a pushed route, so this + // needs a Navigator and an Overlay above the layout. + await tester.pumpWidget( + MaterialApp( + home: PlaygroundThemeScope( + data: const PlaygroundThemeData.light(), + child: child, + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('a wide layout shows the panel beside the body', (tester) async { + await _pump( + tester, + const PlaygroundSidebarLayout( + sidebar: Text('nav'), + body: Text('content'), + ), + ); + + expect(find.text('nav'), findsOneWidget); + expect(find.text('content'), findsOneWidget); + // The panel is in the row, so nothing was pushed over the body. + expect(_sheetBarrier, findsNothing); + }); + + testWidgets('a compact layout drops the panel until it is opened', ( + tester, + ) async { + await _pump( + tester, + const PlaygroundSidebarLayout( + sidebar: Text('nav'), + body: Text('content'), + ), + // Below the 720 logical-pixel default breakpoint. + size: const Size(500, 800), + ); + + expect(find.text('content'), findsOneWidget); + expect(find.text('nav'), findsNothing); + }); + + testWidgets('a controlled compact sheet opens and closes with its host', ( + tester, + ) async { + Widget build({required bool open}) => PlaygroundSidebarLayout( + compactOpen: open, + sidebar: const Text('nav'), + body: const Text('content'), + ); + + await _pump(tester, build(open: false), size: const Size(500, 800)); + expect(find.text('nav'), findsNothing); + + await tester.pumpWidget( + MaterialApp( + home: PlaygroundThemeScope( + data: const PlaygroundThemeData.light(), + child: build(open: true), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('nav'), findsOneWidget); + expect(_sheetBarrier, findsOneWidget); + + await tester.pumpWidget( + MaterialApp( + home: PlaygroundThemeScope( + data: const PlaygroundThemeData.light(), + child: build(open: false), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('nav'), findsNothing); + expect(_sheetBarrier, findsNothing); + }); + + testWidgets('growing past the breakpoint retires the open sheet', ( + tester, + ) async { + final requests = []; + Widget build() => PlaygroundSidebarLayout( + compactOpen: true, + onCompactOpenChanged: requests.add, + sidebar: const Text('nav'), + body: const Text('content'), + ); + + await _pump(tester, build(), size: const Size(500, 800)); + expect(find.text('nav'), findsOneWidget); + requests.clear(); + + tester.view.physicalSize = const Size(1000, 800); + await tester.pumpAndSettle(); + + // The panel is back in the row and not also left behind in a sheet, and the + // layout told its host the open state it is still being handed is stale. + expect(find.text('nav'), findsOneWidget); + expect(_sheetBarrier, findsNothing); + expect(requests, contains(false)); + }); +} diff --git a/apps/playground/test/theme_test.dart b/apps/playground/test/theme_test.dart new file mode 100644 index 000000000..1667b544d --- /dev/null +++ b/apps/playground/test/theme_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/ui/ui.dart'; +import 'package:remix/remix.dart'; + +/// Covers the application-owned theme this app installs from the default +/// registry preset. +/// +/// The previews render the installed components, but nothing renders the +/// theme values themselves. `dart analyze` proves the theme compiles and +/// `tool/check_open_code_dogfood.dart` proves it still matches the templates, +/// but neither one runs it. +void main() { + test('every declared token has a value in both brightnesses', () { + for (final data in const [ + PlaygroundThemeData.light(), + PlaygroundThemeData.dark(), + ]) { + final tokens = data.tokens; + + // Deliberately no token count here. `open_code/fixture` owns that pin for + // a freshly generated consumer; duplicating the number would mean two + // places to edit for one added token. + expect(tokens.keys.toSet(), >{ + ...PlaygroundTokens.colors, + PlaygroundTokens.radius, + }); + for (final token in PlaygroundTokens.colors) { + expect(tokens[token], isA(), reason: token.name); + } + expect(tokens[PlaygroundTokens.radius], data.radius); + } + }); + + test('the declared indigo customization is still here', () { + // `tool/check_open_code_dogfood.dart` records this app's indigo primary and + // matching focus ring as a deliberate edit, and it does fail if the theme + // ever matches the template again, so a plain reinstall is already caught. + // What it cannot see is *which* edit: any custom color satisfies it. These + // are the values themselves, and the tie between the ring and the primary. + const light = PlaygroundThemeData.light(); + + expect(light.primary, const Color(0xFF4F46E5)); + expect(light.focusRing, light.primary); + }); + + testWidgets('the scope resolves tokens for stylers below it', (tester) async { + late BuildContext inner; + await tester.pumpWidget( + PlaygroundThemeScope( + data: const PlaygroundThemeData.light(), + child: Builder( + builder: (context) { + inner = context; + return const SizedBox(); + }, + ), + ), + ); + + // Both halves of the scope, because the two are installed together and a + // styler that resolves below it reads the Mix side, not the inherited one. + expect( + MixScope.tokenOf(PlaygroundTokens.primary, inner), + const Color(0xFF4F46E5), + ); + expect(PlaygroundTheme.of(inner).primary, const Color(0xFF4F46E5)); + }); +} diff --git a/docs/components/accordion.mdx b/docs/components/accordion.mdx index 524597e3e..dc7606fda 100644 --- a/docs/components/accordion.mdx +++ b/docs/components/accordion.mdx @@ -200,7 +200,7 @@ class _FortalAccordionExampleState extends State { - See the [fortalAccordionStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/accordion.dart) for all available options. + See the [fortalAccordionStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/accordion.dart) for all available options. ## Constructor diff --git a/docs/components/avatar.mdx b/docs/components/avatar.mdx index 9d001236c..46c182390 100644 --- a/docs/components/avatar.mdx +++ b/docs/components/avatar.mdx @@ -111,7 +111,7 @@ class FortalAvatarExample extends StatelessWidget { Set `fallbackLength: 2` for two-character labels so the generated widget uses - the pinned two-initial typography. See the [fortalAvatarStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/avatar.dart) for all available options. + the pinned two-initial typography. See the [fortalAvatarStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/avatar.dart) for all available options. ## Constructor diff --git a/docs/components/badge.mdx b/docs/components/badge.mdx index 97b986af5..44daf954c 100644 --- a/docs/components/badge.mdx +++ b/docs/components/badge.mdx @@ -111,7 +111,7 @@ class FortalBadgeExample extends StatelessWidget { - See the [fortalBadgeStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/badge.dart) for all available options. + See the [fortalBadgeStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/badge.dart) for all available options. diff --git a/docs/components/button.mdx b/docs/components/button.mdx index 950b0efec..d16669192 100644 --- a/docs/components/button.mdx +++ b/docs/components/button.mdx @@ -149,7 +149,7 @@ class FortalButtonExample extends StatelessWidget { - See the [fortalButtonStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/button.dart) for all available options. + See the [fortalButtonStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/button.dart) for all available options. ### Fortal button icon sizing @@ -169,7 +169,7 @@ depending on `IconTheme.size` for Fortal buttons should use an explicit override ```dart import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'ui/ui.dart'; class FortalButtonIconSizeExample extends StatelessWidget { const FortalButtonIconSizeExample({super.key}); diff --git a/docs/components/callout.mdx b/docs/components/callout.mdx index e39b97f6d..9ba10874a 100644 --- a/docs/components/callout.mdx +++ b/docs/components/callout.mdx @@ -94,7 +94,7 @@ class FortalCalloutExample extends StatelessWidget { - See the [fortalCalloutStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/callout.dart) for all available options. + See the [fortalCalloutStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/callout.dart) for all available options. ## Constructor diff --git a/docs/components/card.mdx b/docs/components/card.mdx index bb977d099..00987ad99 100644 --- a/docs/components/card.mdx +++ b/docs/components/card.mdx @@ -74,7 +74,7 @@ class FortalCardExample extends StatelessWidget { - See the [fortalCardStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/card.dart) for all available options. + See the [fortalCardStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/card.dart) for all available options. ## Constructor diff --git a/docs/components/checkbox.mdx b/docs/components/checkbox.mdx index 020e97659..d07a9faf0 100644 --- a/docs/components/checkbox.mdx +++ b/docs/components/checkbox.mdx @@ -102,7 +102,7 @@ class _FortalCheckboxExampleState extends State { - See the [fortalCheckboxStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/checkbox.dart) for all available options. + See the [fortalCheckboxStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/checkbox.dart) for all available options. ## Constructor diff --git a/docs/components/checkbox_group.mdx b/docs/components/checkbox_group.mdx index e9355fb3f..1fd3865f7 100644 --- a/docs/components/checkbox_group.mdx +++ b/docs/components/checkbox_group.mdx @@ -305,7 +305,7 @@ class _FortalCheckboxGroupExampleState - See the [fortalCheckboxGroupItemStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/checkbox.dart) for all available options. + See the [fortalCheckboxGroupItemStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/checkbox.dart) for all available options. ## Controlled values diff --git a/docs/components/dialog.mdx b/docs/components/dialog.mdx index 8d160dc98..5de33823e 100644 --- a/docs/components/dialog.mdx +++ b/docs/components/dialog.mdx @@ -131,7 +131,7 @@ class FortalDialogExample extends StatelessWidget { - See the [fortalDialogStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/dialog.dart) for all available options. + See the [fortalDialogStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/dialog.dart) for all available options. ## Show function diff --git a/docs/components/divider.mdx b/docs/components/divider.mdx index 8366ad6a2..43f8e355f 100644 --- a/docs/components/divider.mdx +++ b/docs/components/divider.mdx @@ -67,7 +67,7 @@ class FortalDividerExample extends StatelessWidget { - See the [fortalDividerStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/divider.dart) for all available options. + See the [fortalDividerStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/divider.dart) for all available options. ## Constructor diff --git a/docs/components/icon_button.mdx b/docs/components/icon_button.mdx index af6c21f6a..168b6de27 100644 --- a/docs/components/icon_button.mdx +++ b/docs/components/icon_button.mdx @@ -125,7 +125,7 @@ class FortalIconButtonExample extends StatelessWidget { - See the [fortalIconButtonStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/icon_button.dart) for all available options. + See the [fortalIconButtonStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/icon_button.dart) for all available options. ## Constructor diff --git a/docs/components/link.mdx b/docs/components/link.mdx index 341b9e3e4..c9e0e965f 100644 --- a/docs/components/link.mdx +++ b/docs/components/link.mdx @@ -141,7 +141,7 @@ class FortalLinkExample extends StatelessWidget { - See the [fortalLinkStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/link.dart) for all available options. + See the [fortalLinkStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/link.dart) for all available options. ## Constructor diff --git a/docs/components/menu.mdx b/docs/components/menu.mdx index 3bebd6722..9052df4ef 100644 --- a/docs/components/menu.mdx +++ b/docs/components/menu.mdx @@ -398,7 +398,7 @@ customization, pass `fortalMenuStyle(...).merge(customStyle)` to - See the [fortalMenuStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/menu.dart) for all available options. + See the [fortalMenuStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/menu.dart) for all available options. ## Constructor diff --git a/docs/components/progress.mdx b/docs/components/progress.mdx index 6a907e46f..bf3ffded1 100644 --- a/docs/components/progress.mdx +++ b/docs/components/progress.mdx @@ -74,7 +74,7 @@ class FortalProgressExample extends StatelessWidget { - See the [fortalProgressStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/progress.dart) for all available options. + See the [fortalProgressStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/progress.dart) for all available options. ## Constructor diff --git a/docs/components/radio.mdx b/docs/components/radio.mdx index b2228f890..c3280d4fa 100644 --- a/docs/components/radio.mdx +++ b/docs/components/radio.mdx @@ -166,7 +166,7 @@ class _FortalRadioExampleState extends State { - See the [fortalRadioStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/radio.dart) for all available options. + See the [fortalRadioStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/radio.dart) for all available options. ## Constructor diff --git a/docs/components/select.mdx b/docs/components/select.mdx index 4fec96c76..0fe6a5a69 100644 --- a/docs/components/select.mdx +++ b/docs/components/select.mdx @@ -184,7 +184,7 @@ class _FortalSelectExampleState extends State { `FortalSelect` applies matching trigger, menu container, and default item styles. Set `RemixSelectItem.style` only for a row-level override. - See the [fortalSelectStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/select.dart) for all available options. + See the [fortalSelectStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/select.dart) for all available options. ## Constructor diff --git a/docs/components/sidebar.mdx b/docs/components/sidebar.mdx index eb4b79ccc..f5237f2b2 100644 --- a/docs/components/sidebar.mdx +++ b/docs/components/sidebar.mdx @@ -117,7 +117,7 @@ same width/padding with Mix would add a second interpolation to that geometry. ```dart import 'package:flutter/widgets.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'ui/ui.dart'; Widget buildCollapsibleSidebar({ required bool collapsed, diff --git a/docs/components/slider.mdx b/docs/components/slider.mdx index 14b1ee5eb..0bb0ecc10 100644 --- a/docs/components/slider.mdx +++ b/docs/components/slider.mdx @@ -112,7 +112,7 @@ class _FortalSliderExampleState extends State { - See the [fortalSliderStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/slider.dart) for all available options. + See the [fortalSliderStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/slider.dart) for all available options. ## Constructor diff --git a/docs/components/spinner.mdx b/docs/components/spinner.mdx index c05330312..7e72b2c3d 100644 --- a/docs/components/spinner.mdx +++ b/docs/components/spinner.mdx @@ -83,7 +83,7 @@ class FortalSpinnerExample extends StatelessWidget { - See the [fortalSpinnerStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/spinner.dart) for all available options. + See the [fortalSpinnerStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/spinner.dart) for all available options. ## Constructor diff --git a/docs/components/switch.mdx b/docs/components/switch.mdx index c900f2ff6..cb216ed01 100644 --- a/docs/components/switch.mdx +++ b/docs/components/switch.mdx @@ -124,7 +124,7 @@ class _FortalSwitchExampleState extends State { - See the [fortalSwitchStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/switch.dart) for all available options. + See the [fortalSwitchStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/switch.dart) for all available options. ## Constructor diff --git a/docs/components/tabs.mdx b/docs/components/tabs.mdx index 35ff8ca57..3d6c4027f 100644 --- a/docs/components/tabs.mdx +++ b/docs/components/tabs.mdx @@ -169,7 +169,7 @@ class _FortalTabsExampleState extends State { - See the [fortalTabStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/tabs.dart) for all available options. + See the [fortalTabStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/tabs.dart) for all available options. ## Constructor diff --git a/docs/components/textfield.mdx b/docs/components/textfield.mdx index 8b2252999..56222a791 100644 --- a/docs/components/textfield.mdx +++ b/docs/components/textfield.mdx @@ -129,7 +129,7 @@ class _FortalTextFieldExampleState extends State { - See the [fortalTextFieldStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/textfield.dart) for all available options. + See the [fortalTextFieldStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/textfield.dart) for all available options. ## Constructor diff --git a/docs/components/toast.mdx b/docs/components/toast.mdx index db7f4933d..96791fa02 100644 --- a/docs/components/toast.mdx +++ b/docs/components/toast.mdx @@ -125,7 +125,7 @@ void showUploadFailed(BuildContext context) { - See the [fortalToastStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/toast.dart) for all available options. + See the [fortalToastStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/toast.dart) for all available options. ## Show function diff --git a/docs/components/toggle.mdx b/docs/components/toggle.mdx index c9b0686a8..c3f1cffbf 100644 --- a/docs/components/toggle.mdx +++ b/docs/components/toggle.mdx @@ -104,7 +104,7 @@ class _FortalToggleExampleState extends State { - See the [fortalToggleStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/toggle.dart) for all available options. + See the [fortalToggleStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/toggle.dart) for all available options. ## Constructor diff --git a/docs/components/toggle_group.mdx b/docs/components/toggle_group.mdx index df3dc09ed..a8d5032b9 100644 --- a/docs/components/toggle_group.mdx +++ b/docs/components/toggle_group.mdx @@ -186,5 +186,5 @@ The recipe uses edge-to-edge items, a bordered surface container, selected accent colors, a visible keyboard focus ring, and disabled state styling. - See the [fortalToggleGroupStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/toggle_group.dart) for all available options. + See the [fortalToggleGroupStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/toggle_group.dart) for all available options. diff --git a/docs/components/tooltip.mdx b/docs/components/tooltip.mdx index 369cdaea9..e01ccbb54 100644 --- a/docs/components/tooltip.mdx +++ b/docs/components/tooltip.mdx @@ -135,7 +135,7 @@ class FortalTooltipExample extends StatelessWidget { - See the [fortalTooltipStyle source code](https://github.com/conceptadev/remix/blob/main/packages/remix_fortal/lib/src/components/tooltip.dart) for all available options. + See the [fortalTooltipStyle source code](https://github.com/conceptadev/remix/blob/main/registry_source/lib/src/fortal/components/tooltip.dart) for all available options. ## Constructor diff --git a/docs/fortal.mdx b/docs/fortal.mdx index 2781a0484..3141d51fc 100644 --- a/docs/fortal.mdx +++ b/docs/fortal.mdx @@ -24,7 +24,7 @@ system on Remix, you pay nothing for a theme you never use. If you choose Fortal, the CLI copies its token tables, Radix color data, and recipes into your application, where they become your source. -The repository keeps `packages/remix_fortal` as the analyzed authoring and +The repository keeps `registry_source` (`lib/src/fortal`) as the analyzed authoring and Radix parity surface. Consumer applications do not depend on that package. ## Installation diff --git a/docs/fortal/catalog.mdx b/docs/fortal/catalog.mdx index 673e0b775..5a64cf1c1 100644 --- a/docs/fortal/catalog.mdx +++ b/docs/fortal/catalog.mdx @@ -57,7 +57,7 @@ A `—` value means the widget does not expose that axis. When values are listed ### FortalButton -Radix `Button` · recipe `packages/remix_fortal/lib/src/components/button.dart` +Radix `Button` · recipe `registry_source/lib/src/fortal/components/button.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -82,7 +82,7 @@ Radix `Button` · recipe `packages/remix_fortal/lib/src/components/button.dart` ### FortalIconButton -Radix `IconButton` · recipe `packages/remix_fortal/lib/src/components/icon_button.dart` +Radix `IconButton` · recipe `registry_source/lib/src/fortal/components/icon_button.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -103,7 +103,7 @@ Radix `IconButton` · recipe `packages/remix_fortal/lib/src/components/icon_butt ### FortalCheckbox -Radix `Checkbox` · recipe `packages/remix_fortal/lib/src/components/checkbox.dart` +Radix `Checkbox` · recipe `registry_source/lib/src/fortal/components/checkbox.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -124,7 +124,7 @@ Radix `Checkbox` · recipe `packages/remix_fortal/lib/src/components/checkbox.da ### FortalRadio -Radix `RadioGroup.Item` · recipe `packages/remix_fortal/lib/src/components/radio.dart` +Radix `RadioGroup.Item` · recipe `registry_source/lib/src/fortal/components/radio.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -145,7 +145,7 @@ Radix `RadioGroup.Item` · recipe `packages/remix_fortal/lib/src/components/radi ### FortalSelect -Radix `Select` · recipe `packages/remix_fortal/lib/src/components/select.dart` +Radix `Select` · recipe `registry_source/lib/src/fortal/components/select.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -171,7 +171,7 @@ Radix `Select` · recipe `packages/remix_fortal/lib/src/components/select.dart` ### FortalSlider -Radix `Slider` · recipe `packages/remix_fortal/lib/src/components/slider.dart` +Radix `Slider` · recipe `registry_source/lib/src/fortal/components/slider.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -199,7 +199,7 @@ Radix `Slider` · recipe `packages/remix_fortal/lib/src/components/slider.dart` ### FortalSwitch -Radix `Switch` · recipe `packages/remix_fortal/lib/src/components/switch.dart` +Radix `Switch` · recipe `registry_source/lib/src/fortal/components/switch.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -220,7 +220,7 @@ Radix `Switch` · recipe `packages/remix_fortal/lib/src/components/switch.dart` ### FortalTextArea -Radix `TextArea` · recipe `packages/remix_fortal/lib/src/components/textfield.dart` +Radix `TextArea` · recipe `registry_source/lib/src/fortal/components/textfield.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -254,7 +254,7 @@ Radix `TextArea` · recipe `packages/remix_fortal/lib/src/components/textfield.d ### FortalTextField -Radix `TextField.Root` · recipe `packages/remix_fortal/lib/src/components/textfield.dart` +Radix `TextField.Root` · recipe `registry_source/lib/src/fortal/components/textfield.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -275,7 +275,7 @@ Radix `TextField.Root` · recipe `packages/remix_fortal/lib/src/components/textf ### FortalToggle -Fortal extension (no Radix counterpart) · recipe `packages/remix_fortal/lib/src/components/toggle.dart` +Fortal extension (no Radix counterpart) · recipe `registry_source/lib/src/fortal/components/toggle.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -296,7 +296,7 @@ Fortal extension (no Radix counterpart) · recipe `packages/remix_fortal/lib/src ### FortalToggleGroup -Fortal extension (no Radix counterpart) · recipe `packages/remix_fortal/lib/src/components/toggle_group.dart` +Fortal extension (no Radix counterpart) · recipe `registry_source/lib/src/fortal/components/toggle_group.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -320,7 +320,7 @@ Fortal extension (no Radix counterpart) · recipe `packages/remix_fortal/lib/src ### FortalLink -Radix `Link` · recipe `packages/remix_fortal/lib/src/components/link.dart` +Radix `Link` · recipe `registry_source/lib/src/fortal/components/link.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -366,7 +366,7 @@ Radix `Link` · recipe `packages/remix_fortal/lib/src/components/link.dart` ### FortalMenu -Radix `DropdownMenu` · recipe `packages/remix_fortal/lib/src/components/menu.dart` +Radix `DropdownMenu` · recipe `registry_source/lib/src/fortal/components/menu.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -395,7 +395,7 @@ Radix `DropdownMenu` · recipe `packages/remix_fortal/lib/src/components/menu.da ### FortalSegmentedControl -Radix `SegmentedControl.Root` · recipe `packages/remix_fortal/lib/src/components/segmented_control.dart` +Radix `SegmentedControl.Root` · recipe `registry_source/lib/src/fortal/components/segmented_control.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -432,7 +432,7 @@ Radix `SegmentedControl.Root` · recipe `packages/remix_fortal/lib/src/component ### FortalTabBar -Radix `Tabs` · recipe `packages/remix_fortal/lib/src/components/tabs.dart` +Radix `Tabs` · recipe `registry_source/lib/src/fortal/components/tabs.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -459,7 +459,7 @@ Radix `Tabs` · recipe `packages/remix_fortal/lib/src/components/tabs.dart` ### FortalDialog -Radix `Dialog.Content` · recipe `packages/remix_fortal/lib/src/components/dialog.dart` +Radix `Dialog.Content` · recipe `registry_source/lib/src/fortal/components/dialog.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -480,7 +480,7 @@ Radix `Dialog.Content` · recipe `packages/remix_fortal/lib/src/components/dialo ### FortalPopover -Radix `Popover.Content` · recipe `packages/remix_fortal/lib/src/components/popover.dart` +Radix `Popover.Content` · recipe `registry_source/lib/src/fortal/components/popover.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -496,7 +496,7 @@ Radix `Popover.Content` · recipe `packages/remix_fortal/lib/src/components/popo ### FortalTooltip -Radix `Tooltip` · recipe `packages/remix_fortal/lib/src/components/tooltip.dart` +Radix `Tooltip` · recipe `registry_source/lib/src/fortal/components/tooltip.dart` **Other defaults:** `arrow: true`, `collisionPadding: 10`, `delayMilliseconds: 200`, `disableHoverableContent: false`, `maxWidth: 360`, `sideOffset: 4` @@ -510,7 +510,7 @@ Radix `Tooltip` · recipe `packages/remix_fortal/lib/src/components/tooltip.dart ### FortalCallout -Radix `Callout.Root` · recipe `packages/remix_fortal/lib/src/components/callout.dart` +Radix `Callout.Root` · recipe `registry_source/lib/src/fortal/components/callout.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -531,7 +531,7 @@ Radix `Callout.Root` · recipe `packages/remix_fortal/lib/src/components/callout ### FortalProgress -Radix `Progress` · recipe `packages/remix_fortal/lib/src/components/progress.dart` +Radix `Progress` · recipe `registry_source/lib/src/fortal/components/progress.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -558,7 +558,7 @@ Radix `Progress` · recipe `packages/remix_fortal/lib/src/components/progress.da ### FortalSkeleton -Radix `Skeleton` · recipe `packages/remix_fortal/lib/src/components/skeleton.dart` +Radix `Skeleton` · recipe `registry_source/lib/src/fortal/components/skeleton.dart` **Other defaults:** `loading: true` @@ -579,7 +579,7 @@ Radix `Skeleton` · recipe `packages/remix_fortal/lib/src/components/skeleton.da ### FortalSpinner -Radix `Spinner` · recipe `packages/remix_fortal/lib/src/components/spinner.dart` +Radix `Spinner` · recipe `registry_source/lib/src/fortal/components/spinner.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -602,7 +602,7 @@ Radix `Spinner` · recipe `packages/remix_fortal/lib/src/components/spinner.dart ### FortalAccordion -Fortal extension (no Radix counterpart) · recipe `packages/remix_fortal/lib/src/components/accordion.dart` +Fortal extension (no Radix counterpart) · recipe `registry_source/lib/src/fortal/components/accordion.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -624,7 +624,7 @@ Fortal extension (no Radix counterpart) · recipe `packages/remix_fortal/lib/src ### FortalCard -Radix `Card` · recipe `packages/remix_fortal/lib/src/components/card.dart` +Radix `Card` · recipe `registry_source/lib/src/fortal/components/card.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -647,7 +647,7 @@ Radix `Card` · recipe `packages/remix_fortal/lib/src/components/card.dart` ### FortalDisclosure -Radix `Collapsible` · recipe `packages/remix_fortal/lib/src/components/disclosure.dart` +Radix `Collapsible` · recipe `registry_source/lib/src/fortal/components/disclosure.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -673,7 +673,7 @@ Radix `Collapsible` · recipe `packages/remix_fortal/lib/src/components/disclosu ### FortalDivider -Radix `Separator` · recipe `packages/remix_fortal/lib/src/components/divider.dart` +Radix `Separator` · recipe `registry_source/lib/src/fortal/components/divider.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -694,7 +694,7 @@ Radix `Separator` · recipe `packages/remix_fortal/lib/src/components/divider.da ### FortalAvatar -Radix `Avatar` · recipe `packages/remix_fortal/lib/src/components/avatar.dart` +Radix `Avatar` · recipe `registry_source/lib/src/fortal/components/avatar.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -715,7 +715,7 @@ Radix `Avatar` · recipe `packages/remix_fortal/lib/src/components/avatar.dart` ### FortalBadge -Radix `Badge` · recipe `packages/remix_fortal/lib/src/components/badge.dart` +Radix `Badge` · recipe `registry_source/lib/src/fortal/components/badge.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -736,7 +736,7 @@ Radix `Badge` · recipe `packages/remix_fortal/lib/src/components/badge.dart` ### FortalDataList -Radix `DataList.Root` · recipe `packages/remix_fortal/lib/src/components/data_list.dart` +Radix `DataList.Root` · recipe `registry_source/lib/src/fortal/components/data_list.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -771,7 +771,7 @@ Radix `DataList.Root` · recipe `packages/remix_fortal/lib/src/components/data_l ### FortalDataTable -Radix `Table` · recipe `packages/remix_fortal/lib/src/components/data_table.dart` +Radix `Table` · recipe `registry_source/lib/src/fortal/components/data_table.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -802,7 +802,7 @@ Radix `Table` · recipe `packages/remix_fortal/lib/src/components/data_table.dar ### FortalHeading -Radix `Heading` · recipe `packages/remix_fortal/lib/src/components/heading.dart` +Radix `Heading` · recipe `registry_source/lib/src/fortal/components/heading.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -833,7 +833,7 @@ Radix `Heading` · recipe `packages/remix_fortal/lib/src/components/heading.dart ### FortalText -Radix `Text` · recipe `packages/remix_fortal/lib/src/components/text.dart` +Radix `Text` · recipe `registry_source/lib/src/fortal/components/text.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -864,7 +864,7 @@ Radix `Text` · recipe `packages/remix_fortal/lib/src/components/text.dart` ### FortalCode -Radix `Code` · recipe `packages/remix_fortal/lib/src/components/code.dart` +Radix `Code` · recipe `registry_source/lib/src/fortal/components/code.dart` | Parameter | Values | Default | | --- | --- | --- | @@ -901,7 +901,7 @@ Radix `Code` · recipe `packages/remix_fortal/lib/src/components/code.dart` ### FortalKbd -Radix `Kbd` · recipe `packages/remix_fortal/lib/src/components/kbd.dart` +Radix `Kbd` · recipe `registry_source/lib/src/fortal/components/kbd.dart` | Parameter | Values | Default | | --- | --- | --- | diff --git a/docs/index.mdx b/docs/index.mdx index 8975f7fe2..5f1b85c55 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -97,5 +97,5 @@ complete host, including the scope placement used by its dialog. - [Download both tutorial projects](/assets/remix-cli-tutorial/sample-projects.zip): default and Fortal examples with source and behavior tests. The repository also contains `apps/dashboard`, `apps/demo`, -`packages/remix/example`, and `packages/remix_fortal/example`. The last is an +`packages/remix/example`, and `registry_source/example`. The last is an authoring-package example, not a dependency the CLI adds to consumer apps. diff --git a/docs/open-code.mdx b/docs/open-code.mdx index 9e16bbb24..fd634df42 100644 --- a/docs/open-code.mdx +++ b/docs/open-code.mdx @@ -362,8 +362,8 @@ the child of `MaterialApp.builder` with `AcmeScope`. Routes and dialogs then inherit its tokens and text defaults. The scope example lives in `lib/ui/theme/theme_scope.dart`. -These are application files, not forwarding wrappers around -`package:remix_fortal`. The authored recipes and pinned Radix Themes 3.3.0 +These are application files, not forwarding wrappers around a package. The +authored recipes and pinned Radix Themes 3.3.0 color data are copied from the same source used by the repository's parity suite. `remix` remains the behavior and styling-engine boundary; `mix_annotations`, `build_runner`, and `mix_generator` support adapter @@ -691,3 +691,29 @@ takes focus, for its selection handles. `MaterialApp`, `CupertinoApp`, and any If generation or analysis fails *after* installation, the authored source stays on disk for inspection. Fix the reported problem and rerun the same command. + + +## Agent surfaces in both presets + +`activity`, `answer`, `composer`, `execution`, `message`, `permission`, `plan`, +and `transcript` install unstyled, application-owned behavior source in either +the default or Fortal preset. Each `_recipe` item adds a complete +preset-specific recipe bundle. The +private authoring source (`registry_source`) is not a consumer dependency. The shared `models` files +are exported from the local UI barrel; `support` helpers install transitively +without barrel exports. Appearance remains host-owned. + +```shell +dart run remix_cli:remix add composer +``` + +These surfaces use `@MixableSpec`. The CLI enables the supported Mix generator's +opt-in spec-styler builder for their installed source paths in `build.yaml`, +preserving other builder settings and comments. Explicit exclusions or disabled +generation are reported before writes. `--dry-run` and `--diff` remain read-only. +Agent recipes extend both existing presets; they do not constitute a third preset +or claim Radix component parity. + +Available styled items: `activity_recipe`, `answer_recipe`, `composer_recipe`, +`execution_recipe`, `message_recipe`, `permission_recipe`, `plan_recipe`, and +`transcript_recipe`. Each installs only its component and styled-control closure. diff --git a/open_code/CLEAN_SHEET.md b/open_code/CLEAN_SHEET.md index 444577d7f..0a49ca49a 100644 --- a/open_code/CLEAN_SHEET.md +++ b/open_code/CLEAN_SHEET.md @@ -30,7 +30,8 @@ order, adds its hosted dependencies, exports the authored files, and generates **Since:** the styled component catalog has grown on the same two mechanisms — one authored file plus one generated part per item, every item depending on `theme`. Nothing in the decision below changed to accommodate them: no schema -field, no installer branch, and no addition to the fifteen theme tokens. +field and no installer branch. The token vocabulary moved once, when the chart +item added `chart1`-`chart5` to reach the current twenty. Compound components (a checkbox group option, a tab bar with its tabs and panels) fit by declaring more than one `@MixWidget` in the same file. The current catalog is listed in `docs/open-code.mdx`. @@ -109,13 +110,15 @@ the MVP has an update system. Runtime dependencies are `remix` and `mix_annotations`. Mix and Naked UI arrive through Remix, which avoids choosing a direct Mix version that Remix was not compiled against. Installing the optional chart item adds `mix_chart` directly; -it never adds `remix_fortal`. Development dependencies are `build_runner` and +it never adds the authoring source package. Development dependencies are `build_runner` and `mix_generator`. Chart is deliberately one item with three adapters. `mix_chart` owns the hard -chart contract. The installed file owns presentation and resolves only the -existing fifteen theme tokens. This keeps `remix add chart` compatible with a -theme installed and customized before chart support existed. +chart contract. The installed file owns presentation and resolves theme +tokens only: `background`, `foreground`, `mutedForeground`, `border`, `radius`, +and the five `chart1`-`chart5` entries the chart item added to the vocabulary. +A theme installed before those five existed does not carry them, and authored +files are preserved on normal runs, so such a theme needs them added by hand. The recurring costs are visible: @@ -137,3 +140,22 @@ hosted consumer check. The first public release still requires package bootstrap and hosted CLI verification. Until then, use a checkout or staged package with checkout Remix. The [release instructions](RELEASING.md) define the order and required checks. + + +## Agent source distribution + +Both catalogs include eight domain-specific Agent surfaces. Unlike visual +`@MixWidget` recipes, these copy the private authoring package's +(`registry_source/lib/src/agent`) behavior and empty anatomy specs into the +application, and each preset styles them through recipes authored in its own +source package. They still compose the +public Remix primitives; they do not copy Remix internals or introduce a +private runtime dependency. Applications own the installed domain behavior as +well as its appearance. The scope of future source-update diffs includes that +behavior. + +The supported Mix spec-styler builder is opt-in, so these installs enable it +for their source paths in consumer `build.yaml`. This is a deliberate extension +to the original recipe-only, no-build-config contract. Existing configuration +is preserved, conflicts are rejected during preflight, and configuration changes +appear in dry-run/diff. Registry schema and preset selection are unchanged. diff --git a/open_code/PRESETS.md b/open_code/PRESETS.md index 757d3fa95..ec3aeaae4 100644 --- a/open_code/PRESETS.md +++ b/open_code/PRESETS.md @@ -5,7 +5,7 @@ ## The problem -`remix_cli` installs one registry: a 15-token `theme` item and one authored +`remix_cli` installs one registry: a 20-token `theme` item and one authored recipe per component. Fortal is a second, complete design language on Remix, with 277 tokens, Radix color data, 35 recipes, and a parity contract against Radix Themes 3.3.0. Today an application can adopt Fortal only as a hosted @@ -32,7 +32,7 @@ with the reason, so a reviewer does not reopen it by accident. (`classic`, `solid`, `soft`, `surface`, `outline`, `ghost`) is not the default's (`primary`, `secondary`, `outline`, `ghost`, `destructive`), so a shared recipe with swappable data is not possible between these two. -3. **Fortal is authored once, as Dart, in `packages/remix_fortal`.** The +3. **Fortal is authored once, as Dart, in `registry_source/lib/src/fortal`.** The `remix_cli` templates are derived from that source and committed. A check fails when they drift. The alternative, hand-authored `.tmpl` files, means editing 36 files without an analyzer, and running the 50 test files and @@ -50,16 +50,18 @@ with the reason, so a reviewer does not reopen it by accident. 6. **`mix_chart` stays a hosted dependency of the `chart` item.** It is the chart engine the recipe styles, as `remix` is the button engine. The default `chart` item already declares it. -7. **`remix_fortal` leaves pub.dev after the published replacement works; the directory - stays.** Hosted Fortal has no consumer base to protect: 0 likes and 438 - downloads at `1.0.0-beta.7`. pub.dev cannot delete a package, so the - package is marked discontinued with `remix init --preset fortal` named as - the replacement, after Commit 4 proves that path. The directory - `packages/remix_fortal` is not deleted. It is the authored source the - templates derive from, the target of 50 test files and the parity - checker, and a dependency of `apps/dashboard`, `apps/demo`, and - `apps/playground`. The source and template check detects byte drift: `--check` fails CI on any byte of difference, the same way - `docs/fortal/catalog.mdx` and the playground dogfood are checked today. +7. **`remix_fortal` leaves pub.dev; its source lives in `registry_source/lib/src/fortal`.** + Hosted Fortal has no consumer base to protect: 0 likes and 438 downloads at + `1.0.0-beta.7`. pub.dev cannot delete a package, so the package is marked + discontinued with `remix init --preset fortal` named as the replacement. The + source is not deleted: it is what the templates derive from, the target of + the parity checker and its test suite. It is a private workspace package + with no version, `registry_source`, holding the default and Agent sources + beside it, and no application depends on it — they install. The + source and template check detects byte drift: `--check` fails CI on any byte + of difference, the same way `docs/fortal/catalog.mdx` and the dogfood + consumers are checked today. See + `registry_source/docs/adr/fortal/0002-registry-source.md`. ## What the application gets @@ -145,23 +147,23 @@ import it today, so the move is one path change per file instead of a does not export `radix_colors.dart`; `computed.dart` and `theme_data.dart` import that file directly. -The public barrel `lib/remix_fortal.dart` keeps every export, so the public -API does not change. `test/public_api_test.dart` proves it. Remix's own public +The barrel `registry_source/lib/fortal.dart` keeps every export, so the parity +suite's imports do not change. Remix's own public barrel also re-exports the six Naked constructor/state types referenced by generated adapters, keeping installed recipes on the `package:remix/remix.dart` boundary. ## Derivation -`tool/build_fortal_preset.dart` at the workspace root, next to +`tool/build_registry.dart` at the workspace root, next to `check_open_code.dart`, does the following. -**Input.** Every `.dart` file under `packages/remix_fortal/lib/src` except +**Input.** Every `.dart` file under `registry_source/fortal/lib/src` except `*.g.dart`. The application generates its own parts. **Refusals, checked first.** Any path segment containing `fortal`. Any file containing `{{`, because the renderer treats that as a template token. Any -`package:remix_fortal`, `package:mix`, or `package:naked_ui` import, because +`package:registry_source`, `package:mix`, or `package:naked_ui` import, because installed source must import the public `remix` surface. Each refusal names the file. @@ -330,8 +332,8 @@ independent of Commit 1 and Commit 2. Depends on Commit 1, 2, and 3. The diff is mostly derived content; review the tool, the registry rules, and the check. -- `tool/build_fortal_preset.dart` as specified above, and - `test/tool/build_fortal_preset_test.dart` covering the refusals, the round +- `tool/build_registry.dart` as specified above, and + `test/tool/build_registry_test.dart` covering the refusals, the round trip, the dependency inference, and `--check` on a planted drift. - The committed output: `registry/fortal/registry.yaml` and every template. - `bundledPresets` gains `fortal`. The `remix_cli` unit tests load the @@ -412,7 +414,7 @@ installation check. Follow [the release instructions](RELEASING.md). Keep - Authoring the default preset as Dart with the same derivation. The default has no parity contract, so the pressure is lower. Reconsider after Commit 4 shows the tool's cost. -- Deleting `packages/remix_fortal` from the repository. That would make the +- Deleting `registry_source/fortal` from the repository. That would make the `.tmpl` files the only form of Fortal, move the parity suite behind a temporary-app render, and force `apps/dashboard`, `apps/demo`, `apps/playground`, and 39 doc pages to migrate to installed source. diff --git a/open_code/README.md b/open_code/README.md index 6fe6f0838..812dc05cd 100644 --- a/open_code/README.md +++ b/open_code/README.md @@ -95,8 +95,8 @@ the application owns the copied Radix color table, 277-token theme, component recipes, and generated adapters. The Fortal templates are derived from analyzed Dart in -`packages/remix_fortal/lib/src/`; do not edit the committed `.tmpl` files by -hand. `tool/build_fortal_preset.dart --check` makes source/template drift a CI +`registry_source/fortal/lib/src/`; do not edit the committed `.tmpl` files by +hand. `tool/build_registry.dart --check` makes source/template drift a CI failure. The prefixes `Remix` and `Mix` are reserved for runtime dependencies. @@ -133,7 +133,18 @@ without a generated adapter. Add or rename aliases there as the application evolves. The complete 318-icon catalog is one direct `package:remix_ui_icons/remix_ui_icons.dart` import away. -The CLI does not create or modify `build.yaml`. +Recipe-only installs need no `build.yaml`. Agent surfaces use `@MixableSpec`, +whose styler builder is opt-in in the supported Mix generator. The CLI enables +that builder for the installed source paths in `build.yaml`, preserving other +settings and comments. Explicit disabled builders or excluded source fail in +preflight instead of being overridden. Dry-run/diff remain read-only. + +Agent behavior is available in both bundled presets. Add a bare surface for +behavior-only source, or `_recipe` for the surface plus its complete +preset-specific styling bundle. Each recipe is authored as Dart in its preset's +source (`registry_source/lib/src/{default,fortal}/recipes/`) against the Agent +behavior source beside it, +and derives into the registry like every other item. ## Charts without Fortal @@ -262,15 +273,15 @@ package that takes unresolved stylers can be styled from them too, which keeps its surfaces inside the application's design language instead of adding a second one. -`remix_agent` is the worked example. Its catalog app, -`packages/remix_agent/example/`, installs Theme, Card, TextField, and -IconButton the way any consumer does, then composes them into one recipe bundle -for `AgentComposer`: +Agent is the worked example. Its `composer_recipe` item, authored in +each preset's source and installed like any other recipe, composes the +installed Theme, Card, TextField, and IconButton into one recipe bundle for +the installed `UiComposer`: ```dart final recipe = uiAgentComposerRecipe(); -AgentComposer( +UiComposer( onSubmit: submit, style: recipe.style, surfaceStyle: recipe.surfaceStyle, @@ -284,15 +295,18 @@ A bundle rather than a single styler, because the widget takes five stylers and its own spec covers one of them. The four child stylers are passed on unresolved, so each control resolves its own hover, focus, and disabled state. -`remix_agent` has no registry item. It is `publish_to: none`, and a registry -item's dependency is a hosted version constraint, so there is nothing to -install and nothing to advertise. The example resolves it as a workspace -sibling, which is development evidence rather than proof of hosted -installation. +The eight Agent surfaces now install as `activity`, `answer`, `composer`, +`execution`, `message`, `permission`, `plan`, and `transcript` in both existing +presets. Shared `models` and `support` install through dependency closure. The +private authoring source (`registry_source/lib/src/agent`) is not a consumer +dependency. The dashboard imports installed `Ui*` classes. Fortal recipes use +only the installed Fortal theme and controls. Checkout verification does not +replace the hosted checks required before a release. -Only the composer is wired this way. The catalog's other seven surfaces still -use local review-only stylers, which is the intermediate state the package's -ADR describes: prove one surface before converting eight. +All eight surfaces have equivalent immutable recipe bundles. Call-site overrides +merge last, and child-control stylers remain unresolved until rendered. The +conversation shell and simulated runner belong to the applications, not the +registry or a source package. ## Repository proof @@ -314,13 +328,17 @@ sources. Select `--source checkout` or `--source hosted` for one source. Use `--hosted-cli --source hosted` after CLI publication to verify its hosted assets. Pass `--keep` to retain a generated application for inspection. -Both dogfood consumers are checked against the templates they installed: +All three dogfood consumers are checked against the templates they installed: ```shell fvm dart run tool/check_open_code_dogfood.dart ``` -`apps/playground` holds every item; `packages/remix_agent/example` holds the -four its composer recipe composes. The checker declares those expected items +`apps/playground` holds every default item; `apps/dashboard` installs the +eight Agent recipe closures from Fortal. The checker declares those expected items explicitly, so missing files are checked too. The CLI reads each consumer's `remix.yaml` to locate its installed source. + +Available styled items: `activity_recipe`, `answer_recipe`, `composer_recipe`, +`execution_recipe`, `message_recipe`, `permission_recipe`, `plan_recipe`, and +`transcript_recipe`. Each installs only its component and styled-control closure. diff --git a/open_code/RELEASING.md b/open_code/RELEASING.md index 277d7b42e..382f37b06 100644 --- a/open_code/RELEASING.md +++ b/open_code/RELEASING.md @@ -96,7 +96,7 @@ After the published CLI checks pass, mark `remix_fortal` discontinued in its pub.dev Admin tab. Name `remix_cli` as the replacement package. The migration instructions use `remix init --preset fortal` and application-owned imports. -Keep `packages/remix_fortal` in the repository as the analyzed authoring source. +Keep `registry_source/fortal` in the repository as the analyzed authoring source. Keep `publish_to: none` and its tests. Existing hosted installations remain available; discontinuation does not delete their package versions. diff --git a/open_code/fixture/test/agent_recipe_contract_test.dart b/open_code/fixture/test/agent_recipe_contract_test.dart new file mode 100644 index 000000000..1194087be --- /dev/null +++ b/open_code/fixture/test/agent_recipe_contract_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_code_fixture/ui/ui.dart'; +import 'package:remix/remix.dart'; + +void main() { + testWidgets('answer copy control resolves its own hover override', ( + tester, + ) async { + const idle = Color(0xFF123456); + const hovered = Color(0xFFABCDEF); + final recipe = acmeAgentAnswerRecipe( + copyStyle: IconButtonStyler() + .color(idle) + .onHovered(IconButtonStyler().color(hovered)), + ); + final answer = AcmeAnswer( + status: AcmeAnswerStatus.complete, + style: recipe.style, + surfaceStyle: recipe.surfaceStyle, + copyStyle: recipe.copyStyle, + onCopy: () {}, + child: const Text('Local answer'), + ); + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFFFFFFFF), + builder: (_, _) => + AcmeThemeScope(data: const AcmeThemeData.light(), child: answer), + ), + ); + final copy = find.byWidgetPredicate( + (widget) => + widget is RemixIconButton && widget.semanticLabel == 'Copy answer', + ); + Iterable colors() => tester + .widgetList( + find.descendant(of: copy, matching: find.byType(DecoratedBox)), + ) + .map( + (box) => box.decoration is BoxDecoration + ? (box.decoration as BoxDecoration).color + : null, + ); + expect(colors(), contains(idle)); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(copy)); + await tester.pumpAndSettle(); + expect(colors(), contains(hovered)); + expect(colors(), isNot(contains(idle))); + }); + + testWidgets('composer recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = acmeAgentComposerRecipe( + style: AcmeComposerStyler(toolbar: FlexBoxStyler().spacing(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.toolbar.spec.flex?.spec.spacing, 37); + }); + testWidgets('message recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = acmeAgentMessageRecipe( + style: AcmeMessageStyler(maxWidth: 37), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.maxWidth, 37); + }); + testWidgets('answer recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = acmeAgentAnswerRecipe( + style: AcmeAnswerStyler(actions: FlexBoxStyler().spacing(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.actions.spec.flex?.spec.spacing, 37); + }); + testWidgets('execution recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = acmeAgentExecutionRecipe( + style: AcmeExecutionStyler(header: FlexBoxStyler().spacing(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.header.spec.flex?.spec.spacing, 37); + }); + testWidgets('permission recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = acmeAgentPermissionRecipe( + style: AcmePermissionStyler(actions: FlexBoxStyler().spacing(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.actions.spec.flex?.spec.spacing, 37); + }); + testWidgets('plan recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = acmeAgentPlanRecipe( + style: AcmePlanStyler(viewport: BoxStyler().maxHeight(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.viewport.spec.constraints?.maxHeight, 37); + }); + testWidgets('activity recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = acmeAgentActivityRecipe( + style: AcmeActivityStyler(viewport: BoxStyler().maxHeight(37)), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.viewport.spec.constraints?.maxHeight, 37); + }); + testWidgets('transcript recipe preserves the caller surface override', ( + tester, + ) async { + final recipe = acmeAgentTranscriptRecipe( + style: AcmeTranscriptStyler(spacing: 37), + ); + final result = await _resolve(tester, recipe.style); + expect(result.spec.spacing, 37); + }); +} + +Future> _resolve>( + WidgetTester tester, + Style style, +) async { + late StyleSpec result; + final child = Builder( + builder: (context) { + result = style.build(context); + return const SizedBox.shrink(); + }, + ); + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xFFFFFFFF), + builder: (_, _) => + AcmeThemeScope(data: const AcmeThemeData.light(), child: child), + ), + ); + return result; +} diff --git a/packages/remix_agent/example/test/composer_recipe_test.dart b/open_code/fixture/test/composer_recipe_test.dart similarity index 93% rename from packages/remix_agent/example/test/composer_recipe_test.dart rename to open_code/fixture/test/composer_recipe_test.dart index 00a75e25e..84f5fcd11 100644 --- a/packages/remix_agent/example/test/composer_recipe_test.dart +++ b/open_code/fixture/test/composer_recipe_test.dart @@ -3,23 +3,21 @@ import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:open_code_fixture/ui/ui.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; -import 'package:remix_agent_example/agent_recipes.dart'; -import 'package:remix_agent_example/ui/ui.dart'; -const _sendKey = ValueKey('agent-composer-send'); -const _stopKey = ValueKey('agent-composer-stop'); +const _sendKey = ValueKey('acme-composer-send'); +const _stopKey = ValueKey('acme-composer-stop'); -const _light = UiThemeData.light(); -const _dark = UiThemeData.dark(); +const _light = AcmeThemeData.light(); +const _dark = AcmeThemeData.dark(); /// A standalone installed control for comparing the send button's dimensions. /// The glyph is incidental to the geometry assertion. -final _referenceIconButton = UiIconButton( +final _referenceIconButton = AcmeIconButton( icon: IconData(0x2192), semanticLabel: 'Reference', - size: UiIconButtonSize.small, + size: AcmeIconButtonSize.small, style: IconButtonStyler().size(48, 48), ); @@ -49,7 +47,7 @@ List _nodes(WidgetTester tester) => Future _pump( WidgetTester tester, Widget child, { - UiThemeData theme = _light, + AcmeThemeData theme = _light, Size surface = const Size(800, 600), }) async { await tester.binding.setSurfaceSize(surface); @@ -57,7 +55,7 @@ Future _pump( await tester.pumpWidget( WidgetsApp( color: const Color(0xFF0A0A0A), - builder: (_, _) => UiThemeScope( + builder: (_, _) => AcmeThemeScope( data: theme, child: Overlay.wrap( child: DefaultTextStyle( @@ -72,7 +70,7 @@ Future _pump( /// Builds a composer from the bundle, spreading it across the five parameters. Widget _composer({ - UiAgentComposerRecipe? recipe, + AcmeAgentComposerRecipe? recipe, TextEditingController? controller, FocusNode? focusNode, String? initialValue, @@ -80,9 +78,9 @@ Widget _composer({ ValueChanged? onSubmit, VoidCallback? onStop, }) { - final resolved = recipe ?? uiAgentComposerRecipe(); + final resolved = recipe ?? acmeAgentComposerRecipe(); - return AgentComposer( + return AcmeComposer( controller: controller, focusNode: focusNode, initialValue: initialValue, @@ -117,7 +115,7 @@ void main() { ); final send = find.byKey(_sendKey); - final reference = find.byType(UiIconButton); + final reference = find.byType(AcmeIconButton); expect(_decorationColors(tester, send), contains(_light.primary)); // Compare dimensions without hard-coding the installed recipe's size. @@ -193,7 +191,7 @@ void main() { const focused = Color(0xFFFF0000); final focusNode = FocusNode(); addTearDown(focusNode.dispose); - final recipe = uiAgentComposerRecipe( + final recipe = acmeAgentComposerRecipe( fieldStyle: TextFieldStyler().onFocused( TextFieldStyler(cursorColor: focused), ), @@ -218,7 +216,7 @@ void main() { tester, ) async { const hovered = Color(0xFF00FF00); - final recipe = uiAgentComposerRecipe( + final recipe = acmeAgentComposerRecipe( submitStyle: IconButtonStyler().onHovered( IconButtonStyler().color(hovered), ), @@ -243,7 +241,7 @@ void main() { testWidgets('an instance override beats the recipe', (tester) async { const override = Color(0xFF7C3AED); - final recipe = uiAgentComposerRecipe( + final recipe = acmeAgentComposerRecipe( submitStyle: IconButtonStyler().color(override), ); await _pump( @@ -263,8 +261,8 @@ void main() { const cursor = Color(0xFF234567); const toolbar = Color(0xFF345678); const stop = Color(0xFF456789); - final recipe = uiAgentComposerRecipe( - style: AgentComposerStyler( + final recipe = acmeAgentComposerRecipe( + style: AcmeComposerStyler( toolbar: FlexBoxStyler().color(toolbar).padding(.all(20)), ), surfaceStyle: CardStyler().color(surface), diff --git a/open_code/fixture/test/open_code_test.dart b/open_code/fixture/test/open_code_test.dart index 883b226a2..a24673b3d 100644 --- a/open_code/fixture/test/open_code_test.dart +++ b/open_code/fixture/test/open_code_test.dart @@ -8,6 +8,218 @@ import 'package:open_code_fixture/ui/ui.dart'; import 'package:remix/remix.dart'; void main() { + group('installed Agent behavior', () { + testWidgets('composer submits and clears through Remix controls', ( + tester, + ) async { + final submitted = []; + await _pumpInstalledAgent( + tester, + AcmeComposer(initialValue: ' Explain this ', onSubmit: submitted.add), + ); + await tester.tap(find.byType(RemixIconButton)); + await tester.pump(); + expect(submitted, ['Explain this']); + expect( + tester.widget(find.byType(EditableText)).controller.text, + isEmpty, + ); + }); + + testWidgets('message collapse retains body and toggles explicit overflow', ( + tester, + ) async { + await _pumpInstalledAgent( + tester, + AcmeMessage( + role: AcmeRole.assistant, + child: AcmeMessageCollapsible( + style: AcmeMessageCollapsibleStyler(collapsedHeight: 20), + child: const SizedBox(height: 100, child: Text('Full message')), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Full message'), findsOneWidget); + expect(find.text('Show more'), findsOneWidget); + await tester.tap(find.text('Show more')); + await tester.pumpAndSettle(); + expect(find.text('Show less'), findsOneWidget); + }); + + testWidgets('plan collapses on completion and can be reopened', ( + tester, + ) async { + final changes = []; + Future pump(AcmePlanItemStatus status) => _pumpInstalledAgent( + tester, + AcmePlan( + items: [ + AcmePlanItem(id: 'step', title: 'Installed step', status: status), + ], + onExpandedChanged: changes.add, + ), + ); + await pump(.inProgress); + expect(find.text('Installed step'), findsOneWidget); + await pump(.completed); + await tester.pumpAndSettle(); + expect(find.text('Installed step'), findsNothing); + await tester.tap(find.text('Plan')); + await tester.pumpAndSettle(); + expect(find.text('Installed step'), findsOneWidget); + expect(changes, [false, true]); + }); + + testWidgets('activity stays open while working even under host control', ( + tester, + ) async { + final changes = []; + Future pump(AcmeRunStatus status) => _pumpInstalledAgent( + tester, + AcmeActivity( + status: status, + expanded: false, + onExpandedChanged: changes.add, + items: const [ + AcmeActivityItem(id: 'step', title: 'Installed activity'), + ], + ), + ); + await pump(.working); + await tester.tap(find.text('Activity')); + await tester.pump(); + expect(changes, isEmpty); + expect(find.text('Installed activity'), findsOneWidget); + await pump(.complete); + await tester.pumpAndSettle(); + expect(changes, [false]); + expect(find.text('Installed activity'), findsNothing); + }); + + testWidgets('execution settles its disclosure and exposes retry', ( + tester, + ) async { + var retries = 0; + Future pump(AcmeExecutionStatus status) => _pumpInstalledAgent( + tester, + AcmeExecution( + tool: 'terminal.run', + title: 'Installed execution', + status: status, + onRetry: () => retries++, + child: const Text('Command output'), + ), + ); + await pump(.running); + expect(find.text('Command output'), findsOneWidget); + await pump(.error); + await tester.pumpAndSettle(); + expect(find.text('Command output'), findsNothing); + await tester.tap(find.text('Installed execution')); + await tester.pumpAndSettle(); + expect(find.text('Command output'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Retry execution')); + expect(retries, 1); + }); + + testWidgets( + 'answer requests a source reset without overriding host state', + (tester) async { + final requests = []; + Future pump(int stream, AcmeAnswerStatus status) => + _pumpInstalledAgent( + tester, + AcmeAnswer( + streamId: stream, + status: status, + sourcesExpanded: true, + onSourcesExpandedChanged: requests.add, + sourcesContent: const Text('Installed source'), + child: const Text('Answer'), + ), + ); + await pump(0, .complete); + await pump(1, .streaming); + expect(requests, [false]); + expect(find.text('Installed source'), findsOneWidget); + }, + ); + + testWidgets('permission submits once for each request identity', ( + tester, + ) async { + var allows = 0; + Future pump(int requestId) => _pumpInstalledAgent( + tester, + AcmePermission( + requestId: requestId, + tool: 'terminal.run', + onAllowOnce: () => allows++, + ), + ); + await pump(0); + await tester.tap(find.text('Allow once')); + await tester.pump(); + await tester.tap(find.text('Allow once')); + expect(allows, 1); + await pump(1); + await tester.tap(find.text('Allow once')); + expect(allows, 2); + }); + + testWidgets('transcript releases the live edge while the reader scrolls', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + final changes = []; + Future pump(int count) => _pumpInstalledAgent( + tester, + SizedBox( + width: 300, + height: 120, + child: AcmeTranscript( + controller: controller, + onFollowChanged: changes.add, + children: [ + for (var index = 0; index < count; index++) + SizedBox(height: 30, child: Text('Row $index')), + ], + ), + ), + ); + await pump(20); + await tester.pump(); + expect( + controller.offset, + closeTo(controller.position.maxScrollExtent, 0.5), + ); + await tester.drag(find.byType(ListView), const Offset(0, 160)); + await tester.pump(); + expect(changes, [false]); + final reading = controller.offset; + await pump(25); + await tester.pump(); + expect(controller.offset, closeTo(reading, 0.5)); + await tester.drag(find.byType(ListView), const Offset(0, -2000)); + await tester.pump(); + expect(changes, [false, true]); + }); + + test('public model exports retain value semantics after renaming', () { + expect( + const AcmePlanItem(id: 'one', title: 'Step'), + const AcmePlanItem(id: 'one', title: 'Step'), + ); + const child = SizedBox(height: 12); + expect( + const AcmeActivityItem(id: 'one', title: 'Step', child: child), + const AcmeActivityItem(id: 'one', title: 'Step', child: child), + ); + }); + }); + group('AcmeThemeData exact values', () { test('light carries the declared palette', () { const theme = AcmeThemeData.light(); @@ -6090,3 +6302,17 @@ double _contrastRatio(Color first, Color second) { return (lighter + 0.05) / (darker + 0.05); } + +Future _pumpInstalledAgent(WidgetTester tester, Widget child) => + _pumpInScope( + tester, + Overlay.wrap( + child: Align( + alignment: Alignment.topLeft, + child: DefaultTextStyle( + style: const TextStyle(fontSize: 14, color: Color(0xFF171717)), + child: child, + ), + ), + ), + ); diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md index 00f04cc58..8b1ba76c3 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -6,6 +6,7 @@ - Add controlled sidebar collapse, coordinated width/content transitions configured with AnimationStyle, accessible icon-only destinations, and styled tooltips. - Add controlled tooltip visibility and preserve enclosing dismissal actions when closed. - Add `RemixToastScope`, `showRemixToast`, and the stateless `RemixToast`: queued, nonmodal notifications over `NakedToastScope` with six directional placements, same-id replacement, pause on hover, focus, and background, status or alert announcements, and composed action and close buttons styled through `ToastSpec`. Requires `naked_ui` 1.0.0-beta.15. +- Export `RemixStyleSpecBuilder`, the builder every Remix component already uses to accept either a fluent style or a resolved `styleSpec`. Packages that compose Remix into their own surfaces previously had to reimplement that either/or, and the copies dropped the `controller` and focus-highlight plumbing that makes state variants resolve. ## 1.0.0-beta.9 diff --git a/packages/remix/README.md b/packages/remix/README.md index 5ea262d69..05b8c1653 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -252,7 +252,7 @@ Remix is ideal for: ## Examples Check out `apps/dashboard`, `apps/demo`, and the per-package examples in -`packages/remix/example` and `packages/remix_fortal/example` for complete working examples demonstrating: +`packages/remix/example` and `registry_source/example` for complete working examples demonstrating: - Component usage patterns - Style composition techniques - Design system implementation diff --git a/packages/remix/lib/remix.dart b/packages/remix/lib/remix.dart index cbd2cd6ad..6391a3964 100644 --- a/packages/remix/lib/remix.dart +++ b/packages/remix/lib/remix.dart @@ -74,7 +74,7 @@ export 'src/rendering/remix_box_effects.dart' /// STYLER CONVENIENCES export 'src/utilities/remix_style.dart' - show RemixBoxStylerAnchors, RemixBoxStylerMixin; + show RemixBoxStylerAnchors, RemixBoxStylerMixin, RemixStyleSpecBuilder; export 'src/utilities/selected_mixin.dart' show SelectedWidgetStateVariantExtension; diff --git a/packages/remix/test/components/menu/menu_inherited_style_test.dart b/packages/remix/test/components/menu/menu_inherited_style_test.dart index 0bf406f36..645b43132 100644 --- a/packages/remix/test/components/menu/menu_inherited_style_test.dart +++ b/packages/remix/test/components/menu/menu_inherited_style_test.dart @@ -51,9 +51,7 @@ void main() { ), ], style: MenuStyler().item( - MenuItemStyler().label( - TextStyler().color(Colors.red), - ), + MenuItemStyler().label(TextStyler().color(Colors.red)), ), styleSpec: raw ? const MenuSpec( @@ -105,10 +103,7 @@ void main() { rendered('Inherited')?.fontFamily, raw ? 'RawFont' : 'InheritedFont', ); - expect( - rendered('Inherited')?.color, - raw ? Colors.blue : Colors.red, - ); + expect(rendered('Inherited')?.color, raw ? Colors.blue : Colors.red); expect(rendered('Item override')?.fontSize, raw ? 21 : 31); } expect(tester.takeException(), isNull); diff --git a/packages/remix_agent/analysis_options.yaml b/packages/remix_agent/analysis_options.yaml deleted file mode 100644 index bc4874820..000000000 --- a/packages/remix_agent/analysis_options.yaml +++ /dev/null @@ -1,19 +0,0 @@ -analyzer: - # Keep this list in sync with Flutter's AnalysisOptionsMigration so - # `flutter pub get` does not rewrite this file during publish (dirty git - # working tree makes `dart pub publish` fail with exit code 65). - exclude: - - build/** - - android/** - - ios/** - - web/** - - windows/** - - macos/** - - linux/** - errors: - non_constant_identifier_names: ignore - -linter: - rules: - public_member_api_docs: false - prefer_relative_imports: true diff --git a/packages/remix_agent/example/README.md b/packages/remix_agent/example/README.md deleted file mode 100644 index 8b6969691..000000000 --- a/packages/remix_agent/example/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Remix Agent catalog - -Local review surface for every unpublished `remix_agent` widget. Host chrome -only — the package still ships no theme. - -```bash -cd packages/remix_agent/example -fvm flutter run -d chrome -# or -fvm flutter run -d web-server --web-hostname localhost --web-port 7388 -``` - -Day / Night switches the Composer's installed `UiThemeScope` and the local -styles used by the other seven surfaces. The Agent package contributes no -visual defaults. The catalog uses a fixed two-column wide layout at 880 logical -pixels and a single-column narrow layout below that breakpoint. - -The hero is a full turn. The rail jumps to Composer, Message, Transcript, -Permission, Execution, Plan, Activity, and Answer. Each section is a live -control, not a screenshot. The composed run uses the page scroll so every -permission decision stays visible. Allow or deny the mock command, finish or stop it, and submit a new -message to replay. It never invokes a terminal or model. - -The registry-installed Button recipe also supplies catalog and permission -actions. Composer controls add 48px touch geometry through the recipe override -slots; the installed source remains unchanged. - -The catalog recipes pass Remix child stylers separately from structural Agent -stylers. A shared Lucide chevron builder adds Mix rotation to disclosure -indicators; other glyphs use Agent's defaults. Top-bar and rail actions are -Remix controls, so the same catalog can be reviewed with pointer or keyboard. - -`motion.dart` keeps animation choices in the application: 120ms control feedback -and 200ms chevron and turn-item entrances. The latter fade and move 8px without -changing layout; request keys prevent replay during status updates. Reduced -motion renders the final state immediately. Disclosure panels reuse Remix's -existing fade/size transition and reduced-motion handling. diff --git a/packages/remix_agent/example/analysis_options.yaml b/packages/remix_agent/example/analysis_options.yaml deleted file mode 100644 index 2a60106a5..000000000 --- a/packages/remix_agent/example/analysis_options.yaml +++ /dev/null @@ -1,16 +0,0 @@ -analyzer: - exclude: - - build/** - - android/** - - ios/** - - web/** - - windows/** - - macos/** - - linux/** - errors: - non_constant_identifier_names: ignore - -linter: - rules: - public_member_api_docs: false - prefer_relative_imports: true diff --git a/packages/remix_agent/example/lib/agent_recipes.dart b/packages/remix_agent/example/lib/agent_recipes.dart deleted file mode 100644 index 91f0ae51c..000000000 --- a/packages/remix_agent/example/lib/agent_recipes.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; - -import 'ui/ui.dart'; - -/// Every styler one [AgentComposer] needs, in one value. -/// -/// `AgentComposer` takes five stylers: its own anatomy through `style`, and -/// four unresolved child stylers for the card, the text area, and the send and -/// stop buttons. A single `@MixWidget` recipe cannot supply the other four, -/// because `AgentComposerSpec` holds only the toolbar. So the application -/// hands over a bundle instead, and the call site spreads it across the -/// parameters the widget already has. -/// -/// The child stylers stay **unresolved** on purpose. Resolving a nested -/// `IconButtonSpec` here would freeze the button at one state and lose its own -/// hover, press, focus, and disabled fragments; `RemixIconButton` resolves -/// them against its own controller. -@immutable -class UiAgentComposerRecipe { - /// Creates a bundle of the five stylers a composer takes. - const UiAgentComposerRecipe({ - required this.style, - required this.surfaceStyle, - required this.fieldStyle, - required this.submitStyle, - required this.stopStyle, - }); - - /// The Agent-owned anatomy: the toolbar row under the field. - final AgentComposerStyler style; - - /// The card the field and the toolbar share. - final CardStyler surfaceStyle; - - /// The growable prompt field. - final TextFieldStyler fieldStyle; - - /// The send button, shown while no run is live. - final IconButtonStyler submitStyle; - - /// The stop button, which replaces send during a run. - final IconButtonStyler stopStyle; -} - -/// This application's Composer recipe. -/// -/// It adds application geometry — 48px action targets, toolbar, card inset, -/// and the field's missing second box — and takes everything else from the -/// installed [uiCardStyle], [uiTextAreaStyle], and [uiIconButtonStyle] -/// recipes in `lib/ui/components/`. Editing one of those files changes this -/// composer with it, which is the whole point of installing them. -/// -/// It is the one demo wired this way. The other seven surfaces still use the -/// local structural stylers in `demos.dart`; their buttons reuse installed -/// recipes. The package's ADR records this intermediate state: prove one -/// complete surface before converting eight. -/// -/// Each parameter is merged **last** into its own styler, so a call site can -/// override any one surface without forking the bundle: -/// -/// ```dart -/// final recipe = uiAgentComposerRecipe( -/// submitStyle: IconButtonStyler().color(const Color(0xFF7C3AED)), -/// ); -/// ``` -UiAgentComposerRecipe uiAgentComposerRecipe({ - AgentComposerStyler style = const AgentComposerStyler.create(), - CardStyler surfaceStyle = const CardStyler.create(), - TextFieldStyler fieldStyle = const TextFieldStyler.create(), - IconButtonStyler submitStyle = const IconButtonStyler.create(), - IconButtonStyler stopStyle = const IconButtonStyler.create(), -}) => UiAgentComposerRecipe( - style: _toolbarStyle().merge(style), - surfaceStyle: uiCardStyle(style: _surfaceStyle().merge(surfaceStyle)), - fieldStyle: uiTextAreaStyle(style: _fieldStyle().merge(fieldStyle)), - // Preserve the installed recipe and add the catalog's touch-target geometry. - submitStyle: uiIconButtonStyle( - size: .small, - style: IconButtonStyler().size(48, 48).merge(submitStyle), - ), - // `destructive` is the vocabulary's interrupt colour, and stop interrupts a - // run. Reusing it keeps the composer inside the fifteen theme tokens. - stopStyle: uiIconButtonStyle( - variant: .destructive, - size: .small, - style: IconButtonStyler().size(48, 48).merge(stopStyle), - ), -); - -/// Gap between the toolbar's controls, and between the toolbar and the field. -const _toolbarGap = 8.0; - -/// Inset between the card edge and the field or toolbar. -/// -/// Tighter than the card recipe's own 24: a composer is an input frame, and -/// the field inside it already carries the reading gutter. -const _surfacePadding = 12.0; - -/// A fill that paints nothing. -const _transparent = Color(0x00000000); - -/// The toolbar row: full width, controls pushed to the trailing edge. -/// -/// `AgentComposer` puts a `Spacer` before the trailing slot, so the row has to -/// take the full main axis for that spacer to have anything to distribute. -AgentComposerStyler _toolbarStyle() => AgentComposerStyler( - toolbar: FlexBoxStyler() - .direction(.horizontal) - .mainAxisSize(.max) - .crossAxisAlignment(.center) - .spacing(_toolbarGap) - .padding(.only(top: _toolbarGap)), -); - -/// The card, at the composer's tighter inset. -CardStyler _surfaceStyle() => CardStyler().padding(.all(_surfacePadding)); - -/// The field, with its own surface removed. -/// -/// The card is already the frame, so the text area drops its fill, its border, -/// and uses a small reading gutter inside the shared frame. Its -/// typography, hint colour, cursor colour, focus ring, and disabled fragment -/// all stay, which is what keeps this a recipe edit rather than a fork. -TextFieldStyler _fieldStyle() => TextFieldStyler() - .color(_transparent) - .border(.style(.none)) - .minHeight(56) - .padding(.all(4)); diff --git a/packages/remix_agent/example/lib/demos.dart b/packages/remix_agent/example/lib/demos.dart deleted file mode 100644 index d2ae5e25e..000000000 --- a/packages/remix_agent/example/lib/demos.dart +++ /dev/null @@ -1,831 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter/services.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; - -import 'agent_recipes.dart'; -import 'host.dart'; -import 'motion.dart'; -import 'ui/ui.dart'; - -/// Example-only light/dark recipes. They are deliberately not exported by the -/// headless package. -final class _AgentDemoStyles { - _AgentDemoStyles(this.theme, {required this.narrow, required this.feedback}); - - final AnimationConfig? feedback; - - final bool narrow; - - final HostTheme theme; - - Color get ink => theme.ink; - Color get paper => theme.surface; - Color get line => theme.hairline; - - CardStyler get card => CardStyler() - .color(paper) - .border(.all(.color(line).width(1))) - .borderRadius(.circular(12)) - .padding(.all(16)); - - ButtonStyler get button => uiButtonStyle( - style: ButtonStyler( - animation: feedback, - ).minHeight(48).padding(.horizontal(12)), - ); - - ButtonStyler get quietButton => uiButtonStyle( - variant: .outline, - style: ButtonStyler( - animation: feedback, - ).minHeight(48).padding(.horizontal(12)), - ); - - ButtonStyler get ghostButton => uiButtonStyle( - variant: .ghost, - style: ButtonStyler( - animation: feedback, - ).minHeight(48).padding(.horizontal(12)), - ); - - ButtonStyler decision(ButtonStyler style) => - narrow ? style.width(double.infinity) : style; - - IconButtonStyler get utilityIconButton => uiIconButtonStyle( - variant: .ghost, - style: IconButtonStyler(animation: feedback).size(48, 48), - ); - - DataListStyler get dataList => - DataListStyler().rowSpacing(6).columnSpacing(12); - - DisclosureStyler get disclosure => DisclosureStyler() - .trigger(BoxStyler().minHeight(48).padding(.symmetric(vertical: 8))) - .content(BoxStyler().padding(.only(top: 8))); - - // The trigger already reserves 48px. Avoid stacking another content inset - // above the first row; keep the row spacing and outer card inset intact. - DisclosureStyler get ledger => disclosure - .content(BoxStyler().padding(.all(0))) - .container( - BoxStyler() - .color(paper) - .border(.all(.color(line).width(1))) - .borderRadius(.circular(12)) - .padding(.symmetric(horizontal: 16, vertical: 8)), - ); - - AgentMessageStyler get message => AgentMessageStyler( - row: FlexBoxStyler().mainAxisSize(.max).spacing(8), - avatar: BoxStyler().size(28, 28), - header: BoxStyler().padding(.only(bottom: 6)), - body: BoxStyler(), - footer: BoxStyler().padding(.only(top: 4)), - maxWidth: 560, - ); - - AgentMessageCollapsibleStyler get collapsible => - AgentMessageCollapsibleStyler( - collapsedHeight: 72, - container: BoxStyler(), - clipped: BoxStyler(), - ); - - AgentPlanStyler get plan => AgentPlanStyler( - viewport: BoxStyler().maxHeight(220), - item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), - summaryTitle: TextStyler() - .color(ink) - .fontSize(14) - .fontWeight(FontWeight.w600), - itemTitle: TextStyler().color(ink).fontSize(14), - itemDetail: TextStyler().color(ink.withValues(alpha: 0.62)).fontSize(12), - count: TextStyler() - .color(ink.withValues(alpha: 0.62)) - .fontSize(12) - .wrap(.padding(.only(right: 8))), - indicator: IconStyler().color(ink).size(16), - // 0.5 rather than 0.45: these marks carry state, and the lighter value - // measured 2.94:1 on the light card, under the 3:1 floor for non-text. - pendingStatus: IconStyler().color(ink.withValues(alpha: 0.5)).size(18), - activeStatus: IconStyler().color(theme.live).size(18), - completedStatus: IconStyler().color(theme.live).size(18), - cancelledStatus: IconStyler().color(ink.withValues(alpha: 0.5)).size(18), - ); - - // Keep 12px status marks centered in the same 18px slot as Plan's glyphs. - // With the 6px row gap, both ledgers share the tool headers' 24px text gutter. - IconStyler _leadingStatus(Color color) => - IconStyler().color(color).size(12).wrap(.padding(.horizontal(3))); - - AgentActivityStyler get activity => AgentActivityStyler( - viewport: BoxStyler().maxHeight(200), - item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), - summaryTitle: TextStyler() - .color(ink) - .fontSize(14) - .fontWeight(FontWeight.w600), - itemTitle: TextStyler().color(ink).fontSize(14), - itemDetail: TextStyler().color(ink.withValues(alpha: 0.62)).fontSize(12), - // Same styler as Plan's count, and Activity reserves the chevron's slot - // even while working, so the two ledgers' counts share one right edge. - count: TextStyler() - .color(ink.withValues(alpha: 0.62)) - .fontSize(12) - .wrap(.padding(.only(right: 8))), - indicator: IconStyler().color(ink).size(16), - pendingStatus: _leadingStatus(ink.withValues(alpha: 0.5)), - activeStatus: _leadingStatus(theme.live), - completedStatus: _leadingStatus(theme.live), - ); - - AgentExecutionStyler get execution => AgentExecutionStyler( - header: FlexBoxStyler().spacing(8), - output: BoxStyler() - .color(ink.withValues(alpha: 0.05)) - .borderRadius(.circular(6)) - .padding(.all(12)), - actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), - tool: TextStyler() - .color(ink.withValues(alpha: 0.62)) - .fontSize(12) - .wrap(.padding(.only(top: 4))), - title: TextStyler().color(ink).fontWeight(FontWeight.w600), - meta: TextStyler().color(ink.withValues(alpha: 0.62)).fontSize(12), - status: TextStyler() - .color(ink.withValues(alpha: 0.72)) - .fontSize(12) - .wrap(.padding(.symmetric(horizontal: 6))), - toolIcon: IconStyler().color(ink).size(16), - statusIcon: IconStyler().color(theme.live).size(12), - indicator: IconStyler().color(ink).size(16), - ); - - AgentPermissionStyler get permission => AgentPermissionStyler( - header: FlexBoxStyler().spacing(8), - actions: FlexBoxStyler() - .direction(narrow ? .vertical : .horizontal) - .crossAxisAlignment(narrow ? .stretch : .center) - .spacing(8) - .padding(.only(top: 8)), - title: TextStyler().color(ink).fontWeight(FontWeight.w600), - tool: TextStyler() - .color(ink.withValues(alpha: 0.62)) - .fontSize(12) - .wrap(.padding(.directional(start: 24, top: 4))), - description: TextStyler() - .color(ink.withValues(alpha: 0.72)) - .wrap(.padding(.symmetric(vertical: 8))), - status: TextStyler() - .color(ink.withValues(alpha: 0.72)) - .fontSize(12) - .wrap(.padding(.symmetric(horizontal: 6))), - detailsLabel: TextStyler().color(ink).fontSize(13), - toolIcon: IconStyler().color(ink).size(16), - statusIcon: _leadingStatus(theme.live), - indicator: IconStyler().color(ink).size(16), - ); - - AgentAnswerStyler get answer => AgentAnswerStyler( - body: BoxStyler(), - actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), - sourcesLabel: TextStyler().color(ink).fontSize(13), - indicator: IconStyler().color(ink).size(16), - ); - - AgentTranscriptStyler get transcript => AgentTranscriptStyler( - viewport: BoxStyler().padding(.only(right: 12)), - item: BoxStyler(), - spacing: 16, - ); -} - -_AgentDemoStyles _styles(BuildContext context) => _AgentDemoStyles( - HostTheme.of(context), - narrow: MediaQuery.sizeOf(context).width < 600, - feedback: catalogMotion(context, quick: true), -); - -class CatalogAction extends StatelessWidget { - const CatalogAction({ - super.key, - required this.label, - this.onPressed, - this.quiet = true, - }); - final String label; - final VoidCallback? onPressed; - final bool quiet; - - @override - Widget build(BuildContext context) { - final styles = _styles(context); - return Padding( - padding: const EdgeInsets.only(top: 12), - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: RemixButton( - label: label, - onPressed: onPressed, - enabled: onPressed != null, - style: quiet ? styles.quietButton : styles.button, - ), - ), - ); - } -} - -/// The catalog's composer, styled by the installed recipes. -/// -/// Both call sites go through here so the standalone demo and the composed run -/// cannot drift apart. -Widget _installedComposer( - BuildContext context, { - required ValueChanged onSubmit, - bool running = false, - VoidCallback? onStop, -}) { - final feedback = catalogMotion(context, quick: true); - final recipe = uiAgentComposerRecipe( - // The send control already owns a 48px row of its own under the field. - // A two-line floor on top of that made an empty composer the tallest - // thing in the catalog. - fieldStyle: TextFieldStyler().minHeight(44), - submitStyle: IconButtonStyler(animation: feedback), - stopStyle: IconButtonStyler(animation: feedback), - ); - - return AgentComposer( - style: recipe.style, - surfaceStyle: recipe.surfaceStyle, - fieldStyle: recipe.fieldStyle, - submitStyle: recipe.submitStyle, - stopStyle: recipe.stopStyle, - minLines: 1, - running: running, - onSubmit: onSubmit, - onStop: onStop, - ); -} - -class ComposerDemo extends StatefulWidget { - const ComposerDemo({super.key}); - @override - State createState() => _ComposerDemoState(); -} - -class _ComposerDemoState extends State { - var running = false; - String? sent; - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _installedComposer( - context, - running: running, - onSubmit: (value) => setState(() { - sent = value; - running = true; - }), - onStop: () => setState(() => running = false), - ), - if (sent != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text('Last sent: $sent', style: HostTheme.of(context).meta), - ), - ], - ); - } -} - -class MessageDemo extends StatelessWidget { - const MessageDemo({super.key}); - @override - Widget build(BuildContext context) { - final styles = _styles(context); - return AgentMessageGroup( - spacing: 16, - children: [ - AgentMessage( - role: AgentRole.user, - style: styles.message, - surfaceStyle: styles.card, - header: Text('You', style: HostTheme.of(context).meta), - child: const Text('Review the checkout flow and pause before tests.'), - ), - AgentMessage( - role: AgentRole.assistant, - style: styles.message, - surfaceStyle: styles.card, - header: Text('Agent', style: HostTheme.of(context).meta), - child: AgentMessageCollapsible( - style: styles.collapsible, - toggleStyle: styles.ghostButton, - child: const Text( - 'I will inspect the checkout flow, map the payment path, verify the shared cart model, and pause before running focused checks. ' - 'This longer message demonstrates explicit opt-in clipping: the host asks for it, the collapsed height is the host\'s number, ' - 'and everything past that height stays clipped until someone expands the row. Long enough to clip at the catalog\'s own width, ' - 'not only on a phone.', - ), - ), - ), - ], - ); - } -} - -class TranscriptDemo extends StatefulWidget { - const TranscriptDemo({super.key}); - @override - State createState() => _TranscriptDemoState(); -} - -class _TranscriptDemoState extends State { - final _scroll = ScrollController(); - var lines = 10; - - @override - void dispose() { - _scroll.dispose(); - super.dispose(); - } - - var following = true; - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - RemixCard( - style: _styles(context).card, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - following ? 'Following the live edge' : 'Reading history', - style: HostTheme.of(context).meta, - ), - const SizedBox(height: 12), - SizedBox( - height: 180, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of( - context, - ).copyWith(scrollbars: false), - child: RawScrollbar( - controller: _scroll, - thumbVisibility: true, - thumbColor: HostTheme.of(context).ink.withValues(alpha: 0.45), - child: AgentTranscript.builder( - controller: _scroll, - style: _styles(context).transcript, - itemCount: lines, - itemBuilder: (_, i) => - Text('Line ${i + 1} of the growing log.'), - onFollowChanged: (value) => - setState(() => following = value), - ), - ), - ), - ), - ], - ), - ), - CatalogAction( - label: 'Append lines', - onPressed: () => setState(() => lines += 4), - ), - ], - ); -} - -class PermissionDemo extends StatefulWidget { - const PermissionDemo({super.key}); - @override - State createState() => _PermissionDemoState(); -} - -class _PermissionDemoState extends State { - var status = AgentPermissionStatus.pending; - var request = 0; - @override - Widget build(BuildContext context) { - final styles = _styles(context); - return Column( - children: [ - AgentPermission( - requestId: request, - style: styles.permission, - indicatorBuilder: catalogChevron, - surfaceStyle: styles.card, - detailsStyle: styles.disclosure, - parametersStyle: styles.dataList, - allowOnceStyle: styles.decision(styles.button), - alwaysAllowStyle: styles.decision(styles.quietButton), - denyStyle: styles.decision(styles.ghostButton), - tool: 'terminal.run', - status: status, - description: - 'Run the focused test suite. Always allow applies only ' - 'to this command in this demo session.', - parameters: const [ - RemixDataListItem(label: 'Command', value: 'flutter test'), - RemixDataListItem( - label: 'Directory', - value: 'packages/remix_agent', - ), - ], - onAllowOnce: () => - setState(() => status = AgentPermissionStatus.complete), - onAlwaysAllow: () => - setState(() => status = AgentPermissionStatus.complete), - onDeny: () => setState(() => status = AgentPermissionStatus.denied), - ), - CatalogAction( - label: 'Replay', - onPressed: () => setState(() { - request++; - status = AgentPermissionStatus.pending; - }), - ), - ], - ); - } -} - -String _executionOutput(AgentExecutionStatus status) => switch (status) { - .running => 'Running the focused test suite…', - .success => '12 passed · 0 failed', - .error => 'Checkout validation failed. Review the output and retry.', - .cancelled => 'Checks stopped before completion.', -}; - -class ExecutionDemo extends StatefulWidget { - const ExecutionDemo({super.key}); - @override - State createState() => _ExecutionDemoState(); -} - -class _ExecutionDemoState extends State { - var status = AgentExecutionStatus.running; - @override - Widget build(BuildContext context) { - final styles = _styles(context); - return Column( - children: [ - AgentExecution( - style: styles.execution, - indicatorBuilder: catalogChevron, - surfaceStyle: styles.card, - disclosureStyle: styles.disclosure, - copyStyle: styles.utilityIconButton, - retryStyle: styles.utilityIconButton, - tool: 'terminal.run', - title: 'Focused checks', - status: status, - onCopy: () => - Clipboard.setData(ClipboardData(text: _executionOutput(status))), - onRetry: () => setState(() => status = AgentExecutionStatus.running), - child: Text(_executionOutput(status)), - ), - // Cycles through the failure state too. It was the one status with - // copy written for it that no control in the catalog could reach. - CatalogAction( - label: switch (status) { - AgentExecutionStatus.running => 'Succeed', - AgentExecutionStatus.success => 'Fail', - _ => 'Replay', - }, - onPressed: () => setState( - () => status = switch (status) { - AgentExecutionStatus.running => AgentExecutionStatus.success, - AgentExecutionStatus.success => AgentExecutionStatus.error, - _ => AgentExecutionStatus.running, - }, - ), - ), - ], - ); - } -} - -class PlanDemo extends StatefulWidget { - const PlanDemo({super.key}); - @override - State createState() => _PlanDemoState(); -} - -AgentPlanItemStatus _planItemStatus(int index, int currentStep) { - if (index < currentStep) return AgentPlanItemStatus.completed; - if (index == currentStep) return AgentPlanItemStatus.inProgress; - return AgentPlanItemStatus.pending; -} - -class _PlanDemoState extends State { - var step = 0; - @override - Widget build(BuildContext context) { - final styles = _styles(context); - final items = List.generate( - 3, - (i) => AgentPlanItem( - id: '$i', - title: ['Read the brief', 'Map the path', 'Run checks'][i], - status: _planItemStatus(i, step), - ), - ); - return Column( - children: [ - AgentPlan( - style: styles.plan, - indicatorBuilder: catalogChevron, - disclosureStyle: styles.ledger, - items: items, - ), - CatalogAction( - label: step < 3 ? 'Advance' : 'Replay', - onPressed: () => setState(() => step = step < 3 ? step + 1 : 0), - ), - ], - ); - } -} - -class ActivityDemo extends StatefulWidget { - const ActivityDemo({super.key}); - @override - State createState() => _ActivityDemoState(); -} - -class _ActivityDemoState extends State { - var status = AgentRunStatus.working; - @override - Widget build(BuildContext context) { - final styles = _styles(context); - return Column( - children: [ - AgentActivity( - style: styles.activity, - indicatorBuilder: catalogChevron, - disclosureStyle: styles.ledger, - status: status, - items: [ - const AgentActivityItem( - id: 'read', - title: 'Reading the brief', - status: AgentActivityItemStatus.complete, - ), - AgentActivityItem( - id: 'map', - title: 'Mapping the path', - status: status == AgentRunStatus.working - ? AgentActivityItemStatus.active - : AgentActivityItemStatus.complete, - ), - ], - ), - CatalogAction( - label: status == AgentRunStatus.working ? 'Complete' : 'Replay', - onPressed: () => setState( - () => status = status == AgentRunStatus.working - ? AgentRunStatus.complete - : AgentRunStatus.working, - ), - ), - ], - ); - } -} - -class AnswerDemo extends StatefulWidget { - const AnswerDemo({super.key}); - @override - State createState() => _AnswerDemoState(); -} - -class _AnswerDemoState extends State { - var status = AgentAnswerStatus.streaming; - var stream = 0; - @override - Widget build(BuildContext context) { - final styles = _styles(context); - return Column( - children: [ - AgentAnswer( - style: styles.answer, - sourcesIndicatorBuilder: catalogChevron, - surfaceStyle: styles.card, - sourcesStyle: styles.disclosure, - copyStyle: styles.utilityIconButton, - retryStyle: styles.utilityIconButton, - streamId: stream, - status: status, - onCopy: () => Clipboard.setData( - const ClipboardData(text: 'The checkout flow is ready for review.'), - ), - onRetry: () => setState(() { - stream++; - status = AgentAnswerStatus.streaming; - }), - sourcesContent: status.isStreaming - ? null - : const Text('Checkout brief · payment notes'), - child: Text( - status.isStreaming - ? 'Writing the answer…' - : 'The checkout flow is ready for review.', - ), - ), - CatalogAction( - label: 'Complete', - onPressed: status.isStreaming - ? () => setState(() => status = AgentAnswerStatus.complete) - : null, - ), - ], - ); - } -} - -enum _RunStage { permission, running, complete, denied, cancelled } - -/// A deterministic host-owned run; no model or terminal is contacted. -class ComposedRunDemo extends StatefulWidget { - const ComposedRunDemo({super.key}); - - @override - State createState() => _ComposedRunDemoState(); -} - -class _ComposedRunDemoState extends State { - var stage = _RunStage.permission; - var request = 0; - var prompt = 'Review the checkout flow.'; - - void _start(String value) => setState(() { - prompt = value; - request++; - stage = _RunStage.permission; - }); - - @override - Widget build(BuildContext context) { - final styles = _styles(context); - final working = stage == _RunStage.running; - final complete = stage == _RunStage.complete; - final stopped = stage == _RunStage.denied || stage == _RunStage.cancelled; - final output = _executionOutput( - complete - ? AgentExecutionStatus.success - : stopped - ? AgentExecutionStatus.cancelled - : AgentExecutionStatus.running, - ); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AgentTranscript( - style: styles.transcript.viewport(BoxStyler().padding(.all(0))), - followOutput: false, - children: [ - AgentMessage( - key: ValueKey('message-$request'), - role: AgentRole.user, - style: styles.message, - surfaceStyle: styles.card, - child: Text(prompt), - ), - AgentPlan( - key: ValueKey('plan-$request'), - style: styles.plan, - indicatorBuilder: catalogChevron, - disclosureStyle: styles.ledger, - items: [ - const AgentPlanItem( - id: 'inspect', - title: 'Inspect checkout', - status: .completed, - ), - AgentPlanItem( - id: 'checks', - title: 'Run focused checks', - status: complete - ? .completed - : stopped - ? .cancelled - : stage == _RunStage.permission - ? .pending - : .inProgress, - ), - ], - ), - AgentActivity( - key: ValueKey('activity-$request'), - style: styles.activity, - indicatorBuilder: catalogChevron, - disclosureStyle: styles.ledger, - status: complete || stopped ? .complete : .working, - items: [ - const AgentActivityItem( - id: 'read', - title: 'Read the checkout flow', - status: .complete, - ), - AgentActivityItem( - id: 'checks', - title: stage == _RunStage.permission - ? 'Waiting for permission' - : stopped - ? 'Checks stopped' - : complete - ? 'Finished focused checks' - : 'Running focused checks', - status: complete || stopped ? .complete : .active, - ), - ], - ), - AgentPermission( - key: ValueKey('permission-$request'), - requestId: request, - style: styles.permission, - indicatorBuilder: catalogChevron, - surfaceStyle: styles.card, - detailsStyle: styles.disclosure, - parametersStyle: styles.dataList, - allowOnceStyle: styles.decision(styles.button), - alwaysAllowStyle: styles.decision(styles.quietButton), - denyStyle: styles.decision(styles.ghostButton), - tool: 'terminal.run', - description: - 'Run the focused test suite. This demo never executes a command.', - status: stage == _RunStage.permission - ? .pending - : stage == _RunStage.denied - ? .denied - : working - ? .running - : .complete, - parameters: const [ - RemixDataListItem(label: 'Command', value: 'flutter test'), - ], - onAllowOnce: () => setState(() => stage = _RunStage.running), - onAlwaysAllow: () => setState(() => stage = _RunStage.running), - onDeny: () => setState(() => stage = _RunStage.denied), - ), - if (working || complete || stage == _RunStage.cancelled) - AgentExecution( - key: ValueKey('execution-$request'), - style: styles.execution, - indicatorBuilder: catalogChevron, - surfaceStyle: styles.card, - disclosureStyle: styles.disclosure, - copyStyle: styles.utilityIconButton, - retryStyle: styles.utilityIconButton, - tool: 'terminal.run', - title: 'Focused checks', - status: complete - ? .success - : working - ? .running - : .cancelled, - onCopy: () => Clipboard.setData(ClipboardData(text: output)), - onRetry: () => _start(prompt), - child: Text(output), - ), - if (complete || stopped) - AgentAnswer( - key: ValueKey('answer-$request'), - style: styles.answer, - sourcesIndicatorBuilder: catalogChevron, - surfaceStyle: styles.card, - sourcesStyle: styles.disclosure, - status: .complete, - child: Text( - complete - ? 'All 12 checks passed. The checkout flow is ready for review.' - : stage == _RunStage.denied - ? 'Permission denied. No checks were run.' - : 'Run stopped. Submit another message to try again.', - ), - ), - ].map((child) => CatalogEntrance(key: child.key, child: child)).toList(), - ), - if (working) - CatalogAction( - label: 'Finish checks', - onPressed: () => setState(() => stage = _RunStage.complete), - ), - const SizedBox(height: 16), - _installedComposer( - context, - onSubmit: _start, - running: working, - onStop: () => setState(() => stage = _RunStage.cancelled), - ), - ], - ); - } -} diff --git a/packages/remix_agent/example/lib/host.dart b/packages/remix_agent/example/lib/host.dart deleted file mode 100644 index db60ceb35..000000000 --- a/packages/remix_agent/example/lib/host.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// Example-only host palette. Not part of remix_agent. -/// -/// Cool paper and indigo ink — a workshop ledger, not a product theme. -/// Copper marks the live run. Widgets still read ink from [DefaultTextStyle]. -class HostTheme extends InheritedWidget { - const HostTheme({super.key, required this.dark, required super.child}); - - final bool dark; - - static HostTheme of(BuildContext context) { - final theme = context.dependOnInheritedWidgetOfExactType(); - assert(theme != null, 'HostTheme missing.'); - return theme!; - } - - Color get paper => dark ? const Color(0xFF12151C) : const Color(0xFFE8EDF2); - - Color get ink => dark ? const Color(0xFFE8EDF2) : const Color(0xFF12151C); - - Color get live => const Color(0xFFC45C26); - - Color get rail => dark ? const Color(0xFF1A1E28) : const Color(0xFFDDE3EA); - - /// The fill every card paints, one step off [paper]. - /// - /// The installed recipes read this too: `UiThemeScope` bridges it into - /// `UiTokens.background`, so the composer's card and the seven local cards - /// paint one surface instead of two. - Color get surface => dark ? const Color(0xFF1A1E28) : const Color(0xFFF7F9FB); - - /// The single hairline: card borders, the rail divider, and the installed - /// recipes' `UiTokens.border` all resolve to this one value. - Color get hairline => ink.withValues(alpha: 0.16); - - TextStyle get body => TextStyle( - color: ink, - fontSize: 15, - height: 1.45, - fontFamily: 'Source Sans 3', - fontFamilyFallback: const ['Segoe UI', 'Helvetica Neue', 'sans-serif'], - ); - - TextStyle get display => body.copyWith( - fontSize: 28, - height: 1.15, - fontWeight: FontWeight.w600, - letterSpacing: -0.4, - ); - - TextStyle get meta => body.copyWith( - fontSize: 12, - height: 1.35, - color: ink.withValues(alpha: 0.62), - fontFamily: 'ui-monospace', - fontFamilyFallback: const ['SF Mono', 'Menlo', 'Consolas', 'monospace'], - ); - - @override - bool updateShouldNotify(HostTheme oldWidget) => dark != oldWidget.dark; -} diff --git a/packages/remix_agent/example/lib/main.dart b/packages/remix_agent/example/lib/main.dart deleted file mode 100644 index 7ef7bfa3d..000000000 --- a/packages/remix_agent/example/lib/main.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/semantics.dart'; -import 'package:flutter/widgets.dart'; -import 'package:remix/remix.dart'; - -import 'showcase.dart'; - -final _semanticsHandles = []; - -void main() { - WidgetsFlutterBinding.ensureInitialized(); - runApp(const RemixAgentExampleApp()); - if (kIsWeb) { - _semanticsHandles.add(SemanticsBinding.instance.ensureSemantics()); - } -} - -/// Local catalog host. No theme package and no MaterialApp. -class RemixAgentExampleApp extends StatelessWidget { - const RemixAgentExampleApp({super.key}); - - @override - Widget build(BuildContext context) { - return MixScope.empty( - child: WidgetsApp( - color: const Color(0xFFE8EDF2), - debugShowCheckedModeBanner: false, - builder: (_, _) { - return Overlay.wrap(child: const DarkHost(child: AgentCatalog())); - }, - ), - ); - } -} diff --git a/packages/remix_agent/example/lib/motion.dart b/packages/remix_agent/example/lib/motion.dart deleted file mode 100644 index 2c0719fcc..000000000 --- a/packages/remix_agent/example/lib/motion.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:math' as math; - -import 'package:flutter/widgets.dart'; -import 'package:remix/remix.dart'; - -// Same font-backed Lucide glyph as Agent's default. Importing the generated -// LucideIcons catalog retains thousands of unused font glyphs in a web build. -const _chevronDown = IconData( - 57453, - fontFamily: 'Lucide', - fontPackage: 'lucide_icons_flutter', -); - -/// Application motion policy. Remix still owns disclosure size/fade behavior. -AnimationConfig? catalogMotion(BuildContext context, {bool quick = false}) => - // Mix beta.5 requires positive tween weights. Omit animation entirely - // when motion is reduced instead of supplying a zero-duration tween. - MediaQuery.disableAnimationsOf(context) - ? null - : AnimationConfig.easeOut(Duration(milliseconds: quick ? 120 : 200)); - -/// One rotating glyph for all of the catalog's disclosure builders. -Widget catalogChevron(BuildContext context, bool expanded) => ExcludeSemantics( - child: Box( - style: BoxStyler( - animation: catalogMotion(context), - ).rotate(expanded ? math.pi : 0), - child: Icon( - _chevronDown, - size: 16, - color: DefaultTextStyle.of(context).style.color, - ), - ), -); - -/// A small entrance for a newly inserted turn item, without moving its layout. -/// -/// Stable request keys keep an existing bubble from replaying on status or -/// theme changes. Reduced motion renders the final state on the first frame. -class CatalogEntrance extends StatefulWidget { - const CatalogEntrance({super.key, required this.child}); - - final Widget child; - - @override - State createState() => _CatalogEntranceState(); -} - -class _CatalogEntranceState extends State { - bool _visible = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _visible = true); - }); - } - - @override - Widget build(BuildContext context) { - final visible = _visible || MediaQuery.disableAnimationsOf(context); - return Box( - style: BoxStyler( - animation: catalogMotion(context), - ).wrap(.opacity(visible ? 1 : 0).translate(x: 0, y: visible ? 0 : 8)), - child: widget.child, - ); - } -} diff --git a/packages/remix_agent/example/lib/showcase.dart b/packages/remix_agent/example/lib/showcase.dart deleted file mode 100644 index 5952d425e..000000000 --- a/packages/remix_agent/example/lib/showcase.dart +++ /dev/null @@ -1,542 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:remix/remix.dart'; - -import 'demos.dart'; -import 'host.dart'; -import 'ui/ui.dart'; - -class CatalogEntry { - const CatalogEntry({ - required this.id, - required this.title, - required this.lede, - required this.builder, - }); - - final String id; - final String title; - final String lede; - final WidgetBuilder builder; -} - -final catalogEntries = [ - CatalogEntry( - id: 'run', - title: 'A full turn', - lede: - 'A mock run: allow or deny checks, finish or stop the run, then submit another message.', - builder: (_) => const ComposedRunDemo(), - ), - CatalogEntry( - id: 'composer', - title: 'Composer', - lede: - 'Enter sends. Shift+Enter is a newline. IME composition is ignored. Send becomes Stop while a run is live.', - builder: (_) => const ComposerDemo(), - ), - CatalogEntry( - id: 'message', - title: 'Message', - lede: - 'Sender-aware rows. User aligns to the end, assistant to the start. Grouped turns keep a placeholder avatar.', - builder: (_) => const MessageDemo(), - ), - CatalogEntry( - id: 'transcript', - title: 'Transcript', - lede: - 'Follows growth at the live edge. Scroll away to read history. Return to the edge to follow again.', - builder: (_) => const TranscriptDemo(), - ), - CatalogEntry( - id: 'permission', - title: 'Permission', - lede: - 'Allow once, always allow, or deny. The card stays in the transcript after the decision.', - builder: (_) => const PermissionDemo(), - ), - CatalogEntry( - id: 'execution', - title: 'Execution', - lede: - 'Open while the tool runs. Collapses when it settles. Reopen to read the output.', - builder: (_) => const ExecutionDemo(), - ), - CatalogEntry( - id: 'plan', - title: 'Plan', - lede: 'Advance through three tasks, then replay the plan.', - builder: (_) => const PlanDemo(), - ), - CatalogEntry( - id: 'activity', - title: 'Activity', - lede: - 'A slim ledger. Each row is a title, a status, and an optional host-rendered child.', - builder: (_) => const ActivityDemo(), - ), - CatalogEntry( - id: 'answer', - title: 'Answer', - lede: - 'Host-rendered body. Copy, retry, and sources appear only when the stream settles.', - builder: (_) => const AnswerDemo(), - ), -]; - -class AgentCatalog extends StatefulWidget { - const AgentCatalog({super.key}); - - @override - State createState() => _AgentCatalogState(); -} - -class _AgentCatalogState extends State { - final _keys = {for (final entry in catalogEntries) entry.id: GlobalKey()}; - var _active = catalogEntries.first.id; - late final ScrollController _scroll; - var _jumpInProgress = 0; - - @override - void initState() { - super.initState(); - _scroll = ScrollController()..addListener(_onScroll); - } - - @override - void dispose() { - _scroll - ..removeListener(_onScroll) - ..dispose(); - super.dispose(); - } - - void _onScroll() { - if (_jumpInProgress > 0) return; - CatalogEntry? current; - for (final entry in catalogEntries) { - final box = - _keys[entry.id]?.currentContext?.findRenderObject() as RenderBox?; - if (box == null || !box.hasSize) continue; - final offset = box.localToGlobal(Offset.zero).dy; - if (offset < 160) current = entry; - } - if (current != null && current.id != _active) { - setState(() => _active = current!.id); - } - } - - Future _jump(String id) async { - final context = _keys[id]?.currentContext; - if (context == null) return; - setState(() => _active = id); - _jumpInProgress++; - try { - await Scrollable.ensureVisible( - context, - alignment: 0, - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 220), - ); - } finally { - _jumpInProgress--; - } - } - - @override - Widget build(BuildContext context) { - final theme = HostTheme.of(context); - final wide = MediaQuery.sizeOf(context).width >= 880; - - final rail = _Rail(active: _active, onSelect: _jump, vertical: wide); - - final body = SingleChildScrollView( - controller: _scroll, - padding: EdgeInsets.fromLTRB(wide ? 36 : 20, 28, wide ? 48 : 20, 80), - child: Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 800), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Remix Agent', style: theme.display), - const SizedBox(height: 8), - Text( - 'Surfaces for a long-running run. No theme. No model SDK. ' - 'Compose them in the host.', - style: theme.body.copyWith( - color: theme.ink.withValues(alpha: 0.72), - ), - ), - const SizedBox(height: 8), - Text('UNPUBLISHED REVIEW CATALOG', style: theme.meta), - for (final entry in catalogEntries) - KeyedSubtree( - key: _keys[entry.id], - child: _Section(entry: entry, wide: wide), - ), - ], - ), - ), - ), - ); - - if (!wide) { - return Column( - children: [ - _TopBar(onToggleDark: _toggleDark), - rail, - Expanded(child: body), - ], - ); - } - - return Column( - children: [ - _TopBar(onToggleDark: _toggleDark), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(width: 220, child: rail), - ColoredBox( - color: theme.hairline, - child: const SizedBox(width: 1), - ), - Expanded(child: body), - ], - ), - ), - ], - ); - } - - void _toggleDark() { - final host = context.findAncestorStateOfType<_DarkHostState>(); - host?.toggle(); - } -} - -/// The installed theme, bridged onto this application's palette. -/// -/// The registry ships a neutral scale; this catalog is a workshop ledger. Only -/// the surface, text, hairline, radius and accent tokens move, and they move -/// through the installed theme's own [UiThemeData.copyWith] — so `lib/ui/` -/// stays byte-identical to the registry and every recipe recomputes its hover, -/// focus and disabled fragments from these values for free. -/// -/// `primary` becomes copper, which keeps the accent on exactly two things: a -/// live run, and the one primary action on a surface. `destructive` stays the -/// registry red, because stopping a run is an interrupt, not the accent. -UiThemeData _bridgedTheme(HostTheme theme) { - final base = theme.dark - ? const UiThemeData.dark() - : const UiThemeData.light(); - return base.copyWith( - background: theme.surface, - foreground: theme.ink, - primary: theme.live, - primaryForeground: const Color(0xFFF7F9FB), - muted: theme.ink.withValues(alpha: 0.05), - mutedForeground: theme.ink.withValues(alpha: 0.62), - accent: theme.ink.withValues(alpha: 0.08), - accentForeground: theme.ink, - border: theme.hairline, - focusRing: theme.live, - radius: const Radius.circular(12), - ); -} - -/// Lets the catalog flip the ancestor [HostTheme]. -class DarkHost extends StatefulWidget { - const DarkHost({super.key, required this.child}); - - final Widget child; - - @override - State createState() => _DarkHostState(); -} - -class _DarkHostState extends State { - var dark = false; - - void toggle() => setState(() => dark = !dark); - - @override - Widget build(BuildContext context) { - final theme = HostTheme(dark: dark, child: widget.child); - return HostTheme( - dark: dark, - // The installed recipes resolve `UiTokens` through the `MixScope` this - // scope installs, so the composer follows the same light/dark switch the - // rest of the catalog does. The other seven demos use plain colours and - // do not read tokens, so nesting this over `MixScope.empty` changes - // nothing for them. - child: UiThemeScope( - data: _bridgedTheme(theme), - child: DefaultTextStyle( - style: theme.body, - child: ColoredBox(color: theme.paper, child: widget.child), - ), - ), - ); - } -} - -class _TopBar extends StatelessWidget { - const _TopBar({required this.onToggleDark}); - - final VoidCallback onToggleDark; - - @override - Widget build(BuildContext context) { - final theme = HostTheme.of(context); - return ColoredBox( - color: theme.rail, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - Semantics( - container: true, - explicitChildNodes: true, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ExcludeSemantics( - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: theme.live, - shape: BoxShape.circle, - ), - ), - ), - const SizedBox(width: 10), - Text( - 'Remix Agent', - style: theme.body.copyWith(fontWeight: FontWeight.w600), - ), - if (MediaQuery.sizeOf(context).width >= 480) ...[ - const SizedBox(width: 12), - Text('review catalog', style: theme.meta), - ], - ], - ), - ), - const Spacer(), - Semantics( - container: true, - explicitChildNodes: true, - child: RemixButton( - label: theme.dark ? 'Day' : 'Night', - onPressed: onToggleDark, - style: ButtonStyler() - .minWidth(48) - .minHeight(48) - .padding(.symmetric(horizontal: 8)) - .label( - TextStyler() - .color(theme.ink) - .fontSize(theme.meta.fontSize ?? 12), - ), - ), - ), - ], - ), - ), - ); - } -} - -class _Rail extends StatefulWidget { - const _Rail({ - required this.active, - required this.onSelect, - required this.vertical, - }); - - final String active; - final ValueChanged onSelect; - final bool vertical; - - @override - State<_Rail> createState() => _RailState(); -} - -class _RailState extends State<_Rail> { - final _keys = {for (final entry in catalogEntries) entry.id: GlobalKey()}; - - @override - void didUpdateWidget(_Rail oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.active != widget.active || - oldWidget.vertical != widget.vertical) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - final target = _keys[widget.active]?.currentContext; - if (target == null) return; - // Reveal only within the rail; do not move the page's content scroll. - Scrollable.of( - target, - ).position.ensureVisible(target.findRenderObject()!, alignment: 0.5); - }); - } - } - - @override - Widget build(BuildContext context) { - final theme = HostTheme.of(context); - final items = [ - for (final entry in catalogEntries) - _RailItem( - key: _keys[entry.id], - label: entry.title, - selected: entry.id == widget.active, - onTap: () => widget.onSelect(entry.id), - ), - ]; - - if (!widget.vertical) { - return ColoredBox( - color: theme.rail, - // The strip scrolls, so its edges fade into the rail instead of - // cutting a label mid-word. The scrollbar is dropped with it: a - // hairline of chrome under the chips read as a stray rule. - child: ShaderMask( - shaderCallback: (bounds) => const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Color(0x00000000), - Color(0xFF000000), - Color(0xFF000000), - Color(0x00000000), - ], - stops: [0, 0.04, 0.96, 1], - ).createShader(bounds), - blendMode: BlendMode.dstIn, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of( - context, - ).copyWith(scrollbars: false), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - // 8 here plus the toggle's own 12 puts a chip's label on the - // same left edge as the page content below it. - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row(children: items), - ), - ), - ), - ); - } - - return ColoredBox( - color: theme.rail, - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 20, 12, 20), - children: [ - // The pills start at the list's own 16; their labels sit 12 further - // in. The caption follows the labels, so the column reads as one - // left edge rather than two. - const Padding( - padding: EdgeInsets.only(left: 12), - child: _RailCaption(), - ), - const SizedBox(height: 12), - ...items, - ], - ), - ); - } -} - -class _RailCaption extends StatelessWidget { - const _RailCaption(); - - @override - Widget build(BuildContext context) => - Text('Surfaces', style: HostTheme.of(context).meta); -} - -class _RailItem extends StatelessWidget { - const _RailItem({ - super.key, - required this.label, - required this.selected, - required this.onTap, - }); - - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = HostTheme.of(context); - return Padding( - padding: const EdgeInsets.only(bottom: 6, right: 8), - child: RemixToggle( - selected: selected, - label: label, - onChanged: (_) => onTap(), - style: ToggleStyler() - .minHeight(48) - .padding(.symmetric(horizontal: 12)) - .borderRadius(.circular(8)) - .color( - selected - ? theme.live.withValues(alpha: 0.14) - : const Color(0x00000000), - ) - .label( - TextStyler() - .color(theme.ink) - .fontSize(theme.body.fontSize ?? 14) - .fontWeight(selected ? FontWeight.w600 : FontWeight.w400), - ), - ), - ); - } -} - -class _Section extends StatelessWidget { - const _Section({required this.entry, required this.wide}); - - final CatalogEntry entry; - - final bool wide; - - double get _sectionGap => wide ? 48 : 32; - - @override - Widget build(BuildContext context) { - final theme = HostTheme.of(context); - return Padding( - padding: EdgeInsets.only(top: _sectionGap), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - entry.title, - style: theme.body.copyWith( - fontSize: 20, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text( - entry.lede, - style: theme.body.copyWith(color: theme.ink.withValues(alpha: 0.7)), - ), - const SizedBox(height: 16), - entry.builder(context), - ], - ), - ); - } -} diff --git a/packages/remix_agent/example/lib/ui/ui.dart b/packages/remix_agent/example/lib/ui/ui.dart deleted file mode 100644 index b7d482c15..000000000 --- a/packages/remix_agent/example/lib/ui/ui.dart +++ /dev/null @@ -1,12 +0,0 @@ -library; - -// remix_cli:exports:start -export 'components/button.dart'; -export 'components/card.dart'; -export 'components/icon_button.dart'; -export 'components/textfield.dart'; -export 'theme/theme_data.dart'; -export 'theme/theme_scope.dart'; -export 'theme/tokens.dart'; - -// remix_cli:exports:end diff --git a/packages/remix_agent/example/pubspec.yaml b/packages/remix_agent/example/pubspec.yaml deleted file mode 100644 index 7923d4116..000000000 --- a/packages/remix_agent/example/pubspec.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: remix_agent_example -description: WidgetsApp example for remix_agent. Installed recipes style the composer. -publish_to: none -resolution: workspace -version: 0.1.0+1 - -environment: - sdk: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" - -dependencies: - flutter: - sdk: flutter - # Added by `remix add`: the installed recipes carry `@MixWidget`. - mix_annotations: ^2.2.0-beta.1 - # Match the package's own floor. Workspace resolution binds the local - # siblings, so a stale constraint here compiles and says nothing until - # someone reads it as the tested version. - remix: ^1.0.0-beta.10 - remix_agent: ^0.1.0-beta.1 - -dev_dependencies: - build_runner: ^2.10.1 - flutter_test: - sdk: flutter - mix_generator: ^2.2.0-beta.3 - # Project-local CLI pin for the application-owned UI source. Workspace - # resolution uses the local package during development. - remix_cli: ^0.1.0 - -flutter: - uses-material-design: true diff --git a/packages/remix_agent/example/test/audit_fixes_test.dart b/packages/remix_agent/example/test/audit_fixes_test.dart deleted file mode 100644 index 932e7fc1a..000000000 --- a/packages/remix_agent/example/test/audit_fixes_test.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; -import 'package:remix_agent_example/demos.dart'; -import 'package:remix_agent_example/main.dart'; -import 'package:remix_agent_example/showcase.dart'; - -import 'helpers/pump_catalog.dart'; - -Future pumpDemo(WidgetTester tester, Widget child) async { - await tester.pumpWidget( - WidgetsApp( - color: const Color(0xFFFFFFFF), - builder: (_, _) => Overlay.wrap( - child: DarkHost(child: SingleChildScrollView(child: child)), - ), - ), - ); - await pumpCatalog(tester); -} - -Future tapText(WidgetTester tester, String text) async { - final target = find.text(text).last; - await tester.ensureVisible(target); - await pumpCatalog(tester); - await tester.tap(target); - await pumpCatalog(tester); -} - -void main() { - testWidgets('composed run grants, finishes, resubmits, denies and stops', ( - tester, - ) async { - await pumpDemo(tester, const ComposedRunDemo()); - expect(find.byType(AgentExecution), findsNothing); - await tapText(tester, 'Allow once'); - expect( - tester.widget(find.byType(AgentExecution)).status, - AgentExecutionStatus.running, - ); - expect(find.text('12 passed · 0 failed'), findsNothing); - await tapText(tester, 'Finish checks'); - expect( - find.text('All 12 checks passed. The checkout flow is ready for review.'), - findsOneWidget, - ); - expect(find.text('Finish checks'), findsNothing); - final input = find.byType(EditableText); - await tester.ensureVisible(input); - await tester.enterText(input, 'Check again'); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await pumpCatalog(tester); - expect(find.text('Check again'), findsOneWidget); - await tapText(tester, 'Deny'); - expect(find.text('Permission denied. No checks were run.'), findsOneWidget); - expect(find.byType(AgentExecution), findsNothing); - await tester.ensureVisible(input); - await tester.enterText(input, 'Try once more'); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await pumpCatalog(tester); - await tapText(tester, 'Allow once'); - final stop = find.byKey(const ValueKey('agent-composer-stop')); - await tester.ensureVisible(stop); - await tester.tap(stop); - await pumpCatalog(tester); - expect( - tester.widget(find.byType(AgentExecution)).status, - AgentExecutionStatus.cancelled, - ); - expect( - find.text('Run stopped. Submit another message to try again.'), - findsOneWidget, - ); - }); - - testWidgets('execution reaches failure and retries', (tester) async { - await pumpDemo(tester, const ExecutionDemo()); - await tapText(tester, 'Succeed'); - await tapText(tester, 'Fail'); - expect( - tester.widget(find.byType(AgentExecution)).status, - AgentExecutionStatus.error, - ); - await tapText(tester, 'Focused checks'); - final retry = find.byWidgetPredicate( - (w) => w is RemixIconButton && w.semanticLabel == 'Retry execution', - ); - await tester.ensureVisible(retry); - await tester.tap(retry); - await pumpCatalog(tester); - expect( - tester.widget(find.byType(AgentExecution)).status, - AgentExecutionStatus.running, - ); - }); - - testWidgets('composed run supports always allow', (tester) async { - await pumpDemo(tester, const ComposedRunDemo()); - await tapText(tester, 'Always allow'); - expect( - tester.widget(find.byType(AgentExecution)).status, - AgentExecutionStatus.running, - ); - }); - - testWidgets('plan can replay after completion', (tester) async { - await pumpDemo(tester, const PlanDemo()); - for (var i = 0; i < 3; i++) { - await tapText(tester, 'Advance'); - } - expect(find.text('3/3'), findsOneWidget); - expect(find.text('Advance'), findsNothing); - await tapText(tester, 'Replay'); - expect(find.text('0/3'), findsOneWidget); - expect(find.text('Read the brief'), findsOneWidget); - }); - - testWidgets( - 'answer reveals sources only when complete and copies the answer', - (tester) async { - String? copied; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - (call) async { - if (call.method == 'Clipboard.setData') - copied = (call.arguments as Map)['text'] as String; - return null; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - null, - ), - ); - await pumpDemo(tester, const AnswerDemo()); - expect(find.text('Sources'), findsNothing); - await tapText(tester, 'Complete'); - expect(find.text('Sources'), findsOneWidget); - expect( - tester - .widget(find.widgetWithText(RemixButton, 'Complete')) - .enabled, - isFalse, - ); - final copy = find.byWidgetPredicate( - (w) => w is RemixIconButton && w.semanticLabel == 'Copy answer', - ); - await tester.tap(copy); - await tester.pump(); - expect(copied, 'The checkout flow is ready for review.'); - await tester.tap( - find.byWidgetPredicate( - (w) => w is RemixIconButton && w.semanticLabel == 'Retry answer', - ), - ); - await pumpCatalog(tester); - expect(find.text('Sources'), findsNothing); - expect(find.text('Writing the answer…'), findsOneWidget); - }, - ); - - for (final width in [390.0, 1280.0]) { - testWidgets('navigation preserves clicked destination at width $width', ( - tester, - ) async { - tester.view.physicalSize = Size(width, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget(const RemixAgentExampleApp()); - await pumpCatalog(tester); - for (final label in ['Activity', 'Answer', 'Composer']) { - final nav = find.widgetWithText(RemixToggle, label); - await tester.ensureVisible(nav); - await pumpCatalog(tester); - await tester.tap(nav); - await pumpCatalog(tester); - expect(tester.widget(nav).selected, isTrue); - final rect = tester.getRect(nav); - expect(rect.left, greaterThanOrEqualTo(0)); - expect(rect.right, lessThanOrEqualTo(width)); - } - expect(tester.takeException(), isNull); - }); - } -} diff --git a/packages/remix_agent/example/test/catalog_test.dart b/packages/remix_agent/example/test/catalog_test.dart deleted file mode 100644 index 2bc53f57c..000000000 --- a/packages/remix_agent/example/test/catalog_test.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; -import 'package:remix_agent_example/demos.dart'; -import 'package:remix_agent_example/main.dart'; -import 'package:remix_agent_example/showcase.dart'; - -import 'helpers/pump_catalog.dart'; - -void main() { - testWidgets('catalog lists every surface and the composed run', ( - tester, - ) async { - tester.view.physicalSize = const Size(1200, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - - await tester.pumpWidget(const RemixAgentExampleApp()); - await tester.pump(); - - for (final entry in catalogEntries) { - expect(find.text(entry.title), findsWidgets, reason: entry.id); - } - - expect(find.byType(AgentComposer), findsWidgets); - expect(find.byType(AgentMessage), findsWidgets); - expect(find.byType(AgentTranscript), findsWidgets); - expect(find.byType(AgentPermission), findsWidgets); - expect(find.byType(AgentExecution), findsWidgets); - expect(find.byType(AgentPlan), findsWidgets); - expect(find.byType(AgentActivity), findsWidgets); - expect(find.byType(AgentAnswer), findsWidgets); - }); - - testWidgets('permission deny in the catalog updates the shipped card', ( - tester, - ) async { - tester.view.physicalSize = const Size(1200, 1600); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - - await tester.pumpWidget(const RemixAgentExampleApp()); - await tester.pump(); - - await tester.tap(find.text('Permission').first); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 240)); - - expect(find.text('Deny'), findsWidgets); - await tester.ensureVisible(find.text('Deny').last); - await tester.tap(find.text('Deny').last); - await tester.pump(); - - expect(find.text('Denied'), findsWidgets); - }); - - testWidgets('hero transcript does not auto-follow', (tester) async { - tester.view.physicalSize = const Size(1200, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - - await tester.pumpWidget(const RemixAgentExampleApp()); - await tester.pump(); - - final hero = tester - .widgetList( - find.descendant( - of: find.byType(ComposedRunDemo), - matching: find.byType(AgentTranscript), - ), - ) - .first; - expect(hero.followOutput, isFalse); - }); - - testWidgets('catalog surfaces honor the polish contracts', (tester) async { - tester.view.physicalSize = const Size(1200, 2400); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - - await tester.pumpWidget(const RemixAgentExampleApp()); - await tester.pump(); - - expect(find.text('Show'), findsNothing); - expect(find.text('Hide'), findsNothing); - expect(find.text('View details'), findsWidgets); - expect(find.text('Commandflutter test'), findsNothing); - expect( - find.descendant( - of: find.byType(AgentComposer), - matching: find.byType(RemixCard), - ), - findsWidgets, - ); - - final succeed = find.descendant( - of: find.byType(ExecutionDemo), - matching: find.text('Succeed'), - ); - await tester.ensureVisible(succeed); - await tester.pump(); - await tester.tap(succeed); - await pumpCatalog(tester); - expect(find.text('12 passed · 0 failed'), findsNothing); - - final title = find.descendant( - of: find.byType(ExecutionDemo), - matching: find.text('Focused checks'), - ); - await tester.ensureVisible(title); - await tester.pump(); - await tester.tap(title); - await tester.pump(); - expect(find.text('12 passed · 0 failed'), findsOneWidget); - }); - - testWidgets('styled catalog meets Flutter accessibility guidelines', ( - tester, - ) async { - tester.view.physicalSize = const Size(1200, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - - await tester.pumpWidget(const RemixAgentExampleApp()); - await pumpCatalog(tester); - - expect(tester, meetsGuideline(labeledTapTargetGuideline)); - expect(tester, meetsGuideline(androidTapTargetGuideline)); - expect(tester, meetsGuideline(textContrastGuideline)); - }); - - testWidgets('catalog chrome uses keyboard-operable Remix controls', ( - tester, - ) async { - tester.view.physicalSize = const Size(1200, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - - await tester.pumpWidget(const RemixAgentExampleApp()); - await tester.pump(); - - expect(find.byType(RemixToggle), findsNWidgets(catalogEntries.length)); - expect(find.widgetWithText(RemixButton, 'Night'), findsOneWidget); - - final themeToggleFocus = tester - .widgetList( - find.ancestor(of: find.text('Night'), matching: find.byType(Focus)), - ) - .map((focus) => focus.focusNode) - .whereType() - .firstWhere( - (node) => node.debugLabel?.startsWith('NakedButton') ?? false, - ); - themeToggleFocus.requestFocus(); - await tester.pump(); - expect(themeToggleFocus.hasFocus, isTrue); - await tester.sendKeyEvent(LogicalKeyboardKey.space); - await tester.pump(); - expect(find.widgetWithText(RemixButton, 'Day'), findsOneWidget); - }); -} diff --git a/packages/remix_agent/example/test/consumer_test.dart b/packages/remix_agent/example/test/consumer_test.dart deleted file mode 100644 index fb4a42641..000000000 --- a/packages/remix_agent/example/test/consumer_test.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; -import 'package:remix_agent_example/main.dart'; - -void main() { - testWidgets('example app boots under WidgetsApp', (tester) async { - await tester.pumpWidget(const RemixAgentExampleApp()); - await tester.pump(); - expect(find.byType(WidgetsApp), findsOneWidget); - expect(find.byType(AgentComposer), findsWidgets); - expect(find.byType(AgentPermission), findsWidgets); - expect(find.byType(Navigator), findsNothing); - expect(find.text('Composer'), findsWidgets); - expect(find.text('Permission'), findsWidgets); - expect(find.text('Execution'), findsWidgets); - expect(find.text('Plan'), findsWidgets); - expect(find.text('Activity'), findsWidgets); - expect(find.text('Answer'), findsWidgets); - expect(find.text('Transcript'), findsWidgets); - expect(find.text('Message'), findsWidgets); - }); - - testWidgets('consumer import fires composer submit and permission deny', ( - tester, - ) async { - final submitted = []; - var denied = false; - - await tester.pumpWidget( - MixScope.empty( - child: WidgetsApp( - color: const Color(0xFFFFFFFF), - builder: (_, _) { - return DefaultTextStyle( - style: const TextStyle(color: Color(0xFF18181B), fontSize: 14), - child: Overlay.wrap( - child: Column( - children: [ - SizedBox( - width: 400, - child: AgentComposer(onSubmit: submitted.add), - ), - SizedBox( - width: 400, - child: AgentPermission( - tool: 'demo.tool', - onDeny: () => denied = true, - ), - ), - ], - ), - ), - ); - }, - ), - ), - ); - - await tester.enterText(find.byType(AgentComposer), 'ship it'); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pump(); - expect(submitted, ['ship it']); - - await tester.tap(find.text('Deny')); - await tester.pump(); - expect(denied, isTrue); - }); -} diff --git a/packages/remix_agent/example/test/helpers/pump_catalog.dart b/packages/remix_agent/example/test/helpers/pump_catalog.dart deleted file mode 100644 index ddf559a30..000000000 --- a/packages/remix_agent/example/test/helpers/pump_catalog.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -/// Finish finite catalog transitions without waiting for live loaders to stop. -Future pumpCatalog(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 500)); - await tester.pump(); -} diff --git a/packages/remix_agent/example/test/motion_test.dart b/packages/remix_agent/example/test/motion_test.dart deleted file mode 100644 index e502b2cad..000000000 --- a/packages/remix_agent/example/test/motion_test.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_agent_example/motion.dart'; - -Widget _host(Widget child, {bool reduce = false}) => Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData(disableAnimations: reduce), - child: child, - ), -); - -void main() { - testWidgets('entrance interpolates without changing layout or replaying', ( - tester, - ) async { - const content = SizedBox(key: ValueKey('content'), width: 160, height: 48); - Widget item() => _host( - const Center( - child: CatalogEntrance(key: ValueKey('request-1'), child: content), - ), - ); - double opacity() => tester.widget(find.byType(Opacity)).opacity; - - await tester.pumpWidget(item()); - final bounds = tester.getSize(find.byKey(const ValueKey('content'))); - expect(opacity(), 0); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 60)); - expect(opacity(), greaterThan(0)); - expect(opacity(), lessThan(1)); - expect(tester.getSize(find.byKey(const ValueKey('content'))), bounds); - await tester.pumpAndSettle(); - expect(opacity(), 1); - await tester.pumpWidget(item()); - expect(opacity(), 1, reason: 'Same request must not replay its entrance.'); - }); - - testWidgets('chevron interpolates and can reverse before settling', ( - tester, - ) async { - Widget chevron(bool expanded) => - _host(Builder(builder: (context) => catalogChevron(context, expanded))); - double cosine() => - tester.widget(find.byType(Transform)).transform.entry(0, 0); - - await tester.pumpWidget(chevron(false)); - expect(cosine(), closeTo(1, 0.001)); - await tester.pumpWidget(chevron(true)); - await tester.pump(const Duration(milliseconds: 60)); - expect(cosine(), greaterThan(-1)); - expect(cosine(), lessThan(1)); - await tester.pumpWidget(chevron(false)); - await tester.pumpAndSettle(); - expect(cosine(), closeTo(1, 0.001)); - }); - - testWidgets('reduced motion renders entrances and chevrons immediately', ( - tester, - ) async { - await tester.pumpWidget( - _host(const CatalogEntrance(child: SizedBox()), reduce: true), - ); - expect(tester.widget(find.byType(Opacity)).opacity, 1); - await tester.pumpAndSettle(); - Widget chevron(bool expanded) => _host( - Builder(builder: (context) => catalogChevron(context, expanded)), - reduce: true, - ); - await tester.pumpWidget(chevron(false)); - await tester.pumpWidget(chevron(true)); - expect( - tester.widget(find.byType(Transform)).transform.entry(0, 0), - closeTo(-1, 0.001), - ); - expect(tester.binding.transientCallbackCount, 0); - }); -} diff --git a/packages/remix_agent/example/web/favicon.png b/packages/remix_agent/example/web/favicon.png deleted file mode 100644 index 8aaa46ac1..000000000 Binary files a/packages/remix_agent/example/web/favicon.png and /dev/null differ diff --git a/packages/remix_agent/example/web/index.html b/packages/remix_agent/example/web/index.html deleted file mode 100644 index 30579c326..000000000 --- a/packages/remix_agent/example/web/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - Remix Agent catalog - - - - - - diff --git a/packages/remix_agent/example/web/manifest.json b/packages/remix_agent/example/web/manifest.json deleted file mode 100644 index 0dfd8de0a..000000000 --- a/packages/remix_agent/example/web/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Remix Agent catalog", - "short_name": "Remix Agent", - "start_url": ".", - "display": "standalone", - "background_color": "#E8EDF2", - "theme_color": "#12151C", - "description": "Local review catalog for Remix Agent surfaces.", - "orientation": "any" -} diff --git a/packages/remix_agent/lib/remix_agent.dart b/packages/remix_agent/lib/remix_agent.dart deleted file mode 100644 index 7860f5262..000000000 --- a/packages/remix_agent/lib/remix_agent.dart +++ /dev/null @@ -1,19 +0,0 @@ -/// Agent-run UI surfaces for Remix. -/// -/// Remix Agent ships conversation, permission, and progress widgets with no -/// theme, no token scope, and no model SDK. Import -/// `package:remix/remix.dart` alongside this library when a host needs base -/// Remix widgets or stylers. This barrel does not re-export Remix. -library remix_agent; - -export 'src/components/activity.dart'; -export 'src/components/answer.dart'; -export 'src/components/composer.dart'; -export 'src/components/execution.dart'; -export 'src/components/message.dart'; -export 'src/components/permission.dart'; -export 'src/components/plan.dart'; -export 'src/components/transcript.dart'; -export 'src/models/activity_item.dart'; -export 'src/models/plan_item.dart'; -export 'src/models/statuses.dart'; diff --git a/packages/remix_agent/lib/src/models/plan_item.dart b/packages/remix_agent/lib/src/models/plan_item.dart deleted file mode 100644 index 52e683a0d..000000000 --- a/packages/remix_agent/lib/src/models/plan_item.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'statuses.dart'; - -/// One row in an [AgentPlan]. -class AgentPlanItem { - /// Creates a plan item. - const AgentPlanItem({ - required this.id, - required this.title, - this.status = AgentPlanItemStatus.pending, - this.detail, - }); - - /// Stable identity across list updates. - final String id; - - /// Visible title. - final String title; - - /// Current status. - final AgentPlanItemStatus status; - - /// Optional compact metadata (elapsed time, percent, path). - final String? detail; -} diff --git a/packages/remix_agent/lib/src/style/style_builder.dart b/packages/remix_agent/lib/src/style/style_builder.dart deleted file mode 100644 index 87be24e1f..000000000 --- a/packages/remix_agent/lib/src/style/style_builder.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:mix/mix.dart'; - -/// Resolves a fluent style or bypasses it with a raw resolved spec. -class AgentStyleBuilder> extends StatelessWidget { - const AgentStyleBuilder({ - super.key, - required this.style, - required this.styleSpec, - required this.builder, - this.controller, - }); - - final Style style; - final S? styleSpec; - final WidgetStatesController? controller; - final Widget Function(BuildContext context, S spec) builder; - - @override - Widget build(BuildContext context) { - final resolved = styleSpec; - if (resolved != null) { - return StyleSpecBuilder( - styleSpec: StyleSpec(spec: resolved), - builder: builder, - ); - } - return StyleBuilder( - style: style, - controller: controller, - builder: builder, - ); - } -} diff --git a/packages/remix_agent/pubspec.yaml b/packages/remix_agent/pubspec.yaml deleted file mode 100644 index 4039b6105..000000000 --- a/packages/remix_agent/pubspec.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: remix_agent -description: > - Agent-run UI surfaces for Remix. Unstyled conversation, permission, and - progress widgets with no theme, no token scope, and no model SDK. -publish_to: none -repository: https://github.com/btwld/remix -issue_tracker: https://github.com/btwld/remix/issues -license: BSD-3-Clause -resolution: workspace -version: 0.1.0-beta.1 - -environment: - sdk: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" - -dependencies: - flutter: - sdk: flutter - # Exact pin: the small internal glyph map targets this font's codepoints. - lucide_icons_flutter: 3.1.15 - mix: ^2.2.0-beta.5 - mix_annotations: ^2.2.0-beta.1 - # Match the behavior version exercised by the open-code stack. Workspace - # resolution binds the local sibling while Agent is private. - remix: ^1.0.0-beta.10 - -dev_dependencies: - build_runner: ^2.10.1 - flutter_test: - sdk: flutter - mix_generator: ^2.2.0-beta.3 diff --git a/packages/remix_agent/test/components/execution_test.dart b/packages/remix_agent/test/components/execution_test.dart deleted file mode 100644 index ad175c345..000000000 --- a/packages/remix_agent/test/components/execution_test.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_agent/remix_agent.dart'; - -import '../helpers/pump.dart'; - -void main() { - testWidgets('tool identifier is rendered with the execution title', ( - tester, - ) async { - await pumpAgent( - tester, - const AgentExecution( - tool: 'terminal.run', - title: 'Focused tests', - child: Text('output'), - ), - ); - - expect(find.text('terminal.run'), findsOneWidget); - expect(find.text('Focused tests'), findsOneWidget); - }); - - testWidgets('copy and retry actions appear only after settlement', ( - tester, - ) async { - Widget execution(AgentExecutionStatus status) => AgentExecution( - tool: 'tool', - title: 'Run', - status: status, - collapseOnComplete: false, - onCopy: () {}, - onRetry: () {}, - child: const Text('output'), - ); - - await pumpAgent(tester, execution(AgentExecutionStatus.running)); - expect(find.bySemanticsLabel('Copy output'), findsNothing); - expect(find.bySemanticsLabel('Retry execution'), findsNothing); - - await pumpAgent(tester, execution(AgentExecutionStatus.error)); - expect(find.bySemanticsLabel('Copy output'), findsOneWidget); - expect(find.bySemanticsLabel('Retry execution'), findsOneWidget); - }); - - testWidgets('execution status and indicator builders are replaceable', ( - tester, - ) async { - await pumpAgent( - tester, - AgentExecution( - tool: 'tool', - title: 'Run', - statusBuilder: (context, status) => - const SizedBox(key: ValueKey('custom-execution-status')), - indicatorBuilder: (context, expanded) => - const SizedBox(key: ValueKey('custom-execution-indicator')), - child: const Text('output'), - ), - ); - - expect( - find.byKey(const ValueKey('custom-execution-status')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('custom-execution-indicator')), - findsOneWidget, - ); - }); -} diff --git a/packages/remix_agent/test/public_api_test.dart b/packages/remix_agent/test/public_api_test.dart deleted file mode 100644 index 547cc7033..000000000 --- a/packages/remix_agent/test/public_api_test.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; - -void main() { - test('barrel exports the v1 catalog', () { - const composer = AgentComposer(); - const message = AgentMessage( - role: AgentRole.user, - child: SizedBox.shrink(), - ); - const answer = AgentAnswer(child: SizedBox.shrink()); - const permission = AgentPermission(tool: 't'); - const execution = AgentExecution( - tool: 't', - title: 'T', - child: SizedBox.shrink(), - ); - const plan = AgentPlan(items: []); - const activity = AgentActivity(items: []); - - expect(composer, isA()); - expect(message.role, AgentRole.user); - expect(answer.status, AgentAnswerStatus.streaming); - expect(permission.status, AgentPermissionStatus.pending); - expect(execution.status, AgentExecutionStatus.running); - expect(plan.items, isEmpty); - expect(activity.status, AgentRunStatus.working); - expect(const AgentComposerSpec(), isA()); - expect(const AgentTranscript(children: []), isA()); - expect( - const AgentPermission( - tool: 't', - parameters: [RemixDataListItem(label: 'a', value: 'b')], - ), - isA(), - ); - }); - - test('library sources do not import Material', () { - final lib = Directory('lib').existsSync() - ? Directory('lib') - : Directory('packages/remix_agent/lib'); - expect(lib.existsSync(), isTrue); - final hits = []; - for (final entity in lib.listSync(recursive: true)) { - if (entity is! File || !entity.path.endsWith('.dart')) { - continue; - } - final source = entity.readAsStringSync(); - if (source.contains('package:flutter/material.dart') || - source.contains('package:flutter/src/material/')) { - hits.add(entity.path); - } - } - expect(hits, isEmpty); - }); - - test('barrel hides implementation and test seams', () { - final barrel = File('lib/remix_agent.dart').existsSync() - ? File('lib/remix_agent.dart') - : File('packages/remix_agent/lib/remix_agent.dart'); - final source = barrel.readAsStringSync(); - for (final seam in [ - 'behavior/live_edge.dart', - 'components/disclosure.dart', - 'components/clip_reveal.dart', - 'models/permission_parameter.dart', - 'style/defaults.dart', - 'style/motion.dart', - ]) { - expect(source, isNot(contains(seam))); - } - }); -} diff --git a/packages/remix_cli/README.md b/packages/remix_cli/README.md index ada9ce5bc..eeb1d2191 100644 --- a/packages/remix_cli/README.md +++ b/packages/remix_cli/README.md @@ -24,6 +24,14 @@ The catalog also offers `chart` as an optional extension over `mix_chart`; it does not depend on `remix_fortal`. There is no remote registry, update command, registry lockfile, or content-hash protocol. +Both presets additionally distribute the unstyled Agent items: +`activity`, `answer`, `composer`, `execution`, `message`, `permission`, `plan`, +and `transcript`, with shared `models` and `support` dependencies. These are +application-owned source, not a dependency on the private authoring source. +Each surface has an opt-in `_recipe` item that installs a complete, +preset-specific styler bundle under `lib/ui/recipes/`; bare components remain +unstyled and backward compatible. + ## Install project-locally The CLI has not been published. The hosted commands below apply after its @@ -126,8 +134,13 @@ dart run remix_cli:remix add button The command installs Theme before Button, adds missing compatible hosted dependencies, formats the authored files, runs generation only for the -declared `button.g.dart`, and analyzes the installed UI path. It does not create -or modify `build.yaml`. +declared adapters (including previously installed adapters), and analyzes the +installed UI path. Recipe-only installs do not need `build.yaml`. Installing +Agent `@MixableSpec` source enables Mix's opt-in spec-styler builder there, +scoped to the installed files. The CLI preserves comments and unrelated +settings; it refuses explicit builder disablement, excluded source, or +conflicting target ownership before writing. `--dry-run` reports the required +configuration and `--diff` shows it without changing the application. The current `mix_generator` writes explicit `this.` qualifiers into generated adapters, which `flutter_lints` reports as `unnecessary_this` infos. They do @@ -285,3 +298,7 @@ The MVP's update workflow is explicit: adapter. There is no automatic merge or migration layer in 0.1.0. + +Available styled items: `activity_recipe`, `answer_recipe`, `composer_recipe`, +`execution_recipe`, `message_recipe`, `permission_recipe`, `plan_recipe`, and +`transcript_recipe`. Each installs only its component and styled-control closure. diff --git a/packages/remix_cli/lib/src/builder_config.dart b/packages/remix_cli/lib/src/builder_config.dart new file mode 100644 index 000000000..84f278c15 --- /dev/null +++ b/packages/remix_cli/lib/src/builder_config.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:glob/glob.dart'; +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +const specStylerBuilder = 'mix_generator:spec_styler_generator'; + +/// Enables spec-styler generation only for the installed source that needs it. +/// Existing settings and comments remain application-owned. Explicit opt-outs +/// and split targets require the host to resolve the conflict, not an overwrite. +String configureSpecStylers(String source, List inputs) { + final document = loadYaml(source); + if (document != null && document is! YamlMap) { + throw const FormatException('build.yaml must contain a map.'); + } + if (document == null) { + final paths = inputs.toList()..sort(); + final prefix = source.isEmpty || source.endsWith('\n') + ? source + : '$source\n'; + return '${prefix}targets:\n' + ' \$default:\n' + ' builders:\n' + ' $specStylerBuilder:\n' + ' enabled: true\n' + ' generate_for:\n' + '${paths.map((input) => ' - ${jsonEncode(input)}\n').join()}'; + } + final root = document as YamlMap; + final targets = root['targets']; + if (targets != null && targets is! YamlMap) { + throw const FormatException('build.yaml targets must be a map.'); + } + if (targets is YamlMap) { + for (final entry in targets.entries) { + if (entry.key == r'$default') continue; + if (entry.value is! YamlMap || + inputs.any((input) => _includes(entry.value['sources'], input))) { + throw const FormatException( + 'Agent source must belong to the default build.yaml target. ' + 'Exclude installed UI from other targets before using remix add.', + ); + } + } + } + final target = targets?[r'$default']; + if (target != null && target is! YamlMap) { + throw const FormatException('build.yaml default target must be a map.'); + } + for (final input in inputs) { + if (!_includes(target?['sources'], input)) { + throw FormatException( + 'build.yaml default target excludes $input. ' + 'Include the installed source before using remix add.', + ); + } + } + final builders = target?['builders']; + if (builders != null && builders is! YamlMap) { + throw const FormatException('build.yaml builders must be a map.'); + } + const alias = 'mix_generator|spec_styler_generator'; + if (builders?[specStylerBuilder] != null && builders?[alias] != null) { + throw const FormatException( + 'build.yaml declares the spec-styler builder twice.', + ); + } + final key = builders?[alias] != null ? alias : specStylerBuilder; + final existing = builders?[key]; + if (existing != null && existing is! YamlMap) { + throw const FormatException( + 'build.yaml spec-styler settings must be a map.', + ); + } + if (existing?['enabled'] == false) { + throw const FormatException( + 'build.yaml explicitly disables spec-styler generation. ' + 'Enable it for the installed Agent source before using remix add.', + ); + } + final editor = YamlEditor(source); + final path = ['targets', r'$default', 'builders', key]; + for (var length = 1; length <= path.length; length++) { + final prefix = path.take(length).toList(); + if (editor.parseAt(prefix, orElse: () => wrapAsYamlNode(null)).value == + null) { + editor.update(prefix, {}); + } + } + editor.update([...path, 'enabled'], true); + final generateFor = existing?['generate_for']; + if (existing == null || + (existing['enabled'] != true && generateFor == null)) { + editor.update([...path, 'generate_for'], inputs.toList()..sort()); + } else if (generateFor != null) { + final missing = inputs + .where((input) => !_includes(generateFor, input)) + .toList(); + if (generateFor is YamlMap) { + final excluded = generateFor['exclude']; + if (missing.any( + (input) => _matches(excluded, input, defaultValue: false), + )) { + throw const FormatException( + 'build.yaml excludes installed Agent source ' + 'from spec-styler generation. Adjust generate_for before remix add.', + ); + } + if (missing.isNotEmpty) { + editor.update( + [...path, 'generate_for', 'include'], + [...generateFor['include'] as List? ?? const [], ...missing..sort()], + ); + } + } else if (generateFor is YamlList) { + if (missing.isNotEmpty) { + editor.update( + [...path, 'generate_for'], + [...generateFor, ...missing..sort()], + ); + } + } else { + throw const FormatException( + 'build.yaml generate_for must be a list or map.', + ); + } + } + return editor.toString(); +} + +bool _includes(Object? filter, String input) { + if (filter is Map) { + return _matches(filter['include'], input, defaultValue: true) && + !_matches(filter['exclude'], input, defaultValue: false); + } + return _matches(filter, input, defaultValue: true); +} + +bool _matches(Object? patterns, String input, {required bool defaultValue}) { + if (patterns == null) return defaultValue; + if (patterns is! List || patterns.any((pattern) => pattern is! String)) { + throw const FormatException( + 'build.yaml source patterns must be lists of strings.', + ); + } + return patterns.cast().any((pattern) => Glob(pattern).matches(input)); +} diff --git a/packages/remix_cli/lib/src/installer.dart b/packages/remix_cli/lib/src/installer.dart index be98b4888..766745a8f 100644 --- a/packages/remix_cli/lib/src/installer.dart +++ b/packages/remix_cli/lib/src/installer.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; import 'package:yaml/yaml.dart'; +import 'builder_config.dart'; import 'cli.dart'; import 'process_runner.dart'; import 'project_config.dart'; @@ -124,6 +125,55 @@ final class Installer { } Future add(AddOptions options) async { + final plan = await _planAdd(options); + + _printPlan( + items: plan.items, + requirements: plan.requirements, + filesByItem: plan.filesByItem, + generated: plan.generated, + exports: plan.exports, + states: plan.states, + ); + + if (plan.builderConfiguration != null) { + _writeOut( + 'build.yaml: enable spec-styler generation for installed source.', + ); + } + if (options.mode == AddMode.dryRun) return; + if (options.mode == AddMode.diff) { + // The diff has to predict what `add` would write, and `add` formats with + // the project's Flutter SDK. Formatting the proposed tree with whichever + // Dart happens to run this CLI would report formatter-version + // differences that no install would ever produce. + final diffToolchain = await _resolveFlutter(plan.root); + await _printDiff( + dart: diffToolchain.dart, + root: plan.root, + requestedName: plan.requested.name, + items: plan.items, + states: plan.states, + filesByItem: plan.filesByItem, + rendered: plan.rendered, + barrelRelative: plan.barrelRelative, + currentBarrel: plan.currentBarrel, + proposedBarrel: plan.proposedBarrel, + builderConfiguration: plan.builderConfiguration, + ); + return; + } + + await _install(plan, options); + } + + /// Resolves everything an install depends on without writing anything. + /// + /// The order below is the preflight contract: every way a request can be + /// rejected is reached before the first process runs or the first byte is + /// written, which is also what lets `--dry-run` and `--diff` report from the + /// same work a real install would do rather than from a second guess at it. + Future<_InstallPlan> _planAdd(AddOptions options) async { final root = validateFlutterPackageRoot(projectRoot); final configFile = File(p.join(root.path, projectConfigFileName)); if (!configFile.existsSync()) { @@ -200,36 +250,80 @@ final class Installer { _resolveTarget(config, target), }.toList(growable: false); - _printPlan( + // Spec stylers are opt-in in the supported Mix generator. Detect the + // authored annotation, not Agent item names or consumer prefixes. + final specInputs = []; + for (final item in catalog.items.values) { + for (final file in item.files) { + final relative = _resolveTarget(config, file.target); + final existing = _projectFile(root, relative); + final proposed = rendered[relative]; + final source = + existing.existsSync() && + !(item.name == requested.name && + options.mode == AddMode.overwrite) + ? existing.readAsStringSync() + : proposed; + if (source != null && RegExp(r'@MixableSpec\s*\(').hasMatch(source)) { + specInputs.add(relative); + } + } + } + String? builderConfiguration; + if (specInputs.isNotEmpty) { + validateProjectFilePath(root, 'build.yaml'); + final file = _projectFile(root, 'build.yaml'); + final before = file.existsSync() ? file.readAsStringSync() : ''; + final after = configureSpecStylers(before, specInputs); + if (before != after) builderConfiguration = after; + } + + return _InstallPlan( + root: root, + config: config, items: items, - requirements: requirements, + rendered: rendered, filesByItem: filesByItem, - generated: generated, - exports: exports, states: states, + exports: exports, + barrel: barrel, + barrelRelative: barrelRelative, + currentBarrel: currentBarrel, + proposedBarrel: proposedBarrel, + requirements: requirements, + dependencies: dependencies, + generated: generated, + generationTargets: generationTargets, + pubspec: pubspec, + builderConfiguration: builderConfiguration, ); + } - if (options.mode == AddMode.dryRun) return; - if (options.mode == AddMode.diff) { - // The diff has to predict what `add` would write, and `add` formats with - // the project's Flutter SDK. Formatting the proposed tree with whichever - // Dart happens to run this CLI would report formatter-version - // differences that no install would ever produce. - final diffToolchain = await _resolveFlutter(root); - await _printDiff( - dart: diffToolchain.dart, - root: root, - requestedName: requested.name, - items: items, - states: states, - filesByItem: filesByItem, - rendered: rendered, - barrelRelative: barrelRelative, - currentBarrel: currentBarrel, - proposedBarrel: proposedBarrel, - ); - return; - } + /// Carries out [plan]: dependencies, source, formatting, generation, analysis. + /// + /// Every step appends to `completed` before the next one starts, so a failure + /// can tell the reader how far the install got. + Future _install(_InstallPlan plan, AddOptions options) async { + // Unpacked in one place so the steps below read as prose. The plan stays + // the only description of what gets installed: nothing past this point + // re-reads the project, so no step can act on a different answer than the + // one already printed. + final root = plan.root; + final config = plan.config; + final items = plan.items; + final requested = plan.requested; + final rendered = plan.rendered; + final filesByItem = plan.filesByItem; + final states = plan.states; + final barrel = plan.barrel; + final barrelRelative = plan.barrelRelative; + final currentBarrel = plan.currentBarrel; + final proposedBarrel = plan.proposedBarrel; + final requirements = plan.requirements; + final dependencies = plan.dependencies; + final generated = plan.generated; + final generationTargets = plan.generationTargets; + final pubspec = plan.pubspec; final toolchain = await _resolveFlutter(root); final completed = []; @@ -306,9 +400,17 @@ final class Installer { completed.add('format'); } + if (plan.builderConfiguration != null) { + _fileWriter.write( + _projectFile(root, 'build.yaml'), + plan.builderConfiguration!, + ); + completed.add('builder configuration'); + } final needsGeneration = generated.isNotEmpty && - (pathsToWrite.any((path) => path.endsWith('.dart')) || + (plan.builderConfiguration != null || + pathsToWrite.any((path) => path.endsWith('.dart')) || generated.any((path) => !_projectFile(root, path).existsSync())); if (needsGeneration) { final packageName = @@ -442,11 +544,23 @@ final class Installer { required String barrelRelative, required String currentBarrel, required String proposedBarrel, + required String? builderConfiguration, }) async { final parent = Directory.systemTemp.createTempSync('remix_cli_diff_'); final current = Directory(p.join(parent.path, 'current'))..createSync(); final proposed = Directory(p.join(parent.path, 'proposed'))..createSync(); try { + if (builderConfiguration != null) { + final existingConfig = _projectFile(root, 'build.yaml'); + if (existingConfig.existsSync()) { + _writeDiffFile( + current, + 'build.yaml', + existingConfig.readAsStringSync(), + ); + } + _writeDiffFile(proposed, 'build.yaml', builderConfiguration); + } final proposedDartPaths = {}; for (final item in items) { final include = @@ -860,12 +974,12 @@ String _generationFilter(String packageName, String target) => Uri( scheme: 'package', pathSegments: [ packageName, - ...p.posix.split(Glob.quote(target.substring(4))), + ...p.posix.split(Glob.quote(target.substring(uiTargetPrefix.length))), ], ).toString(); String _resolveTarget(ProjectConfig config, String target) => - p.posix.join(config.uiPath, target.substring('@ui/'.length)); + p.posix.join(config.uiPath, target.substring(uiTargetPrefix.length)); File _projectFile(Directory root, String relative) => File(p.joinAll([root.path, ...p.posix.split(relative)])); @@ -896,6 +1010,58 @@ final class _DependencyInspection { final List<_DependencyRequirement> missing; } +/// Everything `add` resolved before it was allowed to change anything. +/// +/// This exists to keep the preflight honest: the planning phase hands back one +/// value, so a step that runs later cannot quietly re-read the project and act +/// on a different answer than the one already printed. +final class _InstallPlan { + const _InstallPlan({ + required this.root, + required this.config, + required this.items, + required this.rendered, + required this.filesByItem, + required this.states, + required this.exports, + required this.barrel, + required this.barrelRelative, + required this.currentBarrel, + required this.proposedBarrel, + required this.requirements, + required this.dependencies, + required this.generated, + required this.generationTargets, + required this.pubspec, + required this.builderConfiguration, + }); + + final Directory root; + final ProjectConfig config; + + /// The requested item and its dependencies, dependencies first. + final List items; + + /// Rendered source keyed by the project-relative path it belongs at. + final Map rendered; + final Map> filesByItem; + final Map states; + final List exports; + final File barrel; + final String barrelRelative; + final String currentBarrel; + final String proposedBarrel; + final List<_DependencyRequirement> requirements; + final _DependencyInspection dependencies; + final List generated; + final List generationTargets; + final File pubspec; + final String? builderConfiguration; + + /// The item the user asked for; [items] resolves its dependencies ahead of it. + RegistryItem get requested => items.last; +} + enum _ItemState { missing, partial, complete } final _managedExport = RegExp(r"^export '([^']+)';$"); diff --git a/packages/remix_cli/lib/src/registry.dart b/packages/remix_cli/lib/src/registry.dart index 946421a96..90ff95e9f 100644 --- a/packages/remix_cli/lib/src/registry.dart +++ b/packages/remix_cli/lib/src/registry.dart @@ -7,6 +7,14 @@ import 'package:yaml/yaml.dart'; const bundledPresets = {'default', 'fortal'}; +/// Stands in for the consumer's configured UI path in every registry target. +/// +/// Targets are stored independent of where a project installs, so one registry +/// serves every `uiPath`; the installer swaps this prefix for the configured +/// directory. Both the validator and that swap need the same spelling, and the +/// swap also needs its length. +const uiTargetPrefix = '@ui/'; + abstract interface class RegistryAssetLoader { Future read(Uri uri); } @@ -323,10 +331,15 @@ void _exactKeys( } void _validateTarget(String target) { - if (!target.startsWith('@ui/')) { - throw FormatException('Registry target $target must start with @ui/.'); + if (!target.startsWith(uiTargetPrefix)) { + throw FormatException( + 'Registry target $target must start with $uiTargetPrefix.', + ); } - _validateRelative(target.substring(4), label: 'registry target'); + _validateRelative( + target.substring(uiTargetPrefix.length), + label: 'registry target', + ); } void _validateRelative(String value, {required String label, String? prefix}) { diff --git a/packages/remix_cli/lib/src/registry/default/registry.yaml b/packages/remix_cli/lib/src/registry/default/registry.yaml index 55620d6b7..cd9c61f9a 100644 --- a/packages/remix_cli/lib/src/registry/default/registry.yaml +++ b/packages/remix_cli/lib/src/registry/default/registry.yaml @@ -1,15 +1,16 @@ +# Generated by tool/build_registry.dart. Do not edit. schema: 1 items: theme: dependencies: remix: ^1.0.0-beta.10 files: - - source: templates/theme/tokens.dart.tmpl - target: "@ui/theme/tokens.dart" - source: templates/theme/theme_data.dart.tmpl target: "@ui/theme/theme_data.dart" - source: templates/theme/theme_scope.dart.tmpl target: "@ui/theme/theme_scope.dart" + - source: templates/theme/tokens.dart.tmpl + target: "@ui/theme/tokens.dart" exports: - theme/tokens.dart - theme/theme_data.dart @@ -26,7 +27,7 @@ items: exports: - icons.dart - button: + accordion: registryDependencies: - theme dependencies: @@ -35,44 +36,36 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/button/button.dart.tmpl - target: "@ui/components/button.dart" + - source: templates/accordion/accordion.dart.tmpl + target: "@ui/components/accordion.dart" generated: - - "@ui/components/button.g.dart" + - "@ui/components/accordion.g.dart" exports: - - components/button.dart + - components/accordion.dart - checkbox: + activity_recipe: registryDependencies: - theme - dependencies: - mix_annotations: ^2.2.0-beta.1 - devDependencies: - build_runner: ^2.10.1 - mix_generator: ^2.2.0-beta.3 + - activity + - disclosure files: - - source: templates/checkbox/checkbox.dart.tmpl - target: "@ui/components/checkbox.dart" - generated: - - "@ui/components/checkbox.g.dart" + - source: templates/recipes/activity_recipe.dart.tmpl + target: "@ui/recipes/activity_recipe.dart" exports: - - components/checkbox.dart + - recipes/activity_recipe.dart - tabs: + answer_recipe: registryDependencies: - theme - dependencies: - mix_annotations: ^2.2.0-beta.1 - devDependencies: - build_runner: ^2.10.1 - mix_generator: ^2.2.0-beta.3 + - answer + - card + - disclosure + - icon_button files: - - source: templates/tabs/tabs.dart.tmpl - target: "@ui/components/tabs.dart" - generated: - - "@ui/components/tabs.g.dart" + - source: templates/recipes/answer_recipe.dart.tmpl + target: "@ui/recipes/answer_recipe.dart" exports: - - components/tabs.dart + - recipes/answer_recipe.dart avatar: registryDependencies: @@ -106,6 +99,22 @@ items: exports: - components/badge.dart + button: + registryDependencies: + - theme + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/button/button.dart.tmpl + target: "@ui/components/button.dart" + generated: + - "@ui/components/button.g.dart" + exports: + - components/button.dart + callout: registryDependencies: - theme @@ -138,9 +147,6 @@ items: exports: - components/card.dart - # Charts are an optional extension over mix_chart rather than a Remix - # component. mix_chart keeps the data, interaction, semantics, and renderer - # contract; the installed recipe owns the application's visual language. chart: registryDependencies: - theme @@ -158,6 +164,101 @@ items: exports: - components/chart.dart + checkbox: + registryDependencies: + - theme + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/checkbox/checkbox.dart.tmpl + target: "@ui/components/checkbox.dart" + generated: + - "@ui/components/checkbox.g.dart" + exports: + - components/checkbox.dart + + composer_recipe: + registryDependencies: + - card + - composer + - icon_button + - textfield + files: + - source: templates/recipes/composer_recipe.dart.tmpl + target: "@ui/recipes/composer_recipe.dart" + exports: + - recipes/composer_recipe.dart + + data_list: + registryDependencies: + - theme + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/data_list/data_list.dart.tmpl + target: "@ui/components/data_list.dart" + generated: + - "@ui/components/data_list.g.dart" + exports: + - components/data_list.dart + + data_table: + registryDependencies: + - theme + - checkbox + - icon_button + - select + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/data_table/data_table.dart.tmpl + target: "@ui/components/data_table.dart" + generated: + - "@ui/components/data_table.g.dart" + exports: + - components/data_table.dart + + dialog: + registryDependencies: + - theme + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/dialog/dialog.dart.tmpl + target: "@ui/components/dialog.dart" + generated: + - "@ui/components/dialog.g.dart" + exports: + - components/dialog.dart + + disclosure: + registryDependencies: + - theme + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/disclosure/disclosure.dart.tmpl + target: "@ui/components/disclosure.dart" + generated: + - "@ui/components/disclosure.g.dart" + exports: + - components/disclosure.dart + divider: registryDependencies: - theme @@ -174,6 +275,19 @@ items: exports: - components/divider.dart + execution_recipe: + registryDependencies: + - theme + - card + - disclosure + - execution + - icon_button + files: + - source: templates/recipes/execution_recipe.dart.tmpl + target: "@ui/recipes/execution_recipe.dart" + exports: + - recipes/execution_recipe.dart + icon_button: registryDependencies: - theme @@ -206,6 +320,74 @@ items: exports: - components/link.dart + menu: + registryDependencies: + - theme + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/menu/menu.dart.tmpl + target: "@ui/components/menu.dart" + generated: + - "@ui/components/menu.g.dart" + exports: + - components/menu.dart + + message_recipe: + registryDependencies: + - button + - card + - message + files: + - source: templates/recipes/message_recipe.dart.tmpl + target: "@ui/recipes/message_recipe.dart" + exports: + - recipes/message_recipe.dart + + permission_recipe: + registryDependencies: + - theme + - button + - card + - data_list + - disclosure + - permission + files: + - source: templates/recipes/permission_recipe.dart.tmpl + target: "@ui/recipes/permission_recipe.dart" + exports: + - recipes/permission_recipe.dart + + plan_recipe: + registryDependencies: + - theme + - disclosure + - plan + files: + - source: templates/recipes/plan_recipe.dart.tmpl + target: "@ui/recipes/plan_recipe.dart" + exports: + - recipes/plan_recipe.dart + + popover: + registryDependencies: + - theme + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/popover/popover.dart.tmpl + target: "@ui/components/popover.dart" + generated: + - "@ui/components/popover.g.dart" + exports: + - components/popover.dart + progress: registryDependencies: - theme @@ -222,7 +404,7 @@ items: exports: - components/progress.dart - skeleton: + radio: registryDependencies: - theme dependencies: @@ -231,14 +413,14 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/skeleton/skeleton.dart.tmpl - target: "@ui/components/skeleton.dart" + - source: templates/radio/radio.dart.tmpl + target: "@ui/components/radio.dart" generated: - - "@ui/components/skeleton.g.dart" + - "@ui/components/radio.g.dart" exports: - - components/skeleton.dart + - components/radio.dart - spinner: + segmented_control: registryDependencies: - theme dependencies: @@ -247,14 +429,14 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/spinner/spinner.dart.tmpl - target: "@ui/components/spinner.dart" + - source: templates/segmented_control/segmented_control.dart.tmpl + target: "@ui/components/segmented_control.dart" generated: - - "@ui/components/spinner.g.dart" + - "@ui/components/segmented_control.g.dart" exports: - - components/spinner.dart + - components/segmented_control.dart - toggle: + select: registryDependencies: - theme dependencies: @@ -263,30 +445,42 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/toggle/toggle.dart.tmpl - target: "@ui/components/toggle.dart" + - source: templates/select/select.dart.tmpl + target: "@ui/components/select.dart" generated: - - "@ui/components/toggle.g.dart" + - "@ui/components/select.g.dart" exports: - - components/toggle.dart + - components/select.dart - radio: + sidebar: registryDependencies: - theme + - toggle + - tooltip dependencies: mix_annotations: ^2.2.0-beta.1 devDependencies: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/radio/radio.dart.tmpl - target: "@ui/components/radio.dart" + - source: templates/sidebar/sidebar.dart.tmpl + target: "@ui/components/sidebar.dart" generated: - - "@ui/components/radio.g.dart" + - "@ui/components/sidebar.g.dart" exports: - - components/radio.dart + - components/sidebar.dart - segmented_control: + sidebar_layout: + registryDependencies: + - theme + - sidebar + files: + - source: templates/sidebar_layout/sidebar_layout.dart.tmpl + target: "@ui/components/sidebar_layout.dart" + exports: + - components/sidebar_layout.dart + + skeleton: registryDependencies: - theme dependencies: @@ -295,12 +489,12 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/segmented_control/segmented_control.dart.tmpl - target: "@ui/components/segmented_control.dart" + - source: templates/skeleton/skeleton.dart.tmpl + target: "@ui/components/skeleton.dart" generated: - - "@ui/components/segmented_control.g.dart" + - "@ui/components/skeleton.g.dart" exports: - - components/segmented_control.dart + - components/skeleton.dart slider: registryDependencies: @@ -318,6 +512,22 @@ items: exports: - components/slider.dart + spinner: + registryDependencies: + - theme + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/spinner/spinner.dart.tmpl + target: "@ui/components/spinner.dart" + generated: + - "@ui/components/spinner.g.dart" + exports: + - components/spinner.dart + switch: registryDependencies: - theme @@ -334,7 +544,7 @@ items: exports: - components/switch.dart - textfield: + tabs: registryDependencies: - theme dependencies: @@ -343,14 +553,14 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/textfield/textfield.dart.tmpl - target: "@ui/components/textfield.dart" + - source: templates/tabs/tabs.dart.tmpl + target: "@ui/components/tabs.dart" generated: - - "@ui/components/textfield.g.dart" + - "@ui/components/tabs.g.dart" exports: - - components/textfield.dart + - components/tabs.dart - toggle_group: + textfield: registryDependencies: - theme dependencies: @@ -359,32 +569,32 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/toggle_group/toggle_group.dart.tmpl - target: "@ui/components/toggle_group.dart" + - source: templates/textfield/textfield.dart.tmpl + target: "@ui/components/textfield.dart" generated: - - "@ui/components/toggle_group.g.dart" + - "@ui/components/textfield.g.dart" exports: - - components/toggle_group.dart + - components/textfield.dart - sidebar: + toast: registryDependencies: - theme - - toggle - - tooltip + - button + - icon_button dependencies: mix_annotations: ^2.2.0-beta.1 devDependencies: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/sidebar/sidebar.dart.tmpl - target: "@ui/components/sidebar.dart" + - source: templates/toast/toast.dart.tmpl + target: "@ui/components/toast.dart" generated: - - "@ui/components/sidebar.g.dart" + - "@ui/components/toast.g.dart" exports: - - components/sidebar.dart + - components/toast.dart - accordion: + toggle: registryDependencies: - theme dependencies: @@ -393,14 +603,14 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/accordion/accordion.dart.tmpl - target: "@ui/components/accordion.dart" + - source: templates/toggle/toggle.dart.tmpl + target: "@ui/components/toggle.dart" generated: - - "@ui/components/accordion.g.dart" + - "@ui/components/toggle.g.dart" exports: - - components/accordion.dart + - components/toggle.dart - disclosure: + toggle_group: registryDependencies: - theme dependencies: @@ -409,14 +619,14 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/disclosure/disclosure.dart.tmpl - target: "@ui/components/disclosure.dart" + - source: templates/toggle_group/toggle_group.dart.tmpl + target: "@ui/components/toggle_group.dart" generated: - - "@ui/components/disclosure.g.dart" + - "@ui/components/toggle_group.g.dart" exports: - - components/disclosure.dart + - components/toggle_group.dart - dialog: + tooltip: registryDependencies: - theme dependencies: @@ -425,147 +635,178 @@ items: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/dialog/dialog.dart.tmpl - target: "@ui/components/dialog.dart" + - source: templates/tooltip/tooltip.dart.tmpl + target: "@ui/components/tooltip.dart" generated: - - "@ui/components/dialog.g.dart" + - "@ui/components/tooltip.g.dart" exports: - - components/dialog.dart + - components/tooltip.dart - menu: + transcript_recipe: + registryDependencies: + - transcript + files: + - source: templates/recipes/transcript_recipe.dart.tmpl + target: "@ui/recipes/transcript_recipe.dart" + exports: + - recipes/transcript_recipe.dart + + models: + files: + - source: templates/agent/models/activity_item.dart.tmpl + target: "@ui/models/activity_item.dart" + - source: templates/agent/models/plan_item.dart.tmpl + target: "@ui/models/plan_item.dart" + - source: templates/agent/models/statuses.dart.tmpl + target: "@ui/models/statuses.dart" + exports: + - models/activity_item.dart + - models/plan_item.dart + - models/statuses.dart + + support: registryDependencies: - theme + dependencies: + remix_ui_icons: ^0.1.0 + files: + - source: templates/agent/support/disclosure.dart.tmpl + target: "@ui/support/disclosure.dart" + - source: templates/agent/support/functional_glyph.dart.tmpl + target: "@ui/support/functional_glyph.dart" + - source: templates/agent/support/live_edge.dart.tmpl + target: "@ui/support/live_edge.dart" + + activity: + registryDependencies: + - models + - support dependencies: mix_annotations: ^2.2.0-beta.1 devDependencies: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/menu/menu.dart.tmpl - target: "@ui/components/menu.dart" + - source: templates/agent/activity/activity.dart.tmpl + target: "@ui/components/activity.dart" generated: - - "@ui/components/menu.g.dart" + - "@ui/components/activity.g.dart" exports: - - components/menu.dart + - components/activity.dart - popover: + answer: registryDependencies: - - theme + - models + - support dependencies: mix_annotations: ^2.2.0-beta.1 devDependencies: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/popover/popover.dart.tmpl - target: "@ui/components/popover.dart" + - source: templates/agent/answer/answer.dart.tmpl + target: "@ui/components/answer.dart" generated: - - "@ui/components/popover.g.dart" + - "@ui/components/answer.g.dart" exports: - - components/popover.dart + - components/answer.dart - select: + composer: registryDependencies: - - theme + - support dependencies: mix_annotations: ^2.2.0-beta.1 devDependencies: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/select/select.dart.tmpl - target: "@ui/components/select.dart" + - source: templates/agent/composer/composer.dart.tmpl + target: "@ui/components/composer.dart" generated: - - "@ui/components/select.g.dart" + - "@ui/components/composer.g.dart" exports: - - components/select.dart + - components/composer.dart - tooltip: + execution: registryDependencies: - - theme + - models + - support dependencies: mix_annotations: ^2.2.0-beta.1 devDependencies: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/tooltip/tooltip.dart.tmpl - target: "@ui/components/tooltip.dart" + - source: templates/agent/execution/execution.dart.tmpl + target: "@ui/components/execution.dart" generated: - - "@ui/components/tooltip.g.dart" + - "@ui/components/execution.g.dart" exports: - - components/tooltip.dart + - components/execution.dart - data_list: + message: registryDependencies: - - theme + - models + - support dependencies: mix_annotations: ^2.2.0-beta.1 devDependencies: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/data_list/data_list.dart.tmpl - target: "@ui/components/data_list.dart" + - source: templates/agent/message/message.dart.tmpl + target: "@ui/components/message.dart" generated: - - "@ui/components/data_list.g.dart" + - "@ui/components/message.g.dart" exports: - - components/data_list.dart + - components/message.dart - # The only item with non-theme dependencies. A table's selection column is a - # checkbox, its pager is a pair of icon buttons, and its page-size control is - # a select, so its recipe hands `DataTableSpec` the application's own recipes - # rather than restating three components inside a fourth. - data_table: + permission: registryDependencies: - - theme - - checkbox - - icon_button - - select + - models + - support dependencies: mix_annotations: ^2.2.0-beta.1 devDependencies: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/data_table/data_table.dart.tmpl - target: "@ui/components/data_table.dart" + - source: templates/agent/permission/permission.dart.tmpl + target: "@ui/components/permission.dart" generated: - - "@ui/components/data_table.g.dart" + - "@ui/components/permission.g.dart" exports: - - components/data_table.dart + - components/permission.dart - # A toast's action and close control are the application's own Button and - # IconButton recipes, handed to `ToastSpec` so they keep their own states. - toast: + plan: registryDependencies: - - theme - - button - - icon_button + - models + - support dependencies: mix_annotations: ^2.2.0-beta.1 devDependencies: build_runner: ^2.10.1 mix_generator: ^2.2.0-beta.3 files: - - source: templates/toast/toast.dart.tmpl - target: "@ui/components/toast.dart" + - source: templates/agent/plan/plan.dart.tmpl + target: "@ui/components/plan.dart" generated: - - "@ui/components/toast.g.dart" + - "@ui/components/plan.g.dart" exports: - - components/toast.dart + - components/plan.dart - # A layout, not a styled component: it has no Spec and installs no - # generated adapter, so it declares neither `dependencies` (mix_annotations) - # nor `devDependencies` (build_runner, mix_generator). It still depends on - # `sidebar` because it composes an already-installed Sidebar into its row - # and compact sheet, even though its own source never imports that file. - sidebar_layout: + transcript: registryDependencies: - - theme - - sidebar + - support + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 files: - - source: templates/sidebar_layout/sidebar_layout.dart.tmpl - target: "@ui/components/sidebar_layout.dart" + - source: templates/agent/transcript/transcript.dart.tmpl + target: "@ui/components/transcript.dart" + generated: + - "@ui/components/transcript.g.dart" exports: - - components/sidebar_layout.dart + - components/transcript.dart diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/activity/activity.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/activity/activity.dart.tmpl new file mode 100644 index 000000000..ff46115c2 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/activity/activity.dart.tmpl @@ -0,0 +1,310 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/activity_item.dart'; +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'activity.g.dart'; + +typedef {{typePrefix}}ActivityStatusBuilder = + Widget Function(BuildContext context, {{typePrefix}}ActivityItem item); +typedef {{typePrefix}}ActivityStatusLabelBuilder = + String Function({{typePrefix}}ActivityItem item); +typedef {{typePrefix}}ActivityIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Activity ledger that is forced open and non-toggleable only while working. +class {{typePrefix}}Activity extends StatefulWidget { + const {{typePrefix}}Activity({ + super.key, + required this.items, + this.status = {{typePrefix}}RunStatus.working, + this.title = 'Activity', + this.semanticLabel = 'Activity', + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.statusBuilder, + this.statusLabelBuilder, + this.indicatorBuilder, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + this.disclosureStyle = const DisclosureStyler.create(), + this.style = const {{typePrefix}}ActivityStyler.create(), + this.styleSpec, + }); + + final List<{{typePrefix}}ActivityItem> items; + final {{typePrefix}}RunStatus status; + final String title; + final String semanticLabel; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final {{typePrefix}}ActivityStatusBuilder? statusBuilder; + final {{typePrefix}}ActivityStatusLabelBuilder? statusLabelBuilder; + final {{typePrefix}}ActivityIndicatorBuilder? indicatorBuilder; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + final DisclosureStyler disclosureStyle; + final {{typePrefix}}ActivityStyler style; + final {{typePrefix}}ActivitySpec? styleSpec; + + bool get isWorking => status == {{typePrefix}}RunStatus.working; + + /// Number of completed rows in the activity ledger. + int get settledCount => items + .where((item) => item.status == {{typePrefix}}ActivityItemStatus.complete) + .length; + + @override + State<{{typePrefix}}Activity> createState() => _{{typePrefix}}ActivityState(); +} + +class _{{typePrefix}}ActivityState extends State<{{typePrefix}}Activity> { + late final {{typePrefix}}DisclosureEngine _disclosure; + + bool get _expanded => widget.isWorking ? true : (_disclosure.value); + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Activity oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + if (!oldWidget.isWorking && widget.isWorking) { + _request(true, lifecycle: true); + } else if (oldWidget.isWorking && + !widget.isWorking && + widget.collapseOnComplete) { + _request(false, lifecycle: true); + } + } + + void _request(bool next, {bool lifecycle = false}) { + if (widget.isWorking && !lifecycle) return; + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String _statusLabel({{typePrefix}}ActivityItem item) => + widget.statusLabelBuilder?.call(item) ?? + switch (item.status) { + {{typePrefix}}ActivityItemStatus.pending => 'Pending', + {{typePrefix}}ActivityItemStatus.active => 'Active', + {{typePrefix}}ActivityItemStatus.complete => 'Complete', + }; + + {{typePrefix}}FunctionalGlyphKind _statusGlyph({{typePrefix}}ActivityItemStatus status) => + switch (status) { + {{typePrefix}}ActivityItemStatus.pending => .pending, + {{typePrefix}}ActivityItemStatus.active => .active, + {{typePrefix}}ActivityItemStatus.complete => .completed, + }; + + StyleSpec _statusContainer( + {{typePrefix}}ActivitySpec spec, + {{typePrefix}}ActivityItemStatus status, + ) => switch (status) { + {{typePrefix}}ActivityItemStatus.pending => spec.pendingItem, + {{typePrefix}}ActivityItemStatus.active => spec.activeItem, + {{typePrefix}}ActivityItemStatus.complete => spec.completedItem, + }; + + StyleSpec _statusStyle( + {{typePrefix}}ActivitySpec spec, + {{typePrefix}}ActivityItemStatus status, + ) => switch (status) { + {{typePrefix}}ActivityItemStatus.pending => spec.pendingStatus, + {{typePrefix}}ActivityItemStatus.active => spec.activeStatus, + {{typePrefix}}ActivityItemStatus.complete => spec.completedStatus, + }; + + Widget _defaultStatus( + BuildContext context, + {{typePrefix}}ActivitySpec spec, + {{typePrefix}}ActivityItem item, + ) => StyleSpecBuilder( + styleSpec: _statusStyle(spec, item.status), + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph(kind: _statusGlyph(item.status), spec: iconSpec), + ); + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}ActivitySpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixDisclosure( + expanded: _expanded, + enabled: !widget.isWorking, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + // Preserve the count alignment and expansion cue while working. + // RemixDisclosure keeps the forced-open header non-toggleable. + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: Row( + children: [ + Expanded( + child: StyledText(widget.title, styleSpec: spec.summaryTitle), + ), + StyledText( + '${widget.settledCount}/${widget.items.length}', + styleSpec: spec.count, + ), + ], + ), + content: Box( + styleSpec: spec.viewport, + child: {{typePrefix}}LiveEdgeScrollView( + followOutput: widget.followOutput, + followThreshold: widget.followThreshold, + onFollowChanged: widget.onFollowChanged, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in widget.items) + Semantics( + container: true, + explicitChildNodes: true, + label: [ + item.title, + if (item.detail != null) item.detail!, + _statusLabel(item), + ].join(', '), + child: Box( + styleSpec: _statusContainer(spec, item.status), + child: RowBox( + key: ValueKey('{{valuePrefix}}-activity-item-${item.id}'), + styleSpec: spec.item, + children: [ + ExcludeSemantics( + child: + widget.statusBuilder?.call(context, item) ?? + _defaultStatus(context, spec, item), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: StyledText( + item.title, + styleSpec: spec.itemTitle, + ), + ), + if (item.detail != null) + ExcludeSemantics( + child: StyledText( + item.detail!, + styleSpec: spec.itemDetail, + ), + ), + if (item.child != null) item.child!, + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Activity.new) +@immutable +final class {{typePrefix}}ActivitySpec with _${{typePrefix}}ActivitySpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final StyleSpec summaryTitle; + @override + final StyleSpec itemTitle; + @override + final StyleSpec itemDetail; + @override + final StyleSpec count; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingItem; + @override + final StyleSpec activeItem; + @override + final StyleSpec completedItem; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec activeStatus; + @override + final StyleSpec completedStatus; + + const {{typePrefix}}ActivitySpec({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: FlexBoxSpec()), + summaryTitle = summaryTitle ?? const StyleSpec(spec: TextSpec()), + itemTitle = itemTitle ?? const StyleSpec(spec: TextSpec()), + itemDetail = itemDetail ?? const StyleSpec(spec: TextSpec()), + count = count ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingItem = pendingItem ?? const StyleSpec(spec: BoxSpec()), + activeItem = activeItem ?? const StyleSpec(spec: BoxSpec()), + completedItem = completedItem ?? const StyleSpec(spec: BoxSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: IconSpec()), + activeStatus = activeStatus ?? const StyleSpec(spec: IconSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: IconSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/answer/answer.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/answer/answer.dart.tmpl new file mode 100644 index 000000000..ba82dbea5 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/answer/answer.dart.tmpl @@ -0,0 +1,216 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; + +part 'answer.g.dart'; + +typedef {{typePrefix}}AnswerSourcesIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Streaming answer surface with host-owned content and feedback. +class {{typePrefix}}Answer extends StatefulWidget { + const {{typePrefix}}Answer({ + super.key, + required this.child, + this.streamId, + this.status = {{typePrefix}}AnswerStatus.streaming, + this.onCopy, + this.onRetry, + this.copyIconBuilder, + this.retryIconBuilder, + this.sourcesIndicatorBuilder, + this.copyLabel = 'Copy answer', + this.retryLabel = 'Retry answer', + this.showActions, + this.feedback, + this.sourcesContent, + this.sourcesExpanded, + this.defaultSourcesExpanded = false, + this.onSourcesExpandedChanged, + this.sourcesLabel = 'Sources', + this.semanticLabel = 'Answer', + this.surfaceStyle = const CardStyler.create(), + this.sourcesStyle = const DisclosureStyler.create(), + this.copyStyle = const IconButtonStyler.create(), + this.retryStyle = const IconButtonStyler.create(), + this.style = const {{typePrefix}}AnswerStyler.create(), + this.styleSpec, + }); + + final Widget child; + final Object? streamId; + final {{typePrefix}}AnswerStatus status; + final VoidCallback? onCopy; + final VoidCallback? onRetry; + final RemixIconButtonIconBuilder? copyIconBuilder; + final RemixIconButtonIconBuilder? retryIconBuilder; + final {{typePrefix}}AnswerSourcesIndicatorBuilder? sourcesIndicatorBuilder; + final String copyLabel; + final String retryLabel; + final bool? showActions; + final Widget? feedback; + final Widget? sourcesContent; + final bool? sourcesExpanded; + final bool defaultSourcesExpanded; + final ValueChanged? onSourcesExpandedChanged; + final String sourcesLabel; + final String semanticLabel; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; + final {{typePrefix}}AnswerStyler style; + final {{typePrefix}}AnswerSpec? styleSpec; + + @override + State<{{typePrefix}}Answer> createState() => _{{typePrefix}}AnswerState(); +} + +class _{{typePrefix}}AnswerState extends State<{{typePrefix}}Answer> { + late final {{typePrefix}}DisclosureEngine _disclosure; + + bool get _sourcesExpanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.sourcesExpanded, + defaultValue: widget.defaultSourcesExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Answer oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.sourcesExpanded); + final beganStreaming = + !oldWidget.status.isStreaming && widget.status.isStreaming; + final newStreamingIdentity = + oldWidget.streamId != widget.streamId && widget.status.isStreaming; + if (beganStreaming || newStreamingIdentity) _requestSources(false); + } + + void _requestSources(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onSourcesExpandedChanged?.call(next); + } + + @override + Widget build(BuildContext context) { + final revealActions = + !widget.status.isStreaming && + (widget.showActions ?? widget.status.showsActions); + return RemixStyleSpecBuilder<{{typePrefix}}AnswerSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixCard( + style: widget.surfaceStyle, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + liveRegion: widget.status.isStreaming, + child: Box(styleSpec: spec.body, child: widget.child), + ), + if (widget.sourcesContent != null) + RemixDisclosure( + expanded: _sourcesExpanded, + onExpandedChanged: _requestSources, + semanticLabel: widget.sourcesLabel, + style: widget.sourcesStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.sourcesIndicatorBuilder, + ), + ], + ), + trigger: StyledText( + widget.sourcesLabel, + styleSpec: spec.sourcesLabel, + ), + content: widget.sourcesContent!, + ), + if (revealActions) + RowBox( + styleSpec: spec.actions, + children: [ + if (widget.onCopy != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.copyIconBuilder ?? + (context, iconSpec, icon) => {{typePrefix}}FunctionalGlyph( + kind: .copy, + spec: iconSpec, + ), + semanticLabel: widget.copyLabel, + onPressed: widget.onCopy, + style: widget.copyStyle, + ), + if (widget.onRetry != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.retryIconBuilder ?? + (context, iconSpec, icon) => {{typePrefix}}FunctionalGlyph( + kind: .retry, + spec: iconSpec, + ), + semanticLabel: widget.retryLabel, + onPressed: widget.onRetry, + style: widget.retryStyle, + ), + if (widget.status == {{typePrefix}}AnswerStatus.complete && + widget.feedback != null) + Box(styleSpec: spec.feedback, child: widget.feedback), + ], + ), + ], + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Answer.new) +@immutable +final class {{typePrefix}}AnswerSpec with _${{typePrefix}}AnswerSpec { + @override + final StyleSpec body; + @override + final StyleSpec actions; + @override + final StyleSpec feedback; + @override + final StyleSpec sourcesLabel; + @override + final StyleSpec indicator; + + const {{typePrefix}}AnswerSpec({ + StyleSpec? body, + StyleSpec? actions, + StyleSpec? feedback, + StyleSpec? sourcesLabel, + StyleSpec? indicator, + }) : body = body ?? const StyleSpec(spec: BoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + feedback = feedback ?? const StyleSpec(spec: BoxSpec()), + sourcesLabel = sourcesLabel ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/composer/composer.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/composer/composer.dart.tmpl new file mode 100644 index 000000000..f16e0c8fa --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/composer/composer.dart.tmpl @@ -0,0 +1,276 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../support/functional_glyph.dart'; + +part 'composer.g.dart'; + +/// Growable prompt input composed from Remix text-area and icon-button controls. +class {{typePrefix}}Composer extends StatefulWidget { + const {{typePrefix}}Composer({ + super.key, + this.controller, + this.initialValue, + this.focusNode, + this.onChanged, + this.onSubmit, + this.onStop, + this.running = false, + this.enabled = true, + this.canSubmit, + this.clearOnSubmit = true, + this.autofocus = false, + this.hintText = 'Message', + this.semanticLabel = 'Message', + this.minLines = 2, + this.maxLines = 8, + this.leading, + this.trailing, + this.submitIconBuilder, + this.stopIconBuilder, + this.submitLabel = 'Send', + this.stopLabel = 'Stop', + this.surfaceStyle = const CardStyler.create(), + this.fieldStyle = const TextFieldStyler.create(), + this.submitStyle = const IconButtonStyler.create(), + this.stopStyle = const IconButtonStyler.create(), + this.style = const {{typePrefix}}ComposerStyler.create(), + this.styleSpec, + }) : assert( + controller == null || initialValue == null, + 'initialValue cannot be used with an external controller.', + ); + + final TextEditingController? controller; + final String? initialValue; + final FocusNode? focusNode; + final ValueChanged? onChanged; + final ValueChanged? onSubmit; + final VoidCallback? onStop; + final bool running; + final bool enabled; + final bool? canSubmit; + final bool clearOnSubmit; + final bool autofocus; + final String hintText; + final String semanticLabel; + final int minLines; + final int maxLines; + final Widget? leading; + final Widget? trailing; + final RemixIconButtonIconBuilder? submitIconBuilder; + final RemixIconButtonIconBuilder? stopIconBuilder; + final String submitLabel; + final String stopLabel; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; + final {{typePrefix}}ComposerStyler style; + final {{typePrefix}}ComposerSpec? styleSpec; + + @override + State<{{typePrefix}}Composer> createState() => _{{typePrefix}}ComposerState(); +} + +class _{{typePrefix}}ComposerState extends State<{{typePrefix}}Composer> { + TextEditingController? _ownedController; + FocusNode? _ownedFocusNode; + late TextEditingController _controller; + late String _text; + + FocusNode get _focusNode => + widget.focusNode ?? (_ownedFocusNode ??= FocusNode()); + + bool get _isComposing { + final composing = _controller.value.composing; + return composing.isValid && !composing.isCollapsed; + } + + bool get _canSubmit => + widget.enabled && + !widget.running && + _text.trim().isNotEmpty && + widget.onSubmit != null && + (widget.canSubmit ?? true); + + @override + void initState() { + super.initState(); + _controller = + widget.controller ?? + (_ownedController = TextEditingController(text: widget.initialValue)); + _text = _controller.text; + _controller.addListener(_handleControllerChanged); + } + + void _handleControllerChanged() { + final next = _controller.text; + if (next == _text) return; + setState(() => _text = next); + widget.onChanged?.call(next); + } + + @override + void didUpdateWidget({{typePrefix}}Composer oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + final seed = _controller.text; + _controller.removeListener(_handleControllerChanged); + final oldOwnedController = _ownedController; + _ownedController = null; + _controller = + widget.controller ?? + (_ownedController = TextEditingController(text: seed)); + _text = _controller.text; + _controller.addListener(_handleControllerChanged); + _disposeAfterFrame(oldOwnedController); + } + if (!identical(oldWidget.focusNode, widget.focusNode)) { + final oldOwnedFocusNode = _ownedFocusNode; + _ownedFocusNode = null; + _disposeAfterFrame(oldOwnedFocusNode); + } + } + + /// Releases a superseded owned object once the child has let go of it. + /// + /// The same deferral the transcript uses for its scroll controller: the child + /// RemixTextArea still holds the old controller and focus node until this + /// frame's rebuild detaches them, and detaching touches a disposed object. + void _disposeAfterFrame(ChangeNotifier? superseded) { + if (superseded == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) => superseded.dispose()); + } + + void _submit() { + if (!_canSubmit || _isComposing) return; + final prompt = _text.trim(); + widget.onSubmit?.call(prompt); + if (widget.clearOnSubmit) _controller.clear(); + _focusNode.requestFocus(); + } + + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final isEnter = + event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter; + if (!isEnter || HardwareKeyboard.instance.isShiftPressed || _isComposing) { + return KeyEventResult.ignored; + } + if (!_canSubmit) return KeyEventResult.ignored; + _submit(); + return KeyEventResult.handled; + } + + Widget _defaultSubmitIcon( + BuildContext context, + IconSpec spec, + IconData? icon, + ) => {{typePrefix}}FunctionalGlyph(kind: .send, spec: spec); + + Widget _defaultStopIcon( + BuildContext context, + IconSpec spec, + IconData? icon, + ) => {{typePrefix}}FunctionalGlyph(kind: .stop, spec: spec); + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}ComposerSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + // Keep the field and action in separate accessibility nodes. + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _handleKey, + child: RemixCard( + style: widget.surfaceStyle, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ScrollConfiguration( + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false), + child: RemixTextArea( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + autofocus: widget.autofocus, + hintText: widget.hintText, + semanticLabel: widget.semanticLabel, + minLines: widget.minLines, + maxLines: widget.maxLines, + textInputAction: TextInputAction.newline, + style: widget.fieldStyle, + ), + ), + RowBox( + styleSpec: spec.toolbar, + children: [ + if (widget.leading != null) widget.leading!, + const Spacer(), + if (widget.trailing != null) widget.trailing!, + Semantics( + container: true, + child: RemixIconButton( + key: ValueKey( + widget.running + ? '{{valuePrefix}}-composer-stop' + : '{{valuePrefix}}-composer-send', + ), + icon: null, + iconBuilder: widget.running + ? (widget.stopIconBuilder ?? _defaultStopIcon) + : (widget.submitIconBuilder ?? _defaultSubmitIcon), + semanticLabel: widget.running + ? widget.stopLabel + : widget.submitLabel, + enabled: widget.running + ? widget.enabled && widget.onStop != null + : _canSubmit, + onPressed: widget.running ? widget.onStop : _submit, + style: widget.running + ? widget.stopStyle + : widget.submitStyle, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _controller.removeListener(_handleControllerChanged); + _ownedController?.dispose(); + _ownedFocusNode?.dispose(); + super.dispose(); + } +} + +@MixableSpec(target: {{typePrefix}}Composer.new) +@immutable +final class {{typePrefix}}ComposerSpec with _${{typePrefix}}ComposerSpec { + @override + final StyleSpec toolbar; + + const {{typePrefix}}ComposerSpec({StyleSpec? toolbar}) + : toolbar = toolbar ?? const StyleSpec(spec: FlexBoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/execution/execution.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/execution/execution.dart.tmpl new file mode 100644 index 000000000..54523672d --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/execution/execution.dart.tmpl @@ -0,0 +1,342 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'execution.g.dart'; + +typedef {{typePrefix}}ExecutionStatusLabelBuilder = + String Function({{typePrefix}}ExecutionStatus status); +typedef {{typePrefix}}ExecutionStatusBuilder = + Widget Function(BuildContext context, {{typePrefix}}ExecutionStatus status); +typedef {{typePrefix}}ExecutionIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Toggleable tool execution output with lifecycle-driven open requests. +class {{typePrefix}}Execution extends StatefulWidget { + const {{typePrefix}}Execution({ + super.key, + required this.tool, + required this.title, + required this.child, + this.status = {{typePrefix}}ExecutionStatus.running, + this.meta, + this.icon, + this.onCopy, + this.onRetry, + this.copyIconBuilder, + this.retryIconBuilder, + this.indicatorBuilder, + this.statusBuilder, + this.statusLabelBuilder, + this.copyLabel = 'Copy output', + this.retryLabel = 'Retry execution', + this.outputLabel = 'Tool output', + this.showActions = true, + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.semanticLabel = 'Tool execution', + this.surfaceStyle = const CardStyler.create(), + this.disclosureStyle = const DisclosureStyler.create(), + this.copyStyle = const IconButtonStyler.create(), + this.retryStyle = const IconButtonStyler.create(), + this.style = const {{typePrefix}}ExecutionStyler.create(), + this.styleSpec, + }); + + final String tool; + final String title; + final Widget child; + final {{typePrefix}}ExecutionStatus status; + final String? meta; + final Widget? icon; + final VoidCallback? onCopy; + final VoidCallback? onRetry; + final RemixIconButtonIconBuilder? copyIconBuilder; + final RemixIconButtonIconBuilder? retryIconBuilder; + final {{typePrefix}}ExecutionIndicatorBuilder? indicatorBuilder; + final {{typePrefix}}ExecutionStatusBuilder? statusBuilder; + final {{typePrefix}}ExecutionStatusLabelBuilder? statusLabelBuilder; + final String copyLabel; + final String retryLabel; + final String outputLabel; + final bool showActions; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final String semanticLabel; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; + final {{typePrefix}}ExecutionStyler style; + final {{typePrefix}}ExecutionSpec? styleSpec; + + @override + State<{{typePrefix}}Execution> createState() => _{{typePrefix}}ExecutionState(); +} + +class _{{typePrefix}}ExecutionState extends State<{{typePrefix}}Execution> { + late final {{typePrefix}}DisclosureEngine _disclosure; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Execution oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + if (!oldWidget.status.isWorking && widget.status.isWorking) { + _request(true); + } else if (oldWidget.status.isWorking && + !widget.status.isWorking && + widget.collapseOnComplete) { + _request(false); + } + } + + void _request(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String get _statusLabel => + widget.statusLabelBuilder?.call(widget.status) ?? + switch (widget.status) { + {{typePrefix}}ExecutionStatus.running => 'Running', + {{typePrefix}}ExecutionStatus.success => 'Completed', + {{typePrefix}}ExecutionStatus.error => 'Failed', + {{typePrefix}}ExecutionStatus.cancelled => 'Cancelled', + }; + + StyleSpec _statusContainer({{typePrefix}}ExecutionSpec spec) => + switch (widget.status) { + {{typePrefix}}ExecutionStatus.running => spec.runningStatus, + {{typePrefix}}ExecutionStatus.success => spec.successStatus, + {{typePrefix}}ExecutionStatus.error => spec.errorStatus, + {{typePrefix}}ExecutionStatus.cancelled => spec.cancelledStatus, + }; + + {{typePrefix}}FunctionalGlyphKind get _statusGlyph => switch (widget.status) { + {{typePrefix}}ExecutionStatus.running => .loading, + {{typePrefix}}ExecutionStatus.success => .completedCircle, + {{typePrefix}}ExecutionStatus.error => .errorCircle, + {{typePrefix}}ExecutionStatus.cancelled => .cancelledCircle, + }; + + Widget _toolIcon({{typePrefix}}ExecutionSpec spec) { + final icon = widget.icon; + if (icon != null) return icon; + return StyleSpecBuilder( + styleSpec: spec.toolIcon, + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph(kind: .tool, spec: iconSpec), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}ExecutionSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + value: '${widget.tool}, $_statusLabel', + child: RemixCard( + style: widget.surfaceStyle, + child: RemixDisclosure( + expanded: _expanded, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: RowBox( + styleSpec: spec.header, + children: [ + _toolIcon(spec), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StyledText(widget.title, styleSpec: spec.title), + StyledText(widget.tool, styleSpec: spec.tool), + ], + ), + ), + if (widget.meta != null) + StyledText(widget.meta!, styleSpec: spec.meta), + Box( + styleSpec: _statusContainer(spec), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.statusBuilder?.call(context, widget.status) ?? + StyleSpecBuilder( + styleSpec: spec.statusIcon, + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph( + kind: _statusGlyph, + spec: iconSpec, + ), + ), + StyledText(_statusLabel, styleSpec: spec.status), + ], + ), + ), + ], + ), + content: Box( + styleSpec: spec.output, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Deliberately not an {{typePrefix}}Transcript. That installs + // Arrow/Page/Home/End shortcuts and its own Semantics + // container, and an execution card is normally nested inside + // a host transcript: the inner list shrink-wraps to a zero + // scroll extent but its action still consumes those intents, + // so focus landing here stopped the outer transcript from + // scrolling, and its `busy` value announced the status a + // second time. This is the primitive plan and activity use. + Semantics( + label: widget.outputLabel, + child: {{typePrefix}}LiveEdgeScrollView( + followOutput: widget.status.isWorking, + child: widget.child, + ), + ), + if (widget.showActions && widget.status.isSettled) + RowBox( + styleSpec: spec.actions, + children: [ + if (widget.onCopy != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.copyIconBuilder ?? + (context, iconSpec, icon) => + {{typePrefix}}FunctionalGlyph( + kind: .copy, + spec: iconSpec, + ), + semanticLabel: widget.copyLabel, + onPressed: widget.onCopy, + style: widget.copyStyle, + ), + if (widget.onRetry != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.retryIconBuilder ?? + (context, iconSpec, icon) => + {{typePrefix}}FunctionalGlyph( + kind: .retry, + spec: iconSpec, + ), + semanticLabel: widget.retryLabel, + onPressed: widget.onRetry, + style: widget.retryStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Execution.new) +@immutable +final class {{typePrefix}}ExecutionSpec with _${{typePrefix}}ExecutionSpec { + @override + final StyleSpec header; + @override + final StyleSpec output; + @override + final StyleSpec actions; + @override + final StyleSpec tool; + @override + final StyleSpec title; + @override + final StyleSpec meta; + @override + final StyleSpec status; + @override + final StyleSpec toolIcon; + @override + final StyleSpec statusIcon; + @override + final StyleSpec indicator; + @override + final StyleSpec runningStatus; + @override + final StyleSpec successStatus; + @override + final StyleSpec errorStatus; + @override + final StyleSpec cancelledStatus; + + const {{typePrefix}}ExecutionSpec({ + StyleSpec? header, + StyleSpec? output, + StyleSpec? actions, + StyleSpec? tool, + StyleSpec? title, + StyleSpec? meta, + StyleSpec? status, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? runningStatus, + StyleSpec? successStatus, + StyleSpec? errorStatus, + StyleSpec? cancelledStatus, + }) : header = header ?? const StyleSpec(spec: FlexBoxSpec()), + output = output ?? const StyleSpec(spec: BoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + tool = tool ?? const StyleSpec(spec: TextSpec()), + title = title ?? const StyleSpec(spec: TextSpec()), + meta = meta ?? const StyleSpec(spec: TextSpec()), + status = status ?? const StyleSpec(spec: TextSpec()), + toolIcon = toolIcon ?? const StyleSpec(spec: IconSpec()), + statusIcon = statusIcon ?? const StyleSpec(spec: IconSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + runningStatus = runningStatus ?? const StyleSpec(spec: BoxSpec()), + successStatus = successStatus ?? const StyleSpec(spec: BoxSpec()), + errorStatus = errorStatus ?? const StyleSpec(spec: BoxSpec()), + cancelledStatus = cancelledStatus ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/message/message.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/message/message.dart.tmpl new file mode 100644 index 000000000..de5f8c413 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/message/message.dart.tmpl @@ -0,0 +1,389 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; + +part 'message.g.dart'; + +enum {{typePrefix}}MessageAlign { start, end } + +/// Groups chronological message rows without imposing visual chrome. +class {{typePrefix}}MessageGroup extends StatelessWidget { + const {{typePrefix}}MessageGroup({ + super.key, + required this.children, + this.spacing = 0, + }); + + final List children; + final double spacing; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + spacing: spacing, + children: children, + ); +} + +/// Sender-aware message row. Message bodies are never clamped automatically. +class {{typePrefix}}Message extends StatelessWidget { + const {{typePrefix}}Message({ + super.key, + required this.role, + required this.child, + this.align, + this.avatar, + this.showAvatar = false, + this.placeholderAvatar = false, + this.maxWidth, + this.header, + this.footer, + this.semanticLabel, + this.surfaceStyle = const CardStyler.create(), + this.style = const {{typePrefix}}MessageStyler.create(), + this.styleSpec, + }); + + final {{typePrefix}}Role role; + final Widget child; + final {{typePrefix}}MessageAlign? align; + final Widget? avatar; + final bool showAvatar; + final bool placeholderAvatar; + final double? maxWidth; + final Widget? header; + final Widget? footer; + final String? semanticLabel; + final CardStyler surfaceStyle; + final {{typePrefix}}MessageStyler style; + final {{typePrefix}}MessageSpec? styleSpec; + + bool get _alignEnd => + (align ?? + (role == {{typePrefix}}Role.user + ? {{typePrefix}}MessageAlign.end + : {{typePrefix}}MessageAlign.start)) == + {{typePrefix}}MessageAlign.end; + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}MessageSpec>( + style: style, + styleSpec: styleSpec, + builder: (context, spec) { + final body = RemixCard( + style: surfaceStyle, + child: Box(styleSpec: spec.body, child: child), + ); + final cap = maxWidth ?? spec.maxWidth; + final constrained = cap == null + ? body + : ConstrainedBox( + constraints: BoxConstraints(maxWidth: cap), + child: body, + ); + final stack = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _alignEnd + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (header != null) Box(styleSpec: spec.header, child: header), + constrained, + if (footer != null) Box(styleSpec: spec.footer, child: footer), + ], + ); + final avatarSlot = _avatarSlot(spec); + final row = RowBox( + styleSpec: spec.row, + children: [ + if (!_alignEnd && avatarSlot != null) avatarSlot, + Expanded( + child: Align( + alignment: _alignEnd + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: stack, + ), + ), + if (_alignEnd && avatarSlot != null) avatarSlot, + ], + ); + return Semantics( + container: true, + explicitChildNodes: true, + label: + semanticLabel ?? + (role == {{typePrefix}}Role.user ? 'User message' : 'Assistant message'), + child: row, + ); + }, + ); + } + + Widget? _avatarSlot({{typePrefix}}MessageSpec spec) { + if (placeholderAvatar) return Box(styleSpec: spec.avatar); + if (!showAvatar || avatar == null) return null; + return Box(styleSpec: spec.avatar, child: avatar); + } +} + +/// Explicit, opt-in clipping for noninteractive message copy. +/// +/// Do not place buttons, links, or other interactive descendants in [child]. +/// While collapsed, the whole child remains readable to assistive technology +/// but is removed from pointer input, focus, and traversal. +class {{typePrefix}}MessageCollapsible extends StatefulWidget { + const {{typePrefix}}MessageCollapsible({ + super.key, + required this.child, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.showMoreLabel = 'Show more', + this.showLessLabel = 'Show less', + this.toggleStyle = const ButtonStyler.create(), + this.style = const {{typePrefix}}MessageCollapsibleStyler.create(), + this.styleSpec, + }); + + final Widget child; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final String showMoreLabel; + final String showLessLabel; + final ButtonStyler toggleStyle; + final {{typePrefix}}MessageCollapsibleStyler style; + final {{typePrefix}}MessageCollapsibleSpec? styleSpec; + + @override + State<{{typePrefix}}MessageCollapsible> createState() => + _{{typePrefix}}MessageCollapsibleState(); +} + +class _{{typePrefix}}MessageCollapsibleState extends State<{{typePrefix}}MessageCollapsible> { + late final {{typePrefix}}DisclosureEngine _disclosure; + bool _overflows = false; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}MessageCollapsible oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + } + + void _toggle() { + final next = !_expanded; + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + void _handleOverflow(bool value) { + if (value == _overflows) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && value != _overflows) setState(() => _overflows = value); + }); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}MessageCollapsibleSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) { + final height = spec.collapsedHeight; + final collapsed = !_expanded && height != null; + Widget content = _OverflowClip( + maxHeight: height, + clip: collapsed, + onOverflowChanged: _handleOverflow, + child: Box(styleSpec: spec.clipped, child: widget.child), + ); + if (collapsed) { + content = IgnorePointer( + child: Focus( + canRequestFocus: false, + skipTraversal: true, + descendantsAreFocusable: false, + descendantsAreTraversable: false, + child: content, + ), + ); + } + return Box( + styleSpec: spec.container, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + if (_overflows) + RemixButton( + label: _expanded + ? widget.showLessLabel + : widget.showMoreLabel, + semanticLabel: _expanded + ? widget.showLessLabel + : widget.showMoreLabel, + onPressed: _toggle, + style: widget.toggleStyle, + ), + ], + ), + ); + }, + ); + } +} + +class _OverflowClip extends SingleChildRenderObjectWidget { + const _OverflowClip({ + required this.maxHeight, + required this.clip, + required this.onOverflowChanged, + required super.child, + }); + + final double? maxHeight; + final bool clip; + final ValueChanged onOverflowChanged; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderOverflowClip(maxHeight, clip, onOverflowChanged); + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderOverflowClip renderObject, + ) { + renderObject + ..maxHeight = maxHeight + ..clip = clip + ..onOverflowChanged = onOverflowChanged; + } +} + +class _RenderOverflowClip extends RenderProxyBox { + _RenderOverflowClip(this._maxHeight, this._clip, this.onOverflowChanged); + + double? _maxHeight; + bool _clip; + ValueChanged onOverflowChanged; + bool _reportedOverflow = false; + + set maxHeight(double? value) { + if (value == _maxHeight) return; + _maxHeight = value; + markNeedsLayout(); + } + + set clip(bool value) { + if (value == _clip) return; + _clip = value; + markNeedsLayout(); + } + + @override + void performLayout() { + final current = child; + if (current == null) { + size = constraints.smallest; + return; + } + current.layout( + constraints.copyWith(minHeight: 0, maxHeight: double.infinity), + parentUsesSize: true, + ); + final limit = _maxHeight; + final overflow = limit != null && current.size.height > limit; + size = constraints.constrain( + Size(current.size.width, _clip && overflow ? limit : current.size.height), + ); + if (overflow != _reportedOverflow) { + _reportedOverflow = overflow; + onOverflowChanged(overflow); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) return; + if (!_clip) { + super.paint(context, offset); + return; + } + // pushClipRect applies the paint offset to this local rectangle. + context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + super.paint, + ); + } +} + +@MixableSpec(target: {{typePrefix}}Message.new) +@immutable +final class {{typePrefix}}MessageSpec with _${{typePrefix}}MessageSpec { + @override + final double? maxWidth; + @override + final StyleSpec row; + @override + final StyleSpec avatar; + @override + final StyleSpec header; + @override + final StyleSpec body; + @override + final StyleSpec footer; + + const {{typePrefix}}MessageSpec({ + this.maxWidth, + StyleSpec? row, + StyleSpec? avatar, + StyleSpec? header, + StyleSpec? body, + StyleSpec? footer, + }) : row = row ?? const StyleSpec(spec: FlexBoxSpec()), + avatar = avatar ?? const StyleSpec(spec: BoxSpec()), + header = header ?? const StyleSpec(spec: BoxSpec()), + body = body ?? const StyleSpec(spec: BoxSpec()), + footer = footer ?? const StyleSpec(spec: BoxSpec()); +} + +@MixableSpec(target: {{typePrefix}}MessageCollapsible.new) +@immutable +final class {{typePrefix}}MessageCollapsibleSpec with _${{typePrefix}}MessageCollapsibleSpec { + @override + final double? collapsedHeight; + @override + final StyleSpec container; + @override + final StyleSpec clipped; + + const {{typePrefix}}MessageCollapsibleSpec({ + this.collapsedHeight, + StyleSpec? container, + StyleSpec? clipped, + }) : container = container ?? const StyleSpec(spec: BoxSpec()), + clipped = clipped ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/models/activity_item.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/models/activity_item.dart.tmpl new file mode 100644 index 000000000..6d9fb02b4 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/models/activity_item.dart.tmpl @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; + +import 'statuses.dart'; + +/// One row in an [{{typePrefix}}Activity] ledger. +@immutable +class {{typePrefix}}ActivityItem { + /// Creates an activity row. + const {{typePrefix}}ActivityItem({ + required this.id, + required this.title, + this.status = {{typePrefix}}ActivityItemStatus.pending, + this.detail, + this.child, + }); + + /// Stable identity across list updates. + final String id; + + /// Visible title. + final String title; + + /// Current status. + final {{typePrefix}}ActivityItemStatus status; + + /// Optional compact detail rendered with the activity detail style slot. + final String? detail; + + /// Optional host-rendered detail. The catalog does not parse this child. + final Widget? child; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is {{typePrefix}}ActivityItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail && + identical(other.child, child); + + @override + int get hashCode => Object.hash( + runtimeType, + id, + title, + status, + detail, + identityHashCode(child), + ); + + @override + String toString() => + '{{typePrefix}}ActivityItem(id: $id, title: $title, status: $status, detail: $detail, child: $child)'; +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/models/plan_item.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/models/plan_item.dart.tmpl new file mode 100644 index 000000000..da6c48131 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/models/plan_item.dart.tmpl @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; + +import 'statuses.dart'; + +/// One row in an [{{typePrefix}}Plan]. +@immutable +class {{typePrefix}}PlanItem { + /// Creates a plan item. + const {{typePrefix}}PlanItem({ + required this.id, + required this.title, + this.status = {{typePrefix}}PlanItemStatus.pending, + this.detail, + }); + + /// Stable identity across list updates. + final String id; + + /// Visible title. + final String title; + + /// Current status. + final {{typePrefix}}PlanItemStatus status; + + /// Optional compact metadata (elapsed time, percent, path). + final String? detail; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is {{typePrefix}}PlanItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail; + + @override + int get hashCode => Object.hash(runtimeType, id, title, status, detail); + + @override + String toString() => + '{{typePrefix}}PlanItem(id: $id, title: $title, status: $status, detail: $detail)'; +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/models/statuses.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/models/statuses.dart.tmpl new file mode 100644 index 000000000..f77d3b457 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/models/statuses.dart.tmpl @@ -0,0 +1,146 @@ +/// Status of a long-running turn or activity ledger. +enum {{typePrefix}}RunStatus { + /// Work is in progress. Disclosures stay open. + working, + + /// Work finished. Disclosures may collapse. + complete, +} + +/// Status of a streamed answer. +enum {{typePrefix}}AnswerStatus { + /// Tokens are still arriving. + streaming, + + /// The answer finished successfully. + complete, + + /// The answer failed. + error, +} + +/// Status of an in-transcript tool permission. +/// +/// This is a machine, not a boolean loading flag. Actions are offered only +/// while [pending]. +enum {{typePrefix}}PermissionStatus { + /// Waiting for a human decision. + pending, + + /// A decision was submitted and is being recorded. + deciding, + + /// The host accepted this invocation. + allowed, + + /// The approved tool is executing. + running, + + /// The approved tool finished. + complete, + + /// The host refused this invocation. + denied, + + /// Permission or execution failed. + error, +} + +/// Status of a tool execution disclosure. +enum {{typePrefix}}ExecutionStatus { + /// Output is still arriving. + running, + + /// The tool finished successfully. + success, + + /// The tool failed. + error, + + /// The host or runtime cancelled the tool. + cancelled, +} + +/// Status of one item in a task plan. +enum {{typePrefix}}PlanItemStatus { + /// Not started. + pending, + + /// Currently underway. + inProgress, + + /// Finished successfully. + completed, + + /// Abandoned or skipped. + cancelled, +} + +/// Status of one row in an activity ledger. +enum {{typePrefix}}ActivityItemStatus { + /// Not yet started. + pending, + + /// The current step. + active, + + /// Finished. + complete, +} + +/// Who authored a transcript row. +enum {{typePrefix}}Role { + /// The human operator. + user, + + /// The assistant replying to the operator. + assistant, +} + +/// Whether a permission or execution is still occupying the operator. +extension {{typePrefix}}PermissionStatusX on {{typePrefix}}PermissionStatus { + /// True until a terminal outcome. [pending] is working (HITL in flight) + /// but does not keep parameter details open. + bool get isWorking => !isSettled; + + /// True after a terminal decision or outcome. + bool get isSettled => + this == {{typePrefix}}PermissionStatus.complete || + this == {{typePrefix}}PermissionStatus.denied || + this == {{typePrefix}}PermissionStatus.error; + + /// True while parameter details stay open without a user toggle. + /// Pending starts closed. + bool get keepsDetailsOpen => + this == {{typePrefix}}PermissionStatus.deciding || + this == {{typePrefix}}PermissionStatus.allowed || + this == {{typePrefix}}PermissionStatus.running; +} + +/// Working vs settled for an execution disclosure. +extension {{typePrefix}}ExecutionStatusX on {{typePrefix}}ExecutionStatus { + /// True while output should stay expanded. + bool get isWorking => this == {{typePrefix}}ExecutionStatus.running; + + /// True after a terminal outcome. + bool get isSettled => !isWorking; +} + +/// Working vs settled for a streamed answer. +extension {{typePrefix}}AnswerStatusX on {{typePrefix}}AnswerStatus { + /// True while tokens are still arriving. + bool get isStreaming => this == {{typePrefix}}AnswerStatus.streaming; + + /// True when completion actions may appear. + bool get showsActions => + this == {{typePrefix}}AnswerStatus.complete || this == {{typePrefix}}AnswerStatus.error; +} + +/// Working vs settled for a plan item. +extension {{typePrefix}}PlanItemStatusX on {{typePrefix}}PlanItemStatus { + bool get isActive => this == {{typePrefix}}PlanItemStatus.inProgress; + + bool get isDone => + this == {{typePrefix}}PlanItemStatus.completed || + this == {{typePrefix}}PlanItemStatus.cancelled; +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/permission/permission.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/permission/permission.dart.tmpl new file mode 100644 index 000000000..b78cad117 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/permission/permission.dart.tmpl @@ -0,0 +1,386 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; + +part 'permission.g.dart'; + +typedef {{typePrefix}}PermissionStatusLabelBuilder = + String Function({{typePrefix}}PermissionStatus status); +typedef {{typePrefix}}PermissionStatusBuilder = + Widget Function(BuildContext context, {{typePrefix}}PermissionStatus status); +typedef {{typePrefix}}PermissionIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// In-transcript permission request composed from Remix controls. +class {{typePrefix}}Permission extends StatefulWidget { + const {{typePrefix}}Permission({ + super.key, + required this.tool, + this.requestId, + this.title = 'Allow this tool to run?', + this.description, + this.status = {{typePrefix}}PermissionStatus.pending, + this.parameters = const [], + this.showParameters = true, + this.detailsExpanded, + this.defaultDetailsExpanded = false, + this.onDetailsExpandedChanged, + this.onAllowOnce, + this.onAlwaysAllow, + this.onDeny, + this.statusLabelBuilder, + this.statusBuilder, + this.indicatorBuilder, + this.allowOnceLabel = 'Allow once', + this.alwaysAllowLabel = 'Always allow', + this.denyLabel = 'Deny', + this.detailsLabel = 'View details', + this.semanticLabel = 'Tool permission', + this.parameterOrientation = Axis.horizontal, + this.surfaceStyle = const CardStyler.create(), + this.detailsStyle = const DisclosureStyler.create(), + this.parametersStyle = const DataListStyler.create(), + this.allowOnceStyle = const ButtonStyler.create(), + this.alwaysAllowStyle = const ButtonStyler.create(), + this.denyStyle = const ButtonStyler.create(), + this.style = const {{typePrefix}}PermissionStyler.create(), + this.styleSpec, + }); + + final Object? requestId; + final String tool; + final String title; + final String? description; + final {{typePrefix}}PermissionStatus status; + final List parameters; + final bool showParameters; + final bool? detailsExpanded; + final bool defaultDetailsExpanded; + final ValueChanged? onDetailsExpandedChanged; + final VoidCallback? onAllowOnce; + final VoidCallback? onAlwaysAllow; + final VoidCallback? onDeny; + final {{typePrefix}}PermissionStatusLabelBuilder? statusLabelBuilder; + final {{typePrefix}}PermissionStatusBuilder? statusBuilder; + final {{typePrefix}}PermissionIndicatorBuilder? indicatorBuilder; + final String allowOnceLabel; + final String alwaysAllowLabel; + final String denyLabel; + final String detailsLabel; + final String semanticLabel; + final Axis parameterOrientation; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; + final {{typePrefix}}PermissionStyler style; + final {{typePrefix}}PermissionSpec? styleSpec; + + @override + State<{{typePrefix}}Permission> createState() => _{{typePrefix}}PermissionState(); +} + +class _{{typePrefix}}PermissionState extends State<{{typePrefix}}Permission> { + late final {{typePrefix}}DisclosureEngine _disclosure; + bool _decisionSubmitted = false; + + bool get _detailsExpanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.detailsExpanded, + defaultValue: + widget.status.keepsDetailsOpen || widget.defaultDetailsExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Permission oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.detailsExpanded); + final returnedToPending = + oldWidget.status != {{typePrefix}}PermissionStatus.pending && + widget.status == {{typePrefix}}PermissionStatus.pending; + final newPendingRequest = + oldWidget.requestId != widget.requestId && + widget.status == {{typePrefix}}PermissionStatus.pending; + if (returnedToPending || newPendingRequest) _decisionSubmitted = false; + + if (!oldWidget.status.keepsDetailsOpen && widget.status.keepsDetailsOpen) { + _requestDetails(true); + } else if (!oldWidget.status.isSettled && widget.status.isSettled) { + _requestDetails(false); + } + } + + void _requestDetails(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onDetailsExpandedChanged?.call(next); + } + + void _submit(VoidCallback? callback) { + if (_decisionSubmitted || + widget.status != {{typePrefix}}PermissionStatus.pending || + callback == null) { + return; + } + setState(() => _decisionSubmitted = true); + callback(); + } + + String get _statusLabel => + widget.statusLabelBuilder?.call(widget.status) ?? + switch (widget.status) { + {{typePrefix}}PermissionStatus.pending => 'Permission required', + {{typePrefix}}PermissionStatus.deciding => 'Recording', + {{typePrefix}}PermissionStatus.allowed => 'Allowed', + {{typePrefix}}PermissionStatus.running => 'Running', + {{typePrefix}}PermissionStatus.complete => 'Complete', + {{typePrefix}}PermissionStatus.denied => 'Denied', + {{typePrefix}}PermissionStatus.error => 'Error', + }; + + {{typePrefix}}FunctionalGlyphKind get _statusGlyph => switch (widget.status) { + {{typePrefix}}PermissionStatus.pending => .permission, + {{typePrefix}}PermissionStatus.deciding => .loading, + {{typePrefix}}PermissionStatus.allowed => .completed, + {{typePrefix}}PermissionStatus.running => .loading, + {{typePrefix}}PermissionStatus.complete => .completed, + {{typePrefix}}PermissionStatus.denied => .cancelled, + {{typePrefix}}PermissionStatus.error => .error, + }; + + StyleSpec _statusContainer({{typePrefix}}PermissionSpec spec) => + switch (widget.status) { + {{typePrefix}}PermissionStatus.pending => spec.pendingStatus, + {{typePrefix}}PermissionStatus.deciding => spec.decidingStatus, + {{typePrefix}}PermissionStatus.allowed => spec.allowedStatus, + {{typePrefix}}PermissionStatus.running => spec.runningStatus, + {{typePrefix}}PermissionStatus.complete => spec.completedStatus, + {{typePrefix}}PermissionStatus.denied => spec.deniedStatus, + {{typePrefix}}PermissionStatus.error => spec.errorStatus, + }; + + // Horizontal by default; callers may stack actions without losing the + // action slot's box, modifiers, or nested style resolution. + StyleSpec _actionsStyle({{typePrefix}}PermissionSpec spec) { + final actions = spec.actions.spec; + final flex = actions.flex ?? const StyleSpec(spec: FlexSpec()); + return spec.actions.copyWith( + spec: actions.copyWith( + flex: flex.copyWith( + spec: flex.spec.copyWith( + direction: flex.spec.direction ?? Axis.horizontal, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}PermissionSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixCard( + style: widget.surfaceStyle, + child: Box( + styleSpec: spec.content, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RowBox( + styleSpec: spec.header, + children: [ + StyleSpecBuilder( + styleSpec: spec.toolIcon, + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph(kind: .tool, spec: iconSpec), + ), + Expanded( + child: StyledText(widget.title, styleSpec: spec.title), + ), + ], + ), + StyledText(widget.tool, styleSpec: spec.tool), + if (widget.description != null) + StyledText(widget.description!, styleSpec: spec.description), + Box( + styleSpec: _statusContainer(spec), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.statusBuilder?.call(context, widget.status) ?? + StyleSpecBuilder( + styleSpec: spec.statusIcon, + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph( + kind: _statusGlyph, + spec: iconSpec, + ), + ), + Flexible( + child: StyledText(_statusLabel, styleSpec: spec.status), + ), + ], + ), + ), + if (widget.showParameters && widget.parameters.isNotEmpty) + RemixDisclosure( + expanded: _detailsExpanded, + onExpandedChanged: _requestDetails, + semanticLabel: widget.detailsLabel, + style: widget.detailsStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: StyledText( + widget.detailsLabel, + styleSpec: spec.detailsLabel, + ), + content: RemixDataList( + items: widget.parameters, + orientation: widget.parameterOrientation, + style: widget.parametersStyle, + ), + ), + if (widget.status == {{typePrefix}}PermissionStatus.pending) + FlexBox( + styleSpec: _actionsStyle(spec), + children: [ + RemixButton( + key: const ValueKey('{{valuePrefix}}-permission-allow-once'), + label: widget.allowOnceLabel, + enabled: !_decisionSubmitted, + onPressed: widget.onAllowOnce == null + ? null + : () => _submit(widget.onAllowOnce), + style: widget.allowOnceStyle, + ), + if (widget.onAlwaysAllow != null) + RemixButton( + key: const ValueKey('{{valuePrefix}}-permission-always-allow'), + label: widget.alwaysAllowLabel, + enabled: !_decisionSubmitted, + onPressed: () => _submit(widget.onAlwaysAllow), + style: widget.alwaysAllowStyle, + ), + RemixButton( + key: const ValueKey('{{valuePrefix}}-permission-deny'), + label: widget.denyLabel, + enabled: !_decisionSubmitted, + onPressed: widget.onDeny == null + ? null + : () => _submit(widget.onDeny), + style: widget.denyStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Permission.new) +@immutable +final class {{typePrefix}}PermissionSpec with _${{typePrefix}}PermissionSpec { + @override + final StyleSpec content; + @override + final StyleSpec header; + @override + final StyleSpec actions; + @override + final StyleSpec title; + @override + final StyleSpec tool; + @override + final StyleSpec description; + @override + final StyleSpec status; + @override + final StyleSpec detailsLabel; + @override + final StyleSpec toolIcon; + @override + final StyleSpec statusIcon; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec decidingStatus; + @override + final StyleSpec allowedStatus; + @override + final StyleSpec runningStatus; + @override + final StyleSpec completedStatus; + @override + final StyleSpec deniedStatus; + @override + final StyleSpec errorStatus; + + const {{typePrefix}}PermissionSpec({ + StyleSpec? content, + StyleSpec? header, + StyleSpec? actions, + StyleSpec? title, + StyleSpec? tool, + StyleSpec? description, + StyleSpec? status, + StyleSpec? detailsLabel, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? pendingStatus, + StyleSpec? decidingStatus, + StyleSpec? allowedStatus, + StyleSpec? runningStatus, + StyleSpec? completedStatus, + StyleSpec? deniedStatus, + StyleSpec? errorStatus, + }) : content = content ?? const StyleSpec(spec: BoxSpec()), + header = header ?? const StyleSpec(spec: FlexBoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + title = title ?? const StyleSpec(spec: TextSpec()), + tool = tool ?? const StyleSpec(spec: TextSpec()), + description = description ?? const StyleSpec(spec: TextSpec()), + status = status ?? const StyleSpec(spec: TextSpec()), + detailsLabel = detailsLabel ?? const StyleSpec(spec: TextSpec()), + toolIcon = toolIcon ?? const StyleSpec(spec: IconSpec()), + statusIcon = statusIcon ?? const StyleSpec(spec: IconSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: BoxSpec()), + decidingStatus = decidingStatus ?? const StyleSpec(spec: BoxSpec()), + allowedStatus = allowedStatus ?? const StyleSpec(spec: BoxSpec()), + runningStatus = runningStatus ?? const StyleSpec(spec: BoxSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: BoxSpec()), + deniedStatus = deniedStatus ?? const StyleSpec(spec: BoxSpec()), + errorStatus = errorStatus ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/plan/plan.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/plan/plan.dart.tmpl new file mode 100644 index 000000000..ab48a5f79 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/plan/plan.dart.tmpl @@ -0,0 +1,303 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/plan_item.dart'; +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'plan.g.dart'; + +typedef {{typePrefix}}PlanStatusBuilder = + Widget Function(BuildContext context, {{typePrefix}}PlanItem item); +typedef {{typePrefix}}PlanStatusLabelBuilder = String Function({{typePrefix}}PlanItem item); +typedef {{typePrefix}}PlanIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Toggleable task plan with lifecycle-aware uncontrolled disclosure state. +class {{typePrefix}}Plan extends StatefulWidget { + const {{typePrefix}}Plan({ + super.key, + required this.items, + this.title = 'Plan', + this.emptyLabel = 'No tasks yet', + this.semanticLabel = 'Task plan', + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.statusBuilder, + this.statusLabelBuilder, + this.indicatorBuilder, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + this.disclosureStyle = const DisclosureStyler.create(), + this.style = const {{typePrefix}}PlanStyler.create(), + this.styleSpec, + }); + + final List<{{typePrefix}}PlanItem> items; + final String title; + final String emptyLabel; + final String semanticLabel; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final {{typePrefix}}PlanStatusBuilder? statusBuilder; + final {{typePrefix}}PlanStatusLabelBuilder? statusLabelBuilder; + final {{typePrefix}}PlanIndicatorBuilder? indicatorBuilder; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + final DisclosureStyler disclosureStyle; + final {{typePrefix}}PlanStyler style; + final {{typePrefix}}PlanSpec? styleSpec; + + int get settledCount => items.where((item) => item.status.isDone).length; + bool get isWorking => items.any((item) => !item.status.isDone); + + @override + State<{{typePrefix}}Plan> createState() => _{{typePrefix}}PlanState(); +} + +class _{{typePrefix}}PlanState extends State<{{typePrefix}}Plan> { + late final {{typePrefix}}DisclosureEngine _disclosure; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Plan oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + final wasWorking = oldWidget.isWorking; + final working = widget.isWorking; + if (wasWorking && !working && widget.collapseOnComplete) { + _request(false); + } else if (!wasWorking && working) { + _request(true); + } + } + + void _request(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String _statusLabel({{typePrefix}}PlanItem item) => + widget.statusLabelBuilder?.call(item) ?? + switch (item.status) { + {{typePrefix}}PlanItemStatus.pending => 'Pending', + {{typePrefix}}PlanItemStatus.inProgress => 'In progress', + {{typePrefix}}PlanItemStatus.completed => 'Completed', + {{typePrefix}}PlanItemStatus.cancelled => 'Cancelled', + }; + + {{typePrefix}}FunctionalGlyphKind _statusGlyph({{typePrefix}}PlanItemStatus status) => + switch (status) { + {{typePrefix}}PlanItemStatus.pending => .pending, + {{typePrefix}}PlanItemStatus.inProgress => .active, + {{typePrefix}}PlanItemStatus.completed => .completed, + {{typePrefix}}PlanItemStatus.cancelled => .cancelled, + }; + + StyleSpec _statusContainer( + {{typePrefix}}PlanSpec spec, + {{typePrefix}}PlanItemStatus status, + ) => switch (status) { + {{typePrefix}}PlanItemStatus.pending => spec.pendingItem, + {{typePrefix}}PlanItemStatus.inProgress => spec.activeItem, + {{typePrefix}}PlanItemStatus.completed => spec.completedItem, + {{typePrefix}}PlanItemStatus.cancelled => spec.cancelledItem, + }; + + StyleSpec _statusStyle( + {{typePrefix}}PlanSpec spec, + {{typePrefix}}PlanItemStatus status, + ) => switch (status) { + {{typePrefix}}PlanItemStatus.pending => spec.pendingStatus, + {{typePrefix}}PlanItemStatus.inProgress => spec.activeStatus, + {{typePrefix}}PlanItemStatus.completed => spec.completedStatus, + {{typePrefix}}PlanItemStatus.cancelled => spec.cancelledStatus, + }; + + Widget _defaultStatus( + BuildContext context, + {{typePrefix}}PlanSpec spec, + {{typePrefix}}PlanItem item, + ) { + return StyleSpecBuilder( + styleSpec: _statusStyle(spec, item.status), + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph(kind: _statusGlyph(item.status), spec: iconSpec), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}PlanSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixDisclosure( + expanded: _expanded, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: Row( + children: [ + Expanded( + child: StyledText(widget.title, styleSpec: spec.summaryTitle), + ), + StyledText( + '${widget.settledCount}/${widget.items.length}', + styleSpec: spec.count, + ), + ], + ), + content: Box( + styleSpec: spec.viewport, + child: widget.items.isEmpty + ? StyledText(widget.emptyLabel, styleSpec: spec.itemDetail) + : {{typePrefix}}LiveEdgeScrollView( + followOutput: widget.followOutput, + followThreshold: widget.followThreshold, + onFollowChanged: widget.onFollowChanged, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in widget.items) + Semantics( + container: true, + excludeSemantics: true, + label: [ + item.title, + if (item.detail != null) item.detail!, + _statusLabel(item), + ].join(', '), + child: Box( + styleSpec: _statusContainer(spec, item.status), + child: RowBox( + key: ValueKey('{{valuePrefix}}-plan-item-${item.id}'), + styleSpec: spec.item, + children: [ + widget.statusBuilder?.call(context, item) ?? + _defaultStatus(context, spec, item), + Expanded( + child: StyledText( + item.title, + styleSpec: spec.itemTitle, + ), + ), + if (item.detail != null) + StyledText( + item.detail!, + styleSpec: spec.itemDetail, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Plan.new) +@immutable +final class {{typePrefix}}PlanSpec with _${{typePrefix}}PlanSpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final StyleSpec summaryTitle; + @override + final StyleSpec itemTitle; + @override + final StyleSpec itemDetail; + @override + final StyleSpec count; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingItem; + @override + final StyleSpec activeItem; + @override + final StyleSpec completedItem; + @override + final StyleSpec cancelledItem; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec activeStatus; + @override + final StyleSpec completedStatus; + @override + final StyleSpec cancelledStatus; + + const {{typePrefix}}PlanSpec({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? cancelledItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + StyleSpec? cancelledStatus, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: FlexBoxSpec()), + summaryTitle = summaryTitle ?? const StyleSpec(spec: TextSpec()), + itemTitle = itemTitle ?? const StyleSpec(spec: TextSpec()), + itemDetail = itemDetail ?? const StyleSpec(spec: TextSpec()), + count = count ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingItem = pendingItem ?? const StyleSpec(spec: BoxSpec()), + activeItem = activeItem ?? const StyleSpec(spec: BoxSpec()), + completedItem = completedItem ?? const StyleSpec(spec: BoxSpec()), + cancelledItem = cancelledItem ?? const StyleSpec(spec: BoxSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: IconSpec()), + activeStatus = activeStatus ?? const StyleSpec(spec: IconSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: IconSpec()), + cancelledStatus = cancelledStatus ?? const StyleSpec(spec: IconSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/support/disclosure.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/support/disclosure.dart.tmpl new file mode 100644 index 000000000..eb7c44b5a --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/support/disclosure.dart.tmpl @@ -0,0 +1,29 @@ +/// Controlled/uncontrolled storage shared by collapsible surfaces. +/// +/// Widgets own lifecycle policy, rebuilding, and request callbacks. In +/// particular, a request that does not change storage may still notify a host. +class {{typePrefix}}DisclosureEngine { + {{typePrefix}}DisclosureEngine({required bool? value, required bool defaultValue}) + : _controlled = value, + _uncontrolled = value ?? defaultValue; + + bool? _controlled; + bool _uncontrolled; + + bool get value => _controlled ?? _uncontrolled; + + /// Adopt the last controlled value when the host releases control. + void reconcile(bool? value) { + if (_controlled != null && value == null) { + _uncontrolled = _controlled!; + } + _controlled = value; + } + + /// Returns whether local storage changed and the widget needs a rebuild. + bool request(bool next) { + if (_controlled != null || next == _uncontrolled) return false; + _uncontrolled = next; + return true; + } +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/support/functional_glyph.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/support/functional_glyph.dart.tmpl new file mode 100644 index 000000000..277b3b4a3 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/support/functional_glyph.dart.tmpl @@ -0,0 +1,182 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import 'package:remix_ui_icons/remix_ui_icons.dart'; + +abstract final class _Glyphs { + static const arrowUp = RemixIcons.arrowUp; + static const square = RemixIcons.square; + static const copy = RemixIcons.copy; + static const rotateCcw = RemixIcons.reload; + static const chevronUp = RemixIcons.chevronUp; + static const chevronDown = RemixIcons.chevronDown; + static const circle = RemixIcons.circle; + static const circleDot = RemixIcons.dotFilled; + static const check = RemixIcons.check; + static const x = RemixIcons.cross2; + static const circleAlert = RemixIcons.exclamationTriangle; + static const squareTerminal = RemixIcons.code; + static const loaderCircle = RemixIcons.update; + static const circleCheck = RemixIcons.checkCircled; + static const ban = RemixIcons.circleBackslash; + static const circleX = RemixIcons.crossCircled; + static const shieldCheck = RemixIcons.lockClosed; +} + +/// Builds the chevron that reports a collapsible surface's state. +/// +/// Every collapsible {{typePrefix}} surface offers the host the same escape hatch — a +/// builder that replaces the glyph outright — over the same default. Each takes +/// that builder under its own name, because a permission card discloses +/// *details* and an answer discloses *sources*, so the shared part is this +/// body and not the parameter. +class {{typePrefix}}DisclosureIndicator extends StatelessWidget { + const {{typePrefix}}DisclosureIndicator({ + super.key, + required this.styleSpec, + required this.expanded, + this.builder, + }); + + final StyleSpec styleSpec; + final bool expanded; + final Widget Function(BuildContext context, bool expanded)? builder; + + @override + Widget build(BuildContext context) => + builder?.call(context, expanded) ?? + StyleSpecBuilder( + styleSpec: styleSpec, + builder: (context, iconSpec) => {{typePrefix}}FunctionalGlyph( + kind: .chevron, + spec: iconSpec, + expanded: expanded, + ), + ); +} + +/// Internal Material-free glyph set used by {{typePrefix}}'s functional defaults. +/// +/// The types are intentionally not exported from the package barrel. Public +/// icon/status builders remain the replacement mechanism. +enum {{typePrefix}}FunctionalGlyphKind { + send, + stop, + copy, + retry, + chevron, + pending, + active, + completed, + cancelled, + error, + tool, + loading, + completedCircle, + cancelledCircle, + errorCircle, + permission, +} + +class {{typePrefix}}FunctionalGlyph extends StatelessWidget { + const {{typePrefix}}FunctionalGlyph({ + super.key, + required this.kind, + required this.spec, + this.expanded = false, + }); + + final {{typePrefix}}FunctionalGlyphKind kind; + final IconSpec spec; + final bool expanded; + + IconData get _icon => switch (kind) { + .send => _Glyphs.arrowUp, + .stop => _Glyphs.square, + .copy => _Glyphs.copy, + .retry => _Glyphs.rotateCcw, + .chevron => expanded ? _Glyphs.chevronUp : _Glyphs.chevronDown, + .pending => _Glyphs.circle, + .active => _Glyphs.circleDot, + .completed => _Glyphs.check, + .cancelled => _Glyphs.x, + .error => _Glyphs.circleAlert, + .tool => _Glyphs.squareTerminal, + .loading => _Glyphs.loaderCircle, + .completedCircle => _Glyphs.circleCheck, + .cancelledCircle => _Glyphs.ban, + .errorCircle => _Glyphs.circleX, + .permission => _Glyphs.shieldCheck, + }; + + @override + Widget build(BuildContext context) { + final theme = IconTheme.of(context); + final opacity = spec.opacity ?? theme.opacity; + final baseColor = spec.color ?? theme.color; + final color = opacity == null || baseColor == null + ? baseColor + : baseColor.withValues(alpha: baseColor.a * opacity.clamp(0, 1)); + + final icon = Icon( + _icon, + size: spec.size ?? theme.size, + fill: spec.fill ?? theme.fill, + weight: spec.weight ?? theme.weight, + grade: spec.grade ?? theme.grade, + opticalSize: spec.opticalSize ?? theme.opticalSize, + color: color, + shadows: spec.shadows ?? theme.shadows, + textDirection: spec.textDirection, + applyTextScaling: + spec.applyTextScaling ?? theme.applyTextScaling ?? false, + blendMode: spec.blendMode ?? BlendMode.srcOver, + ); + return ExcludeSemantics( + child: kind == {{typePrefix}}FunctionalGlyphKind.loading + ? _LoadingGlyph(child: icon) + : icon, + ); + } +} + +/// Animate only indeterminate loading; status labels own the semantics. +class _LoadingGlyph extends StatefulWidget { + const _LoadingGlyph({required this.child}); + + final Widget child; + + @override + State<_LoadingGlyph> createState() => _LoadingGlyphState(); +} + +class _LoadingGlyphState extends State<_LoadingGlyph> + with SingleTickerProviderStateMixin { + late final _turns = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final animate = + !(MediaQuery.maybeOf(context)?.disableAnimations ?? false) && + TickerMode.valuesOf(context).enabled; + if (animate) { + if (!_turns.isAnimating) _turns.repeat(); + } else { + _turns.stop(); + _turns.value = 0; + } + } + + @override + Widget build(BuildContext context) => + RotationTransition(turns: _turns, child: widget.child); + + @override + void dispose() { + _turns.dispose(); + super.dispose(); + } +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/support/live_edge.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/support/live_edge.dart.tmpl new file mode 100644 index 000000000..7e126e31c --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/support/live_edge.dart.tmpl @@ -0,0 +1,143 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Shared private-package live-edge state machine. +class {{typePrefix}}LiveEdgeEngine { + {{typePrefix}}LiveEdgeEngine({ + required this._enabled, + required this.threshold, + this.onChanged, + }); + + bool _enabled; + bool get enabled => _enabled; + + set enabled(bool value) { + // An explicit false-to-true transition is the host's resume action. + // Ordinary rebuilds with follow enabled must preserve a reader's release. + if (value && !_enabled) _following = true; + _enabled = value; + } + + double threshold; + ValueChanged? onChanged; + bool _following = true; + bool _programmatic = false; + + bool get following => _following; + + void handleScroll( + ScrollNotification notification, + ScrollController controller, + ) { + if (_programmatic || !controller.hasClients) return; + final fromDrag = + notification is ScrollUpdateNotification && + notification.dragDetails != null; + final fromUserDirection = + notification is UserScrollNotification && + notification.direction != ScrollDirection.idle; + if (fromDrag || fromUserDirection) handlePosition(controller.position); + } + + void handlePosition(ScrollPosition position) { + final distance = position.maxScrollExtent - position.pixels; + _setFollowing(distance <= threshold); + } + + void follow(ScrollController controller) { + if (!enabled || !following || !controller.hasClients) return; + final position = controller.position; + if (!position.hasContentDimensions) return; + _programmatic = true; + position.jumpTo(position.maxScrollExtent); + _programmatic = false; + } + + void _setFollowing(bool next) { + if (following == next) return; + _following = next; + onChanged?.call(next); + } +} + +/// Small non-lazy scroll view used by plan and activity ledgers. +class {{typePrefix}}LiveEdgeScrollView extends StatefulWidget { + const {{typePrefix}}LiveEdgeScrollView({ + super.key, + required this.child, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + }); + + final Widget child; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + + @override + State<{{typePrefix}}LiveEdgeScrollView> createState() => + _{{typePrefix}}LiveEdgeScrollViewState(); +} + +class _{{typePrefix}}LiveEdgeScrollViewState extends State<{{typePrefix}}LiveEdgeScrollView> { + late final ScrollController _controller; + late final {{typePrefix}}LiveEdgeEngine _liveEdge; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + _liveEdge = {{typePrefix}}LiveEdgeEngine( + enabled: widget.followOutput, + threshold: widget.followThreshold, + onChanged: widget.onFollowChanged, + ); + _scheduleFollow(); + } + + @override + void didUpdateWidget({{typePrefix}}LiveEdgeScrollView oldWidget) { + super.didUpdateWidget(oldWidget); + _liveEdge + ..enabled = widget.followOutput + ..threshold = widget.followThreshold + ..onChanged = widget.onFollowChanged; + _scheduleFollow(); + } + + void _scheduleFollow() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _liveEdge.follow(_controller); + }); + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && _liveEdge.following) _scheduleFollow(); + return false; + }, + child: NotificationListener( + onNotification: (notification) { + if (notification.depth == 0) { + _liveEdge.handleScroll(notification, _controller); + } + return false; + }, + child: SingleChildScrollView( + controller: _controller, + child: widget.child, + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/agent/transcript/transcript.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/agent/transcript/transcript.dart.tmpl new file mode 100644 index 000000000..ecfb59cbf --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/agent/transcript/transcript.dart.tmpl @@ -0,0 +1,273 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../support/live_edge.dart'; + +part 'transcript.g.dart'; + +/// Chronological transcript with reader-aware live-edge following. +class {{typePrefix}}Transcript extends StatefulWidget { + const {{typePrefix}}Transcript({ + super.key, + required List this.children, + this.followOutput = true, + this.followThreshold = 48.0, + this.busy = false, + this.busyLabel = 'Busy', + this.label = 'Conversation', + this.onFollowChanged, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.style = const {{typePrefix}}TranscriptStyler.create(), + this.styleSpec, + }) : itemCount = null, + itemBuilder = null; + + const {{typePrefix}}Transcript.builder({ + super.key, + required int this.itemCount, + required IndexedWidgetBuilder this.itemBuilder, + this.followOutput = true, + this.followThreshold = 48.0, + this.busy = false, + this.busyLabel = 'Busy', + this.label = 'Conversation', + this.onFollowChanged, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.style = const {{typePrefix}}TranscriptStyler.create(), + this.styleSpec, + }) : children = null; + + final List? children; + final int? itemCount; + final IndexedWidgetBuilder? itemBuilder; + final bool followOutput; + final double followThreshold; + final bool busy; + final String busyLabel; + final String label; + final ValueChanged? onFollowChanged; + final ScrollController? controller; + final Clip clipBehavior; + final {{typePrefix}}TranscriptStyler style; + final {{typePrefix}}TranscriptSpec? styleSpec; + + @override + State<{{typePrefix}}Transcript> createState() => _{{typePrefix}}TranscriptState(); +} + +class _{{typePrefix}}TranscriptState extends State<{{typePrefix}}Transcript> { + ScrollController? _ownedController; + late ScrollController _controller; + late final {{typePrefix}}LiveEdgeEngine _liveEdge; + + /// Publishes this surface's focus to the styles resolved above it. + /// + /// `focused` has no other source here: {{typePrefix}}'s slots resolve above any Naked + /// control, so without this the `focus-visible` state the transcript + /// worksheet documents could never activate. + /// + /// Only `focused`. The pointer-driven states do not resolve on this slot, and + /// did not before this controller existed either — a host's `onHovered` on + /// [{{typePrefix}}TranscriptSpec.viewport] has never had an effect. Passing a + /// controller also means Mix will not mount its own pointer detector, so + /// restoring hover would be this object's job; nothing asks for it yet. + final WidgetStatesController _statesController = WidgetStatesController(); + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? (_ownedController = ScrollController()); + _liveEdge = {{typePrefix}}LiveEdgeEngine( + enabled: widget.followOutput, + threshold: widget.followThreshold, + onChanged: widget.onFollowChanged, + ); + _scheduleFollow(); + } + + @override + void didUpdateWidget({{typePrefix}}Transcript oldWidget) { + super.didUpdateWidget(oldWidget); + _liveEdge + ..enabled = widget.followOutput + ..threshold = widget.followThreshold + ..onChanged = widget.onFollowChanged; + if (!identical(oldWidget.controller, widget.controller)) { + final offset = _controller.hasClients ? _controller.offset : 0.0; + final oldOwned = _ownedController; + _ownedController = null; + _controller = + widget.controller ?? + (_ownedController = ScrollController(initialScrollOffset: offset)); + if (oldOwned != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => oldOwned.dispose()); + } + } + _scheduleFollow(); + } + + void _scheduleFollow() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _liveEdge.follow(_controller); + }); + } + + bool _handleScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + _liveEdge.handleScroll(notification, _controller); + return notification is OverscrollNotification; + } + + void _handleIntent(_TranscriptScrollIntent intent) { + if (!_controller.hasClients) return; + final position = _controller.position; + final target = switch (intent.kind) { + _TranscriptScrollKind.lineUp => position.pixels - 50, + _TranscriptScrollKind.lineDown => position.pixels + 50, + _TranscriptScrollKind.pageUp => + position.pixels - position.viewportDimension * 0.8, + _TranscriptScrollKind.pageDown => + position.pixels + position.viewportDimension * 0.8, + _TranscriptScrollKind.home => position.minScrollExtent, + _TranscriptScrollKind.end => position.maxScrollExtent, + }; + position.jumpTo( + target + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(), + ); + _liveEdge.handlePosition(position); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}TranscriptSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + controller: _statesController, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.label, + value: widget.busy ? widget.busyLabel : null, + child: FocusableActionDetector( + onFocusChange: (focused) => + _statesController.update(WidgetState.focused, focused), + shortcuts: _transcriptShortcuts, + actions: >{ + _TranscriptScrollIntent: CallbackAction<_TranscriptScrollIntent>( + onInvoke: (intent) { + _handleIntent(intent); + return null; + }, + ), + }, + child: Box( + styleSpec: spec.viewport, + child: LayoutBuilder( + builder: (context, constraints) => + NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && _liveEdge.following) { + _scheduleFollow(); + } + return false; + }, + child: NotificationListener( + onNotification: _handleScroll, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + overscroll: false, + physics: const ClampingScrollPhysics(), + ), + child: _buildList( + spec, + shrinkWrap: !constraints.hasBoundedHeight, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildList({{typePrefix}}TranscriptSpec spec, {required bool shrinkWrap}) { + final children = widget.children; + final count = children?.length ?? widget.itemCount!; + final spacing = spec.spacing ?? 0; + assert(spacing >= 0, '{{typePrefix}}Transcript spacing must be non-negative.'); + return ListView.separated( + controller: _controller, + shrinkWrap: shrinkWrap, + physics: const ClampingScrollPhysics(), + clipBehavior: widget.clipBehavior, + itemCount: count, + itemBuilder: (context, index) => Box( + styleSpec: spec.item, + child: children?[index] ?? widget.itemBuilder!(context, index), + ), + separatorBuilder: (context, index) => SizedBox(height: spacing), + ); + } + + @override + void dispose() { + _ownedController?.dispose(); + _statesController.dispose(); + super.dispose(); + } +} + +enum _TranscriptScrollKind { lineUp, lineDown, pageUp, pageDown, home, end } + +class _TranscriptScrollIntent extends Intent { + const _TranscriptScrollIntent(this.kind); + final _TranscriptScrollKind kind; +} + +const _transcriptShortcuts = { + SingleActivator(LogicalKeyboardKey.arrowUp): _TranscriptScrollIntent( + _TranscriptScrollKind.lineUp, + ), + SingleActivator(LogicalKeyboardKey.arrowDown): _TranscriptScrollIntent( + _TranscriptScrollKind.lineDown, + ), + SingleActivator(LogicalKeyboardKey.pageUp): _TranscriptScrollIntent( + _TranscriptScrollKind.pageUp, + ), + SingleActivator(LogicalKeyboardKey.pageDown): _TranscriptScrollIntent( + _TranscriptScrollKind.pageDown, + ), + SingleActivator(LogicalKeyboardKey.home): _TranscriptScrollIntent( + _TranscriptScrollKind.home, + ), + SingleActivator(LogicalKeyboardKey.end): _TranscriptScrollIntent( + _TranscriptScrollKind.end, + ), +}; + +@MixableSpec(target: {{typePrefix}}Transcript.new) +@immutable +final class {{typePrefix}}TranscriptSpec with _${{typePrefix}}TranscriptSpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final double? spacing; + + const {{typePrefix}}TranscriptSpec({ + StyleSpec? viewport, + StyleSpec? item, + this.spacing, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/default/templates/button/button.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/button/button.dart.tmpl index 15b523073..4ed8a37ff 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/button/button.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/button/button.dart.tmpl @@ -111,14 +111,8 @@ ContextToken _dimmed(ColorToken source, double alpha) => final _primaryHoverFill = _dimmed({{typePrefix}}Tokens.primary, _hoverAlpha); final _primaryPressedFill = _dimmed({{typePrefix}}Tokens.primary, _pressedAlpha); final _secondaryHoverFill = _dimmed({{typePrefix}}Tokens.secondary, _hoverAlpha); -final _secondaryPressedFill = _dimmed( - {{typePrefix}}Tokens.secondary, - _pressedAlpha, -); -final _destructiveHoverFill = _dimmed( - {{typePrefix}}Tokens.destructive, - _hoverAlpha, -); +final _secondaryPressedFill = _dimmed({{typePrefix}}Tokens.secondary, _pressedAlpha); +final _destructiveHoverFill = _dimmed({{typePrefix}}Tokens.destructive, _hoverAlpha); final _destructivePressedFill = _dimmed( {{typePrefix}}Tokens.destructive, _pressedAlpha, @@ -152,30 +146,29 @@ typedef _{{typePrefix}}ButtonMetrics = ({ double iconSize, }); -_{{typePrefix}}ButtonMetrics _metricsFor({{typePrefix}}ButtonSize size) => - switch (size) { - .small => ( - minHeight: 32.0, - paddingX: 12.0, - gap: 6.0, - labelSize: 14.0, - iconSize: 16.0, - ), - .medium => ( - minHeight: 36.0, - paddingX: 16.0, - gap: 8.0, - labelSize: 14.0, - iconSize: 16.0, - ), - .large => ( - minHeight: 40.0, - paddingX: 20.0, - gap: 8.0, - labelSize: 16.0, - iconSize: 18.0, - ), - }; +_{{typePrefix}}ButtonMetrics _metricsFor({{typePrefix}}ButtonSize size) => switch (size) { + .small => ( + minHeight: 32.0, + paddingX: 12.0, + gap: 6.0, + labelSize: 14.0, + iconSize: 16.0, + ), + .medium => ( + minHeight: 36.0, + paddingX: 16.0, + gap: 8.0, + labelSize: 14.0, + iconSize: 16.0, + ), + .large => ( + minHeight: 40.0, + paddingX: 20.0, + gap: 8.0, + labelSize: 16.0, + iconSize: 18.0, + ), +}; /// Layout, typography, and spinner defaults shared by every variant. ButtonStyler _base(_{{typePrefix}}ButtonMetrics metrics) => ButtonStyler() @@ -195,29 +188,28 @@ ButtonStyler _base(_{{typePrefix}}ButtonMetrics metrics) => ButtonStyler() ).opacity(_spinnerOpacity).duration(_spinnerDuration), ); -ButtonStyler _variantStyle({{typePrefix}}ButtonVariant variant) => - switch (variant) { - .primary => _filled( - fill: {{typePrefix}}Tokens.primary(), - foreground: {{typePrefix}}Tokens.primaryForeground(), - hoverFill: _primaryHoverFill(), - pressedFill: _primaryPressedFill(), - ), - .secondary => _filled( - fill: {{typePrefix}}Tokens.secondary(), - foreground: {{typePrefix}}Tokens.secondaryForeground(), - hoverFill: _secondaryHoverFill(), - pressedFill: _secondaryPressedFill(), - ), - .destructive => _filled( - fill: {{typePrefix}}Tokens.destructive(), - foreground: {{typePrefix}}Tokens.destructiveForeground(), - hoverFill: _destructiveHoverFill(), - pressedFill: _destructivePressedFill(), - ), - .outline => _quiet(bordered: true), - .ghost => _quiet(bordered: false), - }; +ButtonStyler _variantStyle({{typePrefix}}ButtonVariant variant) => switch (variant) { + .primary => _filled( + fill: {{typePrefix}}Tokens.primary(), + foreground: {{typePrefix}}Tokens.primaryForeground(), + hoverFill: _primaryHoverFill(), + pressedFill: _primaryPressedFill(), + ), + .secondary => _filled( + fill: {{typePrefix}}Tokens.secondary(), + foreground: {{typePrefix}}Tokens.secondaryForeground(), + hoverFill: _secondaryHoverFill(), + pressedFill: _secondaryPressedFill(), + ), + .destructive => _filled( + fill: {{typePrefix}}Tokens.destructive(), + foreground: {{typePrefix}}Tokens.destructiveForeground(), + hoverFill: _destructiveHoverFill(), + pressedFill: _destructivePressedFill(), + ), + .outline => _quiet(bordered: true), + .ghost => _quiet(bordered: false), +}; /// A solid variant: its own fill, dimmed on hover and further on press. ButtonStyler _filled({ diff --git a/packages/remix_cli/lib/src/registry/default/templates/callout/callout.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/callout/callout.dart.tmpl index bb18c3607..8a2b2c0e7 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/callout/callout.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/callout/callout.dart.tmpl @@ -96,19 +96,18 @@ CalloutStyler _base() => CalloutStyler() .text(.fontSize(_textSize).color({{typePrefix}}Tokens.foreground())) .icon(.size(_iconSize).wrap(.translate(x: 0, y: _iconOffsetY))); -CalloutStyler _variantStyle({{typePrefix}}CalloutVariant variant) => - switch (variant) { - .neutral => _toned( - fill: {{typePrefix}}Tokens.muted(), - outline: {{typePrefix}}Tokens.border(), - icon: {{typePrefix}}Tokens.mutedForeground(), - ), - .destructive => _toned( - fill: _noFill, - outline: {{typePrefix}}Tokens.destructive(), - icon: {{typePrefix}}Tokens.destructive(), - ), - }; +CalloutStyler _variantStyle({{typePrefix}}CalloutVariant variant) => switch (variant) { + .neutral => _toned( + fill: {{typePrefix}}Tokens.muted(), + outline: {{typePrefix}}Tokens.border(), + icon: {{typePrefix}}Tokens.mutedForeground(), + ), + .destructive => _toned( + fill: _noFill, + outline: {{typePrefix}}Tokens.destructive(), + icon: {{typePrefix}}Tokens.destructive(), + ), +}; /// One surface, one outline, and the glyph color. /// diff --git a/packages/remix_cli/lib/src/registry/default/templates/card/card.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/card/card.dart.tmpl index 36d4bdcd2..d762290a9 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/card/card.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/card/card.dart.tmpl @@ -18,7 +18,7 @@ part 'card.g.dart'; /// /// The fill is `background`, the same token the page uses, so a card is told /// apart by its outline rather than by a second surface color. That is -/// deliberate: it keeps the token vocabulary at fifteen names, and a theme +/// deliberate: it keeps the token vocabulary at twenty names, and a theme /// that wants a distinct card surface changes this one line. /// /// [style] is merged **last**, so a single call site can override any part of @@ -31,14 +31,13 @@ part 'card.g.dart'; /// ) /// ``` @MixWidget(target: RemixCard.new) -CardStyler {{valuePrefix}}CardStyle({ - CardStyler style = const CardStyler.create(), -}) => CardStyler() - .color({{typePrefix}}Tokens.background()) - .border(.color({{typePrefix}}Tokens.border()).width(_borderWidth)) - .borderRadius(.all({{typePrefix}}Tokens.radius())) - .padding(.all(_padding)) - .merge(style); +CardStyler {{valuePrefix}}CardStyle({CardStyler style = const CardStyler.create()}) => + CardStyler() + .color({{typePrefix}}Tokens.background()) + .border(.color({{typePrefix}}Tokens.border()).width(_borderWidth)) + .borderRadius(.all({{typePrefix}}Tokens.radius())) + .padding(.all(_padding)) + .merge(style); /// Width of the card outline. const _borderWidth = 1.0; diff --git a/packages/remix_cli/lib/src/registry/default/templates/chart/chart.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/chart/chart.dart.tmpl index e5a1ccb50..e3ccc01ca 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/chart/chart.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/chart/chart.dart.tmpl @@ -12,15 +12,9 @@ part 'chart.g.dart'; const _defaultPaletteToken = ContextToken>( resolve{{typePrefix}}ChartPalette, ); -const _tooltipBorderToken = ContextToken( - _resolveTooltipBorder, -); -const _tooltipRadiusToken = ContextToken( - _resolveTooltipRadius, -); -const _tooltipPaddingToken = ContextToken( - _resolveTooltipPadding, -); +const _tooltipBorderToken = ContextToken(_resolveTooltipBorder); +const _tooltipRadiusToken = ContextToken(_resolveTooltipRadius); +const _tooltipPaddingToken = ContextToken(_resolveTooltipPadding); const _barRadiusToken = ContextToken(_resolveBarRadius); /// Returns the categorical palette shared by this application's charts. @@ -183,9 +177,7 @@ ChartFrameStyler _chartFrameStyle() => ChartFrameStyler() ChartAxisStyler _chartAxisStyle() => ChartAxisStyler() .showLabels(true) .label( - TextStyler() - .fontSize(_labelSize) - .color({{typePrefix}}Tokens.mutedForeground()), + TextStyler().fontSize(_labelSize).color({{typePrefix}}Tokens.mutedForeground()), ) .labelSpace(8) .fitInside(true) @@ -198,11 +190,7 @@ ChartGridStyler _chartGridStyle() => ChartGridStyler() .show(true) .showHorizontal(true) .showVertical(false) - .stroke( - ChartStrokeStyler() - .color({{typePrefix}}Tokens.border()) - .width(1), - ); + .stroke(ChartStrokeStyler().color({{typePrefix}}Tokens.border()).width(1)); ChartTooltipStyler _chartTooltipStyle() => ChartTooltipStyler.create( @@ -222,21 +210,17 @@ ChartTooltipStyler _chartTooltipStyle() => .color({{typePrefix}}Tokens.foreground()), ); -BorderSide _resolveTooltipBorder(BuildContext context) => BorderSide( - color: {{typePrefix}}Tokens.border.resolve(context), - width: 1, -); +BorderSide _resolveTooltipBorder(BuildContext context) => + BorderSide(color: {{typePrefix}}Tokens.border.resolve(context), width: 1); -BorderRadius _resolveTooltipRadius(BuildContext context) => BorderRadius.all( - _clampedThemeRadius(context, _maxTooltipRadius), -); +BorderRadius _resolveTooltipRadius(BuildContext context) => + BorderRadius.all(_clampedThemeRadius(context, _maxTooltipRadius)); EdgeInsets _resolveTooltipPadding(BuildContext context) => const EdgeInsets.symmetric(horizontal: 12, vertical: 8); -BorderRadius _resolveBarRadius(BuildContext context) => BorderRadius.all( - _clampedThemeRadius(context, _maxBarRadius), -); +BorderRadius _resolveBarRadius(BuildContext context) => + BorderRadius.all(_clampedThemeRadius(context, _maxBarRadius)); Radius _clampedThemeRadius(BuildContext context, double maximum) { final radius = {{typePrefix}}Tokens.radius.resolve(context); diff --git a/packages/remix_cli/lib/src/registry/default/templates/data_table/data_table.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/data_table/data_table.dart.tmpl index 5731c0e29..2112d8a3f 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/data_table/data_table.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/data_table/data_table.dart.tmpl @@ -69,9 +69,7 @@ DataTableStyler {{valuePrefix}}DataTableStyle({ ).fontWeight(FontWeight.w500).color({{typePrefix}}Tokens.foreground()), ) .cellText(.fontSize(_textSize).color({{typePrefix}}Tokens.foreground())) - .footerLabel( - .fontSize(_labelSize).color({{typePrefix}}Tokens.mutedForeground()), - ) + .footerLabel(.fontSize(_labelSize).color({{typePrefix}}Tokens.mutedForeground())) .sortIcon(.size(_iconSize).color({{typePrefix}}Tokens.mutedForeground())) .sortIconSpacing(_sortIconSpacing) .footer( diff --git a/packages/remix_cli/lib/src/registry/default/templates/dialog/dialog.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/dialog/dialog.dart.tmpl index 43676e329..0c0aa4c71 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/dialog/dialog.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/dialog/dialog.dart.tmpl @@ -31,9 +31,7 @@ DialogStyler {{valuePrefix}}DialogStyle({ .maxWidth(_maxWidth) .shadow(_shadow) .title( - .fontSize( - _titleSize, - ) + .fontSize(_titleSize) .fontWeight(FontWeight.w600) .color({{typePrefix}}Tokens.foreground()) .wrap(.padding(.only(bottom: _titleDescriptionGap))), diff --git a/packages/remix_cli/lib/src/registry/default/templates/icon_button/icon_button.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/icon_button/icon_button.dart.tmpl index 93fbb5d13..519bf71ec 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/icon_button/icon_button.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/icon_button/icon_button.dart.tmpl @@ -107,14 +107,8 @@ ContextToken _dimmed(ColorToken source, double alpha) => final _primaryHoverFill = _dimmed({{typePrefix}}Tokens.primary, _hoverAlpha); final _primaryPressedFill = _dimmed({{typePrefix}}Tokens.primary, _pressedAlpha); final _secondaryHoverFill = _dimmed({{typePrefix}}Tokens.secondary, _hoverAlpha); -final _secondaryPressedFill = _dimmed( - {{typePrefix}}Tokens.secondary, - _pressedAlpha, -); -final _destructiveHoverFill = _dimmed( - {{typePrefix}}Tokens.destructive, - _hoverAlpha, -); +final _secondaryPressedFill = _dimmed({{typePrefix}}Tokens.secondary, _pressedAlpha); +final _destructiveHoverFill = _dimmed({{typePrefix}}Tokens.destructive, _hoverAlpha); final _destructivePressedFill = _dimmed( {{typePrefix}}Tokens.destructive, _pressedAlpha, @@ -156,17 +150,16 @@ _{{typePrefix}}IconButtonMetrics _metricsFor({{typePrefix}}IconButtonSize size) /// /// The box is square and centered, so the control's footprint does not change /// with the glyph inside it. -IconButtonStyler _base(_{{typePrefix}}IconButtonMetrics metrics) => - IconButtonStyler() - .size(metrics.edge, metrics.edge) - .alignment(.center) - .borderRadius(.all({{typePrefix}}Tokens.radius())) - .icon(.size(metrics.iconSize)) - .spinner( - .size( - metrics.iconSize, - ).opacity(_spinnerOpacity).duration(_spinnerDuration), - ); +IconButtonStyler _base(_{{typePrefix}}IconButtonMetrics metrics) => IconButtonStyler() + .size(metrics.edge, metrics.edge) + .alignment(.center) + .borderRadius(.all({{typePrefix}}Tokens.radius())) + .icon(.size(metrics.iconSize)) + .spinner( + .size( + metrics.iconSize, + ).opacity(_spinnerOpacity).duration(_spinnerDuration), + ); IconButtonStyler _variantStyle({{typePrefix}}IconButtonVariant variant) => switch (variant) { diff --git a/packages/remix_cli/lib/src/registry/default/templates/link/link.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/link/link.dart.tmpl index 4f0c0d699..8ab8eb0b5 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/link/link.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/link/link.dart.tmpl @@ -33,22 +33,21 @@ part 'link.g.dart'; /// by depth: an override that must beat the recipe's hover color has to be /// declared as a hover fragment too (`LinkStyler().onHovered(...)`). @MixWidget(target: RemixLink.new) -LinkStyler {{valuePrefix}}LinkStyle({ - LinkStyler style = const LinkStyler.create(), -}) => LinkStyler() - .label( - .color({{typePrefix}}Tokens.foreground()) - .decoration(TextDecoration.underline) - .decorationColor({{typePrefix}}Tokens.border()), - ) - // Hover and keyboard focus both promote the underline to full strength - // rather than adding one: an underline that appears on hover moves the - // text's baseline box on some platforms, and a link that is only - // underlined while hovered is invisible to a keyboard user. - .onHovered(_emphasized()) - .onFocusVisible(_emphasized()) - .onDisabled(_disabledStyle()) - .merge(style); +LinkStyler {{valuePrefix}}LinkStyle({LinkStyler style = const LinkStyler.create()}) => + LinkStyler() + .label( + .color({{typePrefix}}Tokens.foreground()) + .decoration(TextDecoration.underline) + .decorationColor({{typePrefix}}Tokens.border()), + ) + // Hover and keyboard focus both promote the underline to full strength + // rather than adding one: an underline that appears on hover moves the + // text's baseline box on some platforms, and a link that is only + // underlined while hovered is invisible to a keyboard user. + .onHovered(_emphasized()) + .onFocusVisible(_emphasized()) + .onDisabled(_disabledStyle()) + .merge(style); /// Opacity applied to the whole link while disabled. const _disabledOpacity = 0.5; diff --git a/packages/remix_cli/lib/src/registry/default/templates/menu/menu.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/menu/menu.dart.tmpl index 88d7f4c84..f0cdecf2c 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/menu/menu.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/menu/menu.dart.tmpl @@ -28,26 +28,23 @@ part 'menu.g.dart'; /// by depth: an override that must beat a row's hover fill has to be declared /// as a hover fragment too. @MixWidget(target: RemixMenu.new) -MenuStyler {{valuePrefix}}MenuStyle({ - MenuStyler style = const MenuStyler.create(), -}) => MenuStyler() - .trigger(_triggerStyle()) - .overlay( - FlexBoxStyler() - .direction(.vertical) - .mainAxisSize(.min) - .color({{typePrefix}}Tokens.background()) - .border(.color({{typePrefix}}Tokens.border()).width(_borderWidth)) - .borderRadius(.all({{typePrefix}}Tokens.radius())) - .padding(.all(_panelPadding)) - .minWidth(_panelMinWidth), - ) - .containerEffects( - .behindContent(.shadows([_shadow])), - ) - .item(_itemStyle()) - .divider(_dividerStyle()) - .merge(style); +MenuStyler {{valuePrefix}}MenuStyle({MenuStyler style = const MenuStyler.create()}) => + MenuStyler() + .trigger(_triggerStyle()) + .overlay( + FlexBoxStyler() + .direction(.vertical) + .mainAxisSize(.min) + .color({{typePrefix}}Tokens.background()) + .border(.color({{typePrefix}}Tokens.border()).width(_borderWidth)) + .borderRadius(.all({{typePrefix}}Tokens.radius())) + .padding(.all(_panelPadding)) + .minWidth(_panelMinWidth), + ) + .containerEffects(.behindContent(.shadows([_shadow]))) + .item(_itemStyle()) + .divider(_dividerStyle()) + .merge(style); /// Width of the panel and trigger outlines. const _borderWidth = 1.0; diff --git a/packages/remix_cli/lib/src/registry/default/templates/recipes/activity_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/recipes/activity_recipe.dart.tmpl new file mode 100644 index 000000000..3c9591faf --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/recipes/activity_recipe.dart.tmpl @@ -0,0 +1,47 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/activity.dart'; +import '../components/disclosure.dart'; +import '../theme/tokens.dart'; + +@immutable +final class {{typePrefix}}AgentActivityRecipe { + const {{typePrefix}}AgentActivityRecipe({ + required this.style, + required this.disclosureStyle, + }); + final {{typePrefix}}ActivityStyler style; + final DisclosureStyler disclosureStyle; +} + +{{typePrefix}}AgentActivityRecipe {{valuePrefix}}AgentActivityRecipe({ + {{typePrefix}}ActivityStyler style = const {{typePrefix}}ActivityStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => {{typePrefix}}AgentActivityRecipe( + style: {{typePrefix}}ActivityStyler( + viewport: BoxStyler().maxHeight(200), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color({{typePrefix}}Tokens.foreground()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color({{typePrefix}}Tokens.foreground()).fontSize(14), + itemDetail: TextStyler() + .color({{typePrefix}}Tokens.mutedForeground()) + .fontSize(12), + count: TextStyler() + .color({{typePrefix}}Tokens.mutedForeground()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color({{typePrefix}}Tokens.foreground()).size(16), + pendingStatus: IconStyler().color({{typePrefix}}Tokens.mutedForeground()).size(12), + activeStatus: IconStyler().color({{typePrefix}}Tokens.primary()).size(12), + completedStatus: IconStyler().color({{typePrefix}}Tokens.primary()).size(12), + ).merge(style), + disclosureStyle: {{valuePrefix}}DisclosureStyle( + style: DisclosureStyler() + .content(BoxStyler().padding(.all(0))) + .merge(disclosureStyle), + ), +); diff --git a/packages/remix_cli/lib/src/registry/default/templates/recipes/answer_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/recipes/answer_recipe.dart.tmpl new file mode 100644 index 000000000..a81483067 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/recipes/answer_recipe.dart.tmpl @@ -0,0 +1,52 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/answer.dart'; +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/icon_button.dart'; +import '../theme/tokens.dart'; + +@immutable +final class {{typePrefix}}AgentAnswerRecipe { + const {{typePrefix}}AgentAnswerRecipe({ + required this.style, + required this.surfaceStyle, + required this.sourcesStyle, + required this.copyStyle, + required this.retryStyle, + }); + final {{typePrefix}}AnswerStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +{{typePrefix}}AgentAnswerRecipe {{valuePrefix}}AgentAnswerRecipe({ + {{typePrefix}}AnswerStyler style = const {{typePrefix}}AnswerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler sourcesStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => {{typePrefix}}AgentAnswerRecipe( + style: {{typePrefix}}AnswerStyler( + body: BoxStyler(), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + feedback: BoxStyler().padding(.only(top: 6)), + sourcesLabel: TextStyler().color({{typePrefix}}Tokens.foreground()).fontSize(13), + indicator: IconStyler().color({{typePrefix}}Tokens.foreground()).size(16), + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle(style: surfaceStyle), + sourcesStyle: {{valuePrefix}}DisclosureStyle(style: sourcesStyle), + copyStyle: {{valuePrefix}}IconButtonStyle( + variant: .ghost, + size: .small, + style: copyStyle, + ), + retryStyle: {{valuePrefix}}IconButtonStyle( + variant: .ghost, + size: .small, + style: retryStyle, + ), +); diff --git a/packages/remix_cli/lib/src/registry/default/templates/recipes/composer_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/recipes/composer_recipe.dart.tmpl new file mode 100644 index 000000000..e32ebd1ec --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/recipes/composer_recipe.dart.tmpl @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/card.dart'; +import '../components/composer.dart'; +import '../components/icon_button.dart'; +import '../components/textfield.dart'; + +@immutable +final class {{typePrefix}}AgentComposerRecipe { + const {{typePrefix}}AgentComposerRecipe({ + required this.style, + required this.surfaceStyle, + required this.fieldStyle, + required this.submitStyle, + required this.stopStyle, + }); + final {{typePrefix}}ComposerStyler style; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; +} + +{{typePrefix}}AgentComposerRecipe {{valuePrefix}}AgentComposerRecipe({ + {{typePrefix}}ComposerStyler style = const {{typePrefix}}ComposerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + TextFieldStyler fieldStyle = const TextFieldStyler.create(), + IconButtonStyler submitStyle = const IconButtonStyler.create(), + IconButtonStyler stopStyle = const IconButtonStyler.create(), +}) => {{typePrefix}}AgentComposerRecipe( + style: {{typePrefix}}ComposerStyler( + toolbar: FlexBoxStyler() + .direction(.horizontal) + .mainAxisSize(.max) + .crossAxisAlignment(.center) + .spacing(8) + .padding(.only(top: 8)), + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle( + style: CardStyler().padding(.all(12)).merge(surfaceStyle), + ), + fieldStyle: {{valuePrefix}}TextAreaStyle( + style: TextFieldStyler() + .color(const Color(0x00000000)) + .border(.style(.none)) + .minHeight(56) + .padding(.all(4)) + .merge(fieldStyle), + ), + submitStyle: {{valuePrefix}}IconButtonStyle( + size: .small, + style: IconButtonStyler().size(48, 48).merge(submitStyle), + ), + stopStyle: {{valuePrefix}}IconButtonStyle( + variant: .destructive, + size: .small, + style: IconButtonStyler().size(48, 48).merge(stopStyle), + ), +); diff --git a/packages/remix_cli/lib/src/registry/default/templates/recipes/execution_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/recipes/execution_recipe.dart.tmpl new file mode 100644 index 000000000..a7003e4c3 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/recipes/execution_recipe.dart.tmpl @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/execution.dart'; +import '../components/icon_button.dart'; +import '../theme/tokens.dart'; + +@immutable +final class {{typePrefix}}AgentExecutionRecipe { + const {{typePrefix}}AgentExecutionRecipe({ + required this.style, + required this.surfaceStyle, + required this.disclosureStyle, + required this.copyStyle, + required this.retryStyle, + }); + final {{typePrefix}}ExecutionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +{{typePrefix}}AgentExecutionRecipe {{valuePrefix}}AgentExecutionRecipe({ + {{typePrefix}}ExecutionStyler style = const {{typePrefix}}ExecutionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => {{typePrefix}}AgentExecutionRecipe( + style: {{typePrefix}}ExecutionStyler( + header: FlexBoxStyler().spacing(8), + output: BoxStyler() + .color({{typePrefix}}Tokens.muted()) + .borderRadius(.circular(6)) + .padding(.all(12)), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + tool: TextStyler().color({{typePrefix}}Tokens.mutedForeground()).fontSize(12), + title: TextStyler() + .color({{typePrefix}}Tokens.foreground()) + .fontWeight(FontWeight.w600), + meta: TextStyler().color({{typePrefix}}Tokens.mutedForeground()).fontSize(12), + status: TextStyler().color({{typePrefix}}Tokens.mutedForeground()).fontSize(12), + toolIcon: IconStyler().color({{typePrefix}}Tokens.foreground()).size(16), + statusIcon: IconStyler().color({{typePrefix}}Tokens.primary()).size(12), + indicator: IconStyler().color({{typePrefix}}Tokens.foreground()).size(16), + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle(style: surfaceStyle), + disclosureStyle: {{valuePrefix}}DisclosureStyle(style: disclosureStyle), + copyStyle: {{valuePrefix}}IconButtonStyle( + variant: .ghost, + size: .small, + style: copyStyle, + ), + retryStyle: {{valuePrefix}}IconButtonStyle( + variant: .ghost, + size: .small, + style: retryStyle, + ), +); diff --git a/packages/remix_cli/lib/src/registry/default/templates/recipes/message_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/recipes/message_recipe.dart.tmpl new file mode 100644 index 000000000..0b8c84e4c --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/recipes/message_recipe.dart.tmpl @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/message.dart'; + +@immutable +final class {{typePrefix}}AgentMessageRecipe { + const {{typePrefix}}AgentMessageRecipe({ + required this.style, + required this.surfaceStyle, + required this.collapsibleStyle, + required this.toggleStyle, + }); + final {{typePrefix}}MessageStyler style; + final CardStyler surfaceStyle; + final {{typePrefix}}MessageCollapsibleStyler collapsibleStyle; + final ButtonStyler toggleStyle; +} + +{{typePrefix}}AgentMessageRecipe {{valuePrefix}}AgentMessageRecipe({ + {{typePrefix}}MessageStyler style = const {{typePrefix}}MessageStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + {{typePrefix}}MessageCollapsibleStyler collapsibleStyle = + const {{typePrefix}}MessageCollapsibleStyler.create(), + ButtonStyler toggleStyle = const ButtonStyler.create(), +}) => {{typePrefix}}AgentMessageRecipe( + style: {{typePrefix}}MessageStyler( + row: FlexBoxStyler().mainAxisSize(.max).spacing(8), + avatar: BoxStyler().size(28, 28), + header: BoxStyler().padding(.only(bottom: 6)), + body: BoxStyler(), + footer: BoxStyler().padding(.only(top: 4)), + maxWidth: 640, + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle(style: surfaceStyle), + collapsibleStyle: {{typePrefix}}MessageCollapsibleStyler( + collapsedHeight: 72, + container: BoxStyler(), + clipped: BoxStyler(), + ).merge(collapsibleStyle), + toggleStyle: {{valuePrefix}}ButtonStyle( + variant: .ghost, + size: .small, + style: toggleStyle, + ), +); diff --git a/packages/remix_cli/lib/src/registry/default/templates/recipes/permission_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/recipes/permission_recipe.dart.tmpl new file mode 100644 index 000000000..550afec2c --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/recipes/permission_recipe.dart.tmpl @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/data_list.dart'; +import '../components/disclosure.dart'; +import '../components/permission.dart'; +import '../theme/tokens.dart'; + +@immutable +final class {{typePrefix}}AgentPermissionRecipe { + const {{typePrefix}}AgentPermissionRecipe({ + required this.style, + required this.surfaceStyle, + required this.detailsStyle, + required this.parametersStyle, + required this.allowOnceStyle, + required this.alwaysAllowStyle, + required this.denyStyle, + }); + final {{typePrefix}}PermissionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; +} + +{{typePrefix}}AgentPermissionRecipe {{valuePrefix}}AgentPermissionRecipe({ + {{typePrefix}}PermissionStyler style = const {{typePrefix}}PermissionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler detailsStyle = const DisclosureStyler.create(), + DataListStyler parametersStyle = const DataListStyler.create(), + ButtonStyler allowOnceStyle = const ButtonStyler.create(), + ButtonStyler alwaysAllowStyle = const ButtonStyler.create(), + ButtonStyler denyStyle = const ButtonStyler.create(), +}) => {{typePrefix}}AgentPermissionRecipe( + style: {{typePrefix}}PermissionStyler( + header: FlexBoxStyler().spacing(8), + actions: FlexBoxStyler().spacing(8).padding(.only(top: 8)), + title: TextStyler() + .color({{typePrefix}}Tokens.foreground()) + .fontWeight(FontWeight.w600), + tool: TextStyler().color({{typePrefix}}Tokens.mutedForeground()).fontSize(12), + description: TextStyler() + .color({{typePrefix}}Tokens.mutedForeground()) + .wrap(.padding(.symmetric(vertical: 8))), + status: TextStyler().color({{typePrefix}}Tokens.mutedForeground()).fontSize(12), + detailsLabel: TextStyler().color({{typePrefix}}Tokens.foreground()).fontSize(13), + toolIcon: IconStyler().color({{typePrefix}}Tokens.foreground()).size(16), + statusIcon: IconStyler().color({{typePrefix}}Tokens.primary()).size(12), + indicator: IconStyler().color({{typePrefix}}Tokens.foreground()).size(16), + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle(style: surfaceStyle), + detailsStyle: {{valuePrefix}}DisclosureStyle(style: detailsStyle), + parametersStyle: {{valuePrefix}}DataListStyle(style: parametersStyle), + allowOnceStyle: {{valuePrefix}}ButtonStyle(style: allowOnceStyle), + alwaysAllowStyle: {{valuePrefix}}ButtonStyle( + variant: .outline, + style: alwaysAllowStyle, + ), + denyStyle: {{valuePrefix}}ButtonStyle(variant: .ghost, style: denyStyle), +); diff --git a/packages/remix_cli/lib/src/registry/default/templates/recipes/plan_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/recipes/plan_recipe.dart.tmpl new file mode 100644 index 000000000..f8b8f52ff --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/recipes/plan_recipe.dart.tmpl @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/disclosure.dart'; +import '../components/plan.dart'; +import '../theme/tokens.dart'; + +@immutable +final class {{typePrefix}}AgentPlanRecipe { + const {{typePrefix}}AgentPlanRecipe({ + required this.style, + required this.disclosureStyle, + }); + final {{typePrefix}}PlanStyler style; + final DisclosureStyler disclosureStyle; +} + +{{typePrefix}}AgentPlanRecipe {{valuePrefix}}AgentPlanRecipe({ + {{typePrefix}}PlanStyler style = const {{typePrefix}}PlanStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => {{typePrefix}}AgentPlanRecipe( + style: {{typePrefix}}PlanStyler( + viewport: BoxStyler().maxHeight(220), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color({{typePrefix}}Tokens.foreground()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color({{typePrefix}}Tokens.foreground()).fontSize(14), + itemDetail: TextStyler() + .color({{typePrefix}}Tokens.mutedForeground()) + .fontSize(12), + count: TextStyler() + .color({{typePrefix}}Tokens.mutedForeground()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color({{typePrefix}}Tokens.foreground()).size(16), + pendingStatus: IconStyler().color({{typePrefix}}Tokens.mutedForeground()).size(18), + activeStatus: IconStyler().color({{typePrefix}}Tokens.primary()).size(18), + completedStatus: IconStyler().color({{typePrefix}}Tokens.primary()).size(18), + cancelledStatus: IconStyler() + .color({{typePrefix}}Tokens.mutedForeground()) + .size(18), + ).merge(style), + disclosureStyle: {{valuePrefix}}DisclosureStyle(style: disclosureStyle), +); diff --git a/packages/remix_cli/lib/src/registry/default/templates/recipes/transcript_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/recipes/transcript_recipe.dart.tmpl new file mode 100644 index 000000000..777442dac --- /dev/null +++ b/packages/remix_cli/lib/src/registry/default/templates/recipes/transcript_recipe.dart.tmpl @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/transcript.dart'; + +@immutable +final class {{typePrefix}}AgentTranscriptRecipe { + const {{typePrefix}}AgentTranscriptRecipe({required this.style}); + final {{typePrefix}}TranscriptStyler style; +} + +{{typePrefix}}AgentTranscriptRecipe {{valuePrefix}}AgentTranscriptRecipe({ + {{typePrefix}}TranscriptStyler style = const {{typePrefix}}TranscriptStyler.create(), +}) => {{typePrefix}}AgentTranscriptRecipe( + style: {{typePrefix}}TranscriptStyler( + viewport: BoxStyler().padding(.only(right: 12)), + item: BoxStyler(), + spacing: 16, + ).merge(style), +); diff --git a/packages/remix_cli/lib/src/registry/default/templates/segmented_control/segmented_control.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/segmented_control/segmented_control.dart.tmpl index 800ebb4ff..5253506c0 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/segmented_control/segmented_control.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/segmented_control/segmented_control.dart.tmpl @@ -100,8 +100,7 @@ const _iconSize = 16.0; /// floor for text this size. The chosen segment is marked by its raised /// surface and a heavier weight instead, and weight survives where a colour /// difference would not. -SegmentedControlItemStyler -_itemStyle() => _content({{typePrefix}}Tokens.foreground()) +SegmentedControlItemStyler _itemStyle() => _content({{typePrefix}}Tokens.foreground()) .color(_noFill) .alignment(.center) .minHeight(_minHeight) diff --git a/packages/remix_cli/lib/src/registry/default/templates/sidebar_layout/sidebar_layout.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/sidebar_layout/sidebar_layout.dart.tmpl index e94a5b416..ddbabbf7e 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/sidebar_layout/sidebar_layout.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/sidebar_layout/sidebar_layout.dart.tmpl @@ -110,8 +110,7 @@ class {{typePrefix}}SidebarLayout extends StatefulWidget { final ValueChanged? onCompactOpenChanged; @override - State<{{typePrefix}}SidebarLayout> createState() => - _{{typePrefix}}SidebarLayoutState(); + State<{{typePrefix}}SidebarLayout> createState() => _{{typePrefix}}SidebarLayoutState(); } class _{{typePrefix}}SidebarLayoutState extends State<{{typePrefix}}SidebarLayout> { @@ -169,7 +168,8 @@ class _{{typePrefix}}SidebarLayoutState extends State<{{typePrefix}}SidebarLayou Future _pushSheet() async { _sheetShowing = true; - final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; + final reduceMotion = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; await showRemixDialog( context: context, barrierDismissible: true, @@ -190,41 +190,41 @@ class _{{typePrefix}}SidebarLayoutState extends State<{{typePrefix}}SidebarLayou if (!didPop) _closeCompact(); }, child: {{typePrefix}}SidebarLayoutScope._( - isCompact: true, - isCompactOpen: true, - openCompact: _openCompact, - closeCompact: _closeCompact, - child: Align( - alignment: AlignmentDirectional.centerStart, - // A plain DecoratedBox paints the panel surface without - // affecting layout, unlike a Mix `Box`, whose border-box sizing - // would shrink `width` by the border's own stroke width. - child: DecoratedBox( - decoration: BoxDecoration( - color: MixScope.tokenOf( - {{typePrefix}}Tokens.background, - dialogContext, - ), - border: BorderDirectional( - end: BorderSide( - color: MixScope.tokenOf( - {{typePrefix}}Tokens.border, - dialogContext, + isCompact: true, + isCompactOpen: true, + openCompact: _openCompact, + closeCompact: _closeCompact, + child: Align( + alignment: AlignmentDirectional.centerStart, + // A plain DecoratedBox paints the panel surface without + // affecting layout, unlike a Mix `Box`, whose border-box sizing + // would shrink `width` by the border's own stroke width. + child: DecoratedBox( + decoration: BoxDecoration( + color: MixScope.tokenOf( + {{typePrefix}}Tokens.background, + dialogContext, + ), + border: BorderDirectional( + end: BorderSide( + color: MixScope.tokenOf( + {{typePrefix}}Tokens.border, + dialogContext, + ), ), ), ), - ), - child: SizedBox( - width: width, - height: double.infinity, - child: RemixDialog( - semanticLabel: _navigationSemanticLabel, - child: widget.sidebar, + child: SizedBox( + width: width, + height: double.infinity, + child: RemixDialog( + semanticLabel: _navigationSemanticLabel, + child: widget.sidebar, + ), ), ), ), ), - ), ); }, ); @@ -270,7 +270,8 @@ class _{{typePrefix}}SidebarLayoutState extends State<{{typePrefix}}SidebarLayou } Widget _wideRow() { - final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; + final reduceMotion = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -290,7 +291,10 @@ class _{{typePrefix}}SidebarLayoutState extends State<{{typePrefix}}SidebarLayou Widget _body() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [?widget.header, Expanded(child: widget.body)], + children: [ + ?widget.header, + Expanded(child: widget.body), + ], ); } } @@ -344,8 +348,7 @@ class {{typePrefix}}SidebarLayoutScope extends InheritedWidget { /// Reads the nearest [{{typePrefix}}SidebarLayoutScope], or null outside a /// [{{typePrefix}}SidebarLayout]. static {{typePrefix}}SidebarLayoutScope? maybeOf(BuildContext context) => - context - .dependOnInheritedWidgetOfExactType<{{typePrefix}}SidebarLayoutScope>(); + context.dependOnInheritedWidgetOfExactType<{{typePrefix}}SidebarLayoutScope>(); @override bool updateShouldNotify({{typePrefix}}SidebarLayoutScope oldWidget) => diff --git a/packages/remix_cli/lib/src/registry/default/templates/slider/slider.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/slider/slider.dart.tmpl index a4f79a754..29e5d765f 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/slider/slider.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/slider/slider.dart.tmpl @@ -59,9 +59,7 @@ SliderStyler {{valuePrefix}}SliderStyle({ .borderRadius(.all(_circular)) // The thumb is a light disc on a light rail, so its own outline is // what separates it from the range it sits on. - .border( - .color({{typePrefix}}Tokens.primary()).width(_thumbBorderWidth), - ), + .border(.color({{typePrefix}}Tokens.primary()).width(_thumbBorderWidth)), ) // A thumb is a grab target, so it answers the pointer. The outline // keeps identifying it; only the fill moves, which is why hovering does diff --git a/packages/remix_cli/lib/src/registry/default/templates/textfield/textfield.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/textfield/textfield.dart.tmpl index ae737da0a..4648c41a3 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/textfield/textfield.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/textfield/textfield.dart.tmpl @@ -168,7 +168,7 @@ TextFieldStyler _focusVisibleStyle() => TextFieldStyler().containerEffects( /// assistive technology either way. /// /// A theme with a dedicated danger *text* step would put it on the helper -/// line here; this vocabulary has fifteen tokens and no such step. +/// line here; this vocabulary has twenty tokens and no such step. TextFieldStyler _errorStyle() => TextFieldStyler().variant( ContextVariant.widgetState(.error), TextFieldStyler() diff --git a/packages/remix_cli/lib/src/registry/default/templates/theme/theme_data.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/theme/theme_data.dart.tmpl index 3dcca5242..49f5f53ff 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/theme/theme_data.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/theme/theme_data.dart.tmpl @@ -246,5 +246,6 @@ class {{typePrefix}}ThemeData { int get hashCode => Object.hashAll(_fields); @override - String toString() => '{{typePrefix}}ThemeData(background: $background, radius: $radius)'; + String toString() => + '{{typePrefix}}ThemeData(background: $background, radius: $radius)'; } diff --git a/packages/remix_cli/lib/src/registry/default/templates/theme/tokens.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/theme/tokens.dart.tmpl index 941df70c7..f47b6c77d 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/theme/tokens.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/theme/tokens.dart.tmpl @@ -13,27 +13,29 @@ import 'package:remix/remix.dart'; /// ``` abstract final class {{typePrefix}}Tokens { /// Page background the application paints behind its content. - static const background = ColorToken('ui.color.background'); + static const background = ColorToken('{{valuePrefix}}.color.background'); /// Default content color used on top of [background]. - static const foreground = ColorToken('ui.color.foreground'); + static const foreground = ColorToken('{{valuePrefix}}.color.foreground'); /// Highest-emphasis fill. - static const primary = ColorToken('ui.color.primary'); + static const primary = ColorToken('{{valuePrefix}}.color.primary'); /// Content color used on top of [primary]. - static const primaryForeground = ColorToken('ui.color.primary-foreground'); + static const primaryForeground = ColorToken( + '{{valuePrefix}}.color.primary-foreground', + ); /// Medium-emphasis fill. - static const secondary = ColorToken('ui.color.secondary'); + static const secondary = ColorToken('{{valuePrefix}}.color.secondary'); /// Content color used on top of [secondary]. static const secondaryForeground = ColorToken( - 'ui.color.secondary-foreground', + '{{valuePrefix}}.color.secondary-foreground', ); /// De-emphasized surface. - static const muted = ColorToken('ui.color.muted'); + static const muted = ColorToken('{{valuePrefix}}.color.muted'); /// De-emphasized content color. /// @@ -42,24 +44,24 @@ abstract final class {{typePrefix}}Tokens { /// it for text on [background], and for glyphs and other non-text marks /// anywhere; text that lands on a `muted` surface takes [foreground]. Raise /// this value here and that restriction goes away everywhere at once. - static const mutedForeground = ColorToken('ui.color.muted-foreground'); + static const mutedForeground = ColorToken('{{valuePrefix}}.color.muted-foreground'); /// Interaction surface for otherwise transparent controls. - static const accent = ColorToken('ui.color.accent'); + static const accent = ColorToken('{{valuePrefix}}.color.accent'); /// Content color used on top of [accent]. - static const accentForeground = ColorToken('ui.color.accent-foreground'); + static const accentForeground = ColorToken('{{valuePrefix}}.color.accent-foreground'); /// Destructive fill for irreversible actions. - static const destructive = ColorToken('ui.color.destructive'); + static const destructive = ColorToken('{{valuePrefix}}.color.destructive'); /// Content color used on top of [destructive]. static const destructiveForeground = ColorToken( - 'ui.color.destructive-foreground', + '{{valuePrefix}}.color.destructive-foreground', ); /// Hairline separator and control outline color. - static const border = ColorToken('ui.color.border'); + static const border = ColorToken('{{valuePrefix}}.color.border'); /// Focus ring color drawn for keyboard focus. /// @@ -68,7 +70,7 @@ abstract final class {{typePrefix}}Tokens { /// talking rather than the brand, and it clears the 3:1 non-text floor on /// both pages. Give it a brand color here and every control's focus ring /// follows; nothing else reads this token. - static const focusRing = ColorToken('ui.color.focus-ring'); + static const focusRing = ColorToken('{{valuePrefix}}.color.focus-ring'); /// First categorical chart series color. /// @@ -76,22 +78,22 @@ abstract final class {{typePrefix}}Tokens { /// The shipped themes keep one hue per series in both brightnesses, and /// every value clears 4.5:1 against [background]. That also keeps pie labels, /// which are drawn in [background], readable on their slice. - static const chart1 = ColorToken('ui.color.chart-1'); + static const chart1 = ColorToken('{{valuePrefix}}.color.chart-1'); /// Second categorical chart series color. See [chart1]. - static const chart2 = ColorToken('ui.color.chart-2'); + static const chart2 = ColorToken('{{valuePrefix}}.color.chart-2'); /// Third categorical chart series color. See [chart1]. - static const chart3 = ColorToken('ui.color.chart-3'); + static const chart3 = ColorToken('{{valuePrefix}}.color.chart-3'); /// Fourth categorical chart series color. See [chart1]. - static const chart4 = ColorToken('ui.color.chart-4'); + static const chart4 = ColorToken('{{valuePrefix}}.color.chart-4'); /// Fifth categorical chart series color. See [chart1]. - static const chart5 = ColorToken('ui.color.chart-5'); + static const chart5 = ColorToken('{{valuePrefix}}.color.chart-5'); /// Corner radius shared by the application's controls. - static const radius = RadiusToken('ui.radius'); + static const radius = RadiusToken('{{valuePrefix}}.radius'); /// The chart series colors in the order charts assign them. static const chart = [chart1, chart2, chart3, chart4, chart5]; diff --git a/packages/remix_cli/lib/src/registry/default/templates/toast/toast.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/toast/toast.dart.tmpl index ea7d04fb1..6c76484e7 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/toast/toast.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/toast/toast.dart.tmpl @@ -112,12 +112,10 @@ ToastStyler _base() => ToastStyler() /// The tone shows in the glyph and, for `destructive`, the outline. The /// sentence stays in `foreground` for contrast. -ToastStyler _variantStyle({{typePrefix}}ToastVariant variant) => - switch (variant) { - .neutral => ToastStyler().icon( - .color({{typePrefix}}Tokens.mutedForeground()), - ), - .destructive => ToastStyler() - .border(.color({{typePrefix}}Tokens.destructive())) - .icon(.color({{typePrefix}}Tokens.destructive())), - }; +ToastStyler _variantStyle({{typePrefix}}ToastVariant variant) => switch (variant) { + .neutral => ToastStyler().icon(.color({{typePrefix}}Tokens.mutedForeground())), + .destructive => + ToastStyler() + .border(.color({{typePrefix}}Tokens.destructive())) + .icon(.color({{typePrefix}}Tokens.destructive())), +}; diff --git a/packages/remix_cli/lib/src/registry/default/templates/toggle/toggle.dart.tmpl b/packages/remix_cli/lib/src/registry/default/templates/toggle/toggle.dart.tmpl index 0bd679055..6b812622b 100644 --- a/packages/remix_cli/lib/src/registry/default/templates/toggle/toggle.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/default/templates/toggle/toggle.dart.tmpl @@ -95,30 +95,29 @@ typedef _{{typePrefix}}ToggleMetrics = ({ double iconSize, }); -_{{typePrefix}}ToggleMetrics _metricsFor({{typePrefix}}ToggleSize size) => - switch (size) { - .small => ( - minHeight: 32.0, - paddingX: 10.0, - gap: 6.0, - labelSize: 14.0, - iconSize: 16.0, - ), - .medium => ( - minHeight: 36.0, - paddingX: 12.0, - gap: 8.0, - labelSize: 14.0, - iconSize: 16.0, - ), - .large => ( - minHeight: 40.0, - paddingX: 16.0, - gap: 8.0, - labelSize: 16.0, - iconSize: 18.0, - ), - }; +_{{typePrefix}}ToggleMetrics _metricsFor({{typePrefix}}ToggleSize size) => switch (size) { + .small => ( + minHeight: 32.0, + paddingX: 10.0, + gap: 6.0, + labelSize: 14.0, + iconSize: 16.0, + ), + .medium => ( + minHeight: 36.0, + paddingX: 12.0, + gap: 8.0, + labelSize: 14.0, + iconSize: 16.0, + ), + .large => ( + minHeight: 40.0, + paddingX: 16.0, + gap: 8.0, + labelSize: 16.0, + iconSize: 18.0, + ), +}; /// Layout, typography, and the off appearance shared by both variants. ToggleStyler _base(_{{typePrefix}}ToggleMetrics metrics) => diff --git a/packages/remix_cli/lib/src/registry/fortal/registry.yaml b/packages/remix_cli/lib/src/registry/fortal/registry.yaml index 388fae90c..c21e1d77b 100644 --- a/packages/remix_cli/lib/src/registry/fortal/registry.yaml +++ b/packages/remix_cli/lib/src/registry/fortal/registry.yaml @@ -1,4 +1,4 @@ -# Generated by tool/build_fortal_preset.dart. Do not edit. +# Generated by tool/build_registry.dart. Do not edit. schema: 1 items: theme: @@ -51,6 +51,30 @@ items: exports: - components/accordion.dart + activity_recipe: + registryDependencies: + - theme + - activity + - disclosure + files: + - source: templates/recipes/activity_recipe.dart.tmpl + target: "@ui/recipes/activity_recipe.dart" + exports: + - recipes/activity_recipe.dart + + answer_recipe: + registryDependencies: + - theme + - answer + - card + - disclosure + - icon_button + files: + - source: templates/recipes/answer_recipe.dart.tmpl + target: "@ui/recipes/answer_recipe.dart" + exports: + - recipes/answer_recipe.dart + avatar: registryDependencies: - theme @@ -184,6 +208,19 @@ items: exports: - components/code.dart + composer_recipe: + registryDependencies: + - theme + - card + - composer + - icon_button + - textfield + files: + - source: templates/recipes/composer_recipe.dart.tmpl + target: "@ui/recipes/composer_recipe.dart" + exports: + - recipes/composer_recipe.dart + data_list: registryDependencies: - theme @@ -267,6 +304,19 @@ items: exports: - components/divider.dart + execution_recipe: + registryDependencies: + - theme + - card + - disclosure + - execution + - icon_button + files: + - source: templates/recipes/execution_recipe.dart.tmpl + target: "@ui/recipes/execution_recipe.dart" + exports: + - recipes/execution_recipe.dart + heading: registryDependencies: - theme @@ -330,6 +380,42 @@ items: exports: - components/menu.dart + message_recipe: + registryDependencies: + - button + - card + - message + files: + - source: templates/recipes/message_recipe.dart.tmpl + target: "@ui/recipes/message_recipe.dart" + exports: + - recipes/message_recipe.dart + + permission_recipe: + registryDependencies: + - theme + - button + - card + - data_list + - disclosure + - permission + files: + - source: templates/recipes/permission_recipe.dart.tmpl + target: "@ui/recipes/permission_recipe.dart" + exports: + - recipes/permission_recipe.dart + + plan_recipe: + registryDependencies: + - theme + - disclosure + - plan + files: + - source: templates/recipes/plan_recipe.dart.tmpl + target: "@ui/recipes/plan_recipe.dart" + exports: + - recipes/plan_recipe.dart + popover: registryDependencies: - theme @@ -618,6 +704,15 @@ items: exports: - components/tooltip.dart + transcript_recipe: + registryDependencies: + - transcript + files: + - source: templates/recipes/transcript_recipe.dart.tmpl + target: "@ui/recipes/transcript_recipe.dart" + exports: + - recipes/transcript_recipe.dart + typography: registryDependencies: - theme @@ -626,3 +721,163 @@ items: target: "@ui/components/typography.dart" exports: - components/typography.dart + + models: + files: + - source: templates/agent/models/activity_item.dart.tmpl + target: "@ui/models/activity_item.dart" + - source: templates/agent/models/plan_item.dart.tmpl + target: "@ui/models/plan_item.dart" + - source: templates/agent/models/statuses.dart.tmpl + target: "@ui/models/statuses.dart" + exports: + - models/activity_item.dart + - models/plan_item.dart + - models/statuses.dart + + support: + registryDependencies: + - theme + dependencies: + remix_ui_icons: ^0.1.0 + files: + - source: templates/agent/support/disclosure.dart.tmpl + target: "@ui/support/disclosure.dart" + - source: templates/agent/support/functional_glyph.dart.tmpl + target: "@ui/support/functional_glyph.dart" + - source: templates/agent/support/live_edge.dart.tmpl + target: "@ui/support/live_edge.dart" + + activity: + registryDependencies: + - models + - support + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/agent/activity/activity.dart.tmpl + target: "@ui/components/activity.dart" + generated: + - "@ui/components/activity.g.dart" + exports: + - components/activity.dart + + answer: + registryDependencies: + - models + - support + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/agent/answer/answer.dart.tmpl + target: "@ui/components/answer.dart" + generated: + - "@ui/components/answer.g.dart" + exports: + - components/answer.dart + + composer: + registryDependencies: + - support + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/agent/composer/composer.dart.tmpl + target: "@ui/components/composer.dart" + generated: + - "@ui/components/composer.g.dart" + exports: + - components/composer.dart + + execution: + registryDependencies: + - models + - support + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/agent/execution/execution.dart.tmpl + target: "@ui/components/execution.dart" + generated: + - "@ui/components/execution.g.dart" + exports: + - components/execution.dart + + message: + registryDependencies: + - models + - support + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/agent/message/message.dart.tmpl + target: "@ui/components/message.dart" + generated: + - "@ui/components/message.g.dart" + exports: + - components/message.dart + + permission: + registryDependencies: + - models + - support + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/agent/permission/permission.dart.tmpl + target: "@ui/components/permission.dart" + generated: + - "@ui/components/permission.g.dart" + exports: + - components/permission.dart + + plan: + registryDependencies: + - models + - support + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/agent/plan/plan.dart.tmpl + target: "@ui/components/plan.dart" + generated: + - "@ui/components/plan.g.dart" + exports: + - components/plan.dart + + transcript: + registryDependencies: + - support + dependencies: + mix_annotations: ^2.2.0-beta.1 + devDependencies: + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 + files: + - source: templates/agent/transcript/transcript.dart.tmpl + target: "@ui/components/transcript.dart" + generated: + - "@ui/components/transcript.g.dart" + exports: + - components/transcript.dart diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/activity/activity.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/activity/activity.dart.tmpl new file mode 100644 index 000000000..ff46115c2 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/activity/activity.dart.tmpl @@ -0,0 +1,310 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/activity_item.dart'; +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'activity.g.dart'; + +typedef {{typePrefix}}ActivityStatusBuilder = + Widget Function(BuildContext context, {{typePrefix}}ActivityItem item); +typedef {{typePrefix}}ActivityStatusLabelBuilder = + String Function({{typePrefix}}ActivityItem item); +typedef {{typePrefix}}ActivityIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Activity ledger that is forced open and non-toggleable only while working. +class {{typePrefix}}Activity extends StatefulWidget { + const {{typePrefix}}Activity({ + super.key, + required this.items, + this.status = {{typePrefix}}RunStatus.working, + this.title = 'Activity', + this.semanticLabel = 'Activity', + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.statusBuilder, + this.statusLabelBuilder, + this.indicatorBuilder, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + this.disclosureStyle = const DisclosureStyler.create(), + this.style = const {{typePrefix}}ActivityStyler.create(), + this.styleSpec, + }); + + final List<{{typePrefix}}ActivityItem> items; + final {{typePrefix}}RunStatus status; + final String title; + final String semanticLabel; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final {{typePrefix}}ActivityStatusBuilder? statusBuilder; + final {{typePrefix}}ActivityStatusLabelBuilder? statusLabelBuilder; + final {{typePrefix}}ActivityIndicatorBuilder? indicatorBuilder; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + final DisclosureStyler disclosureStyle; + final {{typePrefix}}ActivityStyler style; + final {{typePrefix}}ActivitySpec? styleSpec; + + bool get isWorking => status == {{typePrefix}}RunStatus.working; + + /// Number of completed rows in the activity ledger. + int get settledCount => items + .where((item) => item.status == {{typePrefix}}ActivityItemStatus.complete) + .length; + + @override + State<{{typePrefix}}Activity> createState() => _{{typePrefix}}ActivityState(); +} + +class _{{typePrefix}}ActivityState extends State<{{typePrefix}}Activity> { + late final {{typePrefix}}DisclosureEngine _disclosure; + + bool get _expanded => widget.isWorking ? true : (_disclosure.value); + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Activity oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + if (!oldWidget.isWorking && widget.isWorking) { + _request(true, lifecycle: true); + } else if (oldWidget.isWorking && + !widget.isWorking && + widget.collapseOnComplete) { + _request(false, lifecycle: true); + } + } + + void _request(bool next, {bool lifecycle = false}) { + if (widget.isWorking && !lifecycle) return; + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String _statusLabel({{typePrefix}}ActivityItem item) => + widget.statusLabelBuilder?.call(item) ?? + switch (item.status) { + {{typePrefix}}ActivityItemStatus.pending => 'Pending', + {{typePrefix}}ActivityItemStatus.active => 'Active', + {{typePrefix}}ActivityItemStatus.complete => 'Complete', + }; + + {{typePrefix}}FunctionalGlyphKind _statusGlyph({{typePrefix}}ActivityItemStatus status) => + switch (status) { + {{typePrefix}}ActivityItemStatus.pending => .pending, + {{typePrefix}}ActivityItemStatus.active => .active, + {{typePrefix}}ActivityItemStatus.complete => .completed, + }; + + StyleSpec _statusContainer( + {{typePrefix}}ActivitySpec spec, + {{typePrefix}}ActivityItemStatus status, + ) => switch (status) { + {{typePrefix}}ActivityItemStatus.pending => spec.pendingItem, + {{typePrefix}}ActivityItemStatus.active => spec.activeItem, + {{typePrefix}}ActivityItemStatus.complete => spec.completedItem, + }; + + StyleSpec _statusStyle( + {{typePrefix}}ActivitySpec spec, + {{typePrefix}}ActivityItemStatus status, + ) => switch (status) { + {{typePrefix}}ActivityItemStatus.pending => spec.pendingStatus, + {{typePrefix}}ActivityItemStatus.active => spec.activeStatus, + {{typePrefix}}ActivityItemStatus.complete => spec.completedStatus, + }; + + Widget _defaultStatus( + BuildContext context, + {{typePrefix}}ActivitySpec spec, + {{typePrefix}}ActivityItem item, + ) => StyleSpecBuilder( + styleSpec: _statusStyle(spec, item.status), + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph(kind: _statusGlyph(item.status), spec: iconSpec), + ); + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}ActivitySpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixDisclosure( + expanded: _expanded, + enabled: !widget.isWorking, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + // Preserve the count alignment and expansion cue while working. + // RemixDisclosure keeps the forced-open header non-toggleable. + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: Row( + children: [ + Expanded( + child: StyledText(widget.title, styleSpec: spec.summaryTitle), + ), + StyledText( + '${widget.settledCount}/${widget.items.length}', + styleSpec: spec.count, + ), + ], + ), + content: Box( + styleSpec: spec.viewport, + child: {{typePrefix}}LiveEdgeScrollView( + followOutput: widget.followOutput, + followThreshold: widget.followThreshold, + onFollowChanged: widget.onFollowChanged, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in widget.items) + Semantics( + container: true, + explicitChildNodes: true, + label: [ + item.title, + if (item.detail != null) item.detail!, + _statusLabel(item), + ].join(', '), + child: Box( + styleSpec: _statusContainer(spec, item.status), + child: RowBox( + key: ValueKey('{{valuePrefix}}-activity-item-${item.id}'), + styleSpec: spec.item, + children: [ + ExcludeSemantics( + child: + widget.statusBuilder?.call(context, item) ?? + _defaultStatus(context, spec, item), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: StyledText( + item.title, + styleSpec: spec.itemTitle, + ), + ), + if (item.detail != null) + ExcludeSemantics( + child: StyledText( + item.detail!, + styleSpec: spec.itemDetail, + ), + ), + if (item.child != null) item.child!, + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Activity.new) +@immutable +final class {{typePrefix}}ActivitySpec with _${{typePrefix}}ActivitySpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final StyleSpec summaryTitle; + @override + final StyleSpec itemTitle; + @override + final StyleSpec itemDetail; + @override + final StyleSpec count; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingItem; + @override + final StyleSpec activeItem; + @override + final StyleSpec completedItem; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec activeStatus; + @override + final StyleSpec completedStatus; + + const {{typePrefix}}ActivitySpec({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: FlexBoxSpec()), + summaryTitle = summaryTitle ?? const StyleSpec(spec: TextSpec()), + itemTitle = itemTitle ?? const StyleSpec(spec: TextSpec()), + itemDetail = itemDetail ?? const StyleSpec(spec: TextSpec()), + count = count ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingItem = pendingItem ?? const StyleSpec(spec: BoxSpec()), + activeItem = activeItem ?? const StyleSpec(spec: BoxSpec()), + completedItem = completedItem ?? const StyleSpec(spec: BoxSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: IconSpec()), + activeStatus = activeStatus ?? const StyleSpec(spec: IconSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: IconSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/answer/answer.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/answer/answer.dart.tmpl new file mode 100644 index 000000000..ba82dbea5 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/answer/answer.dart.tmpl @@ -0,0 +1,216 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; + +part 'answer.g.dart'; + +typedef {{typePrefix}}AnswerSourcesIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Streaming answer surface with host-owned content and feedback. +class {{typePrefix}}Answer extends StatefulWidget { + const {{typePrefix}}Answer({ + super.key, + required this.child, + this.streamId, + this.status = {{typePrefix}}AnswerStatus.streaming, + this.onCopy, + this.onRetry, + this.copyIconBuilder, + this.retryIconBuilder, + this.sourcesIndicatorBuilder, + this.copyLabel = 'Copy answer', + this.retryLabel = 'Retry answer', + this.showActions, + this.feedback, + this.sourcesContent, + this.sourcesExpanded, + this.defaultSourcesExpanded = false, + this.onSourcesExpandedChanged, + this.sourcesLabel = 'Sources', + this.semanticLabel = 'Answer', + this.surfaceStyle = const CardStyler.create(), + this.sourcesStyle = const DisclosureStyler.create(), + this.copyStyle = const IconButtonStyler.create(), + this.retryStyle = const IconButtonStyler.create(), + this.style = const {{typePrefix}}AnswerStyler.create(), + this.styleSpec, + }); + + final Widget child; + final Object? streamId; + final {{typePrefix}}AnswerStatus status; + final VoidCallback? onCopy; + final VoidCallback? onRetry; + final RemixIconButtonIconBuilder? copyIconBuilder; + final RemixIconButtonIconBuilder? retryIconBuilder; + final {{typePrefix}}AnswerSourcesIndicatorBuilder? sourcesIndicatorBuilder; + final String copyLabel; + final String retryLabel; + final bool? showActions; + final Widget? feedback; + final Widget? sourcesContent; + final bool? sourcesExpanded; + final bool defaultSourcesExpanded; + final ValueChanged? onSourcesExpandedChanged; + final String sourcesLabel; + final String semanticLabel; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; + final {{typePrefix}}AnswerStyler style; + final {{typePrefix}}AnswerSpec? styleSpec; + + @override + State<{{typePrefix}}Answer> createState() => _{{typePrefix}}AnswerState(); +} + +class _{{typePrefix}}AnswerState extends State<{{typePrefix}}Answer> { + late final {{typePrefix}}DisclosureEngine _disclosure; + + bool get _sourcesExpanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.sourcesExpanded, + defaultValue: widget.defaultSourcesExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Answer oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.sourcesExpanded); + final beganStreaming = + !oldWidget.status.isStreaming && widget.status.isStreaming; + final newStreamingIdentity = + oldWidget.streamId != widget.streamId && widget.status.isStreaming; + if (beganStreaming || newStreamingIdentity) _requestSources(false); + } + + void _requestSources(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onSourcesExpandedChanged?.call(next); + } + + @override + Widget build(BuildContext context) { + final revealActions = + !widget.status.isStreaming && + (widget.showActions ?? widget.status.showsActions); + return RemixStyleSpecBuilder<{{typePrefix}}AnswerSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixCard( + style: widget.surfaceStyle, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + liveRegion: widget.status.isStreaming, + child: Box(styleSpec: spec.body, child: widget.child), + ), + if (widget.sourcesContent != null) + RemixDisclosure( + expanded: _sourcesExpanded, + onExpandedChanged: _requestSources, + semanticLabel: widget.sourcesLabel, + style: widget.sourcesStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.sourcesIndicatorBuilder, + ), + ], + ), + trigger: StyledText( + widget.sourcesLabel, + styleSpec: spec.sourcesLabel, + ), + content: widget.sourcesContent!, + ), + if (revealActions) + RowBox( + styleSpec: spec.actions, + children: [ + if (widget.onCopy != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.copyIconBuilder ?? + (context, iconSpec, icon) => {{typePrefix}}FunctionalGlyph( + kind: .copy, + spec: iconSpec, + ), + semanticLabel: widget.copyLabel, + onPressed: widget.onCopy, + style: widget.copyStyle, + ), + if (widget.onRetry != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.retryIconBuilder ?? + (context, iconSpec, icon) => {{typePrefix}}FunctionalGlyph( + kind: .retry, + spec: iconSpec, + ), + semanticLabel: widget.retryLabel, + onPressed: widget.onRetry, + style: widget.retryStyle, + ), + if (widget.status == {{typePrefix}}AnswerStatus.complete && + widget.feedback != null) + Box(styleSpec: spec.feedback, child: widget.feedback), + ], + ), + ], + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Answer.new) +@immutable +final class {{typePrefix}}AnswerSpec with _${{typePrefix}}AnswerSpec { + @override + final StyleSpec body; + @override + final StyleSpec actions; + @override + final StyleSpec feedback; + @override + final StyleSpec sourcesLabel; + @override + final StyleSpec indicator; + + const {{typePrefix}}AnswerSpec({ + StyleSpec? body, + StyleSpec? actions, + StyleSpec? feedback, + StyleSpec? sourcesLabel, + StyleSpec? indicator, + }) : body = body ?? const StyleSpec(spec: BoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + feedback = feedback ?? const StyleSpec(spec: BoxSpec()), + sourcesLabel = sourcesLabel ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/composer/composer.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/composer/composer.dart.tmpl new file mode 100644 index 000000000..f16e0c8fa --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/composer/composer.dart.tmpl @@ -0,0 +1,276 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../support/functional_glyph.dart'; + +part 'composer.g.dart'; + +/// Growable prompt input composed from Remix text-area and icon-button controls. +class {{typePrefix}}Composer extends StatefulWidget { + const {{typePrefix}}Composer({ + super.key, + this.controller, + this.initialValue, + this.focusNode, + this.onChanged, + this.onSubmit, + this.onStop, + this.running = false, + this.enabled = true, + this.canSubmit, + this.clearOnSubmit = true, + this.autofocus = false, + this.hintText = 'Message', + this.semanticLabel = 'Message', + this.minLines = 2, + this.maxLines = 8, + this.leading, + this.trailing, + this.submitIconBuilder, + this.stopIconBuilder, + this.submitLabel = 'Send', + this.stopLabel = 'Stop', + this.surfaceStyle = const CardStyler.create(), + this.fieldStyle = const TextFieldStyler.create(), + this.submitStyle = const IconButtonStyler.create(), + this.stopStyle = const IconButtonStyler.create(), + this.style = const {{typePrefix}}ComposerStyler.create(), + this.styleSpec, + }) : assert( + controller == null || initialValue == null, + 'initialValue cannot be used with an external controller.', + ); + + final TextEditingController? controller; + final String? initialValue; + final FocusNode? focusNode; + final ValueChanged? onChanged; + final ValueChanged? onSubmit; + final VoidCallback? onStop; + final bool running; + final bool enabled; + final bool? canSubmit; + final bool clearOnSubmit; + final bool autofocus; + final String hintText; + final String semanticLabel; + final int minLines; + final int maxLines; + final Widget? leading; + final Widget? trailing; + final RemixIconButtonIconBuilder? submitIconBuilder; + final RemixIconButtonIconBuilder? stopIconBuilder; + final String submitLabel; + final String stopLabel; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; + final {{typePrefix}}ComposerStyler style; + final {{typePrefix}}ComposerSpec? styleSpec; + + @override + State<{{typePrefix}}Composer> createState() => _{{typePrefix}}ComposerState(); +} + +class _{{typePrefix}}ComposerState extends State<{{typePrefix}}Composer> { + TextEditingController? _ownedController; + FocusNode? _ownedFocusNode; + late TextEditingController _controller; + late String _text; + + FocusNode get _focusNode => + widget.focusNode ?? (_ownedFocusNode ??= FocusNode()); + + bool get _isComposing { + final composing = _controller.value.composing; + return composing.isValid && !composing.isCollapsed; + } + + bool get _canSubmit => + widget.enabled && + !widget.running && + _text.trim().isNotEmpty && + widget.onSubmit != null && + (widget.canSubmit ?? true); + + @override + void initState() { + super.initState(); + _controller = + widget.controller ?? + (_ownedController = TextEditingController(text: widget.initialValue)); + _text = _controller.text; + _controller.addListener(_handleControllerChanged); + } + + void _handleControllerChanged() { + final next = _controller.text; + if (next == _text) return; + setState(() => _text = next); + widget.onChanged?.call(next); + } + + @override + void didUpdateWidget({{typePrefix}}Composer oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + final seed = _controller.text; + _controller.removeListener(_handleControllerChanged); + final oldOwnedController = _ownedController; + _ownedController = null; + _controller = + widget.controller ?? + (_ownedController = TextEditingController(text: seed)); + _text = _controller.text; + _controller.addListener(_handleControllerChanged); + _disposeAfterFrame(oldOwnedController); + } + if (!identical(oldWidget.focusNode, widget.focusNode)) { + final oldOwnedFocusNode = _ownedFocusNode; + _ownedFocusNode = null; + _disposeAfterFrame(oldOwnedFocusNode); + } + } + + /// Releases a superseded owned object once the child has let go of it. + /// + /// The same deferral the transcript uses for its scroll controller: the child + /// RemixTextArea still holds the old controller and focus node until this + /// frame's rebuild detaches them, and detaching touches a disposed object. + void _disposeAfterFrame(ChangeNotifier? superseded) { + if (superseded == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) => superseded.dispose()); + } + + void _submit() { + if (!_canSubmit || _isComposing) return; + final prompt = _text.trim(); + widget.onSubmit?.call(prompt); + if (widget.clearOnSubmit) _controller.clear(); + _focusNode.requestFocus(); + } + + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final isEnter = + event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter; + if (!isEnter || HardwareKeyboard.instance.isShiftPressed || _isComposing) { + return KeyEventResult.ignored; + } + if (!_canSubmit) return KeyEventResult.ignored; + _submit(); + return KeyEventResult.handled; + } + + Widget _defaultSubmitIcon( + BuildContext context, + IconSpec spec, + IconData? icon, + ) => {{typePrefix}}FunctionalGlyph(kind: .send, spec: spec); + + Widget _defaultStopIcon( + BuildContext context, + IconSpec spec, + IconData? icon, + ) => {{typePrefix}}FunctionalGlyph(kind: .stop, spec: spec); + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}ComposerSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + // Keep the field and action in separate accessibility nodes. + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _handleKey, + child: RemixCard( + style: widget.surfaceStyle, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ScrollConfiguration( + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false), + child: RemixTextArea( + controller: _controller, + focusNode: _focusNode, + enabled: widget.enabled, + autofocus: widget.autofocus, + hintText: widget.hintText, + semanticLabel: widget.semanticLabel, + minLines: widget.minLines, + maxLines: widget.maxLines, + textInputAction: TextInputAction.newline, + style: widget.fieldStyle, + ), + ), + RowBox( + styleSpec: spec.toolbar, + children: [ + if (widget.leading != null) widget.leading!, + const Spacer(), + if (widget.trailing != null) widget.trailing!, + Semantics( + container: true, + child: RemixIconButton( + key: ValueKey( + widget.running + ? '{{valuePrefix}}-composer-stop' + : '{{valuePrefix}}-composer-send', + ), + icon: null, + iconBuilder: widget.running + ? (widget.stopIconBuilder ?? _defaultStopIcon) + : (widget.submitIconBuilder ?? _defaultSubmitIcon), + semanticLabel: widget.running + ? widget.stopLabel + : widget.submitLabel, + enabled: widget.running + ? widget.enabled && widget.onStop != null + : _canSubmit, + onPressed: widget.running ? widget.onStop : _submit, + style: widget.running + ? widget.stopStyle + : widget.submitStyle, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _controller.removeListener(_handleControllerChanged); + _ownedController?.dispose(); + _ownedFocusNode?.dispose(); + super.dispose(); + } +} + +@MixableSpec(target: {{typePrefix}}Composer.new) +@immutable +final class {{typePrefix}}ComposerSpec with _${{typePrefix}}ComposerSpec { + @override + final StyleSpec toolbar; + + const {{typePrefix}}ComposerSpec({StyleSpec? toolbar}) + : toolbar = toolbar ?? const StyleSpec(spec: FlexBoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/execution/execution.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/execution/execution.dart.tmpl new file mode 100644 index 000000000..54523672d --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/execution/execution.dart.tmpl @@ -0,0 +1,342 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'execution.g.dart'; + +typedef {{typePrefix}}ExecutionStatusLabelBuilder = + String Function({{typePrefix}}ExecutionStatus status); +typedef {{typePrefix}}ExecutionStatusBuilder = + Widget Function(BuildContext context, {{typePrefix}}ExecutionStatus status); +typedef {{typePrefix}}ExecutionIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Toggleable tool execution output with lifecycle-driven open requests. +class {{typePrefix}}Execution extends StatefulWidget { + const {{typePrefix}}Execution({ + super.key, + required this.tool, + required this.title, + required this.child, + this.status = {{typePrefix}}ExecutionStatus.running, + this.meta, + this.icon, + this.onCopy, + this.onRetry, + this.copyIconBuilder, + this.retryIconBuilder, + this.indicatorBuilder, + this.statusBuilder, + this.statusLabelBuilder, + this.copyLabel = 'Copy output', + this.retryLabel = 'Retry execution', + this.outputLabel = 'Tool output', + this.showActions = true, + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.semanticLabel = 'Tool execution', + this.surfaceStyle = const CardStyler.create(), + this.disclosureStyle = const DisclosureStyler.create(), + this.copyStyle = const IconButtonStyler.create(), + this.retryStyle = const IconButtonStyler.create(), + this.style = const {{typePrefix}}ExecutionStyler.create(), + this.styleSpec, + }); + + final String tool; + final String title; + final Widget child; + final {{typePrefix}}ExecutionStatus status; + final String? meta; + final Widget? icon; + final VoidCallback? onCopy; + final VoidCallback? onRetry; + final RemixIconButtonIconBuilder? copyIconBuilder; + final RemixIconButtonIconBuilder? retryIconBuilder; + final {{typePrefix}}ExecutionIndicatorBuilder? indicatorBuilder; + final {{typePrefix}}ExecutionStatusBuilder? statusBuilder; + final {{typePrefix}}ExecutionStatusLabelBuilder? statusLabelBuilder; + final String copyLabel; + final String retryLabel; + final String outputLabel; + final bool showActions; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final String semanticLabel; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; + final {{typePrefix}}ExecutionStyler style; + final {{typePrefix}}ExecutionSpec? styleSpec; + + @override + State<{{typePrefix}}Execution> createState() => _{{typePrefix}}ExecutionState(); +} + +class _{{typePrefix}}ExecutionState extends State<{{typePrefix}}Execution> { + late final {{typePrefix}}DisclosureEngine _disclosure; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Execution oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + if (!oldWidget.status.isWorking && widget.status.isWorking) { + _request(true); + } else if (oldWidget.status.isWorking && + !widget.status.isWorking && + widget.collapseOnComplete) { + _request(false); + } + } + + void _request(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String get _statusLabel => + widget.statusLabelBuilder?.call(widget.status) ?? + switch (widget.status) { + {{typePrefix}}ExecutionStatus.running => 'Running', + {{typePrefix}}ExecutionStatus.success => 'Completed', + {{typePrefix}}ExecutionStatus.error => 'Failed', + {{typePrefix}}ExecutionStatus.cancelled => 'Cancelled', + }; + + StyleSpec _statusContainer({{typePrefix}}ExecutionSpec spec) => + switch (widget.status) { + {{typePrefix}}ExecutionStatus.running => spec.runningStatus, + {{typePrefix}}ExecutionStatus.success => spec.successStatus, + {{typePrefix}}ExecutionStatus.error => spec.errorStatus, + {{typePrefix}}ExecutionStatus.cancelled => spec.cancelledStatus, + }; + + {{typePrefix}}FunctionalGlyphKind get _statusGlyph => switch (widget.status) { + {{typePrefix}}ExecutionStatus.running => .loading, + {{typePrefix}}ExecutionStatus.success => .completedCircle, + {{typePrefix}}ExecutionStatus.error => .errorCircle, + {{typePrefix}}ExecutionStatus.cancelled => .cancelledCircle, + }; + + Widget _toolIcon({{typePrefix}}ExecutionSpec spec) { + final icon = widget.icon; + if (icon != null) return icon; + return StyleSpecBuilder( + styleSpec: spec.toolIcon, + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph(kind: .tool, spec: iconSpec), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}ExecutionSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + value: '${widget.tool}, $_statusLabel', + child: RemixCard( + style: widget.surfaceStyle, + child: RemixDisclosure( + expanded: _expanded, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: RowBox( + styleSpec: spec.header, + children: [ + _toolIcon(spec), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StyledText(widget.title, styleSpec: spec.title), + StyledText(widget.tool, styleSpec: spec.tool), + ], + ), + ), + if (widget.meta != null) + StyledText(widget.meta!, styleSpec: spec.meta), + Box( + styleSpec: _statusContainer(spec), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.statusBuilder?.call(context, widget.status) ?? + StyleSpecBuilder( + styleSpec: spec.statusIcon, + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph( + kind: _statusGlyph, + spec: iconSpec, + ), + ), + StyledText(_statusLabel, styleSpec: spec.status), + ], + ), + ), + ], + ), + content: Box( + styleSpec: spec.output, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Deliberately not an {{typePrefix}}Transcript. That installs + // Arrow/Page/Home/End shortcuts and its own Semantics + // container, and an execution card is normally nested inside + // a host transcript: the inner list shrink-wraps to a zero + // scroll extent but its action still consumes those intents, + // so focus landing here stopped the outer transcript from + // scrolling, and its `busy` value announced the status a + // second time. This is the primitive plan and activity use. + Semantics( + label: widget.outputLabel, + child: {{typePrefix}}LiveEdgeScrollView( + followOutput: widget.status.isWorking, + child: widget.child, + ), + ), + if (widget.showActions && widget.status.isSettled) + RowBox( + styleSpec: spec.actions, + children: [ + if (widget.onCopy != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.copyIconBuilder ?? + (context, iconSpec, icon) => + {{typePrefix}}FunctionalGlyph( + kind: .copy, + spec: iconSpec, + ), + semanticLabel: widget.copyLabel, + onPressed: widget.onCopy, + style: widget.copyStyle, + ), + if (widget.onRetry != null) + RemixIconButton( + icon: null, + iconBuilder: + widget.retryIconBuilder ?? + (context, iconSpec, icon) => + {{typePrefix}}FunctionalGlyph( + kind: .retry, + spec: iconSpec, + ), + semanticLabel: widget.retryLabel, + onPressed: widget.onRetry, + style: widget.retryStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Execution.new) +@immutable +final class {{typePrefix}}ExecutionSpec with _${{typePrefix}}ExecutionSpec { + @override + final StyleSpec header; + @override + final StyleSpec output; + @override + final StyleSpec actions; + @override + final StyleSpec tool; + @override + final StyleSpec title; + @override + final StyleSpec meta; + @override + final StyleSpec status; + @override + final StyleSpec toolIcon; + @override + final StyleSpec statusIcon; + @override + final StyleSpec indicator; + @override + final StyleSpec runningStatus; + @override + final StyleSpec successStatus; + @override + final StyleSpec errorStatus; + @override + final StyleSpec cancelledStatus; + + const {{typePrefix}}ExecutionSpec({ + StyleSpec? header, + StyleSpec? output, + StyleSpec? actions, + StyleSpec? tool, + StyleSpec? title, + StyleSpec? meta, + StyleSpec? status, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? runningStatus, + StyleSpec? successStatus, + StyleSpec? errorStatus, + StyleSpec? cancelledStatus, + }) : header = header ?? const StyleSpec(spec: FlexBoxSpec()), + output = output ?? const StyleSpec(spec: BoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + tool = tool ?? const StyleSpec(spec: TextSpec()), + title = title ?? const StyleSpec(spec: TextSpec()), + meta = meta ?? const StyleSpec(spec: TextSpec()), + status = status ?? const StyleSpec(spec: TextSpec()), + toolIcon = toolIcon ?? const StyleSpec(spec: IconSpec()), + statusIcon = statusIcon ?? const StyleSpec(spec: IconSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + runningStatus = runningStatus ?? const StyleSpec(spec: BoxSpec()), + successStatus = successStatus ?? const StyleSpec(spec: BoxSpec()), + errorStatus = errorStatus ?? const StyleSpec(spec: BoxSpec()), + cancelledStatus = cancelledStatus ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/message/message.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/message/message.dart.tmpl new file mode 100644 index 000000000..de5f8c413 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/message/message.dart.tmpl @@ -0,0 +1,389 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; + +part 'message.g.dart'; + +enum {{typePrefix}}MessageAlign { start, end } + +/// Groups chronological message rows without imposing visual chrome. +class {{typePrefix}}MessageGroup extends StatelessWidget { + const {{typePrefix}}MessageGroup({ + super.key, + required this.children, + this.spacing = 0, + }); + + final List children; + final double spacing; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + spacing: spacing, + children: children, + ); +} + +/// Sender-aware message row. Message bodies are never clamped automatically. +class {{typePrefix}}Message extends StatelessWidget { + const {{typePrefix}}Message({ + super.key, + required this.role, + required this.child, + this.align, + this.avatar, + this.showAvatar = false, + this.placeholderAvatar = false, + this.maxWidth, + this.header, + this.footer, + this.semanticLabel, + this.surfaceStyle = const CardStyler.create(), + this.style = const {{typePrefix}}MessageStyler.create(), + this.styleSpec, + }); + + final {{typePrefix}}Role role; + final Widget child; + final {{typePrefix}}MessageAlign? align; + final Widget? avatar; + final bool showAvatar; + final bool placeholderAvatar; + final double? maxWidth; + final Widget? header; + final Widget? footer; + final String? semanticLabel; + final CardStyler surfaceStyle; + final {{typePrefix}}MessageStyler style; + final {{typePrefix}}MessageSpec? styleSpec; + + bool get _alignEnd => + (align ?? + (role == {{typePrefix}}Role.user + ? {{typePrefix}}MessageAlign.end + : {{typePrefix}}MessageAlign.start)) == + {{typePrefix}}MessageAlign.end; + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}MessageSpec>( + style: style, + styleSpec: styleSpec, + builder: (context, spec) { + final body = RemixCard( + style: surfaceStyle, + child: Box(styleSpec: spec.body, child: child), + ); + final cap = maxWidth ?? spec.maxWidth; + final constrained = cap == null + ? body + : ConstrainedBox( + constraints: BoxConstraints(maxWidth: cap), + child: body, + ); + final stack = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: _alignEnd + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (header != null) Box(styleSpec: spec.header, child: header), + constrained, + if (footer != null) Box(styleSpec: spec.footer, child: footer), + ], + ); + final avatarSlot = _avatarSlot(spec); + final row = RowBox( + styleSpec: spec.row, + children: [ + if (!_alignEnd && avatarSlot != null) avatarSlot, + Expanded( + child: Align( + alignment: _alignEnd + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: stack, + ), + ), + if (_alignEnd && avatarSlot != null) avatarSlot, + ], + ); + return Semantics( + container: true, + explicitChildNodes: true, + label: + semanticLabel ?? + (role == {{typePrefix}}Role.user ? 'User message' : 'Assistant message'), + child: row, + ); + }, + ); + } + + Widget? _avatarSlot({{typePrefix}}MessageSpec spec) { + if (placeholderAvatar) return Box(styleSpec: spec.avatar); + if (!showAvatar || avatar == null) return null; + return Box(styleSpec: spec.avatar, child: avatar); + } +} + +/// Explicit, opt-in clipping for noninteractive message copy. +/// +/// Do not place buttons, links, or other interactive descendants in [child]. +/// While collapsed, the whole child remains readable to assistive technology +/// but is removed from pointer input, focus, and traversal. +class {{typePrefix}}MessageCollapsible extends StatefulWidget { + const {{typePrefix}}MessageCollapsible({ + super.key, + required this.child, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.showMoreLabel = 'Show more', + this.showLessLabel = 'Show less', + this.toggleStyle = const ButtonStyler.create(), + this.style = const {{typePrefix}}MessageCollapsibleStyler.create(), + this.styleSpec, + }); + + final Widget child; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final String showMoreLabel; + final String showLessLabel; + final ButtonStyler toggleStyle; + final {{typePrefix}}MessageCollapsibleStyler style; + final {{typePrefix}}MessageCollapsibleSpec? styleSpec; + + @override + State<{{typePrefix}}MessageCollapsible> createState() => + _{{typePrefix}}MessageCollapsibleState(); +} + +class _{{typePrefix}}MessageCollapsibleState extends State<{{typePrefix}}MessageCollapsible> { + late final {{typePrefix}}DisclosureEngine _disclosure; + bool _overflows = false; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}MessageCollapsible oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + } + + void _toggle() { + final next = !_expanded; + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + void _handleOverflow(bool value) { + if (value == _overflows) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && value != _overflows) setState(() => _overflows = value); + }); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}MessageCollapsibleSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) { + final height = spec.collapsedHeight; + final collapsed = !_expanded && height != null; + Widget content = _OverflowClip( + maxHeight: height, + clip: collapsed, + onOverflowChanged: _handleOverflow, + child: Box(styleSpec: spec.clipped, child: widget.child), + ); + if (collapsed) { + content = IgnorePointer( + child: Focus( + canRequestFocus: false, + skipTraversal: true, + descendantsAreFocusable: false, + descendantsAreTraversable: false, + child: content, + ), + ); + } + return Box( + styleSpec: spec.container, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + if (_overflows) + RemixButton( + label: _expanded + ? widget.showLessLabel + : widget.showMoreLabel, + semanticLabel: _expanded + ? widget.showLessLabel + : widget.showMoreLabel, + onPressed: _toggle, + style: widget.toggleStyle, + ), + ], + ), + ); + }, + ); + } +} + +class _OverflowClip extends SingleChildRenderObjectWidget { + const _OverflowClip({ + required this.maxHeight, + required this.clip, + required this.onOverflowChanged, + required super.child, + }); + + final double? maxHeight; + final bool clip; + final ValueChanged onOverflowChanged; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderOverflowClip(maxHeight, clip, onOverflowChanged); + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderOverflowClip renderObject, + ) { + renderObject + ..maxHeight = maxHeight + ..clip = clip + ..onOverflowChanged = onOverflowChanged; + } +} + +class _RenderOverflowClip extends RenderProxyBox { + _RenderOverflowClip(this._maxHeight, this._clip, this.onOverflowChanged); + + double? _maxHeight; + bool _clip; + ValueChanged onOverflowChanged; + bool _reportedOverflow = false; + + set maxHeight(double? value) { + if (value == _maxHeight) return; + _maxHeight = value; + markNeedsLayout(); + } + + set clip(bool value) { + if (value == _clip) return; + _clip = value; + markNeedsLayout(); + } + + @override + void performLayout() { + final current = child; + if (current == null) { + size = constraints.smallest; + return; + } + current.layout( + constraints.copyWith(minHeight: 0, maxHeight: double.infinity), + parentUsesSize: true, + ); + final limit = _maxHeight; + final overflow = limit != null && current.size.height > limit; + size = constraints.constrain( + Size(current.size.width, _clip && overflow ? limit : current.size.height), + ); + if (overflow != _reportedOverflow) { + _reportedOverflow = overflow; + onOverflowChanged(overflow); + } + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) return; + if (!_clip) { + super.paint(context, offset); + return; + } + // pushClipRect applies the paint offset to this local rectangle. + context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + super.paint, + ); + } +} + +@MixableSpec(target: {{typePrefix}}Message.new) +@immutable +final class {{typePrefix}}MessageSpec with _${{typePrefix}}MessageSpec { + @override + final double? maxWidth; + @override + final StyleSpec row; + @override + final StyleSpec avatar; + @override + final StyleSpec header; + @override + final StyleSpec body; + @override + final StyleSpec footer; + + const {{typePrefix}}MessageSpec({ + this.maxWidth, + StyleSpec? row, + StyleSpec? avatar, + StyleSpec? header, + StyleSpec? body, + StyleSpec? footer, + }) : row = row ?? const StyleSpec(spec: FlexBoxSpec()), + avatar = avatar ?? const StyleSpec(spec: BoxSpec()), + header = header ?? const StyleSpec(spec: BoxSpec()), + body = body ?? const StyleSpec(spec: BoxSpec()), + footer = footer ?? const StyleSpec(spec: BoxSpec()); +} + +@MixableSpec(target: {{typePrefix}}MessageCollapsible.new) +@immutable +final class {{typePrefix}}MessageCollapsibleSpec with _${{typePrefix}}MessageCollapsibleSpec { + @override + final double? collapsedHeight; + @override + final StyleSpec container; + @override + final StyleSpec clipped; + + const {{typePrefix}}MessageCollapsibleSpec({ + this.collapsedHeight, + StyleSpec? container, + StyleSpec? clipped, + }) : container = container ?? const StyleSpec(spec: BoxSpec()), + clipped = clipped ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/models/activity_item.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/models/activity_item.dart.tmpl new file mode 100644 index 000000000..6d9fb02b4 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/models/activity_item.dart.tmpl @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; + +import 'statuses.dart'; + +/// One row in an [{{typePrefix}}Activity] ledger. +@immutable +class {{typePrefix}}ActivityItem { + /// Creates an activity row. + const {{typePrefix}}ActivityItem({ + required this.id, + required this.title, + this.status = {{typePrefix}}ActivityItemStatus.pending, + this.detail, + this.child, + }); + + /// Stable identity across list updates. + final String id; + + /// Visible title. + final String title; + + /// Current status. + final {{typePrefix}}ActivityItemStatus status; + + /// Optional compact detail rendered with the activity detail style slot. + final String? detail; + + /// Optional host-rendered detail. The catalog does not parse this child. + final Widget? child; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is {{typePrefix}}ActivityItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail && + identical(other.child, child); + + @override + int get hashCode => Object.hash( + runtimeType, + id, + title, + status, + detail, + identityHashCode(child), + ); + + @override + String toString() => + '{{typePrefix}}ActivityItem(id: $id, title: $title, status: $status, detail: $detail, child: $child)'; +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/models/plan_item.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/models/plan_item.dart.tmpl new file mode 100644 index 000000000..da6c48131 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/models/plan_item.dart.tmpl @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; + +import 'statuses.dart'; + +/// One row in an [{{typePrefix}}Plan]. +@immutable +class {{typePrefix}}PlanItem { + /// Creates a plan item. + const {{typePrefix}}PlanItem({ + required this.id, + required this.title, + this.status = {{typePrefix}}PlanItemStatus.pending, + this.detail, + }); + + /// Stable identity across list updates. + final String id; + + /// Visible title. + final String title; + + /// Current status. + final {{typePrefix}}PlanItemStatus status; + + /// Optional compact metadata (elapsed time, percent, path). + final String? detail; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is {{typePrefix}}PlanItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail; + + @override + int get hashCode => Object.hash(runtimeType, id, title, status, detail); + + @override + String toString() => + '{{typePrefix}}PlanItem(id: $id, title: $title, status: $status, detail: $detail)'; +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/models/statuses.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/models/statuses.dart.tmpl new file mode 100644 index 000000000..f77d3b457 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/models/statuses.dart.tmpl @@ -0,0 +1,146 @@ +/// Status of a long-running turn or activity ledger. +enum {{typePrefix}}RunStatus { + /// Work is in progress. Disclosures stay open. + working, + + /// Work finished. Disclosures may collapse. + complete, +} + +/// Status of a streamed answer. +enum {{typePrefix}}AnswerStatus { + /// Tokens are still arriving. + streaming, + + /// The answer finished successfully. + complete, + + /// The answer failed. + error, +} + +/// Status of an in-transcript tool permission. +/// +/// This is a machine, not a boolean loading flag. Actions are offered only +/// while [pending]. +enum {{typePrefix}}PermissionStatus { + /// Waiting for a human decision. + pending, + + /// A decision was submitted and is being recorded. + deciding, + + /// The host accepted this invocation. + allowed, + + /// The approved tool is executing. + running, + + /// The approved tool finished. + complete, + + /// The host refused this invocation. + denied, + + /// Permission or execution failed. + error, +} + +/// Status of a tool execution disclosure. +enum {{typePrefix}}ExecutionStatus { + /// Output is still arriving. + running, + + /// The tool finished successfully. + success, + + /// The tool failed. + error, + + /// The host or runtime cancelled the tool. + cancelled, +} + +/// Status of one item in a task plan. +enum {{typePrefix}}PlanItemStatus { + /// Not started. + pending, + + /// Currently underway. + inProgress, + + /// Finished successfully. + completed, + + /// Abandoned or skipped. + cancelled, +} + +/// Status of one row in an activity ledger. +enum {{typePrefix}}ActivityItemStatus { + /// Not yet started. + pending, + + /// The current step. + active, + + /// Finished. + complete, +} + +/// Who authored a transcript row. +enum {{typePrefix}}Role { + /// The human operator. + user, + + /// The assistant replying to the operator. + assistant, +} + +/// Whether a permission or execution is still occupying the operator. +extension {{typePrefix}}PermissionStatusX on {{typePrefix}}PermissionStatus { + /// True until a terminal outcome. [pending] is working (HITL in flight) + /// but does not keep parameter details open. + bool get isWorking => !isSettled; + + /// True after a terminal decision or outcome. + bool get isSettled => + this == {{typePrefix}}PermissionStatus.complete || + this == {{typePrefix}}PermissionStatus.denied || + this == {{typePrefix}}PermissionStatus.error; + + /// True while parameter details stay open without a user toggle. + /// Pending starts closed. + bool get keepsDetailsOpen => + this == {{typePrefix}}PermissionStatus.deciding || + this == {{typePrefix}}PermissionStatus.allowed || + this == {{typePrefix}}PermissionStatus.running; +} + +/// Working vs settled for an execution disclosure. +extension {{typePrefix}}ExecutionStatusX on {{typePrefix}}ExecutionStatus { + /// True while output should stay expanded. + bool get isWorking => this == {{typePrefix}}ExecutionStatus.running; + + /// True after a terminal outcome. + bool get isSettled => !isWorking; +} + +/// Working vs settled for a streamed answer. +extension {{typePrefix}}AnswerStatusX on {{typePrefix}}AnswerStatus { + /// True while tokens are still arriving. + bool get isStreaming => this == {{typePrefix}}AnswerStatus.streaming; + + /// True when completion actions may appear. + bool get showsActions => + this == {{typePrefix}}AnswerStatus.complete || this == {{typePrefix}}AnswerStatus.error; +} + +/// Working vs settled for a plan item. +extension {{typePrefix}}PlanItemStatusX on {{typePrefix}}PlanItemStatus { + bool get isActive => this == {{typePrefix}}PlanItemStatus.inProgress; + + bool get isDone => + this == {{typePrefix}}PlanItemStatus.completed || + this == {{typePrefix}}PlanItemStatus.cancelled; +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/permission/permission.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/permission/permission.dart.tmpl new file mode 100644 index 000000000..b78cad117 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/permission/permission.dart.tmpl @@ -0,0 +1,386 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; + +part 'permission.g.dart'; + +typedef {{typePrefix}}PermissionStatusLabelBuilder = + String Function({{typePrefix}}PermissionStatus status); +typedef {{typePrefix}}PermissionStatusBuilder = + Widget Function(BuildContext context, {{typePrefix}}PermissionStatus status); +typedef {{typePrefix}}PermissionIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// In-transcript permission request composed from Remix controls. +class {{typePrefix}}Permission extends StatefulWidget { + const {{typePrefix}}Permission({ + super.key, + required this.tool, + this.requestId, + this.title = 'Allow this tool to run?', + this.description, + this.status = {{typePrefix}}PermissionStatus.pending, + this.parameters = const [], + this.showParameters = true, + this.detailsExpanded, + this.defaultDetailsExpanded = false, + this.onDetailsExpandedChanged, + this.onAllowOnce, + this.onAlwaysAllow, + this.onDeny, + this.statusLabelBuilder, + this.statusBuilder, + this.indicatorBuilder, + this.allowOnceLabel = 'Allow once', + this.alwaysAllowLabel = 'Always allow', + this.denyLabel = 'Deny', + this.detailsLabel = 'View details', + this.semanticLabel = 'Tool permission', + this.parameterOrientation = Axis.horizontal, + this.surfaceStyle = const CardStyler.create(), + this.detailsStyle = const DisclosureStyler.create(), + this.parametersStyle = const DataListStyler.create(), + this.allowOnceStyle = const ButtonStyler.create(), + this.alwaysAllowStyle = const ButtonStyler.create(), + this.denyStyle = const ButtonStyler.create(), + this.style = const {{typePrefix}}PermissionStyler.create(), + this.styleSpec, + }); + + final Object? requestId; + final String tool; + final String title; + final String? description; + final {{typePrefix}}PermissionStatus status; + final List parameters; + final bool showParameters; + final bool? detailsExpanded; + final bool defaultDetailsExpanded; + final ValueChanged? onDetailsExpandedChanged; + final VoidCallback? onAllowOnce; + final VoidCallback? onAlwaysAllow; + final VoidCallback? onDeny; + final {{typePrefix}}PermissionStatusLabelBuilder? statusLabelBuilder; + final {{typePrefix}}PermissionStatusBuilder? statusBuilder; + final {{typePrefix}}PermissionIndicatorBuilder? indicatorBuilder; + final String allowOnceLabel; + final String alwaysAllowLabel; + final String denyLabel; + final String detailsLabel; + final String semanticLabel; + final Axis parameterOrientation; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; + final {{typePrefix}}PermissionStyler style; + final {{typePrefix}}PermissionSpec? styleSpec; + + @override + State<{{typePrefix}}Permission> createState() => _{{typePrefix}}PermissionState(); +} + +class _{{typePrefix}}PermissionState extends State<{{typePrefix}}Permission> { + late final {{typePrefix}}DisclosureEngine _disclosure; + bool _decisionSubmitted = false; + + bool get _detailsExpanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.detailsExpanded, + defaultValue: + widget.status.keepsDetailsOpen || widget.defaultDetailsExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Permission oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.detailsExpanded); + final returnedToPending = + oldWidget.status != {{typePrefix}}PermissionStatus.pending && + widget.status == {{typePrefix}}PermissionStatus.pending; + final newPendingRequest = + oldWidget.requestId != widget.requestId && + widget.status == {{typePrefix}}PermissionStatus.pending; + if (returnedToPending || newPendingRequest) _decisionSubmitted = false; + + if (!oldWidget.status.keepsDetailsOpen && widget.status.keepsDetailsOpen) { + _requestDetails(true); + } else if (!oldWidget.status.isSettled && widget.status.isSettled) { + _requestDetails(false); + } + } + + void _requestDetails(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onDetailsExpandedChanged?.call(next); + } + + void _submit(VoidCallback? callback) { + if (_decisionSubmitted || + widget.status != {{typePrefix}}PermissionStatus.pending || + callback == null) { + return; + } + setState(() => _decisionSubmitted = true); + callback(); + } + + String get _statusLabel => + widget.statusLabelBuilder?.call(widget.status) ?? + switch (widget.status) { + {{typePrefix}}PermissionStatus.pending => 'Permission required', + {{typePrefix}}PermissionStatus.deciding => 'Recording', + {{typePrefix}}PermissionStatus.allowed => 'Allowed', + {{typePrefix}}PermissionStatus.running => 'Running', + {{typePrefix}}PermissionStatus.complete => 'Complete', + {{typePrefix}}PermissionStatus.denied => 'Denied', + {{typePrefix}}PermissionStatus.error => 'Error', + }; + + {{typePrefix}}FunctionalGlyphKind get _statusGlyph => switch (widget.status) { + {{typePrefix}}PermissionStatus.pending => .permission, + {{typePrefix}}PermissionStatus.deciding => .loading, + {{typePrefix}}PermissionStatus.allowed => .completed, + {{typePrefix}}PermissionStatus.running => .loading, + {{typePrefix}}PermissionStatus.complete => .completed, + {{typePrefix}}PermissionStatus.denied => .cancelled, + {{typePrefix}}PermissionStatus.error => .error, + }; + + StyleSpec _statusContainer({{typePrefix}}PermissionSpec spec) => + switch (widget.status) { + {{typePrefix}}PermissionStatus.pending => spec.pendingStatus, + {{typePrefix}}PermissionStatus.deciding => spec.decidingStatus, + {{typePrefix}}PermissionStatus.allowed => spec.allowedStatus, + {{typePrefix}}PermissionStatus.running => spec.runningStatus, + {{typePrefix}}PermissionStatus.complete => spec.completedStatus, + {{typePrefix}}PermissionStatus.denied => spec.deniedStatus, + {{typePrefix}}PermissionStatus.error => spec.errorStatus, + }; + + // Horizontal by default; callers may stack actions without losing the + // action slot's box, modifiers, or nested style resolution. + StyleSpec _actionsStyle({{typePrefix}}PermissionSpec spec) { + final actions = spec.actions.spec; + final flex = actions.flex ?? const StyleSpec(spec: FlexSpec()); + return spec.actions.copyWith( + spec: actions.copyWith( + flex: flex.copyWith( + spec: flex.spec.copyWith( + direction: flex.spec.direction ?? Axis.horizontal, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}PermissionSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixCard( + style: widget.surfaceStyle, + child: Box( + styleSpec: spec.content, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RowBox( + styleSpec: spec.header, + children: [ + StyleSpecBuilder( + styleSpec: spec.toolIcon, + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph(kind: .tool, spec: iconSpec), + ), + Expanded( + child: StyledText(widget.title, styleSpec: spec.title), + ), + ], + ), + StyledText(widget.tool, styleSpec: spec.tool), + if (widget.description != null) + StyledText(widget.description!, styleSpec: spec.description), + Box( + styleSpec: _statusContainer(spec), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + widget.statusBuilder?.call(context, widget.status) ?? + StyleSpecBuilder( + styleSpec: spec.statusIcon, + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph( + kind: _statusGlyph, + spec: iconSpec, + ), + ), + Flexible( + child: StyledText(_statusLabel, styleSpec: spec.status), + ), + ], + ), + ), + if (widget.showParameters && widget.parameters.isNotEmpty) + RemixDisclosure( + expanded: _detailsExpanded, + onExpandedChanged: _requestDetails, + semanticLabel: widget.detailsLabel, + style: widget.detailsStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: StyledText( + widget.detailsLabel, + styleSpec: spec.detailsLabel, + ), + content: RemixDataList( + items: widget.parameters, + orientation: widget.parameterOrientation, + style: widget.parametersStyle, + ), + ), + if (widget.status == {{typePrefix}}PermissionStatus.pending) + FlexBox( + styleSpec: _actionsStyle(spec), + children: [ + RemixButton( + key: const ValueKey('{{valuePrefix}}-permission-allow-once'), + label: widget.allowOnceLabel, + enabled: !_decisionSubmitted, + onPressed: widget.onAllowOnce == null + ? null + : () => _submit(widget.onAllowOnce), + style: widget.allowOnceStyle, + ), + if (widget.onAlwaysAllow != null) + RemixButton( + key: const ValueKey('{{valuePrefix}}-permission-always-allow'), + label: widget.alwaysAllowLabel, + enabled: !_decisionSubmitted, + onPressed: () => _submit(widget.onAlwaysAllow), + style: widget.alwaysAllowStyle, + ), + RemixButton( + key: const ValueKey('{{valuePrefix}}-permission-deny'), + label: widget.denyLabel, + enabled: !_decisionSubmitted, + onPressed: widget.onDeny == null + ? null + : () => _submit(widget.onDeny), + style: widget.denyStyle, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Permission.new) +@immutable +final class {{typePrefix}}PermissionSpec with _${{typePrefix}}PermissionSpec { + @override + final StyleSpec content; + @override + final StyleSpec header; + @override + final StyleSpec actions; + @override + final StyleSpec title; + @override + final StyleSpec tool; + @override + final StyleSpec description; + @override + final StyleSpec status; + @override + final StyleSpec detailsLabel; + @override + final StyleSpec toolIcon; + @override + final StyleSpec statusIcon; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec decidingStatus; + @override + final StyleSpec allowedStatus; + @override + final StyleSpec runningStatus; + @override + final StyleSpec completedStatus; + @override + final StyleSpec deniedStatus; + @override + final StyleSpec errorStatus; + + const {{typePrefix}}PermissionSpec({ + StyleSpec? content, + StyleSpec? header, + StyleSpec? actions, + StyleSpec? title, + StyleSpec? tool, + StyleSpec? description, + StyleSpec? status, + StyleSpec? detailsLabel, + StyleSpec? toolIcon, + StyleSpec? statusIcon, + StyleSpec? indicator, + StyleSpec? pendingStatus, + StyleSpec? decidingStatus, + StyleSpec? allowedStatus, + StyleSpec? runningStatus, + StyleSpec? completedStatus, + StyleSpec? deniedStatus, + StyleSpec? errorStatus, + }) : content = content ?? const StyleSpec(spec: BoxSpec()), + header = header ?? const StyleSpec(spec: FlexBoxSpec()), + actions = actions ?? const StyleSpec(spec: FlexBoxSpec()), + title = title ?? const StyleSpec(spec: TextSpec()), + tool = tool ?? const StyleSpec(spec: TextSpec()), + description = description ?? const StyleSpec(spec: TextSpec()), + status = status ?? const StyleSpec(spec: TextSpec()), + detailsLabel = detailsLabel ?? const StyleSpec(spec: TextSpec()), + toolIcon = toolIcon ?? const StyleSpec(spec: IconSpec()), + statusIcon = statusIcon ?? const StyleSpec(spec: IconSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: BoxSpec()), + decidingStatus = decidingStatus ?? const StyleSpec(spec: BoxSpec()), + allowedStatus = allowedStatus ?? const StyleSpec(spec: BoxSpec()), + runningStatus = runningStatus ?? const StyleSpec(spec: BoxSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: BoxSpec()), + deniedStatus = deniedStatus ?? const StyleSpec(spec: BoxSpec()), + errorStatus = errorStatus ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/plan/plan.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/plan/plan.dart.tmpl new file mode 100644 index 000000000..ab48a5f79 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/plan/plan.dart.tmpl @@ -0,0 +1,303 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../models/plan_item.dart'; +import '../models/statuses.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; + +part 'plan.g.dart'; + +typedef {{typePrefix}}PlanStatusBuilder = + Widget Function(BuildContext context, {{typePrefix}}PlanItem item); +typedef {{typePrefix}}PlanStatusLabelBuilder = String Function({{typePrefix}}PlanItem item); +typedef {{typePrefix}}PlanIndicatorBuilder = + Widget Function(BuildContext context, bool expanded); + +/// Toggleable task plan with lifecycle-aware uncontrolled disclosure state. +class {{typePrefix}}Plan extends StatefulWidget { + const {{typePrefix}}Plan({ + super.key, + required this.items, + this.title = 'Plan', + this.emptyLabel = 'No tasks yet', + this.semanticLabel = 'Task plan', + this.collapseOnComplete = true, + this.expanded, + this.defaultExpanded = true, + this.onExpandedChanged, + this.statusBuilder, + this.statusLabelBuilder, + this.indicatorBuilder, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + this.disclosureStyle = const DisclosureStyler.create(), + this.style = const {{typePrefix}}PlanStyler.create(), + this.styleSpec, + }); + + final List<{{typePrefix}}PlanItem> items; + final String title; + final String emptyLabel; + final String semanticLabel; + final bool collapseOnComplete; + final bool? expanded; + final bool defaultExpanded; + final ValueChanged? onExpandedChanged; + final {{typePrefix}}PlanStatusBuilder? statusBuilder; + final {{typePrefix}}PlanStatusLabelBuilder? statusLabelBuilder; + final {{typePrefix}}PlanIndicatorBuilder? indicatorBuilder; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + final DisclosureStyler disclosureStyle; + final {{typePrefix}}PlanStyler style; + final {{typePrefix}}PlanSpec? styleSpec; + + int get settledCount => items.where((item) => item.status.isDone).length; + bool get isWorking => items.any((item) => !item.status.isDone); + + @override + State<{{typePrefix}}Plan> createState() => _{{typePrefix}}PlanState(); +} + +class _{{typePrefix}}PlanState extends State<{{typePrefix}}Plan> { + late final {{typePrefix}}DisclosureEngine _disclosure; + + bool get _expanded => _disclosure.value; + + @override + void initState() { + super.initState(); + _disclosure = {{typePrefix}}DisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); + } + + @override + void didUpdateWidget({{typePrefix}}Plan oldWidget) { + super.didUpdateWidget(oldWidget); + _disclosure.reconcile(widget.expanded); + final wasWorking = oldWidget.isWorking; + final working = widget.isWorking; + if (wasWorking && !working && widget.collapseOnComplete) { + _request(false); + } else if (!wasWorking && working) { + _request(true); + } + } + + void _request(bool next) { + if (_disclosure.request(next)) setState(() {}); + widget.onExpandedChanged?.call(next); + } + + String _statusLabel({{typePrefix}}PlanItem item) => + widget.statusLabelBuilder?.call(item) ?? + switch (item.status) { + {{typePrefix}}PlanItemStatus.pending => 'Pending', + {{typePrefix}}PlanItemStatus.inProgress => 'In progress', + {{typePrefix}}PlanItemStatus.completed => 'Completed', + {{typePrefix}}PlanItemStatus.cancelled => 'Cancelled', + }; + + {{typePrefix}}FunctionalGlyphKind _statusGlyph({{typePrefix}}PlanItemStatus status) => + switch (status) { + {{typePrefix}}PlanItemStatus.pending => .pending, + {{typePrefix}}PlanItemStatus.inProgress => .active, + {{typePrefix}}PlanItemStatus.completed => .completed, + {{typePrefix}}PlanItemStatus.cancelled => .cancelled, + }; + + StyleSpec _statusContainer( + {{typePrefix}}PlanSpec spec, + {{typePrefix}}PlanItemStatus status, + ) => switch (status) { + {{typePrefix}}PlanItemStatus.pending => spec.pendingItem, + {{typePrefix}}PlanItemStatus.inProgress => spec.activeItem, + {{typePrefix}}PlanItemStatus.completed => spec.completedItem, + {{typePrefix}}PlanItemStatus.cancelled => spec.cancelledItem, + }; + + StyleSpec _statusStyle( + {{typePrefix}}PlanSpec spec, + {{typePrefix}}PlanItemStatus status, + ) => switch (status) { + {{typePrefix}}PlanItemStatus.pending => spec.pendingStatus, + {{typePrefix}}PlanItemStatus.inProgress => spec.activeStatus, + {{typePrefix}}PlanItemStatus.completed => spec.completedStatus, + {{typePrefix}}PlanItemStatus.cancelled => spec.cancelledStatus, + }; + + Widget _defaultStatus( + BuildContext context, + {{typePrefix}}PlanSpec spec, + {{typePrefix}}PlanItem item, + ) { + return StyleSpecBuilder( + styleSpec: _statusStyle(spec, item.status), + builder: (context, iconSpec) => + {{typePrefix}}FunctionalGlyph(kind: _statusGlyph(item.status), spec: iconSpec), + ); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}PlanSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: RemixDisclosure( + expanded: _expanded, + onExpandedChanged: _request, + semanticLabel: widget.title, + style: widget.disclosureStyle, + triggerBuilder: (context, state, trigger) => Row( + children: [ + Expanded(child: trigger!), + {{typePrefix}}DisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), + ], + ), + trigger: Row( + children: [ + Expanded( + child: StyledText(widget.title, styleSpec: spec.summaryTitle), + ), + StyledText( + '${widget.settledCount}/${widget.items.length}', + styleSpec: spec.count, + ), + ], + ), + content: Box( + styleSpec: spec.viewport, + child: widget.items.isEmpty + ? StyledText(widget.emptyLabel, styleSpec: spec.itemDetail) + : {{typePrefix}}LiveEdgeScrollView( + followOutput: widget.followOutput, + followThreshold: widget.followThreshold, + onFollowChanged: widget.onFollowChanged, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in widget.items) + Semantics( + container: true, + excludeSemantics: true, + label: [ + item.title, + if (item.detail != null) item.detail!, + _statusLabel(item), + ].join(', '), + child: Box( + styleSpec: _statusContainer(spec, item.status), + child: RowBox( + key: ValueKey('{{valuePrefix}}-plan-item-${item.id}'), + styleSpec: spec.item, + children: [ + widget.statusBuilder?.call(context, item) ?? + _defaultStatus(context, spec, item), + Expanded( + child: StyledText( + item.title, + styleSpec: spec.itemTitle, + ), + ), + if (item.detail != null) + StyledText( + item.detail!, + styleSpec: spec.itemDetail, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +@MixableSpec(target: {{typePrefix}}Plan.new) +@immutable +final class {{typePrefix}}PlanSpec with _${{typePrefix}}PlanSpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final StyleSpec summaryTitle; + @override + final StyleSpec itemTitle; + @override + final StyleSpec itemDetail; + @override + final StyleSpec count; + @override + final StyleSpec indicator; + @override + final StyleSpec pendingItem; + @override + final StyleSpec activeItem; + @override + final StyleSpec completedItem; + @override + final StyleSpec cancelledItem; + @override + final StyleSpec pendingStatus; + @override + final StyleSpec activeStatus; + @override + final StyleSpec completedStatus; + @override + final StyleSpec cancelledStatus; + + const {{typePrefix}}PlanSpec({ + StyleSpec? viewport, + StyleSpec? item, + StyleSpec? summaryTitle, + StyleSpec? itemTitle, + StyleSpec? itemDetail, + StyleSpec? count, + StyleSpec? indicator, + StyleSpec? pendingItem, + StyleSpec? activeItem, + StyleSpec? completedItem, + StyleSpec? cancelledItem, + StyleSpec? pendingStatus, + StyleSpec? activeStatus, + StyleSpec? completedStatus, + StyleSpec? cancelledStatus, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: FlexBoxSpec()), + summaryTitle = summaryTitle ?? const StyleSpec(spec: TextSpec()), + itemTitle = itemTitle ?? const StyleSpec(spec: TextSpec()), + itemDetail = itemDetail ?? const StyleSpec(spec: TextSpec()), + count = count ?? const StyleSpec(spec: TextSpec()), + indicator = indicator ?? const StyleSpec(spec: IconSpec()), + pendingItem = pendingItem ?? const StyleSpec(spec: BoxSpec()), + activeItem = activeItem ?? const StyleSpec(spec: BoxSpec()), + completedItem = completedItem ?? const StyleSpec(spec: BoxSpec()), + cancelledItem = cancelledItem ?? const StyleSpec(spec: BoxSpec()), + pendingStatus = pendingStatus ?? const StyleSpec(spec: IconSpec()), + activeStatus = activeStatus ?? const StyleSpec(spec: IconSpec()), + completedStatus = completedStatus ?? const StyleSpec(spec: IconSpec()), + cancelledStatus = cancelledStatus ?? const StyleSpec(spec: IconSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/support/disclosure.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/support/disclosure.dart.tmpl new file mode 100644 index 000000000..eb7c44b5a --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/support/disclosure.dart.tmpl @@ -0,0 +1,29 @@ +/// Controlled/uncontrolled storage shared by collapsible surfaces. +/// +/// Widgets own lifecycle policy, rebuilding, and request callbacks. In +/// particular, a request that does not change storage may still notify a host. +class {{typePrefix}}DisclosureEngine { + {{typePrefix}}DisclosureEngine({required bool? value, required bool defaultValue}) + : _controlled = value, + _uncontrolled = value ?? defaultValue; + + bool? _controlled; + bool _uncontrolled; + + bool get value => _controlled ?? _uncontrolled; + + /// Adopt the last controlled value when the host releases control. + void reconcile(bool? value) { + if (_controlled != null && value == null) { + _uncontrolled = _controlled!; + } + _controlled = value; + } + + /// Returns whether local storage changed and the widget needs a rebuild. + bool request(bool next) { + if (_controlled != null || next == _uncontrolled) return false; + _uncontrolled = next; + return true; + } +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/support/functional_glyph.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/support/functional_glyph.dart.tmpl new file mode 100644 index 000000000..277b3b4a3 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/support/functional_glyph.dart.tmpl @@ -0,0 +1,182 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import 'package:remix_ui_icons/remix_ui_icons.dart'; + +abstract final class _Glyphs { + static const arrowUp = RemixIcons.arrowUp; + static const square = RemixIcons.square; + static const copy = RemixIcons.copy; + static const rotateCcw = RemixIcons.reload; + static const chevronUp = RemixIcons.chevronUp; + static const chevronDown = RemixIcons.chevronDown; + static const circle = RemixIcons.circle; + static const circleDot = RemixIcons.dotFilled; + static const check = RemixIcons.check; + static const x = RemixIcons.cross2; + static const circleAlert = RemixIcons.exclamationTriangle; + static const squareTerminal = RemixIcons.code; + static const loaderCircle = RemixIcons.update; + static const circleCheck = RemixIcons.checkCircled; + static const ban = RemixIcons.circleBackslash; + static const circleX = RemixIcons.crossCircled; + static const shieldCheck = RemixIcons.lockClosed; +} + +/// Builds the chevron that reports a collapsible surface's state. +/// +/// Every collapsible {{typePrefix}} surface offers the host the same escape hatch — a +/// builder that replaces the glyph outright — over the same default. Each takes +/// that builder under its own name, because a permission card discloses +/// *details* and an answer discloses *sources*, so the shared part is this +/// body and not the parameter. +class {{typePrefix}}DisclosureIndicator extends StatelessWidget { + const {{typePrefix}}DisclosureIndicator({ + super.key, + required this.styleSpec, + required this.expanded, + this.builder, + }); + + final StyleSpec styleSpec; + final bool expanded; + final Widget Function(BuildContext context, bool expanded)? builder; + + @override + Widget build(BuildContext context) => + builder?.call(context, expanded) ?? + StyleSpecBuilder( + styleSpec: styleSpec, + builder: (context, iconSpec) => {{typePrefix}}FunctionalGlyph( + kind: .chevron, + spec: iconSpec, + expanded: expanded, + ), + ); +} + +/// Internal Material-free glyph set used by {{typePrefix}}'s functional defaults. +/// +/// The types are intentionally not exported from the package barrel. Public +/// icon/status builders remain the replacement mechanism. +enum {{typePrefix}}FunctionalGlyphKind { + send, + stop, + copy, + retry, + chevron, + pending, + active, + completed, + cancelled, + error, + tool, + loading, + completedCircle, + cancelledCircle, + errorCircle, + permission, +} + +class {{typePrefix}}FunctionalGlyph extends StatelessWidget { + const {{typePrefix}}FunctionalGlyph({ + super.key, + required this.kind, + required this.spec, + this.expanded = false, + }); + + final {{typePrefix}}FunctionalGlyphKind kind; + final IconSpec spec; + final bool expanded; + + IconData get _icon => switch (kind) { + .send => _Glyphs.arrowUp, + .stop => _Glyphs.square, + .copy => _Glyphs.copy, + .retry => _Glyphs.rotateCcw, + .chevron => expanded ? _Glyphs.chevronUp : _Glyphs.chevronDown, + .pending => _Glyphs.circle, + .active => _Glyphs.circleDot, + .completed => _Glyphs.check, + .cancelled => _Glyphs.x, + .error => _Glyphs.circleAlert, + .tool => _Glyphs.squareTerminal, + .loading => _Glyphs.loaderCircle, + .completedCircle => _Glyphs.circleCheck, + .cancelledCircle => _Glyphs.ban, + .errorCircle => _Glyphs.circleX, + .permission => _Glyphs.shieldCheck, + }; + + @override + Widget build(BuildContext context) { + final theme = IconTheme.of(context); + final opacity = spec.opacity ?? theme.opacity; + final baseColor = spec.color ?? theme.color; + final color = opacity == null || baseColor == null + ? baseColor + : baseColor.withValues(alpha: baseColor.a * opacity.clamp(0, 1)); + + final icon = Icon( + _icon, + size: spec.size ?? theme.size, + fill: spec.fill ?? theme.fill, + weight: spec.weight ?? theme.weight, + grade: spec.grade ?? theme.grade, + opticalSize: spec.opticalSize ?? theme.opticalSize, + color: color, + shadows: spec.shadows ?? theme.shadows, + textDirection: spec.textDirection, + applyTextScaling: + spec.applyTextScaling ?? theme.applyTextScaling ?? false, + blendMode: spec.blendMode ?? BlendMode.srcOver, + ); + return ExcludeSemantics( + child: kind == {{typePrefix}}FunctionalGlyphKind.loading + ? _LoadingGlyph(child: icon) + : icon, + ); + } +} + +/// Animate only indeterminate loading; status labels own the semantics. +class _LoadingGlyph extends StatefulWidget { + const _LoadingGlyph({required this.child}); + + final Widget child; + + @override + State<_LoadingGlyph> createState() => _LoadingGlyphState(); +} + +class _LoadingGlyphState extends State<_LoadingGlyph> + with SingleTickerProviderStateMixin { + late final _turns = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final animate = + !(MediaQuery.maybeOf(context)?.disableAnimations ?? false) && + TickerMode.valuesOf(context).enabled; + if (animate) { + if (!_turns.isAnimating) _turns.repeat(); + } else { + _turns.stop(); + _turns.value = 0; + } + } + + @override + Widget build(BuildContext context) => + RotationTransition(turns: _turns, child: widget.child); + + @override + void dispose() { + _turns.dispose(); + super.dispose(); + } +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/support/live_edge.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/support/live_edge.dart.tmpl new file mode 100644 index 000000000..7e126e31c --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/support/live_edge.dart.tmpl @@ -0,0 +1,143 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Shared private-package live-edge state machine. +class {{typePrefix}}LiveEdgeEngine { + {{typePrefix}}LiveEdgeEngine({ + required this._enabled, + required this.threshold, + this.onChanged, + }); + + bool _enabled; + bool get enabled => _enabled; + + set enabled(bool value) { + // An explicit false-to-true transition is the host's resume action. + // Ordinary rebuilds with follow enabled must preserve a reader's release. + if (value && !_enabled) _following = true; + _enabled = value; + } + + double threshold; + ValueChanged? onChanged; + bool _following = true; + bool _programmatic = false; + + bool get following => _following; + + void handleScroll( + ScrollNotification notification, + ScrollController controller, + ) { + if (_programmatic || !controller.hasClients) return; + final fromDrag = + notification is ScrollUpdateNotification && + notification.dragDetails != null; + final fromUserDirection = + notification is UserScrollNotification && + notification.direction != ScrollDirection.idle; + if (fromDrag || fromUserDirection) handlePosition(controller.position); + } + + void handlePosition(ScrollPosition position) { + final distance = position.maxScrollExtent - position.pixels; + _setFollowing(distance <= threshold); + } + + void follow(ScrollController controller) { + if (!enabled || !following || !controller.hasClients) return; + final position = controller.position; + if (!position.hasContentDimensions) return; + _programmatic = true; + position.jumpTo(position.maxScrollExtent); + _programmatic = false; + } + + void _setFollowing(bool next) { + if (following == next) return; + _following = next; + onChanged?.call(next); + } +} + +/// Small non-lazy scroll view used by plan and activity ledgers. +class {{typePrefix}}LiveEdgeScrollView extends StatefulWidget { + const {{typePrefix}}LiveEdgeScrollView({ + super.key, + required this.child, + this.followOutput = true, + this.followThreshold = 48, + this.onFollowChanged, + }); + + final Widget child; + final bool followOutput; + final double followThreshold; + final ValueChanged? onFollowChanged; + + @override + State<{{typePrefix}}LiveEdgeScrollView> createState() => + _{{typePrefix}}LiveEdgeScrollViewState(); +} + +class _{{typePrefix}}LiveEdgeScrollViewState extends State<{{typePrefix}}LiveEdgeScrollView> { + late final ScrollController _controller; + late final {{typePrefix}}LiveEdgeEngine _liveEdge; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + _liveEdge = {{typePrefix}}LiveEdgeEngine( + enabled: widget.followOutput, + threshold: widget.followThreshold, + onChanged: widget.onFollowChanged, + ); + _scheduleFollow(); + } + + @override + void didUpdateWidget({{typePrefix}}LiveEdgeScrollView oldWidget) { + super.didUpdateWidget(oldWidget); + _liveEdge + ..enabled = widget.followOutput + ..threshold = widget.followThreshold + ..onChanged = widget.onFollowChanged; + _scheduleFollow(); + } + + void _scheduleFollow() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _liveEdge.follow(_controller); + }); + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && _liveEdge.following) _scheduleFollow(); + return false; + }, + child: NotificationListener( + onNotification: (notification) { + if (notification.depth == 0) { + _liveEdge.handleScroll(notification, _controller); + } + return false; + }, + child: SingleChildScrollView( + controller: _controller, + child: widget.child, + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/agent/transcript/transcript.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/agent/transcript/transcript.dart.tmpl new file mode 100644 index 000000000..ecfb59cbf --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/agent/transcript/transcript.dart.tmpl @@ -0,0 +1,273 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../support/live_edge.dart'; + +part 'transcript.g.dart'; + +/// Chronological transcript with reader-aware live-edge following. +class {{typePrefix}}Transcript extends StatefulWidget { + const {{typePrefix}}Transcript({ + super.key, + required List this.children, + this.followOutput = true, + this.followThreshold = 48.0, + this.busy = false, + this.busyLabel = 'Busy', + this.label = 'Conversation', + this.onFollowChanged, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.style = const {{typePrefix}}TranscriptStyler.create(), + this.styleSpec, + }) : itemCount = null, + itemBuilder = null; + + const {{typePrefix}}Transcript.builder({ + super.key, + required int this.itemCount, + required IndexedWidgetBuilder this.itemBuilder, + this.followOutput = true, + this.followThreshold = 48.0, + this.busy = false, + this.busyLabel = 'Busy', + this.label = 'Conversation', + this.onFollowChanged, + this.controller, + this.clipBehavior = Clip.hardEdge, + this.style = const {{typePrefix}}TranscriptStyler.create(), + this.styleSpec, + }) : children = null; + + final List? children; + final int? itemCount; + final IndexedWidgetBuilder? itemBuilder; + final bool followOutput; + final double followThreshold; + final bool busy; + final String busyLabel; + final String label; + final ValueChanged? onFollowChanged; + final ScrollController? controller; + final Clip clipBehavior; + final {{typePrefix}}TranscriptStyler style; + final {{typePrefix}}TranscriptSpec? styleSpec; + + @override + State<{{typePrefix}}Transcript> createState() => _{{typePrefix}}TranscriptState(); +} + +class _{{typePrefix}}TranscriptState extends State<{{typePrefix}}Transcript> { + ScrollController? _ownedController; + late ScrollController _controller; + late final {{typePrefix}}LiveEdgeEngine _liveEdge; + + /// Publishes this surface's focus to the styles resolved above it. + /// + /// `focused` has no other source here: {{typePrefix}}'s slots resolve above any Naked + /// control, so without this the `focus-visible` state the transcript + /// worksheet documents could never activate. + /// + /// Only `focused`. The pointer-driven states do not resolve on this slot, and + /// did not before this controller existed either — a host's `onHovered` on + /// [{{typePrefix}}TranscriptSpec.viewport] has never had an effect. Passing a + /// controller also means Mix will not mount its own pointer detector, so + /// restoring hover would be this object's job; nothing asks for it yet. + final WidgetStatesController _statesController = WidgetStatesController(); + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? (_ownedController = ScrollController()); + _liveEdge = {{typePrefix}}LiveEdgeEngine( + enabled: widget.followOutput, + threshold: widget.followThreshold, + onChanged: widget.onFollowChanged, + ); + _scheduleFollow(); + } + + @override + void didUpdateWidget({{typePrefix}}Transcript oldWidget) { + super.didUpdateWidget(oldWidget); + _liveEdge + ..enabled = widget.followOutput + ..threshold = widget.followThreshold + ..onChanged = widget.onFollowChanged; + if (!identical(oldWidget.controller, widget.controller)) { + final offset = _controller.hasClients ? _controller.offset : 0.0; + final oldOwned = _ownedController; + _ownedController = null; + _controller = + widget.controller ?? + (_ownedController = ScrollController(initialScrollOffset: offset)); + if (oldOwned != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => oldOwned.dispose()); + } + } + _scheduleFollow(); + } + + void _scheduleFollow() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _liveEdge.follow(_controller); + }); + } + + bool _handleScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + _liveEdge.handleScroll(notification, _controller); + return notification is OverscrollNotification; + } + + void _handleIntent(_TranscriptScrollIntent intent) { + if (!_controller.hasClients) return; + final position = _controller.position; + final target = switch (intent.kind) { + _TranscriptScrollKind.lineUp => position.pixels - 50, + _TranscriptScrollKind.lineDown => position.pixels + 50, + _TranscriptScrollKind.pageUp => + position.pixels - position.viewportDimension * 0.8, + _TranscriptScrollKind.pageDown => + position.pixels + position.viewportDimension * 0.8, + _TranscriptScrollKind.home => position.minScrollExtent, + _TranscriptScrollKind.end => position.maxScrollExtent, + }; + position.jumpTo( + target + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(), + ); + _liveEdge.handlePosition(position); + } + + @override + Widget build(BuildContext context) { + return RemixStyleSpecBuilder<{{typePrefix}}TranscriptSpec>( + style: widget.style, + styleSpec: widget.styleSpec, + controller: _statesController, + builder: (context, spec) => Semantics( + container: true, + explicitChildNodes: true, + label: widget.label, + value: widget.busy ? widget.busyLabel : null, + child: FocusableActionDetector( + onFocusChange: (focused) => + _statesController.update(WidgetState.focused, focused), + shortcuts: _transcriptShortcuts, + actions: >{ + _TranscriptScrollIntent: CallbackAction<_TranscriptScrollIntent>( + onInvoke: (intent) { + _handleIntent(intent); + return null; + }, + ), + }, + child: Box( + styleSpec: spec.viewport, + child: LayoutBuilder( + builder: (context, constraints) => + NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && _liveEdge.following) { + _scheduleFollow(); + } + return false; + }, + child: NotificationListener( + onNotification: _handleScroll, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + overscroll: false, + physics: const ClampingScrollPhysics(), + ), + child: _buildList( + spec, + shrinkWrap: !constraints.hasBoundedHeight, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildList({{typePrefix}}TranscriptSpec spec, {required bool shrinkWrap}) { + final children = widget.children; + final count = children?.length ?? widget.itemCount!; + final spacing = spec.spacing ?? 0; + assert(spacing >= 0, '{{typePrefix}}Transcript spacing must be non-negative.'); + return ListView.separated( + controller: _controller, + shrinkWrap: shrinkWrap, + physics: const ClampingScrollPhysics(), + clipBehavior: widget.clipBehavior, + itemCount: count, + itemBuilder: (context, index) => Box( + styleSpec: spec.item, + child: children?[index] ?? widget.itemBuilder!(context, index), + ), + separatorBuilder: (context, index) => SizedBox(height: spacing), + ); + } + + @override + void dispose() { + _ownedController?.dispose(); + _statesController.dispose(); + super.dispose(); + } +} + +enum _TranscriptScrollKind { lineUp, lineDown, pageUp, pageDown, home, end } + +class _TranscriptScrollIntent extends Intent { + const _TranscriptScrollIntent(this.kind); + final _TranscriptScrollKind kind; +} + +const _transcriptShortcuts = { + SingleActivator(LogicalKeyboardKey.arrowUp): _TranscriptScrollIntent( + _TranscriptScrollKind.lineUp, + ), + SingleActivator(LogicalKeyboardKey.arrowDown): _TranscriptScrollIntent( + _TranscriptScrollKind.lineDown, + ), + SingleActivator(LogicalKeyboardKey.pageUp): _TranscriptScrollIntent( + _TranscriptScrollKind.pageUp, + ), + SingleActivator(LogicalKeyboardKey.pageDown): _TranscriptScrollIntent( + _TranscriptScrollKind.pageDown, + ), + SingleActivator(LogicalKeyboardKey.home): _TranscriptScrollIntent( + _TranscriptScrollKind.home, + ), + SingleActivator(LogicalKeyboardKey.end): _TranscriptScrollIntent( + _TranscriptScrollKind.end, + ), +}; + +@MixableSpec(target: {{typePrefix}}Transcript.new) +@immutable +final class {{typePrefix}}TranscriptSpec with _${{typePrefix}}TranscriptSpec { + @override + final StyleSpec viewport; + @override + final StyleSpec item; + @override + final double? spacing; + + const {{typePrefix}}TranscriptSpec({ + StyleSpec? viewport, + StyleSpec? item, + this.spacing, + }) : viewport = viewport ?? const StyleSpec(spec: BoxSpec()), + item = item ?? const StyleSpec(spec: BoxSpec()); +} diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/recipes/activity_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/activity_recipe.dart.tmpl new file mode 100644 index 000000000..eae29c7db --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/activity_recipe.dart.tmpl @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/activity.dart'; +import '../components/disclosure.dart'; +import '../theme/theme.dart'; + +@immutable +final class {{typePrefix}}AgentActivityRecipe { + const {{typePrefix}}AgentActivityRecipe({ + required this.style, + required this.disclosureStyle, + }); + final {{typePrefix}}ActivityStyler style; + final DisclosureStyler disclosureStyle; +} + +{{typePrefix}}AgentActivityRecipe {{valuePrefix}}AgentActivityRecipe({ + {{typePrefix}}ActivityStyler style = const {{typePrefix}}ActivityStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => {{typePrefix}}AgentActivityRecipe( + style: {{typePrefix}}ActivityStyler( + viewport: BoxStyler().maxHeight(200), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color({{typePrefix}}Tokens.gray12()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color({{typePrefix}}Tokens.gray12()).fontSize(14), + itemDetail: TextStyler().color({{typePrefix}}Tokens.gray11()).fontSize(12), + count: TextStyler() + .color({{typePrefix}}Tokens.gray11()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color({{typePrefix}}Tokens.gray12()).size(16), + pendingStatus: IconStyler().color({{typePrefix}}Tokens.gray9()).size(12), + activeStatus: IconStyler().color({{typePrefix}}Tokens.accent9()).size(12), + completedStatus: IconStyler().color({{typePrefix}}Tokens.accent9()).size(12), + ).merge(style), + disclosureStyle: {{valuePrefix}}DisclosureStyle( + style: DisclosureStyler() + .content(BoxStyler().padding(.all(0))) + .merge(disclosureStyle), + ), +); diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/recipes/answer_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/answer_recipe.dart.tmpl new file mode 100644 index 000000000..00bc9eee8 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/answer_recipe.dart.tmpl @@ -0,0 +1,52 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/answer.dart'; +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/icon_button.dart'; +import '../theme/theme.dart'; + +@immutable +final class {{typePrefix}}AgentAnswerRecipe { + const {{typePrefix}}AgentAnswerRecipe({ + required this.style, + required this.surfaceStyle, + required this.sourcesStyle, + required this.copyStyle, + required this.retryStyle, + }); + final {{typePrefix}}AnswerStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +{{typePrefix}}AgentAnswerRecipe {{valuePrefix}}AgentAnswerRecipe({ + {{typePrefix}}AnswerStyler style = const {{typePrefix}}AnswerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler sourcesStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => {{typePrefix}}AgentAnswerRecipe( + style: {{typePrefix}}AnswerStyler( + body: BoxStyler(), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + feedback: BoxStyler().padding(.only(top: 6)), + sourcesLabel: TextStyler().color({{typePrefix}}Tokens.gray12()).fontSize(13), + indicator: IconStyler().color({{typePrefix}}Tokens.gray12()).size(16), + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle(size: .size2, style: surfaceStyle), + sourcesStyle: {{valuePrefix}}DisclosureStyle(style: sourcesStyle), + copyStyle: {{valuePrefix}}IconButtonStyle( + variant: .ghost, + size: .size1, + style: copyStyle, + ), + retryStyle: {{valuePrefix}}IconButtonStyle( + variant: .ghost, + size: .size1, + style: retryStyle, + ), +); diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/recipes/composer_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/composer_recipe.dart.tmpl new file mode 100644 index 000000000..825e671ee --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/composer_recipe.dart.tmpl @@ -0,0 +1,64 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/card.dart'; +import '../components/composer.dart'; +import '../components/icon_button.dart'; +import '../components/textfield.dart'; +import '../theme/theme.dart'; + +@immutable +final class {{typePrefix}}AgentComposerRecipe { + const {{typePrefix}}AgentComposerRecipe({ + required this.style, + required this.surfaceStyle, + required this.fieldStyle, + required this.submitStyle, + required this.stopStyle, + }); + final {{typePrefix}}ComposerStyler style; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; +} + +{{typePrefix}}AgentComposerRecipe {{valuePrefix}}AgentComposerRecipe({ + {{typePrefix}}ComposerStyler style = const {{typePrefix}}ComposerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + TextFieldStyler fieldStyle = const TextFieldStyler.create(), + IconButtonStyler submitStyle = const IconButtonStyler.create(), + IconButtonStyler stopStyle = const IconButtonStyler.create(), +}) => {{typePrefix}}AgentComposerRecipe( + style: {{typePrefix}}ComposerStyler( + toolbar: FlexBoxStyler() + .direction(.horizontal) + .mainAxisSize(.max) + .crossAxisAlignment(.center) + .spacing(8) + .padding(.only(top: 8)), + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle( + size: .size2, + style: CardStyler().padding(.all(12)).merge(surfaceStyle), + ), + fieldStyle: {{valuePrefix}}TextAreaStyle( + style: TextFieldStyler() + .color(const Color(0x00000000)) + .border(.style(.none)) + .minHeight(56) + .padding(.all(4)) + .merge(fieldStyle), + ), + submitStyle: {{valuePrefix}}IconButtonStyle( + size: .size2, + style: IconButtonStyler().size(40, 40).merge(submitStyle), + ), + stopStyle: {{valuePrefix}}IconButtonStyle( + size: .size2, + style: IconButtonStyler() + .color({{typePrefix}}Tokens.error9()) + .size(40, 40) + .merge(stopStyle), + ), +); diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/recipes/execution_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/execution_recipe.dart.tmpl new file mode 100644 index 000000000..082ef4244 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/execution_recipe.dart.tmpl @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/execution.dart'; +import '../components/icon_button.dart'; +import '../theme/theme.dart'; + +@immutable +final class {{typePrefix}}AgentExecutionRecipe { + const {{typePrefix}}AgentExecutionRecipe({ + required this.style, + required this.surfaceStyle, + required this.disclosureStyle, + required this.copyStyle, + required this.retryStyle, + }); + final {{typePrefix}}ExecutionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +{{typePrefix}}AgentExecutionRecipe {{valuePrefix}}AgentExecutionRecipe({ + {{typePrefix}}ExecutionStyler style = const {{typePrefix}}ExecutionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => {{typePrefix}}AgentExecutionRecipe( + style: {{typePrefix}}ExecutionStyler( + header: FlexBoxStyler().spacing(8), + output: BoxStyler() + .color({{typePrefix}}Tokens.gray3()) + .borderRadius(.circular(6)) + .padding(.all(12)), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + tool: TextStyler().color({{typePrefix}}Tokens.gray11()).fontSize(12), + title: TextStyler() + .color({{typePrefix}}Tokens.gray12()) + .fontWeight(FontWeight.w600), + meta: TextStyler().color({{typePrefix}}Tokens.gray11()).fontSize(12), + status: TextStyler().color({{typePrefix}}Tokens.gray11()).fontSize(12), + toolIcon: IconStyler().color({{typePrefix}}Tokens.gray12()).size(16), + statusIcon: IconStyler().color({{typePrefix}}Tokens.accent9()).size(12), + indicator: IconStyler().color({{typePrefix}}Tokens.gray12()).size(16), + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle(size: .size2, style: surfaceStyle), + disclosureStyle: {{valuePrefix}}DisclosureStyle(style: disclosureStyle), + copyStyle: {{valuePrefix}}IconButtonStyle( + variant: .ghost, + size: .size1, + style: copyStyle, + ), + retryStyle: {{valuePrefix}}IconButtonStyle( + variant: .ghost, + size: .size1, + style: retryStyle, + ), +); diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/recipes/message_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/message_recipe.dart.tmpl new file mode 100644 index 000000000..0c54a430a --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/message_recipe.dart.tmpl @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/message.dart'; + +@immutable +final class {{typePrefix}}AgentMessageRecipe { + const {{typePrefix}}AgentMessageRecipe({ + required this.style, + required this.surfaceStyle, + required this.collapsibleStyle, + required this.toggleStyle, + }); + final {{typePrefix}}MessageStyler style; + final CardStyler surfaceStyle; + final {{typePrefix}}MessageCollapsibleStyler collapsibleStyle; + final ButtonStyler toggleStyle; +} + +{{typePrefix}}AgentMessageRecipe {{valuePrefix}}AgentMessageRecipe({ + {{typePrefix}}MessageStyler style = const {{typePrefix}}MessageStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + {{typePrefix}}MessageCollapsibleStyler collapsibleStyle = + const {{typePrefix}}MessageCollapsibleStyler.create(), + ButtonStyler toggleStyle = const ButtonStyler.create(), +}) => {{typePrefix}}AgentMessageRecipe( + style: {{typePrefix}}MessageStyler( + row: FlexBoxStyler().mainAxisSize(.max).spacing(8), + avatar: BoxStyler().size(28, 28), + header: BoxStyler().padding(.only(bottom: 6)), + body: BoxStyler(), + footer: BoxStyler().padding(.only(top: 4)), + maxWidth: 640, + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle(style: surfaceStyle), + collapsibleStyle: {{typePrefix}}MessageCollapsibleStyler( + collapsedHeight: 72, + container: BoxStyler(), + clipped: BoxStyler(), + ).merge(collapsibleStyle), + toggleStyle: {{valuePrefix}}ButtonStyle( + variant: .ghost, + size: .size1, + style: toggleStyle, + ), +); diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/recipes/permission_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/permission_recipe.dart.tmpl new file mode 100644 index 000000000..4fc800303 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/permission_recipe.dart.tmpl @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/data_list.dart'; +import '../components/disclosure.dart'; +import '../components/permission.dart'; +import '../theme/theme.dart'; + +@immutable +final class {{typePrefix}}AgentPermissionRecipe { + const {{typePrefix}}AgentPermissionRecipe({ + required this.style, + required this.surfaceStyle, + required this.detailsStyle, + required this.parametersStyle, + required this.allowOnceStyle, + required this.alwaysAllowStyle, + required this.denyStyle, + }); + final {{typePrefix}}PermissionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; +} + +{{typePrefix}}AgentPermissionRecipe {{valuePrefix}}AgentPermissionRecipe({ + {{typePrefix}}PermissionStyler style = const {{typePrefix}}PermissionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler detailsStyle = const DisclosureStyler.create(), + DataListStyler parametersStyle = const DataListStyler.create(), + ButtonStyler allowOnceStyle = const ButtonStyler.create(), + ButtonStyler alwaysAllowStyle = const ButtonStyler.create(), + ButtonStyler denyStyle = const ButtonStyler.create(), +}) => {{typePrefix}}AgentPermissionRecipe( + style: {{typePrefix}}PermissionStyler( + header: FlexBoxStyler().spacing(8), + actions: FlexBoxStyler().spacing(8).padding(.only(top: 8)), + title: TextStyler() + .color({{typePrefix}}Tokens.gray12()) + .fontWeight(FontWeight.w600), + tool: TextStyler().color({{typePrefix}}Tokens.gray11()).fontSize(12), + description: TextStyler() + .color({{typePrefix}}Tokens.gray11()) + .wrap(.padding(.symmetric(vertical: 8))), + status: TextStyler().color({{typePrefix}}Tokens.gray11()).fontSize(12), + detailsLabel: TextStyler().color({{typePrefix}}Tokens.gray12()).fontSize(13), + toolIcon: IconStyler().color({{typePrefix}}Tokens.gray12()).size(16), + statusIcon: IconStyler().color({{typePrefix}}Tokens.accent9()).size(12), + indicator: IconStyler().color({{typePrefix}}Tokens.gray12()).size(16), + ).merge(style), + surfaceStyle: {{valuePrefix}}CardStyle(size: .size2, style: surfaceStyle), + detailsStyle: {{valuePrefix}}DisclosureStyle(style: detailsStyle), + parametersStyle: {{valuePrefix}}DataListStyle(style: parametersStyle), + allowOnceStyle: {{valuePrefix}}ButtonStyle(style: allowOnceStyle), + alwaysAllowStyle: {{valuePrefix}}ButtonStyle( + variant: .outline, + style: alwaysAllowStyle, + ), + denyStyle: {{valuePrefix}}ButtonStyle(variant: .ghost, style: denyStyle), +); diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/recipes/plan_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/plan_recipe.dart.tmpl new file mode 100644 index 000000000..23511b222 --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/plan_recipe.dart.tmpl @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/disclosure.dart'; +import '../components/plan.dart'; +import '../theme/theme.dart'; + +@immutable +final class {{typePrefix}}AgentPlanRecipe { + const {{typePrefix}}AgentPlanRecipe({ + required this.style, + required this.disclosureStyle, + }); + final {{typePrefix}}PlanStyler style; + final DisclosureStyler disclosureStyle; +} + +{{typePrefix}}AgentPlanRecipe {{valuePrefix}}AgentPlanRecipe({ + {{typePrefix}}PlanStyler style = const {{typePrefix}}PlanStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => {{typePrefix}}AgentPlanRecipe( + style: {{typePrefix}}PlanStyler( + viewport: BoxStyler().maxHeight(220), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color({{typePrefix}}Tokens.gray12()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color({{typePrefix}}Tokens.gray12()).fontSize(14), + itemDetail: TextStyler().color({{typePrefix}}Tokens.gray11()).fontSize(12), + count: TextStyler() + .color({{typePrefix}}Tokens.gray11()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color({{typePrefix}}Tokens.gray12()).size(16), + pendingStatus: IconStyler().color({{typePrefix}}Tokens.gray9()).size(18), + activeStatus: IconStyler().color({{typePrefix}}Tokens.accent9()).size(18), + completedStatus: IconStyler().color({{typePrefix}}Tokens.accent9()).size(18), + cancelledStatus: IconStyler().color({{typePrefix}}Tokens.gray9()).size(18), + ).merge(style), + disclosureStyle: {{valuePrefix}}DisclosureStyle(style: disclosureStyle), +); diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/recipes/transcript_recipe.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/transcript_recipe.dart.tmpl new file mode 100644 index 000000000..777442dac --- /dev/null +++ b/packages/remix_cli/lib/src/registry/fortal/templates/recipes/transcript_recipe.dart.tmpl @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../components/transcript.dart'; + +@immutable +final class {{typePrefix}}AgentTranscriptRecipe { + const {{typePrefix}}AgentTranscriptRecipe({required this.style}); + final {{typePrefix}}TranscriptStyler style; +} + +{{typePrefix}}AgentTranscriptRecipe {{valuePrefix}}AgentTranscriptRecipe({ + {{typePrefix}}TranscriptStyler style = const {{typePrefix}}TranscriptStyler.create(), +}) => {{typePrefix}}AgentTranscriptRecipe( + style: {{typePrefix}}TranscriptStyler( + viewport: BoxStyler().padding(.only(right: 12)), + item: BoxStyler(), + spacing: 16, + ).merge(style), +); diff --git a/packages/remix_cli/lib/src/registry/fortal/templates/sidebar_layout/sidebar_layout.dart.tmpl b/packages/remix_cli/lib/src/registry/fortal/templates/sidebar_layout/sidebar_layout.dart.tmpl index 7be5b5f93..4da37cc59 100644 --- a/packages/remix_cli/lib/src/registry/fortal/templates/sidebar_layout/sidebar_layout.dart.tmpl +++ b/packages/remix_cli/lib/src/registry/fortal/templates/sidebar_layout/sidebar_layout.dart.tmpl @@ -310,7 +310,7 @@ class _{{typePrefix}}SidebarLayoutState extends State<{{typePrefix}}SidebarLayou /// destination's `onSelected` callback closing the sheet after navigating), /// since the layout re-provides this scope inside the sheet route. class {{typePrefix}}SidebarLayoutScope extends InheritedWidget { - // `remix_{{valuePrefix}}` floors at Dart 3.11, one release before private named + // {{typePrefix}} source floors at Dart 3.11, one release before private named // parameters, so this assigns the private fields explicitly instead of // naming the parameters after them. const {{typePrefix}}SidebarLayoutScope._({ @@ -319,8 +319,8 @@ class {{typePrefix}}SidebarLayoutScope extends InheritedWidget { required VoidCallback openCompact, required VoidCallback closeCompact, required super.child, - }) : _openCompact = openCompact, - _closeCompact = closeCompact; + }) : _openCompact = openCompact, // ignore: prefer_initializing_formals + _closeCompact = closeCompact; // ignore: prefer_initializing_formals /// Whether the layout is currently in its compact presentation. final bool isCompact; diff --git a/packages/remix_cli/pubspec.yaml b/packages/remix_cli/pubspec.yaml index 004d76fb1..65d226773 100644 --- a/packages/remix_cli/pubspec.yaml +++ b/packages/remix_cli/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: path: ^1.9.1 pub_semver: ^2.2.0 yaml: ^3.1.3 + yaml_edit: ^2.2.4 dev_dependencies: test: ^1.31.0 diff --git a/packages/remix_cli/test/builder_config_test.dart b/packages/remix_cli/test/builder_config_test.dart new file mode 100644 index 000000000..97f5d958a --- /dev/null +++ b/packages/remix_cli/test/builder_config_test.dart @@ -0,0 +1,85 @@ +import 'package:remix_cli/src/builder_config.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + const first = 'lib/ui/components/activity.dart'; + const second = 'lib/ui/components/message.dart'; + test('new config is narrowly scoped and repeated configuration is stable', () { + final source = configureSpecStylers('', [first]); + final builder = + (loadYaml(source) + as Map)['targets'][r'$default']['builders'][specStylerBuilder]; + expect(builder['enabled'], isTrue); + expect(builder['generate_for'], [first]); + expect(configureSpecStylers(source, [first]), source); + final updated = configureSpecStylers(source, [first, second]); + expect( + (loadYaml(updated) + as Map)['targets'][r'$default']['builders'][specStylerBuilder]['generate_for'], + [first, second], + ); + expect(configureSpecStylers(updated, [first, second]), updated); + }); + test('preserves comments, unrelated builders, options and custom sources', () { + const source = '''# Application-owned configuration. +targets: + \$default: + sources: + include: [lib/**] + exclude: [lib/private/**] + builders: + custom:builder: + options: {keep: true} # untouched + mix_generator|spec_styler_generator: + enabled: true + options: {custom: value} + generate_for: [lib/custom/**] +'''; + final updated = configureSpecStylers(source, [first]); + expect(updated, contains('# Application-owned configuration.')); + expect(updated, contains('options: {keep: true} # untouched')); + final builder = + (loadYaml(updated) + as Map)['targets'][r'$default']['builders']['mix_generator|spec_styler_generator']; + expect(builder['options'], {'custom': 'value'}); + expect(builder['generate_for'], ['lib/custom/**', first]); + }); + test('an options-only builder is enabled only for installed source', () { + const source = + r'targets: {$default: {builders: {mix_generator:spec_styler_generator: {options: {custom: value}}}}}'; + final updated = configureSpecStylers(source, [first]); + final builder = + (loadYaml(updated) + as Map)['targets'][r'$default']['builders'][specStylerBuilder]; + expect(builder['generate_for'], [first]); + expect(builder['options'], {'custom': 'value'}); + }); + test('respects existing glob coverage without changing it', () { + const source = '''targets: + \$default: + builders: + mix_generator:spec_styler_generator: + enabled: true + generate_for: + include: [lib/ui/**] + exclude: [lib/ui/private/**] +'''; + expect(configureSpecStylers(source, [first]), source); + }); + test('explicit opt-outs and split targets fail instead of being overwritten', () { + for (final source in [ + 'targets: {custom: {}}', + r'targets: {$default: {sources: {exclude: [lib/ui/**]}}}', + r'targets: {$default: {builders: {mix_generator:spec_styler_generator: {enabled: false}}}}', + r'targets: {$default: {builders: {mix_generator:spec_styler_generator: {generate_for: {exclude: [lib/ui/**]}}}}}', + '[]', + ]) { + expect( + () => configureSpecStylers(source, [first]), + throwsFormatException, + reason: source, + ); + } + }); +} diff --git a/packages/remix_cli/test/init_command_test.dart b/packages/remix_cli/test/init_command_test.dart index 1f4568738..dedb81eb6 100644 --- a/packages/remix_cli/test/init_command_test.dart +++ b/packages/remix_cli/test/init_command_test.dart @@ -194,21 +194,13 @@ paths: test('rejects changing the preset of an initialized project', () async { await installer.initialize( - const InitOptions( - prefix: 'Ui', - preset: 'default', - uiPath: 'lib/ui', - ), + const InitOptions(prefix: 'Ui', preset: 'default', uiPath: 'lib/ui'), ); final before = snapshotFiles(root); await expectLater( installer.initialize( - const InitOptions( - prefix: 'Ui', - preset: 'fortal', - uiPath: 'lib/ui', - ), + const InitOptions(prefix: 'Ui', preset: 'fortal', uiPath: 'lib/ui'), ), throwsA( isA().having( diff --git a/packages/remix_cli/test/installer_test.dart b/packages/remix_cli/test/installer_test.dart index c0ddbfc8e..19bd25720 100644 --- a/packages/remix_cli/test/installer_test.dart +++ b/packages/remix_cli/test/installer_test.dart @@ -57,6 +57,92 @@ void main() { }); tearDown(() => root.deleteSync(recursive: true)); + test( + 'Agent install configures generation and repairs a missing config', + () async { + writeRequiredPubspec(root, remixUiIcons: '^0.1.0'); + writeRequiredLock(root, remixUiIcons: '0.1.0'); + final runner = happyRunner(root, writeLockOnPubGet: false); + final installer = Installer( + projectRoot: root, + writeOut: (_) {}, + processRunner: runner, + ); + await installer.add( + const AddOptions(item: 'activity', mode: AddMode.write), + ); + final config = File(p.join(root.path, 'build.yaml')); + expect( + config.readAsStringSync(), + contains('lib/ui/components/activity.dart'), + ); + final before = snapshotFiles(root); + await installer.add( + const AddOptions(item: 'activity', mode: AddMode.write), + ); + expect(snapshotFiles(root), before); + config.deleteSync(); + final buildsBefore = runner.calls + .where((call) => call.arguments.contains('build_runner')) + .length; + await installer.add( + const AddOptions(item: 'activity', mode: AddMode.write), + ); + expect(config.existsSync(), isTrue); + expect( + runner.calls + .where((call) => call.arguments.contains('build_runner')) + .length, + buildsBefore + 1, + ); + }, + ); + + test('Agent dry-run and diff show config without writing it', () async { + final output = []; + final before = snapshotFiles(root); + final installer = Installer( + projectRoot: root, + writeOut: output.add, + processRunner: happyRunner( + root, + runRealFormatter: true, + runRealGit: true, + ), + ); + await installer.add( + const AddOptions(item: 'activity', mode: AddMode.dryRun), + ); + expect(output.join('\n'), contains('build.yaml')); + expect(snapshotFiles(root), before); + output.clear(); + await installer.add(const AddOptions(item: 'activity', mode: AddMode.diff)); + expect(output.join('\n'), contains('mix_generator:spec_styler_generator')); + expect(snapshotFiles(root), before); + }); + + test( + 'disabled Agent generation fails preflight without processes or writes', + () async { + File(p.join(root.path, 'build.yaml')).writeAsStringSync( + r'targets: {$default: {builders: {mix_generator:spec_styler_generator: {enabled: false}}}}', + ); + final before = snapshotFiles(root); + final runner = happyRunner(root); + final installer = Installer( + projectRoot: root, + writeOut: (_) {}, + processRunner: runner, + ); + await expectLater( + installer.add(const AddOptions(item: 'activity', mode: AddMode.write)), + throwsFormatException, + ); + expect(runner.calls, isEmpty); + expect(snapshotFiles(root), before); + }, + ); + test('add loads the registry selected by project configuration', () async { File(p.join(root.path, 'remix.yaml')).writeAsStringSync('''schema: 2 prefix: Ui @@ -97,9 +183,9 @@ paths: ); expect(writer.paths, [ - 'lib/ui/theme/tokens.dart', 'lib/ui/theme/theme_data.dart', 'lib/ui/theme/theme_scope.dart', + 'lib/ui/theme/tokens.dart', 'lib/ui/components/button.dart', 'lib/ui/ui.dart', ]); @@ -534,7 +620,14 @@ packages: uiPath: 'lib/ui', ), ); - final remixUiIcons = item == 'icons' ? '0.1.0' : null; + final remixUiIcons = + catalog + .resolve(item) + .any( + (entry) => entry.dependencies.containsKey('remix_ui_icons'), + ) + ? '0.1.0' + : null; final mixChart = item == 'chart' ? '0.0.1-beta.1' : null; writeRequiredPubspec( caseRoot, diff --git a/packages/remix_cli/test/registry_test.dart b/packages/remix_cli/test/registry_test.dart index 07aed53ea..1500ed9a6 100644 --- a/packages/remix_cli/test/registry_test.dart +++ b/packages/remix_cli/test/registry_test.dart @@ -1,9 +1,25 @@ +import 'dart:io'; + import 'package:path/path.dart' as p; import 'package:remix_cli/src/registry.dart'; import 'package:remix_cli/src/template_renderer.dart'; import 'package:test/test.dart'; void main() { + test('bundledPresets matches the preset trees on disk', () { + // `dart test` runs with the package root as the current directory. + final onDisk = Directory(p.join('lib', 'src', 'registry')) + .listSync() + .whereType() + .map((directory) => p.basename(directory.path)) + .toSet(); + + // bundledPresets gates both loadBundled and `--preset` validation, so a + // tree shipped without an entry here is unreachable behind "Unknown + // preset", and an entry without a tree fails only once someone selects it. + expect(onDisk, bundledPresets); + }); + test( 'bundled registry resolves theme before button and loads assets', () async { @@ -51,7 +67,7 @@ void main() { // sidebar_layout composes an already-installed sidebar into its row and // compact sheet without ever importing components/sidebar.dart (its // `sidebar` field stays generically typed as `Widget`), so this - // dependency comes from build_fortal_preset.dart's manual override, not + // dependency comes from build_registry.dart's manual override, not // from import inference. Regression coverage for that gap: a fresh // `remix add sidebar_layout` on the fortal preset must still pull in a // working Sidebar, matching the default preset's registry.yaml. @@ -137,6 +153,50 @@ items: }, ); + test('template prose states the theme vocabulary\'s real size', () async { + final catalog = await RegistryCatalog.loadBundled(preset: 'default'); + + // The real size is one required parameter per token on the theme data + // constructor. Counting `required this.` is enough: the template declares + // them nowhere else. + final themeData = catalog.items['theme']!.files.singleWhere( + (file) => file.source.endsWith('theme_data.dart.tmpl'), + ); + final tokenCount = RegExp( + r'^\s+required this\.', + multiLine: true, + ).allMatches(await catalog.readTemplate(themeData)).length; + expect(tokenCount, 20); + + // Two templates state that size in prose, and templates are copied verbatim + // into consumer source. `chart1`-`chart5` were added for the chart item and + // both sentences stayed at fifteen, because nothing compared them. + const spellings = { + 15: 'fifteen', + 16: 'sixteen', + 17: 'seventeen', + 18: 'eighteen', + 19: 'nineteen', + 20: 'twenty', + }; + final expected = spellings[tokenCount]; + expect(expected, isNotNull, reason: 'spell $tokenCount in `spellings`'); + final spelled = RegExp('\\b(${spellings.values.join('|')})\\b'); + + for (final name in const ['card', 'textfield']) { + final source = await catalog.readTemplate( + catalog.items[name]!.files.single, + ); + expect( + spelled.allMatches(source).map((match) => match[1]).toSet(), + {expected}, + reason: + "$name's prose must say the vocabulary has $tokenCount tokens, and " + 'say it once', + ); + } + }); + test( 'sidebar_layout is a plain layout with no Spec or generated adapter', () async { @@ -333,45 +393,108 @@ items: }, ); - test('every item resolves theme first and owns its expected files', () async { - final catalog = await RegistryCatalog.loadBundled(preset: 'default'); - - // Both directions. Checking only that each listed name exists would let a - // new registry item ship with no surface pinned and no rendering asserted. - expect( - catalog.items.keys.where((name) => name != 'theme').toSet(), - _componentSurfaces.keys.toSet(), - ); + test( + 'every item resolves its foundations and owns its expected files', + () async { + final catalog = await RegistryCatalog.loadBundled(preset: 'default'); - for (final name in _componentSurfaces.keys) { - final item = catalog.items[name]; - expect(item, isNotNull, reason: name); - // Dependency-first order, and `theme` always leads because every - // component declares it. Compound items retain their dependency-first - // order, including both toast controls and the sidebar layout's panel. - expect(catalog.resolve(name).map((item) => item.name), switch (name) { - 'data_table' => ['theme', 'checkbox', 'icon_button', 'select', name], - 'sidebar' => ['theme', 'toggle', 'tooltip', name], - 'sidebar_layout' => ['theme', 'toggle', 'tooltip', 'sidebar', name], - 'toast' => ['theme', 'button', 'icon_button', name], - _ => ['theme', name], - }, reason: name); - if (name == 'icons') { - expect(item!.files.single.target, '@ui/icons.dart'); - expect(item.generated, isEmpty); - expect(item.exports, ['icons.dart']); - } else if (name == 'sidebar_layout') { - // A layout, not a styled component: no Spec, no generated adapter. - expect(item!.files.single.target, '@ui/components/$name.dart'); + // Both directions. Checking only that each listed name exists would let a + // new registry item ship with no surface pinned and no rendering asserted. + expect(catalog.items.keys.toSet(), { + 'theme', + 'models', + 'support', + ..._componentSurfaces.keys, + ..._agentSurfaces.keys, + ..._agentRecipeNames, + }); + + for (final name in [..._componentSurfaces.keys, ..._agentSurfaces.keys]) { + final item = catalog.items[name]; + expect(item, isNotNull, reason: name); + // Agent model foundations can precede theme. Every component still + // reaches the single Remix floor through theme, in dependency order. + expect(catalog.resolve(name).map((item) => item.name), switch (name) { + 'composer' || 'transcript' => ['theme', 'support', name], + 'activity' || + 'answer' || + 'execution' || + 'message' || + 'permission' || + 'plan' => ['models', 'theme', 'support', name], + 'data_table' => ['theme', 'checkbox', 'icon_button', 'select', name], + 'sidebar' => ['theme', 'toggle', 'tooltip', name], + 'sidebar_layout' => ['theme', 'toggle', 'tooltip', 'sidebar', name], + 'toast' => ['theme', 'button', 'icon_button', name], + _ => ['theme', name], + }, reason: name); + if (name == 'icons') { + expect(item!.files.single.target, '@ui/icons.dart'); + expect(item.generated, isEmpty); + expect(item.exports, ['icons.dart']); + } else if (name == 'sidebar_layout') { + // A layout, not a styled component: no Spec, no generated adapter. + expect(item!.files.single.target, '@ui/components/$name.dart'); + expect(item.generated, isEmpty); + expect(item.exports, ['components/$name.dart']); + } else { + expect(item!.files.single.target, '@ui/components/$name.dart'); + expect(item.generated, ['@ui/components/$name.g.dart']); + expect(item.exports, ['components/$name.dart']); + } + } + for (final name in _agentRecipeNames) { + final item = catalog.items[name]!; + expect(item.files.single.target, '@ui/recipes/$name.dart'); expect(item.generated, isEmpty); - expect(item.exports, ['components/$name.dart']); - } else { - expect(item!.files.single.target, '@ui/components/$name.dart'); - expect(item.generated, ['@ui/components/$name.g.dart']); - expect(item.exports, ['components/$name.dart']); + expect(item.exports, ['recipes/$name.dart']); + expect(catalog.resolve(name).map((item) => item.name), contains(name)); } - } - }); + }, + ); + + test( + 'Agent foundations export only public models and preserve source layout', + () async { + final catalog = await RegistryCatalog.loadBundled(preset: 'default'); + expect(catalog.items['models']!.files.map((file) => file.target), [ + '@ui/models/activity_item.dart', + '@ui/models/plan_item.dart', + '@ui/models/statuses.dart', + ]); + expect(catalog.items['support']!.files.map((file) => file.target), [ + '@ui/support/disclosure.dart', + '@ui/support/functional_glyph.dart', + '@ui/support/live_edge.dart', + ]); + expect(catalog.items['support']!.exports, isEmpty); + expect(catalog.items['models']!.exports, [ + 'models/activity_item.dart', + 'models/plan_item.dart', + 'models/statuses.dart', + ]); + for (final entry in _agentSurfaces.entries) { + final source = await catalog.readTemplate( + catalog.items[entry.key]!.files.single, + ); + for (final prefix in ['Acme', 'Ui']) { + final rendered = const TemplateRenderer().render( + source, + typePrefix: prefix, + valuePrefix: prefix.toLowerCase(), + ); + for (final widget in entry.value) { + expect( + rendered, + contains( + RegExp('^class $prefix$widget extends ', multiLine: true), + ), + ); + } + } + } + }, + ); test('both prefixes render every configured public surface', () async { final catalog = await RegistryCatalog.loadBundled(preset: 'default'); @@ -425,6 +548,27 @@ items: } }); + test('both presets render prefixed Agent recipe bundles', () async { + for (final preset in ['default', 'fortal']) { + final catalog = await RegistryCatalog.loadBundled(preset: preset); + for (final name in _agentRecipeNames) { + final source = await catalog.readTemplate( + catalog.items[name]!.files.single, + ); + final rendered = const TemplateRenderer().render( + source, + typePrefix: 'Acme', + valuePrefix: 'acme', + ); + final component = name.split('_').first; + final type = '${component[0].toUpperCase()}${component.substring(1)}'; + expect(rendered, contains('class AcmeAgent${type}Recipe')); + expect(rendered, contains('acmeAgent${type}Recipe(')); + expect(rendered, isNot(contains('package:remix_agent'))); + } + } + }); + test('bundled templates stay inside the allowed import boundary', () async { final catalog = await RegistryCatalog.loadBundled(preset: 'default'); const allowedPackages = { @@ -450,7 +594,10 @@ items: } else if (!uri.startsWith('dart:')) { expect(uri, isNot(startsWith('/')), reason: file.source); final resolved = p.posix.normalize( - p.posix.join(p.posix.dirname(file.target.substring(4)), uri), + p.posix.join( + p.posix.dirname(file.target.substring(uiTargetPrefix.length)), + uri, + ), ); expect(resolved, isNot(startsWith('../')), reason: file.source); } @@ -534,3 +681,25 @@ final class _NoopLoader implements RegistryAssetLoader { @override Future read(Uri uri) => throw StateError('Unexpected read of $uri'); } + +const _agentRecipeNames = { + 'activity_recipe', + 'answer_recipe', + 'composer_recipe', + 'execution_recipe', + 'message_recipe', + 'permission_recipe', + 'plan_recipe', + 'transcript_recipe', +}; + +const _agentSurfaces = >{ + 'activity': ['Activity'], + 'answer': ['Answer'], + 'composer': ['Composer'], + 'execution': ['Execution'], + 'message': ['Message', 'MessageGroup', 'MessageCollapsible'], + 'permission': ['Permission'], + 'plan': ['Plan'], + 'transcript': ['Transcript'], +}; diff --git a/packages/remix_cli/test/template_format_test.dart b/packages/remix_cli/test/template_format_test.dart new file mode 100644 index 000000000..1fa7e7aca --- /dev/null +++ b/packages/remix_cli/test/template_format_test.dart @@ -0,0 +1,107 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:remix_cli/src/project_config.dart'; +import 'package:remix_cli/src/template_renderer.dart'; +import 'package:test/test.dart'; + +/// The prefix templates are rendered with before they are formatted. +/// +/// "Formatter-clean" is only meaningful at a stated prefix, because the +/// formatter wraps on line width and the prefix is substituted into type names. +/// This is the authoring word of `registry_source/lib/src/default`, whose formatted +/// source every default template derives from. +const _referencePrefix = 'Vanilla'; + +void main() { + // Every default template is derived from formatted source by + // `tool/build_registry.dart`, which holds it byte-identical through a + // round-trip assertion: the Agent subtree from `registry_source/lib/src/agent`, + // the rest from `lib/src/default`. So is `fortal`, from `lib/src/fortal`. + // This test is what makes that claim checkable + // from the CLI package alone. + // + // No consumer is affected either way: `add` formats the tree it writes, so + // installed source is formatted regardless of what the template looked like. + // This keeps the committed templates diffable instead of differing by pure + // whitespace, which nothing else could see. + test('every default template is formatter-clean at its authoring prefix', () { + // `dart test` runs with the package root as the current directory. + final templates = Directory( + p.join('lib', 'src', 'registry', 'default', 'templates'), + ); + expect(templates.existsSync(), isTrue, reason: templates.path); + + // Both prefixes come from the code the installer itself uses, so this can + // never drift from what `add` writes. + final config = ProjectConfig.create( + packageRoot: Directory.current, + prefix: _referencePrefix, + preset: 'default', + uiPath: p.join('lib', 'ui'), + ); + const renderer = TemplateRenderer(); + + final sources = {}; + final staging = Directory.systemTemp.createTempSync('remix_tmpl_format_'); + addTearDown(() => staging.deleteSync(recursive: true)); + + // Every rendered template lands in one flat directory so a single formatter + // invocation covers all of them. + for (final template + in templates + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart.tmpl'))) { + final relative = p.relative(template.path, from: templates.path); + final name = + '${relative.replaceAll(p.separator, '__').replaceAll('.dart.tmpl', '')}.dart'; + File(p.join(staging.path, name)).writeAsStringSync( + renderer.render( + template.readAsStringSync(), + // Derived Agent templates round-trip the formatted authoring source. + // Long-prefix consumer formatting is separately tested by Installer. + typePrefix: relative.startsWith('agent${p.separator}') + ? 'Agent' + : config.prefix, + valuePrefix: relative.startsWith('agent${p.separator}') + ? 'agent' + : config.valuePrefix, + ), + ); + sources[name] = template; + } + expect(sources, isNotEmpty); + + final rendered = { + for (final name in sources.keys) + name: File(p.join(staging.path, name)).readAsStringSync(), + }; + + final result = Process.runSync(Platform.resolvedExecutable, [ + 'format', + staging.path, + ]); + expect( + result.exitCode, + 0, + reason: 'the formatter failed: ${result.stdout}${result.stderr}', + ); + + final drifted = [ + for (final name in sources.keys) + if (File(p.join(staging.path, name)).readAsStringSync() != + rendered[name]) + p.relative(sources[name]!.path), + ]..sort(); + + expect( + drifted, + isEmpty, + reason: + 'these templates do not match the formatter when rendered with ' + 'its authoring prefix (Agent for derived source, $_referencePrefix otherwise). Render one, format it, and reverse the ' + 'prefix substitution to update it.', + ); + }); +} diff --git a/packages/remix_fortal/.pubignore b/packages/remix_fortal/.pubignore deleted file mode 100644 index cec23811e..000000000 --- a/packages/remix_fortal/.pubignore +++ /dev/null @@ -1,9 +0,0 @@ -pubspec_overrides.yaml -.vscode/* -.cursor/* -docs/* -scripts/* -test/* -reference/* -tool/* -radix_colors.generated.json diff --git a/packages/remix_fortal/LICENSE b/packages/remix_fortal/LICENSE deleted file mode 100644 index deeda1dce..000000000 --- a/packages/remix_fortal/LICENSE +++ /dev/null @@ -1,29 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2022, Concepta -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/remix_fortal/build.yaml b/packages/remix_fortal/build.yaml deleted file mode 100644 index 22f664c40..000000000 --- a/packages/remix_fortal/build.yaml +++ /dev/null @@ -1,17 +0,0 @@ -targets: - $default: - builders: - mix_generator|mix_widget_generator: - enabled: true - generate_for: - - lib/**/*.dart - mix_generator|mix_generator: - enabled: false - mix_generator|spec_styler_generator: - enabled: false - mix_generator|styler_generator: - enabled: false - mix_generator|mixable_generator: - enabled: false - mix_generator|modifier_generator: - enabled: false diff --git a/packages/remix_fortal/lib/remix_fortal.dart b/packages/remix_fortal/lib/remix_fortal.dart deleted file mode 100644 index 42ff559a4..000000000 --- a/packages/remix_fortal/lib/remix_fortal.dart +++ /dev/null @@ -1,60 +0,0 @@ -/// Fortal: a Radix Themes-inspired preset theme and widget catalog for Remix. -/// -/// Fortal is built on [package:remix](https://pub.dev/packages/remix). It adds a -/// token scope ([FortalScope]), the `fortal*Style()` recipes, and a matching -/// catalog of ready-made `Fortal*` widgets. It does not re-export `remix`; add -/// `package:remix/remix.dart` alongside this import when you need the base -/// widgets or stylers. -library remix_fortal; - -/// ICONS -export 'src/icons.dart'; - -/// THEME -export 'src/theme/radix_colors.dart'; -export 'src/theme/theme.dart'; -export 'src/components/base_button.dart' - hide - FortalBaseButtonStateStyle, - FortalBaseButtonStateStyles, - fortalBaseButtonStateStyles; - -/// RECIPES -export 'src/components/accordion.dart'; -export 'src/components/avatar.dart'; -export 'src/components/badge.dart'; -export 'src/components/button.dart'; -export 'src/components/callout.dart'; -export 'src/components/card.dart'; -export 'src/components/chart.dart'; -export 'src/components/checkbox.dart'; -export 'src/components/code.dart'; -export 'src/components/data_list.dart'; -export 'src/components/data_table.dart'; -export 'src/components/dialog.dart'; -export 'src/components/disclosure.dart'; -export 'src/components/divider.dart'; -export 'src/components/heading.dart'; -export 'src/components/icon_button.dart'; -export 'src/components/kbd.dart'; -export 'src/components/link.dart'; -export 'src/components/menu.dart'; -export 'src/components/popover.dart'; -export 'src/components/progress.dart'; -export 'src/components/radio.dart'; -export 'src/components/segmented_control.dart'; -export 'src/components/select.dart'; -export 'src/components/sidebar.dart'; -export 'src/components/sidebar_layout.dart'; -export 'src/components/skeleton.dart'; -export 'src/components/slider.dart'; -export 'src/components/spinner.dart'; -export 'src/components/switch.dart'; -export 'src/components/tabs.dart'; -export 'src/components/text.dart'; -export 'src/components/textfield.dart'; -export 'src/components/toast.dart'; -export 'src/components/toggle.dart'; -export 'src/components/toggle_group.dart'; -export 'src/components/tooltip.dart'; -export 'src/components/typography.dart'; diff --git a/packages/remix_fortal/pubspec.yaml b/packages/remix_fortal/pubspec.yaml deleted file mode 100644 index d2a91e074..000000000 --- a/packages/remix_fortal/pubspec.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: remix_fortal -description: Fortal is a Radix Themes-inspired preset theme and widget catalog built on Remix. -publish_to: none -repository: https://github.com/btwld/remix -documentation: https://docs.page/btwld/remix/fortal -issue_tracker: https://github.com/btwld/remix/issues -license: BSD-3-Clause -resolution: workspace -topics: - - design-system - - ui - - widgets - - styling - - mix - -# Retained for workspace package resolution; releases now ship through the -# application-owned Fortal registry in remix_cli. -version: 1.0.0-beta.9 - -environment: - sdk: ">=3.11.0 <4.0.0" - flutter: ">=3.41.0" - -dependencies: - flutter: - sdk: flutter - # The workspace resolves this to the sibling source package. The derived - # registry owns the hosted Remix floor installed into consumer applications. - remix: ^1.0.0-beta.10 - mix: ^2.2.0-beta.5 - mix_annotations: ^2.2.0-beta.1 - mix_chart: ^0.0.1-beta.1 - remix_ui_icons: ^0.1.0 - -dev_dependencies: - flutter_test: - sdk: flutter - build_runner: ^2.10.1 - mix_generator: ^2.2.0-beta.3 diff --git a/packages/remix_fortal/test/public_api_compatibility_test.dart b/packages/remix_fortal/test/public_api_compatibility_test.dart deleted file mode 100644 index 87ff92db3..000000000 --- a/packages/remix_fortal/test/public_api_compatibility_test.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; - -void main() { - test('generated wrappers preserve generic and named constructors', () { - const menu = FortalMenu.soft( - trigger: RemixMenuTrigger(label: 'Actions'), - items: [RemixMenuItem(value: 'save', label: 'Save')], - ); - const select = FortalSelect.ghost( - trigger: RemixSelectTrigger(placeholder: 'Choose'), - items: [RemixSelectItem(value: 'one', label: 'One')], - ); - const radio = FortalRadio.soft( - value: 'one', - semanticLabel: 'Option', - ); - const button = FortalButton.soft(label: 'Save'); - const checkbox = FortalCheckbox.soft( - selected: false, - label: 'Receive updates', - minimumTapTargetSize: Size.zero, - ); - const segmented = FortalSegmentedControl.classic( - items: [RemixSegmentedControlItem(value: 'list', label: 'List')], - selectedValue: 'list', - ); - const textArea = FortalTextArea.classic(); - - expect(menu.variant, FortalMenuVariant.soft); - expect(menu.trigger.label, 'Actions'); - expect(menu.trigger.icon, isNull); - - Widget accountBuilder( - BuildContext context, - NakedMenuState state, - Widget? child, - ) { - return child ?? const SizedBox.shrink(); - } - - final unnamedBuilderMenu = FortalMenu( - trigger: RemixMenuTrigger.builder( - label: 'Account menu', - builder: accountBuilder, - ), - items: const [RemixMenuItem(value: 'save', label: 'Save')], - ); - final namedBuilderMenu = FortalMenu.soft( - trigger: RemixMenuTrigger.builder( - label: 'Account menu', - builder: accountBuilder, - ), - items: const [RemixMenuItem(value: 'save', label: 'Save')], - ); - - expect(unnamedBuilderMenu.trigger.label, 'Account menu'); - expect(unnamedBuilderMenu.trigger.builder, same(accountBuilder)); - expect(namedBuilderMenu.variant, FortalMenuVariant.soft); - expect(namedBuilderMenu.trigger.builder, same(accountBuilder)); - expect(select.variant, FortalSelectVariant.ghost); - expect(radio.variant, FortalRadioVariant.soft); - expect(button.variant, FortalButtonVariant.soft); - expect(checkbox.label, 'Receive updates'); - expect(checkbox.minimumTapTargetSize, Size.zero); - expect(segmented, isA>()); - expect(segmented.variant, FortalSegmentedControlVariant.classic); - expect(textArea.variant, FortalTextAreaVariant.classic); - }); - test('theme configuration exposes only canonical names', () { - const config = FortalThemeConfig( - accent: .red, - gray: .mauve, - brightness: .dark, - panelBackground: .solid, - radius: .large, - scaling: .percent105, - hasBackground: false, - ); - - expect(config.accent, FortalAccentColor.red); - expect(config.gray, FortalGrayColor.mauve); - expect(config.brightness, Brightness.dark); - }); -} diff --git a/packages/remix_fortal/test/public_api_test.dart b/packages/remix_fortal/test/public_api_test.dart deleted file mode 100644 index 276a3bb23..000000000 --- a/packages/remix_fortal/test/public_api_test.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mix_chart/mix_chart.dart'; -import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; - -import 'helpers/test_helpers.dart'; - -void main() { - test('the Fortal surface frame helper is public', () { - final frame = fortalSurfaceFrame( - fillColor: const Color(0xFFFFFFFF), - borderColor: const Color(0xFF000000), - borderWidth: 1, - radius: const Radius.circular(8), - ); - - expect(frame, isA()); - }); - - test( - 'the Fortal disclosure wrapper is constructible from the public API', - () { - const disclosure = FortalDisclosure.soft( - trigger: Text('Details'), - content: Text('Account details'), - size: FortalDisclosureSize.size3, - ); - - expect(disclosure.variant, FortalDisclosureVariant.soft); - expect(disclosure.size, FortalDisclosureSize.size3); - expect(fortalDisclosureStyle(), isA()); - }, - ); - - test('new Fortal controls expose generated public wrappers', () { - const segmented = FortalSegmentedControl.classic( - items: [RemixSegmentedControlItem(value: 'one', label: 'One')], - selectedValue: 'one', - size: FortalSegmentedControlSize.size3, - ); - const textArea = FortalTextArea.soft( - hintText: 'Notes', - size: FortalTextAreaSize.size1, - ); - - expect(segmented, isA>()); - expect(segmented.variant, FortalSegmentedControlVariant.classic); - expect(segmented.size, FortalSegmentedControlSize.size3); - expect(textArea, isA()); - expect(textArea.variant, FortalTextAreaVariant.soft); - expect(textArea.size, FortalTextAreaSize.size1); - expect(fortalSegmentedControlStyle(), isA()); - expect(fortalTextAreaStyle(), isA()); - }); - - test('mode-aware Fortal filters are constructible from the public API', () { - final modifier = fortalModeAwareFilter( - light: const [RemixCssColorFilterOperation.brightness(1.1)], - dark: const [RemixCssColorFilterOperation.contrast(0.9)], - ); - - expect(modifier, isNotNull); - }); - - test('the Fortal skeleton wrapper is constructible from the public API', () { - expect(const FortalSkeleton(), isA()); - expect(fortalSkeletonStyle(), isA()); - }); - - test('the Fortal data list wrapper is constructible from the public API', () { - const item = RemixDataListItem(label: 'Name', value: 'Jane'); - const fortal = FortalDataList( - items: [item], - size: FortalDataListSize.size3, - highContrast: true, - ); - - expect(fortal, isA()); - expect(fortal.size, FortalDataListSize.size3); - expect(fortal.highContrast, isTrue); - expect(fortalDataListStyle(), isA()); - }); - - test('the Fortal sidebar wrapper is constructible from the public API', () { - const navigation = FortalSidebar( - sections: [ - RemixSidebarSection( - destinations: [ - RemixSidebarDestination(value: 'overview', label: 'Overview'), - ], - ), - ], - selectedValue: 'overview', - semanticLabel: 'Primary navigation', - highContrast: true, - panelPadding: EdgeInsets.all(8), - ); - - expect(navigation, isA>()); - expect(navigation.highContrast, isTrue); - expect(navigation.panelPadding, const EdgeInsets.all(8)); - expect(fortalSidebarStyle(), isA()); - }); - - test('the Fortal toast wrapper is constructible from the public API', () { - const toast = FortalToast.surface( - title: 'Draft saved', - size: FortalToastSize.size3, - intent: FortalToastIntent.error, - ); - - expect(toast, isA()); - expect(toast.variant, FortalToastVariant.surface); - expect(toast.intent, FortalToastIntent.error); - expect(fortalToastStyle(), isA()); - }); - - test( - 'the Fortal data table wrapper is constructible from the public API', - () { - const fortal = FortalDataTable.surface( - rows: ['one'], - columns: [], - size: FortalDataTableSize.size3, - ); - - expect(fortal, isA>()); - expect(fortal.variant, FortalDataTableVariant.surface); - expect(fortalDataTableStyle(), isA()); - }, - ); - - test('the typography wrappers are constructible from the public API', () { - const text = FortalText( - 'Body', - size: FortalTextSize.size3, - weight: FortalTextWeight.medium, - ); - const heading = FortalHeading( - 'Title', - headingLevel: 2, - size: FortalTextSize.size4, - weight: FortalTextWeight.medium, - ); - const code = FortalCode.outline('code', size: FortalTextSize.size2); - const kbd = FortalKbd.soft('⌘K', semanticLabel: 'Command K'); - const link = FortalLink( - 'Docs', - underline: FortalLinkUnderline.always, - size: FortalTextSize.size2, - ); - - expect(text.size, FortalTextSize.size3); - expect(text.weight, FortalTextWeight.medium); - expect(heading.headingLevel, 2); - expect(heading.size, FortalTextSize.size4); - expect(code.variant, FortalCodeVariant.outline); - expect(kbd.variant, FortalKbdVariant.soft); - expect(link.underline, FortalLinkUnderline.always); - expect(fortalTextStyle(), isA()); - expect(fortalHeadingStyle(), isA()); - }); - - testWidgets('the context-bound typography recipes are public', ( - tester, - ) async { - final recipes = await resolveInFortalScope( - tester, - (context) => ( - code: fortalCodeStyle(context), - kbd: fortalKbdStyle(context), - link: fortalLinkStyle(context, actionable: true), - ), - ); - - expect(recipes.code, isA()); - expect(recipes.kbd, isA()); - expect(recipes.link, isA()); - }); - - test('Fortal chart wrappers are constructible from the public API', () { - final line = FortalLineChart( - series: [ - LineSeries( - id: 'revenue', - label: 'Revenue', - points: [ChartPoint(id: 'monday', x: 0, y: 18)], - ), - ], - ); - final bar = FortalBarChart( - groups: [ - BarGroup( - id: 'q1', - label: 'Q1', - bars: [BarValue(id: 'actual', label: 'Actual', toY: 42)], - ), - ], - ); - final pie = FortalPieChart( - slices: [PieSlice(id: 'direct', label: 'Direct', value: 64)], - ); - - expect(line, isA()); - expect(bar, isA()); - expect(pie, isA()); - expect(fortalLineChartStyle(), isA()); - expect(fortalBarChartStyle(), isA()); - expect(fortalPieChartStyle(), isA()); - }); -} diff --git a/pubspec.lock b/pubspec.lock index 7107cd69a..393c06676 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -495,14 +495,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - lucide_icons_flutter: - dependency: transitive - description: - name: lucide_icons_flutter - sha256: "234155b10641b8ef7bab8a077b93ea6c92fe849c06740cf89dcd86dcf350934f" - url: "https://pub.dev" - source: hosted - version: "3.1.15" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 76d371311..a417d0aca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,16 +19,14 @@ workspace: - packages/naked_ui - packages/remix - packages/remix_cli - - packages/remix_fortal - - packages/remix_agent - - packages/remix_agent/example - packages/remix_ui_icons + - registry_source melos: packages: - apps/* - packages/* - - packages/remix_agent/example + - registry_source # No `command.bootstrap.environment` here on purpose. It rewrites one # `environment:` into every package it manages, and this workspace has two @@ -82,7 +80,6 @@ melos: pub_semver: ^2.2.0 yaml: ^3.1.3 remix: ^1.0.0-beta.10 - remix_fortal: ^1.0.0-beta.9 remix_ui_icons: ^0.1.0 dev_dependencies: build_runner: ^2.10.1 @@ -93,36 +90,31 @@ melos: scripts: # No `release:*` versioning scripts here on purpose. The version workflow - # is the supported path for the published Remix package; remix_fortal is an - # internal authoring and parity package whose source derives the bundled - # Fortal preset, so release automation must not version it. + # is the supported path for the published Remix package; registry_source is + # the internal authoring and parity package whose source derives the bundled + # presets, so release automation must not version it. generate: description: Regenerate Remix sources - # remix_fortal's generator resolves package:remix, so it must not build + # registry_source's generator resolves package:remix, so it must not build # while remix is rewriting its own lib/. exec: command: dart run build_runner build concurrency: 1 orderDependents: true packageFilters: - scope: [remix, remix_agent, remix_fortal] + scope: [remix, registry_source] generate:check: description: Regenerate Remix sources and fail only on generated drift exec: - # `$MELOS_ROOT_PATH`, not a fixed `../../`: the scope below spans two - # directory depths, and `packages/remix_agent/example` is three levels - # down. command: dart run $MELOS_ROOT_PATH/tool/check_generated.dart concurrency: 1 orderDependents: true - # The playground and the Agent example are in scope because each holds a - # committed, CLI-installed copy of registry source. Nothing renders those - # files in the playground, so a `.g.dart` left behind by a recipe edit - # would compile and ship green. + # Installed-source applications regenerate their own adapters. This also + # checks dashboard's explicit chat closure, not the full Fortal catalog. packageFilters: scope: - [remix, remix_agent, remix_fortal, playground, remix_agent_example] + [remix, registry_source, playground, dashboard] test:flutter: exec: flutter test --reporter=failures-only @@ -153,7 +145,7 @@ melos: material:check: run: dart run tool/check_material_independence.dart - description: Verify Remix and Fortal consumer sources contain no direct Material usage + description: Verify Remix, Fortal, and Agent consumer sources contain no direct Material usage open-code:check: steps: @@ -168,19 +160,19 @@ melos: - dart run tool/check_open_code.dart --preset fortal --source hosted description: Verify both presets against the required published Remix release - open-code:fortal:build: - run: dart run tool/build_fortal_preset.dart - description: Derive the Fortal registry from analyzer-checked package source + open-code:registry:build: + run: dart run tool/build_registry.dart + description: Derive every bundled preset from analyzer-checked package source - open-code:fortal:check: + open-code:registry:check: steps: - - dart test test/tool/build_fortal_preset_test.dart - - dart run tool/build_fortal_preset.dart --check - description: Test Fortal derivation and fail on committed preset drift + - dart test test/tool/build_registry_test.dart test/tool/build_registry_agent_test.dart + - dart run tool/build_registry.dart --check + description: Test preset derivation and fail on committed registry drift open-code:dogfood:check: run: dart run tool/check_open_code_dogfood.dart - description: Verify the playground's installed source still mirrors the registry + description: Verify both dogfood consumers' installed source still mirrors the registry icons:generate: steps: @@ -199,6 +191,21 @@ melos: run: dart run tool/check_dependency_constraints.dart description: Verify shared dependency constraints match the melos bootstrap config + analyze: + # The shared `dart-actions` workflow already runs an analyzer step, so + # this is not what guards `main`. It exists so `melos run ci` -- the + # documented local gate -- reports the same failures a pull request + # would, instead of deferring them to a push. + run: dart analyze + description: Analyze every workspace package + + format:check: + # The analyzer does not check formatting, so nothing else here would. + # Generated sources are covered too, which is why this runs after + # `generate:check` rather than before it. + run: dart format --output=none --set-exit-if-changed . + description: Verify every Dart source matches the formatter + # `ci` is the whole verification surface, and every step below is one CI # job. Running it here executes them in sequence; .github/workflows/ci.yaml # runs the same six scripts as parallel jobs, which is what took the @@ -230,19 +237,22 @@ melos: - ci:coverage:check - toolchain:check - constraints:check + - analyze - version:align:check - mix:consumer:check - material:check - docs:check - fortal:parity:check - - open-code:fortal:check + - open-code:registry:check description: >- Every cheap contract checker; seconds in total, so they share one job ci:codegen: steps: - generate:check - description: Regenerate Remix sources and fail on generated drift + - format:check + description: >- + Regenerate Remix sources, then fail on generated or formatting drift ci:cli: steps: @@ -276,7 +286,7 @@ melos: exec: dart run tool/fortal_parity/check.dart description: Verify the pinned Radix Themes 3.3.0 parity contract and coverage packageFilters: - scope: remix_fortal + scope: registry_source docs:catalog: run: dart run tool/generate_fortal_catalog.dart diff --git a/packages/remix_agent/LICENSE b/registry_source/LICENSE similarity index 100% rename from packages/remix_agent/LICENSE rename to registry_source/LICENSE diff --git a/packages/remix_fortal/analysis_options.yaml b/registry_source/analysis_options.yaml similarity index 100% rename from packages/remix_fortal/analysis_options.yaml rename to registry_source/analysis_options.yaml diff --git a/packages/remix_agent/build.yaml b/registry_source/build.yaml similarity index 65% rename from packages/remix_agent/build.yaml rename to registry_source/build.yaml index 68c8730fb..94a102bf0 100644 --- a/packages/remix_agent/build.yaml +++ b/registry_source/build.yaml @@ -1,23 +1,20 @@ targets: $default: - # The nested example is a separate workspace package with its own builders. - # Including it here lets the combining builder delete its generated parts. - sources: - exclude: - - example/** builders: - mix_generator|mix_generator: + mix_generator|mix_widget_generator: enabled: true generate_for: - lib/**/*.dart - mix_generator|spec_styler_generator: + # Agent components are @MixableSpec sources; the presets are @MixWidget + # recipes and generate nothing else. + mix_generator|mix_generator: enabled: true generate_for: - - lib/**/*.dart - mix_generator|mix_widget_generator: + - lib/src/agent/**/*.dart + mix_generator|spec_styler_generator: enabled: true generate_for: - - lib/**/*.dart + - lib/src/agent/**/*.dart mix_generator|styler_generator: enabled: false mix_generator|mixable_generator: diff --git a/packages/remix_agent/docs/adr/0001-package-boundary.md b/registry_source/docs/adr/agent/0001-package-boundary.md similarity index 61% rename from packages/remix_agent/docs/adr/0001-package-boundary.md rename to registry_source/docs/adr/agent/0001-package-boundary.md index 35e0d1f8f..cea97c203 100644 --- a/packages/remix_agent/docs/adr/0001-package-boundary.md +++ b/registry_source/docs/adr/agent/0001-package-boundary.md @@ -2,14 +2,14 @@ A clean-sheet review of this package against the open-code workflow retained the behavior boundary below and proposed registry-distributed, -application-owned recipes instead of an Agent theme. That integration is -planned, not implemented by this package decision. What the first consumer -established, and what is still gated, is recorded under +application-owned recipes instead of an Agent theme. The eight surfaces are now derived into both existing registries. The private +package remains the single authoring/test source. Distribution and remaining +release checks are recorded under [Runtime and recipe split](#runtime-and-recipe-split). ## Decision -Ship agent-run surfaces as `remix_agent`, a private workspace package that +Author and test agent-run surfaces in `remix_agent`, source under `registry_source/lib/src/agent` that depends on `remix` plus Mix's generator/runtime. Do not add these widgets to `remix` or `remix_fortal`, and do not create an `AgentScope`. @@ -66,9 +66,9 @@ installed `remix_cli` source rather than as an Agent theme. | Accessibility semantics and keyboard rules | Per-instance overrides at the call site | | `Agent*Spec` slot names, empty by default | Which installed recipes a surface reuses | -One surface has a proven consumer, and it is the catalog app rather than a -purpose-built fixture. `example/` runs `remix init` and installs Theme, Card, -TextField, and IconButton exactly as any application does; the installed source +The example installs all eight surfaces plus Theme, Card, TextField, +IconButton, and Button from the default registry. Its Composer recipe is the +worked styling integration; the installed source is committed, and `tool/check_open_code_dogfood.dart` holds it against the templates. `example/test/composer_recipe_test.dart` then proves: @@ -82,8 +82,10 @@ templates. `example/test/composer_recipe_test.dart` then proves: - the semantic tree holds one field and one action; - the light and dark themes both reach Agent through the same recipe. -The example resolves `remix_agent` as a workspace sibling. That is development -evidence; nothing here claims hosted installation works. +The example imports installed `Ui*` source and no longer depends on +`remix_agent`. Fresh consumer checks exercise both complete catalogs and each +Agent component and recipe independently. These checkout checks are distinct +from hosted-release verification. The recipe is a **bundle**, not a single styler. `AgentComposer` takes `style`, `surfaceStyle`, `fieldStyle`, `submitStyle`, and `stopStyle`, and @@ -92,19 +94,30 @@ supply the other four. `uiAgentComposerRecipe()` returns all five and the call site spreads them. The four child stylers stay unresolved, for the reason the section above gives. -Two things remain gated, and neither is claimed anywhere in this package: - -1. **Publication.** The package is `publish_to: none`. A registry item's - dependency is a hosted version constraint, not a way to publish a private - workspace sibling, so there is no Agent registry item and hosted - installation is not advertised. Resolving the publication metadata, - dependency floors, provenance, and release checks is the next decision, and - it is not a code change. -2. **The other seven surfaces.** Message, transcript, answer, permission, - execution, plan, and activity have no proven recipe yet. Their worksheets - record the benchmark measurements, not shipped defaults. - -Local Chrome checks cover the catalog in light, dark, narrow, wide, and -reduced-motion states, including Composer submit/stop and permission and -disclosure interactions. Hosted-consumer verification remains open and depends -on publication; local browser and widget tests do not establish installability. +## Source distribution and remaining boundaries + +Each registry contains eight component items, eight opt-in recipe items, plus shared `models` and +`support`. Source comes from this package; generated adapters come from the +consumer's resolved Mix generator. No installed item depends on this private +package. Public model files are exported; internal support helpers stay out of +the managed barrel. The existing theme item owns the single Remix dependency +floor, inherited through support. + +`tool/build_registry.dart` derives this package into `templates/agent/**` of +both presets as an extension of each preset's own source; the preset writer +merges it and owns the whole tree, so regeneration cannot prune it and a +whole-tree drift check covers it. + +Mix's spec-styler builder is opt-in at the current supported version. The CLI +therefore enables it for installed spec sources in application `build.yaml`, +preserving unrelated settings and refusing explicit exclusions or disabled +builders. This is a discovered prerequisite to distribution, not a new theme, +registry schema, or copied generated implementation. + +All eight surfaces expose preset-specific recipe bundles, authored as Dart in +`registry_source/lib/src/{default,fortal}/recipes/` against these components, +and derived into each preset. The dashboard and playground use those installed +bundles; their chat orchestration remains application-owned rather than a +runtime API. +Hosted release validation remains a separate release gate. Private-package +publication is neither required nor planned for these source-distributed items. diff --git a/packages/remix_agent/docs/provenance.md b/registry_source/docs/adr/agent/provenance.md similarity index 89% rename from packages/remix_agent/docs/provenance.md rename to registry_source/docs/adr/agent/provenance.md index 4abe8dc9a..ecb9333b4 100644 --- a/packages/remix_agent/docs/provenance.md +++ b/registry_source/docs/adr/agent/provenance.md @@ -25,10 +25,8 @@ vocabulary benchmark. The pinned local checkout is: The benchmark informed workflow questions such as component boundaries, disclosure placement, lifecycle states, live-edge behavior, and functional icon choices. It is not an API, branding, or pixel-parity target. The implementation -uses Flutter, Naked UI behavior, Remix controls, generated Mix specs, and the -independently licensed `lucide_icons_flutter` dependency. That dependency is -exactly pinned because the implementation references a small, web-safe subset -of its font codepoints instead of importing its full generated catalog. It +uses Flutter, Naked UI behavior, Remix controls, generated Mix specs, and +this repository's own `remix_ui_icons` font for its functional glyphs. It deliberately ships empty visual defaults; the example's light/dark appearance is local and non-exported. Remix Agent also defines its own controlled/uncontrolled contracts, accessibility tree, keyboard behavior, diff --git a/registry_source/docs/adr/fortal/0001-authoring-package-retention.md b/registry_source/docs/adr/fortal/0001-authoring-package-retention.md new file mode 100644 index 000000000..8425b4aeb --- /dev/null +++ b/registry_source/docs/adr/fortal/0001-authoring-package-retention.md @@ -0,0 +1,36 @@ +# ADR 0001 — Retain the private Fortal authoring package + +Date: 2026-09-14. Status: superseded by [ADR 0002](0002-registry-source.md) — the +trigger below fired and the source moved to `registry_source/lib/src/fortal`. + +## Decision + +Keep `remix_fortal` as a private workspace authoring and parity-test package. +Consumers receive its derived, application-owned source from the Fortal registry, +not a new published dependency. This follows the ownership direction in +[the clean-sheet decision](../../../../open_code/CLEAN_SHEET.md) and +[the Fortal preset decision](../../../../open_code/PRESETS.md). + +A future directory-only authoring layout is possible, but does not currently +improve the installed consumer contract. Do not move the source as part of Agent +registry integration. + +## Cost and trigger + +At this decision, 78 Dart files under app `lib/` directories import the package: +39 in demo, 30 in dashboard, and 9 in playground. A migration must retarget those +imports, preserve generated output and parity fixtures, update workspace and +build configuration, and retain a single authoring source for derivation. The +file counts measure import sites, not estimated engineering effort. + +Reconsider when the private package boundary causes a demonstrated maintenance +problem, such as duplicate source ownership or blocking consumer adoption, and +a concrete migration shows lower ongoing cost than retaining it. Merely matching +the eventual directory shape is not a sufficient trigger. + +## Consequences + +Package tests and Radix parity remain useful independent checks. Applications +can move to installed source incrementally when justified. Until then, neither +the source package's version nor its workspace imports imply a hosted release. +No source move, app migration, or release-policy change is authorized by this ADR. diff --git a/registry_source/docs/adr/fortal/0002-registry-source.md b/registry_source/docs/adr/fortal/0002-registry-source.md new file mode 100644 index 000000000..025a15689 --- /dev/null +++ b/registry_source/docs/adr/fortal/0002-registry-source.md @@ -0,0 +1,65 @@ +# ADR 0002 — Registry source + +Date: 2026-09-14. Status: accepted. Supersedes +[ADR 0001](0001-authoring-package-retention.md). + +## Decision + +Every file under `packages/remix_cli/lib/src/registry/**/templates/` is build +output. One builder, `tool/build_registry.dart`, derives it from analyzer-checked +Dart under `registry_source/`, and a drift check over the whole tree fails CI on +any hand edit. Applications consume installed source only. + +```text +packages/ what pub.dev sees: remix, remix_ui_icons, remix_cli +registry_source/ one private package: the authored catalog + lib/src/default/ the default preset, authored under the word Vanilla + lib/src/fortal/ the Fortal preset, authored under the word Fortal + lib/src/agent/ Agent behavior, merged into both presets +``` + +`registry_source` is one package, not three, so nothing in it is a dependency +of anything: the recipes reach Agent behavior by relative path. It has no +version and no publish target. `packages/` now reads as "published". + +## What this reverses + +| record | it said | now | +|---|---|---| +| [PRESETS.md](../../../../open_code/PRESETS.md) decision 7 | `remix_fortal` leaves pub.dev; **the directory stays** at `packages/remix_fortal` | the source is `registry_source/lib/src/fortal`; `remix_fortal` is no longer a package name anywhere | +| [ADR 0001](0001-authoring-package-retention.md) | retain the package; reconsider on duplicate source ownership | the trigger fired: `apps/dashboard` carried 38 installed `lib/ui/` files *and* 31 `package:remix_fortal` importers, bridged by five identity-mapped enum switches in `pages/chat_page.dart` | +| [Agent ADR 0001](../agent/0001-package-boundary.md) | recipes are hand-authored `.tmpl` files under `open_code/agent_recipes/` | recipes are Dart in each preset's `recipes/`, analyzed against `lib/src/agent` beside them | + +Not reversed: Agent behavior stays private and installs as source. Publishing +it was considered and rejected; the Agent ADR's "neither required nor planned" +stands. It is not a package at all any more, only source that is tested and +derived. + +## Why + +The default preset was 37 hand-written templates with no analyzer behind them, +and the 16 Agent recipes were the same. Both now derive from formatted, +analyzed source, which is the rule PRESETS.md decision 3 already stated for +Fortal. The proof for the default preset was mechanical: rendering the +templates with the word `Vanilla` (zero collisions across the tree) and deriving +back was byte-identical before any human edited the result. + +The Fortal move is the retention ADR's own trigger. Three applications reached +`package:remix_fortal` directly while one of them also installed the derived +source; the dashboard needed enum bridges to hand Fortal settings to its own +installed `Ui*` types. Once every application installs, the package is only a +source tree, and `packages/` should not suggest otherwise. + +## The invariant + +Derivation is plain text substitution of one word per source, asserted to +round-trip. The authoring word must therefore appear nowhere in a source +package except as the prefix, comments included. Recipes are the one +two-word case: they name Agent behavior while authoring, and the builder +rewrites the `../../agent/` import to the installed relative path and the +identifier-initial `Agent` to the consumer prefix before the preset's own word +goes. `uiAgentComposerRecipe()` keeps its domain name; `AgentComposerStyler` +becomes the installed `UiComposerStyler`. + +`tool/check_open_code_dogfood.dart` fails when any `apps/*/lib` file outside +`lib/ui/` imports `package:registry_source`. diff --git a/packages/remix_agent/CHANGELOG.md b/registry_source/docs/agent/CHANGELOG.md similarity index 100% rename from packages/remix_agent/CHANGELOG.md rename to registry_source/docs/agent/CHANGELOG.md diff --git a/packages/remix_agent/README.md b/registry_source/docs/agent/README.md similarity index 65% rename from packages/remix_agent/README.md rename to registry_source/docs/agent/README.md index 7e9761c75..600e74608 100644 --- a/packages/remix_agent/README.md +++ b/registry_source/docs/agent/README.md @@ -3,12 +3,13 @@ Unstyled Flutter widgets for long-running agent work: compose a prompt, follow a transcript, pause for permission, and inspect execution and plans. -This branch is a draft stacked on the open-code workflow. See -[ADR 0001](docs/adr/0001-package-boundary.md) for the package boundary and the -runtime/recipe split. Agent is not yet an installable CLI registry item. +The eight surfaces are application-owned source in the default CLI registry. +See [ADR 0001](docs/adr/0001-package-boundary.md) for the private authoring +package boundary and the runtime/recipe split. They are available from both +existing presets; there is no separate Agent preset. This private workspace package depends on [remix](https://pub.dev/packages/remix), -Mix's styling runtime, and Lucide's icon font. It ships no theme, token scope, +Mix's styling runtime, and remix_ui_icons. It ships no theme, token scope, Fortal dependency, or model SDK. Every visual surface exposes a generated `Agent*Spec` and `Agent*Styler`; every visual field is empty until the host supplies a style. @@ -18,7 +19,7 @@ and `disclosureStyle`, so their hover, press, focus, selected variants, animations, and modifiers resolve against the child control's own state. Functional glyphs (send, stop, copy, retry, disclosure, tool, and statuses) -have neutral Material-free Lucide defaults. Their public builders remain the +have neutral Material-free `remix_ui_icons` defaults. Their public builders remain the replacement point; visual color and size still come from host styles. A `MixScope` is not an Agent requirement. Add one only when the host's own @@ -27,13 +28,36 @@ styles resolve scoped Mix tokens. The catalog's Composer uses the installed ## Install -The package is unpublished. Use it as a workspace member or a local path -dependency, then: +The package remains unpublished authoring/test source. Install individual +surfaces through the project-local checkout CLI (see [open-code setup](../../open_code/README.md)): + +```shell +dart run remix_cli:remix init --prefix Ui --preset default +dart run remix_cli:remix add composer +``` + +The CLI installs shared `models` and `support` when needed, enables Mix's +spec-styler builder for the installed source in `build.yaml`, and generates +adapters in the application. Existing builder settings are preserved; explicit +exclusions or disabled generation must be resolved by the host. Public models +are exported from the UI barrel, internal support helpers are not. + +Consumers import their local barrel, not the private package. Authoring names +in the table below use `Agent`; installed names follow the chosen prefix: +`AgentComposer` becomes `UiComposer`. + +```dart +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import 'ui/ui.dart'; +``` + +For work on the private authoring package itself, its barrel remains: ```dart import 'package:flutter/widgets.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; ``` Ordinary surfaces need only a normal Flutter host. `AgentComposer` is the one @@ -56,8 +80,9 @@ does not. A model picker built with `RemixSelect` needs the same `Overlay`. | `AgentPlan` | Task plan with pending / in-progress / completed / cancelled and a completion count. | | `AgentActivity` | Slim activity ledger. Hosts supply each item’s child. | -There is no chat shell, sidebar, or file tree. Compose these widgets in the -host. +There is no runtime chat shell, sidebar, or file tree. The dashboard and +playground compose them into an interactive simulated chat; applications retain +ownership of that orchestration. ## What this is not @@ -70,14 +95,14 @@ host. An application styles these surfaces with the same `remix_cli` source it installs for the rest of its UI. There is no Agent theme and no Agent preset. -A surface takes more than one styler, so a recipe returns a **bundle** and the -call site spreads it. `AgentComposer` takes five: its own anatomy, plus +A surface can take more than one styler, so each opt-in recipe returns a +**bundle** and the call site spreads it. `AgentComposer` takes five: its own anatomy, plus unresolved stylers for the card, the field, and the two buttons. ```dart final recipe = uiAgentComposerRecipe(); -AgentComposer( +UiComposer( onSubmit: submit, style: recipe.style, surfaceStyle: recipe.surfaceStyle, @@ -89,20 +114,10 @@ AgentComposer( The bundle calls the application's installed `uiCardStyle`, `uiTextAreaStyle`, and `uiIconButtonStyle` and adds only Agent-specific geometry, so editing one -of those files changes the composer with it. A working recipe lives in -[`example/lib/agent_recipes.dart`](example/lib/agent_recipes.dart), against -source the CLI installed into `example/lib/ui/`. The other seven surfaces still -use the catalog's local review-only stylers. - -## Local catalog - -A full review page lives in `example/`. It is unpublished and meant for -walking every surface: - -```bash -cd packages/remix_agent/example -fvm flutter run -d chrome -``` +of those files changes the composer with it. Install a bundle with +`remix add composer_recipe`; all eight follow the same `_recipe` +convention. Default recipes use default tokens and Fortal recipes use Fortal +tokens and controls without crossing presets. ## Host diff --git a/packages/remix_fortal/CHANGELOG.md b/registry_source/docs/fortal/CHANGELOG.md similarity index 100% rename from packages/remix_fortal/CHANGELOG.md rename to registry_source/docs/fortal/CHANGELOG.md diff --git a/packages/remix_fortal/README.md b/registry_source/docs/fortal/README.md similarity index 100% rename from packages/remix_fortal/README.md rename to registry_source/docs/fortal/README.md diff --git a/packages/remix_fortal/example/main.dart b/registry_source/example/main.dart similarity index 99% rename from packages/remix_fortal/example/main.dart rename to registry_source/example/main.dart index 9f831df0f..20f1958c2 100644 --- a/packages/remix_fortal/example/main.dart +++ b/registry_source/example/main.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { runApp(const FortalExampleApp()); diff --git a/packages/remix_fortal/example/misc/radix_button_comprehensive.dart b/registry_source/example/misc/radix_button_comprehensive.dart similarity index 99% rename from packages/remix_fortal/example/misc/radix_button_comprehensive.dart rename to registry_source/example/misc/radix_button_comprehensive.dart index 8dcbfc392..079f267b3 100644 --- a/packages/remix_fortal/example/misc/radix_button_comprehensive.dart +++ b/registry_source/example/misc/radix_button_comprehensive.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { runApp(const FortalButtonComprehensiveTest()); diff --git a/packages/remix_fortal/example/misc/radix_button_example.dart b/registry_source/example/misc/radix_button_example.dart similarity index 99% rename from packages/remix_fortal/example/misc/radix_button_example.dart rename to registry_source/example/misc/radix_button_example.dart index 691a4e3c9..9f8ce6edf 100644 --- a/packages/remix_fortal/example/misc/radix_button_example.dart +++ b/registry_source/example/misc/radix_button_example.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { runApp(const FortalButtonExampleApp()); diff --git a/registry_source/lib/agent.dart b/registry_source/lib/agent.dart new file mode 100644 index 000000000..452b91582 --- /dev/null +++ b/registry_source/lib/agent.dart @@ -0,0 +1,19 @@ +/// Agent-run UI surfaces for Remix. +/// +/// Remix Agent ships conversation, permission, and progress widgets with no +/// theme, no token scope, and no model SDK. Import +/// `package:remix/remix.dart` alongside this library when a host needs base +/// Remix widgets or stylers. This barrel does not re-export Remix. +library; + +export 'src/agent/components/activity.dart'; +export 'src/agent/components/answer.dart'; +export 'src/agent/components/composer.dart'; +export 'src/agent/components/execution.dart'; +export 'src/agent/components/message.dart'; +export 'src/agent/components/permission.dart'; +export 'src/agent/components/plan.dart'; +export 'src/agent/components/transcript.dart'; +export 'src/agent/models/activity_item.dart'; +export 'src/agent/models/plan_item.dart'; +export 'src/agent/models/statuses.dart'; diff --git a/registry_source/lib/fortal.dart b/registry_source/lib/fortal.dart new file mode 100644 index 000000000..cc91aa87c --- /dev/null +++ b/registry_source/lib/fortal.dart @@ -0,0 +1,60 @@ +/// Fortal: a Radix Themes-inspired preset theme and widget catalog for Remix. +/// +/// Fortal is built on [package:remix](https://pub.dev/packages/remix). It adds a +/// token scope ([FortalScope]), the `fortal*Style()` recipes, and a matching +/// catalog of ready-made `Fortal*` widgets. It does not re-export `remix`; add +/// `package:remix/remix.dart` alongside this import when you need the base +/// widgets or stylers. +library; + +/// ICONS +export 'src/fortal/icons.dart'; + +/// THEME +export 'src/fortal/theme/radix_colors.dart'; +export 'src/fortal/theme/theme.dart'; +export 'src/fortal/components/base_button.dart' + hide + FortalBaseButtonStateStyle, + FortalBaseButtonStateStyles, + fortalBaseButtonStateStyles; + +/// RECIPES +export 'src/fortal/components/accordion.dart'; +export 'src/fortal/components/avatar.dart'; +export 'src/fortal/components/badge.dart'; +export 'src/fortal/components/button.dart'; +export 'src/fortal/components/callout.dart'; +export 'src/fortal/components/card.dart'; +export 'src/fortal/components/chart.dart'; +export 'src/fortal/components/checkbox.dart'; +export 'src/fortal/components/code.dart'; +export 'src/fortal/components/data_list.dart'; +export 'src/fortal/components/data_table.dart'; +export 'src/fortal/components/dialog.dart'; +export 'src/fortal/components/disclosure.dart'; +export 'src/fortal/components/divider.dart'; +export 'src/fortal/components/heading.dart'; +export 'src/fortal/components/icon_button.dart'; +export 'src/fortal/components/kbd.dart'; +export 'src/fortal/components/link.dart'; +export 'src/fortal/components/menu.dart'; +export 'src/fortal/components/popover.dart'; +export 'src/fortal/components/progress.dart'; +export 'src/fortal/components/radio.dart'; +export 'src/fortal/components/segmented_control.dart'; +export 'src/fortal/components/select.dart'; +export 'src/fortal/components/sidebar.dart'; +export 'src/fortal/components/sidebar_layout.dart'; +export 'src/fortal/components/skeleton.dart'; +export 'src/fortal/components/slider.dart'; +export 'src/fortal/components/spinner.dart'; +export 'src/fortal/components/switch.dart'; +export 'src/fortal/components/tabs.dart'; +export 'src/fortal/components/text.dart'; +export 'src/fortal/components/textfield.dart'; +export 'src/fortal/components/toast.dart'; +export 'src/fortal/components/toggle.dart'; +export 'src/fortal/components/toggle_group.dart'; +export 'src/fortal/components/tooltip.dart'; +export 'src/fortal/components/typography.dart'; diff --git a/packages/remix_agent/lib/src/components/activity.dart b/registry_source/lib/src/agent/components/activity.dart similarity index 90% rename from packages/remix_agent/lib/src/components/activity.dart rename to registry_source/lib/src/agent/components/activity.dart index f65d74869..bd5c3d099 100644 --- a/packages/remix_agent/lib/src/components/activity.dart +++ b/registry_source/lib/src/agent/components/activity.dart @@ -5,9 +5,9 @@ import 'package:remix/remix.dart'; import '../models/activity_item.dart'; import '../models/statuses.dart'; -import '../style/functional_glyph.dart'; -import '../style/live_edge.dart'; -import '../style/style_builder.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; part 'activity.g.dart'; @@ -71,23 +71,23 @@ class AgentActivity extends StatefulWidget { } class _AgentActivityState extends State { - late bool _uncontrolledExpanded; + late final AgentDisclosureEngine _disclosure; - bool get _expanded => - widget.isWorking ? true : (widget.expanded ?? _uncontrolledExpanded); + bool get _expanded => widget.isWorking ? true : (_disclosure.value); @override void initState() { super.initState(); - _uncontrolledExpanded = widget.expanded ?? widget.defaultExpanded; + _disclosure = AgentDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); } @override void didUpdateWidget(AgentActivity oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.expanded != null && widget.expanded == null) { - _uncontrolledExpanded = oldWidget.expanded!; - } + _disclosure.reconcile(widget.expanded); if (!oldWidget.isWorking && widget.isWorking) { _request(true, lifecycle: true); } else if (oldWidget.isWorking && @@ -99,9 +99,7 @@ class _AgentActivityState extends State { void _request(bool next, {bool lifecycle = false}) { if (widget.isWorking && !lifecycle) return; - if (widget.expanded == null && next != _uncontrolledExpanded) { - setState(() => _uncontrolledExpanded = next); - } + if (_disclosure.request(next)) setState(() {}); widget.onExpandedChanged?.call(next); } @@ -148,24 +146,9 @@ class _AgentActivityState extends State { AgentFunctionalGlyph(kind: _statusGlyph(item.status), spec: iconSpec), ); - Widget _indicator( - BuildContext context, - AgentActivitySpec spec, - bool expanded, - ) => - widget.indicatorBuilder?.call(context, expanded) ?? - StyleSpecBuilder( - styleSpec: spec.indicator, - builder: (context, iconSpec) => AgentFunctionalGlyph( - kind: .chevron, - spec: iconSpec, - expanded: expanded, - ), - ); - @override Widget build(BuildContext context) { - return AgentStyleBuilder( + return RemixStyleSpecBuilder( style: widget.style, styleSpec: widget.styleSpec, builder: (context, spec) => Semantics( @@ -183,7 +166,11 @@ class _AgentActivityState extends State { Expanded(child: trigger!), // Preserve the count alignment and expansion cue while working. // RemixDisclosure keeps the forced-open header non-toggleable. - _indicator(context, spec, state.isExpanded), + AgentDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), ], ), trigger: Row( diff --git a/packages/remix_agent/lib/src/components/activity.g.dart b/registry_source/lib/src/agent/components/activity.g.dart similarity index 100% rename from packages/remix_agent/lib/src/components/activity.g.dart rename to registry_source/lib/src/agent/components/activity.g.dart diff --git a/packages/remix_agent/lib/src/components/answer.dart b/registry_source/lib/src/agent/components/answer.dart similarity index 85% rename from packages/remix_agent/lib/src/components/answer.dart rename to registry_source/lib/src/agent/components/answer.dart index ae294dc8c..244bebd29 100644 --- a/packages/remix_agent/lib/src/components/answer.dart +++ b/registry_source/lib/src/agent/components/answer.dart @@ -4,8 +4,8 @@ import 'package:mix_annotations/mix_annotations.dart'; import 'package:remix/remix.dart'; import '../models/statuses.dart'; -import '../style/functional_glyph.dart'; -import '../style/style_builder.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; part 'answer.g.dart'; @@ -72,24 +72,23 @@ class AgentAnswer extends StatefulWidget { } class _AgentAnswerState extends State { - late bool _uncontrolledSourcesExpanded; + late final AgentDisclosureEngine _disclosure; - bool get _sourcesExpanded => - widget.sourcesExpanded ?? _uncontrolledSourcesExpanded; + bool get _sourcesExpanded => _disclosure.value; @override void initState() { super.initState(); - _uncontrolledSourcesExpanded = - widget.sourcesExpanded ?? widget.defaultSourcesExpanded; + _disclosure = AgentDisclosureEngine( + value: widget.sourcesExpanded, + defaultValue: widget.defaultSourcesExpanded, + ); } @override void didUpdateWidget(AgentAnswer oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.sourcesExpanded != null && widget.sourcesExpanded == null) { - _uncontrolledSourcesExpanded = oldWidget.sourcesExpanded!; - } + _disclosure.reconcile(widget.sourcesExpanded); final beganStreaming = !oldWidget.status.isStreaming && widget.status.isStreaming; final newStreamingIdentity = @@ -98,34 +97,16 @@ class _AgentAnswerState extends State { } void _requestSources(bool next) { - if (widget.sourcesExpanded == null && - next != _uncontrolledSourcesExpanded) { - setState(() => _uncontrolledSourcesExpanded = next); - } + if (_disclosure.request(next)) setState(() {}); widget.onSourcesExpandedChanged?.call(next); } - Widget _indicator( - BuildContext context, - AgentAnswerSpec spec, - bool expanded, - ) => - widget.sourcesIndicatorBuilder?.call(context, expanded) ?? - StyleSpecBuilder( - styleSpec: spec.indicator, - builder: (context, iconSpec) => AgentFunctionalGlyph( - kind: .chevron, - spec: iconSpec, - expanded: expanded, - ), - ); - @override Widget build(BuildContext context) { final revealActions = !widget.status.isStreaming && (widget.showActions ?? widget.status.showsActions); - return AgentStyleBuilder( + return RemixStyleSpecBuilder( style: widget.style, styleSpec: widget.styleSpec, builder: (context, spec) => Semantics( @@ -151,7 +132,11 @@ class _AgentAnswerState extends State { triggerBuilder: (context, state, trigger) => Row( children: [ Expanded(child: trigger!), - _indicator(context, spec, state.isExpanded), + AgentDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.sourcesIndicatorBuilder, + ), ], ), trigger: StyledText( diff --git a/packages/remix_agent/lib/src/components/answer.g.dart b/registry_source/lib/src/agent/components/answer.g.dart similarity index 100% rename from packages/remix_agent/lib/src/components/answer.g.dart rename to registry_source/lib/src/agent/components/answer.g.dart diff --git a/packages/remix_agent/lib/src/components/composer.dart b/registry_source/lib/src/agent/components/composer.dart similarity index 91% rename from packages/remix_agent/lib/src/components/composer.dart rename to registry_source/lib/src/agent/components/composer.dart index 3d0a3b347..26b4e4df4 100644 --- a/packages/remix_agent/lib/src/components/composer.dart +++ b/registry_source/lib/src/agent/components/composer.dart @@ -4,8 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:mix_annotations/mix_annotations.dart'; import 'package:remix/remix.dart'; -import '../style/functional_glyph.dart'; -import '../style/style_builder.dart'; +import '../support/functional_glyph.dart'; part 'composer.g.dart'; @@ -121,20 +120,32 @@ class _AgentComposerState extends State { if (!identical(oldWidget.controller, widget.controller)) { final seed = _controller.text; _controller.removeListener(_handleControllerChanged); - _ownedController?.dispose(); + final oldOwnedController = _ownedController; _ownedController = null; _controller = widget.controller ?? (_ownedController = TextEditingController(text: seed)); _text = _controller.text; _controller.addListener(_handleControllerChanged); + _disposeAfterFrame(oldOwnedController); } if (!identical(oldWidget.focusNode, widget.focusNode)) { - _ownedFocusNode?.dispose(); + final oldOwnedFocusNode = _ownedFocusNode; _ownedFocusNode = null; + _disposeAfterFrame(oldOwnedFocusNode); } } + /// Releases a superseded owned object once the child has let go of it. + /// + /// The same deferral the transcript uses for its scroll controller: the child + /// RemixTextArea still holds the old controller and focus node until this + /// frame's rebuild detaches them, and detaching touches a disposed object. + void _disposeAfterFrame(ChangeNotifier? superseded) { + if (superseded == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) => superseded.dispose()); + } + void _submit() { if (!_canSubmit || _isComposing) return; final prompt = _text.trim(); @@ -170,7 +181,7 @@ class _AgentComposerState extends State { @override Widget build(BuildContext context) { - return AgentStyleBuilder( + return RemixStyleSpecBuilder( style: widget.style, styleSpec: widget.styleSpec, // Keep the field and action in separate accessibility nodes. diff --git a/packages/remix_agent/lib/src/components/composer.g.dart b/registry_source/lib/src/agent/components/composer.g.dart similarity index 100% rename from packages/remix_agent/lib/src/components/composer.g.dart rename to registry_source/lib/src/agent/components/composer.g.dart diff --git a/packages/remix_agent/lib/src/components/execution.dart b/registry_source/lib/src/agent/components/execution.dart similarity index 87% rename from packages/remix_agent/lib/src/components/execution.dart rename to registry_source/lib/src/agent/components/execution.dart index 80824db20..a015b9ecc 100644 --- a/packages/remix_agent/lib/src/components/execution.dart +++ b/registry_source/lib/src/agent/components/execution.dart @@ -4,9 +4,9 @@ import 'package:mix_annotations/mix_annotations.dart'; import 'package:remix/remix.dart'; import '../models/statuses.dart'; -import '../style/functional_glyph.dart'; -import '../style/style_builder.dart'; -import 'transcript.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; part 'execution.g.dart'; @@ -85,22 +85,23 @@ class AgentExecution extends StatefulWidget { } class _AgentExecutionState extends State { - late bool _uncontrolledExpanded; + late final AgentDisclosureEngine _disclosure; - bool get _expanded => widget.expanded ?? _uncontrolledExpanded; + bool get _expanded => _disclosure.value; @override void initState() { super.initState(); - _uncontrolledExpanded = widget.expanded ?? widget.defaultExpanded; + _disclosure = AgentDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); } @override void didUpdateWidget(AgentExecution oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.expanded != null && widget.expanded == null) { - _uncontrolledExpanded = oldWidget.expanded!; - } + _disclosure.reconcile(widget.expanded); if (!oldWidget.status.isWorking && widget.status.isWorking) { _request(true); } else if (oldWidget.status.isWorking && @@ -111,9 +112,7 @@ class _AgentExecutionState extends State { } void _request(bool next) { - if (widget.expanded == null && next != _uncontrolledExpanded) { - setState(() => _uncontrolledExpanded = next); - } + if (_disclosure.request(next)) setState(() {}); widget.onExpandedChanged?.call(next); } @@ -141,21 +140,6 @@ class _AgentExecutionState extends State { AgentExecutionStatus.cancelled => .cancelledCircle, }; - Widget _indicator( - BuildContext context, - AgentExecutionSpec spec, - bool expanded, - ) => - widget.indicatorBuilder?.call(context, expanded) ?? - StyleSpecBuilder( - styleSpec: spec.indicator, - builder: (context, iconSpec) => AgentFunctionalGlyph( - kind: .chevron, - spec: iconSpec, - expanded: expanded, - ), - ); - Widget _toolIcon(AgentExecutionSpec spec) { final icon = widget.icon; if (icon != null) return icon; @@ -168,7 +152,7 @@ class _AgentExecutionState extends State { @override Widget build(BuildContext context) { - return AgentStyleBuilder( + return RemixStyleSpecBuilder( style: widget.style, styleSpec: widget.styleSpec, builder: (context, spec) => Semantics( @@ -186,7 +170,11 @@ class _AgentExecutionState extends State { triggerBuilder: (context, state, trigger) => Row( children: [ Expanded(child: trigger!), - _indicator(context, spec, state.isExpanded), + AgentDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), ], ), trigger: RowBox( @@ -231,11 +219,20 @@ class _AgentExecutionState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - AgentTranscript( - children: [widget.child], - followOutput: widget.status.isWorking, - busy: widget.status.isWorking, + // Deliberately not an AgentTranscript. That installs + // Arrow/Page/Home/End shortcuts and its own Semantics + // container, and an execution card is normally nested inside + // a host transcript: the inner list shrink-wraps to a zero + // scroll extent but its action still consumes those intents, + // so focus landing here stopped the outer transcript from + // scrolling, and its `busy` value announced the status a + // second time. This is the primitive plan and activity use. + Semantics( label: widget.outputLabel, + child: AgentLiveEdgeScrollView( + followOutput: widget.status.isWorking, + child: widget.child, + ), ), if (widget.showActions && widget.status.isSettled) RowBox( diff --git a/packages/remix_agent/lib/src/components/execution.g.dart b/registry_source/lib/src/agent/components/execution.g.dart similarity index 100% rename from packages/remix_agent/lib/src/components/execution.g.dart rename to registry_source/lib/src/agent/components/execution.g.dart diff --git a/packages/remix_agent/lib/src/components/message.dart b/registry_source/lib/src/agent/components/message.dart similarity index 95% rename from packages/remix_agent/lib/src/components/message.dart rename to registry_source/lib/src/agent/components/message.dart index d6fbccc54..7f37af66c 100644 --- a/packages/remix_agent/lib/src/components/message.dart +++ b/registry_source/lib/src/agent/components/message.dart @@ -5,7 +5,7 @@ import 'package:mix_annotations/mix_annotations.dart'; import 'package:remix/remix.dart'; import '../models/statuses.dart'; -import '../style/style_builder.dart'; +import '../support/disclosure.dart'; part 'message.g.dart'; @@ -73,7 +73,7 @@ class AgentMessage extends StatelessWidget { @override Widget build(BuildContext context) { - return AgentStyleBuilder( + return RemixStyleSpecBuilder( style: style, styleSpec: styleSpec, builder: (context, spec) { @@ -169,30 +169,29 @@ class AgentMessageCollapsible extends StatefulWidget { } class _AgentMessageCollapsibleState extends State { - late bool _uncontrolledExpanded; + late final AgentDisclosureEngine _disclosure; bool _overflows = false; - bool get _expanded => widget.expanded ?? _uncontrolledExpanded; + bool get _expanded => _disclosure.value; @override void initState() { super.initState(); - _uncontrolledExpanded = widget.expanded ?? widget.defaultExpanded; + _disclosure = AgentDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); } @override void didUpdateWidget(AgentMessageCollapsible oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.expanded != null && widget.expanded == null) { - _uncontrolledExpanded = oldWidget.expanded!; - } + _disclosure.reconcile(widget.expanded); } void _toggle() { final next = !_expanded; - if (widget.expanded == null) { - setState(() => _uncontrolledExpanded = next); - } + if (_disclosure.request(next)) setState(() {}); widget.onExpandedChanged?.call(next); } @@ -205,7 +204,7 @@ class _AgentMessageCollapsibleState extends State { @override Widget build(BuildContext context) { - return AgentStyleBuilder( + return RemixStyleSpecBuilder( style: widget.style, styleSpec: widget.styleSpec, builder: (context, spec) { diff --git a/packages/remix_agent/lib/src/components/message.g.dart b/registry_source/lib/src/agent/components/message.g.dart similarity index 100% rename from packages/remix_agent/lib/src/components/message.g.dart rename to registry_source/lib/src/agent/components/message.g.dart diff --git a/packages/remix_agent/lib/src/components/permission.dart b/registry_source/lib/src/agent/components/permission.dart similarity index 91% rename from packages/remix_agent/lib/src/components/permission.dart rename to registry_source/lib/src/agent/components/permission.dart index b9e06183c..616d94ba5 100644 --- a/packages/remix_agent/lib/src/components/permission.dart +++ b/registry_source/lib/src/agent/components/permission.dart @@ -4,8 +4,8 @@ import 'package:mix_annotations/mix_annotations.dart'; import 'package:remix/remix.dart'; import '../models/statuses.dart'; -import '../style/functional_glyph.dart'; -import '../style/style_builder.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; part 'permission.g.dart'; @@ -88,26 +88,25 @@ class AgentPermission extends StatefulWidget { } class _AgentPermissionState extends State { - late bool _uncontrolledDetailsExpanded; + late final AgentDisclosureEngine _disclosure; bool _decisionSubmitted = false; - bool get _detailsExpanded => - widget.detailsExpanded ?? _uncontrolledDetailsExpanded; + bool get _detailsExpanded => _disclosure.value; @override void initState() { super.initState(); - _uncontrolledDetailsExpanded = - widget.detailsExpanded ?? - (widget.status.keepsDetailsOpen || widget.defaultDetailsExpanded); + _disclosure = AgentDisclosureEngine( + value: widget.detailsExpanded, + defaultValue: + widget.status.keepsDetailsOpen || widget.defaultDetailsExpanded, + ); } @override void didUpdateWidget(AgentPermission oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.detailsExpanded != null && widget.detailsExpanded == null) { - _uncontrolledDetailsExpanded = oldWidget.detailsExpanded!; - } + _disclosure.reconcile(widget.detailsExpanded); final returnedToPending = oldWidget.status != AgentPermissionStatus.pending && widget.status == AgentPermissionStatus.pending; @@ -124,10 +123,7 @@ class _AgentPermissionState extends State { } void _requestDetails(bool next) { - if (widget.detailsExpanded == null && - next != _uncontrolledDetailsExpanded) { - setState(() => _uncontrolledDetailsExpanded = next); - } + if (_disclosure.request(next)) setState(() {}); widget.onDetailsExpandedChanged?.call(next); } @@ -174,21 +170,6 @@ class _AgentPermissionState extends State { AgentPermissionStatus.error => spec.errorStatus, }; - Widget _indicator( - BuildContext context, - AgentPermissionSpec spec, - bool expanded, - ) => - widget.indicatorBuilder?.call(context, expanded) ?? - StyleSpecBuilder( - styleSpec: spec.indicator, - builder: (context, iconSpec) => AgentFunctionalGlyph( - kind: .chevron, - spec: iconSpec, - expanded: expanded, - ), - ); - // Horizontal by default; callers may stack actions without losing the // action slot's box, modifiers, or nested style resolution. StyleSpec _actionsStyle(AgentPermissionSpec spec) { @@ -207,7 +188,7 @@ class _AgentPermissionState extends State { @override Widget build(BuildContext context) { - return AgentStyleBuilder( + return RemixStyleSpecBuilder( style: widget.style, styleSpec: widget.styleSpec, builder: (context, spec) => Semantics( @@ -252,7 +233,9 @@ class _AgentPermissionState extends State { spec: iconSpec, ), ), - StyledText(_statusLabel, styleSpec: spec.status), + Flexible( + child: StyledText(_statusLabel, styleSpec: spec.status), + ), ], ), ), @@ -265,7 +248,11 @@ class _AgentPermissionState extends State { triggerBuilder: (context, state, trigger) => Row( children: [ Expanded(child: trigger!), - _indicator(context, spec, state.isExpanded), + AgentDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), ], ), trigger: StyledText( diff --git a/packages/remix_agent/lib/src/components/permission.g.dart b/registry_source/lib/src/agent/components/permission.g.dart similarity index 100% rename from packages/remix_agent/lib/src/components/permission.g.dart rename to registry_source/lib/src/agent/components/permission.g.dart diff --git a/packages/remix_agent/lib/src/components/plan.dart b/registry_source/lib/src/agent/components/plan.dart similarity index 90% rename from packages/remix_agent/lib/src/components/plan.dart rename to registry_source/lib/src/agent/components/plan.dart index 2c2492a43..8eb760b90 100644 --- a/packages/remix_agent/lib/src/components/plan.dart +++ b/registry_source/lib/src/agent/components/plan.dart @@ -5,9 +5,9 @@ import 'package:remix/remix.dart'; import '../models/plan_item.dart'; import '../models/statuses.dart'; -import '../style/functional_glyph.dart'; -import '../style/live_edge.dart'; -import '../style/style_builder.dart'; +import '../support/disclosure.dart'; +import '../support/functional_glyph.dart'; +import '../support/live_edge.dart'; part 'plan.g.dart'; @@ -66,22 +66,23 @@ class AgentPlan extends StatefulWidget { } class _AgentPlanState extends State { - late bool _uncontrolledExpanded; + late final AgentDisclosureEngine _disclosure; - bool get _expanded => widget.expanded ?? _uncontrolledExpanded; + bool get _expanded => _disclosure.value; @override void initState() { super.initState(); - _uncontrolledExpanded = widget.expanded ?? widget.defaultExpanded; + _disclosure = AgentDisclosureEngine( + value: widget.expanded, + defaultValue: widget.defaultExpanded, + ); } @override void didUpdateWidget(AgentPlan oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.expanded != null && widget.expanded == null) { - _uncontrolledExpanded = oldWidget.expanded!; - } + _disclosure.reconcile(widget.expanded); final wasWorking = oldWidget.isWorking; final working = widget.isWorking; if (wasWorking && !working && widget.collapseOnComplete) { @@ -92,9 +93,7 @@ class _AgentPlanState extends State { } void _request(bool next) { - if (widget.expanded == null && next != _uncontrolledExpanded) { - setState(() => _uncontrolledExpanded = next); - } + if (_disclosure.request(next)) setState(() {}); widget.onExpandedChanged?.call(next); } @@ -147,21 +146,9 @@ class _AgentPlanState extends State { ); } - Widget _indicator(BuildContext context, AgentPlanSpec spec, bool expanded) { - return widget.indicatorBuilder?.call(context, expanded) ?? - StyleSpecBuilder( - styleSpec: spec.indicator, - builder: (context, iconSpec) => AgentFunctionalGlyph( - kind: .chevron, - spec: iconSpec, - expanded: expanded, - ), - ); - } - @override Widget build(BuildContext context) { - return AgentStyleBuilder( + return RemixStyleSpecBuilder( style: widget.style, styleSpec: widget.styleSpec, builder: (context, spec) => Semantics( @@ -176,7 +163,11 @@ class _AgentPlanState extends State { triggerBuilder: (context, state, trigger) => Row( children: [ Expanded(child: trigger!), - _indicator(context, spec, state.isExpanded), + AgentDisclosureIndicator( + styleSpec: spec.indicator, + expanded: state.isExpanded, + builder: widget.indicatorBuilder, + ), ], ), trigger: Row( diff --git a/packages/remix_agent/lib/src/components/plan.g.dart b/registry_source/lib/src/agent/components/plan.g.dart similarity index 100% rename from packages/remix_agent/lib/src/components/plan.g.dart rename to registry_source/lib/src/agent/components/plan.g.dart diff --git a/packages/remix_agent/lib/src/components/transcript.dart b/registry_source/lib/src/agent/components/transcript.dart similarity index 89% rename from packages/remix_agent/lib/src/components/transcript.dart rename to registry_source/lib/src/agent/components/transcript.dart index e2ece3995..43846bcfb 100644 --- a/packages/remix_agent/lib/src/components/transcript.dart +++ b/registry_source/lib/src/agent/components/transcript.dart @@ -4,8 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:mix_annotations/mix_annotations.dart'; import 'package:remix/remix.dart'; -import '../style/live_edge.dart'; -import '../style/style_builder.dart'; +import '../support/live_edge.dart'; part 'transcript.g.dart'; @@ -66,6 +65,19 @@ class _AgentTranscriptState extends State { late ScrollController _controller; late final AgentLiveEdgeEngine _liveEdge; + /// Publishes this surface's focus to the styles resolved above it. + /// + /// `focused` has no other source here: Agent's slots resolve above any Naked + /// control, so without this the `focus-visible` state the transcript + /// worksheet documents could never activate. + /// + /// Only `focused`. The pointer-driven states do not resolve on this slot, and + /// did not before this controller existed either — a host's `onHovered` on + /// [AgentTranscriptSpec.viewport] has never had an effect. Passing a + /// controller also means Mix will not mount its own pointer detector, so + /// restoring hover would be this object's job; nothing asks for it yet. + final WidgetStatesController _statesController = WidgetStatesController(); + @override void initState() { super.initState(); @@ -134,15 +146,18 @@ class _AgentTranscriptState extends State { @override Widget build(BuildContext context) { - return AgentStyleBuilder( + return RemixStyleSpecBuilder( style: widget.style, styleSpec: widget.styleSpec, + controller: _statesController, builder: (context, spec) => Semantics( container: true, explicitChildNodes: true, label: widget.label, value: widget.busy ? widget.busyLabel : null, child: FocusableActionDetector( + onFocusChange: (focused) => + _statesController.update(WidgetState.focused, focused), shortcuts: _transcriptShortcuts, actions: >{ _TranscriptScrollIntent: CallbackAction<_TranscriptScrollIntent>( @@ -206,6 +221,7 @@ class _AgentTranscriptState extends State { @override void dispose() { _ownedController?.dispose(); + _statesController.dispose(); super.dispose(); } } diff --git a/packages/remix_agent/lib/src/components/transcript.g.dart b/registry_source/lib/src/agent/components/transcript.g.dart similarity index 100% rename from packages/remix_agent/lib/src/components/transcript.g.dart rename to registry_source/lib/src/agent/components/transcript.g.dart diff --git a/packages/remix_agent/lib/src/models/activity_item.dart b/registry_source/lib/src/agent/models/activity_item.dart similarity index 52% rename from packages/remix_agent/lib/src/models/activity_item.dart rename to registry_source/lib/src/agent/models/activity_item.dart index 058721b9b..db0308e28 100644 --- a/packages/remix_agent/lib/src/models/activity_item.dart +++ b/registry_source/lib/src/agent/models/activity_item.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'statuses.dart'; /// One row in an [AgentActivity] ledger. +@immutable class AgentActivityItem { /// Creates an activity row. const AgentActivityItem({ @@ -27,4 +28,29 @@ class AgentActivityItem { /// Optional host-rendered detail. The catalog does not parse this child. final Widget? child; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AgentActivityItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail && + identical(other.child, child); + + @override + int get hashCode => Object.hash( + runtimeType, + id, + title, + status, + detail, + identityHashCode(child), + ); + + @override + String toString() => + 'AgentActivityItem(id: $id, title: $title, status: $status, detail: $detail, child: $child)'; } diff --git a/registry_source/lib/src/agent/models/plan_item.dart b/registry_source/lib/src/agent/models/plan_item.dart new file mode 100644 index 000000000..dde8dd230 --- /dev/null +++ b/registry_source/lib/src/agent/models/plan_item.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; + +import 'statuses.dart'; + +/// One row in an [AgentPlan]. +@immutable +class AgentPlanItem { + /// Creates a plan item. + const AgentPlanItem({ + required this.id, + required this.title, + this.status = AgentPlanItemStatus.pending, + this.detail, + }); + + /// Stable identity across list updates. + final String id; + + /// Visible title. + final String title; + + /// Current status. + final AgentPlanItemStatus status; + + /// Optional compact metadata (elapsed time, percent, path). + final String? detail; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AgentPlanItem && + other.runtimeType == runtimeType && + other.id == id && + other.title == title && + other.status == status && + other.detail == detail; + + @override + int get hashCode => Object.hash(runtimeType, id, title, status, detail); + + @override + String toString() => + 'AgentPlanItem(id: $id, title: $title, status: $status, detail: $detail)'; +} diff --git a/packages/remix_agent/lib/src/models/statuses.dart b/registry_source/lib/src/agent/models/statuses.dart similarity index 97% rename from packages/remix_agent/lib/src/models/statuses.dart rename to registry_source/lib/src/agent/models/statuses.dart index c53807d3c..4544047c5 100644 --- a/packages/remix_agent/lib/src/models/statuses.dart +++ b/registry_source/lib/src/agent/models/statuses.dart @@ -1,4 +1,4 @@ -/// Status of a long-running agent turn or activity ledger. +/// Status of a long-running turn or activity ledger. enum AgentRunStatus { /// Work is in progress. Disclosures stay open. working, @@ -93,7 +93,7 @@ enum AgentRole { /// The human operator. user, - /// The agent. + /// The assistant replying to the operator. assistant, } diff --git a/registry_source/lib/src/agent/support/disclosure.dart b/registry_source/lib/src/agent/support/disclosure.dart new file mode 100644 index 000000000..af1772433 --- /dev/null +++ b/registry_source/lib/src/agent/support/disclosure.dart @@ -0,0 +1,29 @@ +/// Controlled/uncontrolled storage shared by collapsible surfaces. +/// +/// Widgets own lifecycle policy, rebuilding, and request callbacks. In +/// particular, a request that does not change storage may still notify a host. +class AgentDisclosureEngine { + AgentDisclosureEngine({required bool? value, required bool defaultValue}) + : _controlled = value, + _uncontrolled = value ?? defaultValue; + + bool? _controlled; + bool _uncontrolled; + + bool get value => _controlled ?? _uncontrolled; + + /// Adopt the last controlled value when the host releases control. + void reconcile(bool? value) { + if (_controlled != null && value == null) { + _uncontrolled = _controlled!; + } + _controlled = value; + } + + /// Returns whether local storage changed and the widget needs a rebuild. + bool request(bool next) { + if (_controlled != null || next == _uncontrolled) return false; + _uncontrolled = next; + return true; + } +} diff --git a/packages/remix_agent/lib/src/style/functional_glyph.dart b/registry_source/lib/src/agent/support/functional_glyph.dart similarity index 52% rename from packages/remix_agent/lib/src/style/functional_glyph.dart rename to registry_source/lib/src/agent/support/functional_glyph.dart index 11bdecfec..0487ee2cd 100644 --- a/packages/remix_agent/lib/src/style/functional_glyph.dart +++ b/registry_source/lib/src/agent/support/functional_glyph.dart @@ -1,91 +1,57 @@ import 'package:flutter/widgets.dart'; import 'package:remix/remix.dart'; +import 'package:remix_ui_icons/remix_ui_icons.dart'; -abstract final class _LucideGlyphs { - static const _family = 'Lucide'; - static const _package = 'lucide_icons_flutter'; +abstract final class _Glyphs { + static const arrowUp = RemixIcons.arrowUp; + static const square = RemixIcons.square; + static const copy = RemixIcons.copy; + static const rotateCcw = RemixIcons.reload; + static const chevronUp = RemixIcons.chevronUp; + static const chevronDown = RemixIcons.chevronDown; + static const circle = RemixIcons.circle; + static const circleDot = RemixIcons.dotFilled; + static const check = RemixIcons.check; + static const x = RemixIcons.cross2; + static const circleAlert = RemixIcons.exclamationTriangle; + static const squareTerminal = RemixIcons.code; + static const loaderCircle = RemixIcons.update; + static const circleCheck = RemixIcons.checkCircled; + static const ban = RemixIcons.circleBackslash; + static const circleX = RemixIcons.crossCircled; + static const shieldCheck = RemixIcons.lockClosed; +} - static const arrowUp = IconData( - 57418, - fontFamily: _family, - fontPackage: _package, - ); - static const square = IconData( - 57703, - fontFamily: _family, - fontPackage: _package, - ); - static const copy = IconData( - 57502, - fontFamily: _family, - fontPackage: _package, - ); - static const rotateCcw = IconData( - 57672, - fontFamily: _family, - fontPackage: _package, - ); - static const chevronUp = IconData( - 57456, - fontFamily: _family, - fontPackage: _package, - ); - static const chevronDown = IconData( - 57453, - fontFamily: _family, - fontPackage: _package, - ); - static const circle = IconData( - 57462, - fontFamily: _family, - fontPackage: _package, - ); - static const circleDot = IconData( - 58181, - fontFamily: _family, - fontPackage: _package, - ); - static const check = IconData( - 57452, - fontFamily: _family, - fontPackage: _package, - ); - static const x = IconData(57778, fontFamily: _family, fontPackage: _package); - static const circleAlert = IconData( - 57463, - fontFamily: _family, - fontPackage: _package, - ); - static const squareTerminal = IconData( - 57866, - fontFamily: _family, - fontPackage: _package, - ); - static const loaderCircle = IconData( - 57610, - fontFamily: _family, - fontPackage: _package, - ); - static const circleCheck = IconData( - 57894, - fontFamily: _family, - fontPackage: _package, - ); - static const ban = IconData( - 57425, - fontFamily: _family, - fontPackage: _package, - ); - static const circleX = IconData( - 57476, - fontFamily: _family, - fontPackage: _package, - ); - static const shieldCheck = IconData( - 57855, - fontFamily: _family, - fontPackage: _package, - ); +/// Builds the chevron that reports a collapsible surface's state. +/// +/// Every collapsible Agent surface offers the host the same escape hatch — a +/// builder that replaces the glyph outright — over the same default. Each takes +/// that builder under its own name, because a permission card discloses +/// *details* and an answer discloses *sources*, so the shared part is this +/// body and not the parameter. +class AgentDisclosureIndicator extends StatelessWidget { + const AgentDisclosureIndicator({ + super.key, + required this.styleSpec, + required this.expanded, + this.builder, + }); + + final StyleSpec styleSpec; + final bool expanded; + final Widget Function(BuildContext context, bool expanded)? builder; + + @override + Widget build(BuildContext context) => + builder?.call(context, expanded) ?? + StyleSpecBuilder( + styleSpec: styleSpec, + builder: (context, iconSpec) => AgentFunctionalGlyph( + kind: .chevron, + spec: iconSpec, + expanded: expanded, + ), + ); } /// Internal Material-free glyph set used by Agent's functional defaults. @@ -124,22 +90,22 @@ class AgentFunctionalGlyph extends StatelessWidget { final bool expanded; IconData get _icon => switch (kind) { - .send => _LucideGlyphs.arrowUp, - .stop => _LucideGlyphs.square, - .copy => _LucideGlyphs.copy, - .retry => _LucideGlyphs.rotateCcw, - .chevron => expanded ? _LucideGlyphs.chevronUp : _LucideGlyphs.chevronDown, - .pending => _LucideGlyphs.circle, - .active => _LucideGlyphs.circleDot, - .completed => _LucideGlyphs.check, - .cancelled => _LucideGlyphs.x, - .error => _LucideGlyphs.circleAlert, - .tool => _LucideGlyphs.squareTerminal, - .loading => _LucideGlyphs.loaderCircle, - .completedCircle => _LucideGlyphs.circleCheck, - .cancelledCircle => _LucideGlyphs.ban, - .errorCircle => _LucideGlyphs.circleX, - .permission => _LucideGlyphs.shieldCheck, + .send => _Glyphs.arrowUp, + .stop => _Glyphs.square, + .copy => _Glyphs.copy, + .retry => _Glyphs.rotateCcw, + .chevron => expanded ? _Glyphs.chevronUp : _Glyphs.chevronDown, + .pending => _Glyphs.circle, + .active => _Glyphs.circleDot, + .completed => _Glyphs.check, + .cancelled => _Glyphs.x, + .error => _Glyphs.circleAlert, + .tool => _Glyphs.squareTerminal, + .loading => _Glyphs.loaderCircle, + .completedCircle => _Glyphs.circleCheck, + .cancelledCircle => _Glyphs.ban, + .errorCircle => _Glyphs.circleX, + .permission => _Glyphs.shieldCheck, }; @override diff --git a/packages/remix_agent/lib/src/style/live_edge.dart b/registry_source/lib/src/agent/support/live_edge.dart similarity index 89% rename from packages/remix_agent/lib/src/style/live_edge.dart rename to registry_source/lib/src/agent/support/live_edge.dart index f1c73250c..f0adba456 100644 --- a/packages/remix_agent/lib/src/style/live_edge.dart +++ b/registry_source/lib/src/agent/support/live_edge.dart @@ -4,17 +4,28 @@ import 'package:flutter/widgets.dart'; /// Shared private-package live-edge state machine. class AgentLiveEdgeEngine { AgentLiveEdgeEngine({ - required this.enabled, + required this._enabled, required this.threshold, this.onChanged, }); - bool enabled; + bool _enabled; + bool get enabled => _enabled; + + set enabled(bool value) { + // An explicit false-to-true transition is the host's resume action. + // Ordinary rebuilds with follow enabled must preserve a reader's release. + if (value && !_enabled) _following = true; + _enabled = value; + } + double threshold; ValueChanged? onChanged; - bool following = true; + bool _following = true; bool _programmatic = false; + bool get following => _following; + void handleScroll( ScrollNotification notification, ScrollController controller, @@ -45,7 +56,7 @@ class AgentLiveEdgeEngine { void _setFollowing(bool next) { if (following == next) return; - following = next; + _following = next; onChanged?.call(next); } } diff --git a/registry_source/lib/src/default/components/accordion.dart b/registry_source/lib/src/default/components/accordion.dart new file mode 100644 index 000000000..e49d33311 --- /dev/null +++ b/registry_source/lib/src/default/components/accordion.dart @@ -0,0 +1,185 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'accordion.g.dart'; + +/// The application's Accordion recipe. +/// +/// Remix owns the rendering, the expand and collapse animation, the group +/// coordination, keyboard activation, and the accessibility semantics; this +/// recipe supplies the row, the two icons, the title, and the panel. +/// +/// `RemixAccordionGroup` — the behavioral coordinator that owns which values +/// are expanded — carries no styler and therefore no recipe. Compose it +/// directly around these: +/// +/// ```dart +/// // `RemixAccordionController` is Remix's alias for the Naked UI type, so +/// // the controller does not pull `package:naked_ui` into this layer. +/// RemixAccordionGroup( +/// controller: RemixAccordionController(), +/// child: Column(children: const [ +/// VanillaAccordion( +/// value: 'shipping', +/// title: 'Shipping', +/// child: Text('Two to four business days.'), +/// ), +/// ]), +/// ) +/// ``` +/// +/// Each section is separated by a rule along its bottom edge rather than by a +/// box of its own, so a stack of them reads as one list. The `border` token +/// is the same hairline the divider draws, which is what makes a divider +/// between sections indistinguishable from the sections' own edges. +/// +/// `builder` is deliberately not forwarded to the generated +/// `VanillaAccordion`. Its type is `NakedAccordionTriggerBuilder`, +/// which comes from `package:naked_ui` — a package this layer does not depend +/// on. Use `title` with the icons, or reach for `RemixAccordion` directly on +/// the rare call site that needs to build its own trigger row. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's open title has to be +/// declared as a selected fragment too (`AccordionStyler().onSelected(...)`). +@MixWidget( + target: RemixAccordion.new, + widgetParameters: .only({ + 'value', + 'child', + 'title', + 'leadingIcon', + 'trailingIcon', + 'enabled', + 'mouseCursor', + 'enableFeedback', + 'autofocus', + 'focusNode', + 'onFocusChange', + 'onHoverChange', + 'onPressChange', + 'semanticLabel', + 'transitionBuilder', + }), +) +AccordionStyler vanillaAccordionStyle({ + AccordionStyler style = const AccordionStyler.create(), +}) => AccordionStyler() + // `container` has to be reached by name. `AccordionStyler` forwards its + // box shorthand to `trigger`, so a bare `.border(...)` would outline the + // clickable row rather than the section, and the rule between sections + // would move with the panel as it opens. + .container( + .border(.bottom(.color(VanillaTokens.border()).width(_borderWidth))), + ) + // These *are* the forwarded shorthand, so they land on `trigger`: the row + // a reader clicks to open the section. + .direction(.horizontal) + .crossAxisAlignment(.center) + .minHeight(_triggerHeight) + .padding(.horizontal(_paddingX)) + .spacing(_gap) + .title( + .fontSize( + _titleSize, + ).fontWeight(FontWeight.w500).color(VanillaTokens.foreground()), + ) + .leadingIcon(.size(_iconSize).color(VanillaTokens.mutedForeground())) + // Both icons are markers, not the state: Remix renders whatever + // `IconData` the caller passes and does not rotate it, so a chevron that + // turns is a caller passing a different glyph when the section is open. + .trailingIcon(.size(_iconSize).color(VanillaTokens.mutedForeground())) + .content( + .padding(.only(left: _paddingX, right: _paddingX, bottom: _contentGap)), + ) + .onHovered(_hoverStyle()) + .onSelected(_openStyle()) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()) + .merge(style); + +/// Width of the rule under each section. +const _borderWidth = 1.0; + +/// Minimum height of the row a reader clicks to open a section. +const _triggerHeight = 44.0; + +/// Gap between the icons and the title. +const _gap = 8.0; + +/// Horizontal inset inside the row and its panel. +/// +/// The same 12 every other row-like surface in this layer uses — the table's +/// cells, the select's trigger, the callout. Upstream shadcn leaves its +/// accordion trigger flush because the item it sits in supplies the inset; +/// nothing wraps this one, so flush would put the title hard against whatever +/// contains it while the rule below still spans the full width. +const _paddingX = 12.0; + +/// Title size, matching body copy: a section heading, not a page heading. +const _titleSize = 14.0; + +/// Size of the leading and trailing icons. +const _iconSize = 16.0; + +/// Gap between the open panel's content and the rule below it. +const _contentGap = 16.0; + +/// Width of the keyboard focus ring. +/// +/// It carries no offset, unlike the button's. Sections stack directly on top +/// of one another, so a ring pushed outward would cross into the section +/// above and below it. +const _focusRingWidth = 2.0; + +/// Opacity applied to the whole section while disabled. +const _disabledOpacity = 0.5; + +/// Hovering underlines the title and promotes the icons. +/// +/// The icons alone were not enough: they move from `mutedForeground` to +/// `foreground`, which at 16px is invisible next to a title that is already +/// at full strength — a hovered row looked exactly like a resting one. The +/// underline is what a reader actually sees, and it is what shadcn's own +/// accordion trigger uses (`hover:underline`). +/// +/// The title's *weight* stays out of it, because that is what the open state +/// uses; leaving it here would erase the difference between "the pointer is +/// here" and "this section is open". +AccordionStyler _hoverStyle() => _icons( + VanillaTokens.foreground(), +).title(.decoration(TextDecoration.underline)); + +/// The open section: promoted icons *and* a heavier title. +/// +/// The weight is what separates "open" from "the pointer is here" — the two +/// states otherwise share the icon promotion, and a reader scanning a +/// collapsed list needs to find the open one without moving the mouse. +AccordionStyler _openStyle() => + _icons(VanillaTokens.foreground()).title(.fontWeight(FontWeight.w600)); + +/// Applies one color to both icons. +AccordionStyler _icons(Color color) => + AccordionStyler().leadingIcon(.color(color)).trailingIcon(.color(color)); + +/// The keyboard focus ring. +/// +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// section without taking layout space, and the section's own border is +/// already carrying the rule between rows. +AccordionStyler _focusVisibleStyle() => AccordionStyler().containerEffects( + .outline( + .color( + VanillaTokens.focusRing(), + ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), + ), +); + +/// Declared last so it wins over every other state fragment. +AccordionStyler _disabledStyle() => AccordionStyler() + .containerEffects(.outline(.style(.none))) + .wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/accordion.g.dart b/registry_source/lib/src/default/components/accordion.g.dart new file mode 100644 index 000000000..ee85b20d0 --- /dev/null +++ b/registry_source/lib/src/default/components/accordion.g.dart @@ -0,0 +1,124 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'accordion.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Accordion recipe. +/// +/// Remix owns the rendering, the expand and collapse animation, the group +/// coordination, keyboard activation, and the accessibility semantics; this +/// recipe supplies the row, the two icons, the title, and the panel. +/// +/// `RemixAccordionGroup` — the behavioral coordinator that owns which values +/// are expanded — carries no styler and therefore no recipe. Compose it +/// directly around these: +/// +/// ```dart +/// // `RemixAccordionController` is Remix's alias for the Naked UI type, so +/// // the controller does not pull `package:naked_ui` into this layer. +/// RemixAccordionGroup( +/// controller: RemixAccordionController(), +/// child: Column(children: const [ +/// VanillaAccordion( +/// value: 'shipping', +/// title: 'Shipping', +/// child: Text('Two to four business days.'), +/// ), +/// ]), +/// ) +/// ``` +/// +/// Each section is separated by a rule along its bottom edge rather than by a +/// box of its own, so a stack of them reads as one list. The `border` token +/// is the same hairline the divider draws, which is what makes a divider +/// between sections indistinguishable from the sections' own edges. +/// +/// `builder` is deliberately not forwarded to the generated +/// `VanillaAccordion`. Its type is `NakedAccordionTriggerBuilder`, +/// which comes from `package:naked_ui` — a package this layer does not depend +/// on. Use `title` with the icons, or reach for `RemixAccordion` directly on +/// the rare call site that needs to build its own trigger row. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's open title has to be +/// declared as a selected fragment too (`AccordionStyler().onSelected(...)`). +class VanillaAccordion extends StatelessWidget { + const VanillaAccordion({ + super.key, + this.style = const AccordionStyler.create(), + required this.value, + required this.child, + this.title, + this.leadingIcon, + this.trailingIcon, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.transitionBuilder, + }); + + final AccordionStyler style; + + final T value; + + final Widget child; + + final String? title; + + final IconData? leadingIcon; + + final IconData? trailingIcon; + + final bool enabled; + + final MouseCursor mouseCursor; + + final bool enableFeedback; + + final bool autofocus; + + final FocusNode? focusNode; + + final ValueChanged? onFocusChange; + + final ValueChanged? onHoverChange; + + final ValueChanged? onPressChange; + + final String? semanticLabel; + + final Widget Function(Widget, Animation)? transitionBuilder; + + @override + Widget build(BuildContext context) { + return RemixAccordion( + key: this.key, + style: vanillaAccordionStyle(style: this.style), + value: this.value, + child: this.child, + title: this.title, + leadingIcon: this.leadingIcon, + trailingIcon: this.trailingIcon, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + enableFeedback: this.enableFeedback, + autofocus: this.autofocus, + focusNode: this.focusNode, + onFocusChange: this.onFocusChange, + onHoverChange: this.onHoverChange, + onPressChange: this.onPressChange, + semanticLabel: this.semanticLabel, + transitionBuilder: this.transitionBuilder, + ); + } +} diff --git a/registry_source/lib/src/default/components/avatar.dart b/registry_source/lib/src/default/components/avatar.dart new file mode 100644 index 000000000..30e93f0b6 --- /dev/null +++ b/registry_source/lib/src/default/components/avatar.dart @@ -0,0 +1,64 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'avatar.g.dart'; + +/// The application's Avatar recipe. +/// +/// Remix owns the fallback chain — image, then label, then icon — and the +/// clipping; this recipe supplies the circle, the neutral surface behind it, +/// and the scale of whatever fallback shows through. +/// +/// The surface is `muted`, so an avatar with no image reads as a placeholder +/// rather than as a filled control. An image covers all of it, which is why +/// the fill only ever shows in the fallback case. The recipe sets no +/// alignment: Remix already centers whichever fallback it renders. +/// +/// The fallback takes `foreground`, not `mutedForeground`. Initials are the +/// content — they name a person — and `mutedForeground` on `muted` measures +/// 4.35:1 in the shipped light theme, under the 4.5:1 WCAG floor for text +/// this size. `mutedForeground` remains correct for the markers that pair +/// with it elsewhere; it is not a color to set names in. +/// +/// The shape is a full circle rather than the theme's control radius. An +/// avatar stands for a person or an organisation, and that is a circle in +/// every system this application is likely to sit beside; a theme that wants +/// squircles overrides `borderRadius` in one place. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +@MixWidget(target: RemixAvatar.new) +AvatarStyler vanillaAvatarStyle({ + AvatarStyler style = const AvatarStyler.create(), +}) => AvatarStyler() + .size(_diameter, _diameter) + .borderRadius(.all(_circular)) + // The clip is what rounds an image: Remix renders `backgroundImage` as + // a child of the container, not as part of its decoration. + .clipBehavior(Clip.antiAlias) + .color(VanillaTokens.muted()) + .label( + .fontSize( + _labelSize, + ).fontWeight(FontWeight.w500).color(VanillaTokens.foreground()), + ) + .icon(.size(_iconSize).color(VanillaTokens.foreground())) + .merge(style); + +/// A radius large enough to round any avatar in this scale into a circle. +const _circular = Radius.circular(999); + +/// The avatar's diameter, matching shadcn's `h-10 w-10`. +/// +/// One size, not a scale. A call site that wants a dense list row or a +/// profile header sets `.size(...)` through [style]. +const _diameter = 40.0; + +/// Initials size, one step below body copy so two letters fit the circle. +const _labelSize = 14.0; + +/// Fallback icon size. +const _iconSize = 20.0; diff --git a/registry_source/lib/src/default/components/avatar.g.dart b/registry_source/lib/src/default/components/avatar.g.dart new file mode 100644 index 000000000..55d00daa2 --- /dev/null +++ b/registry_source/lib/src/default/components/avatar.g.dart @@ -0,0 +1,84 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'avatar.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Avatar recipe. +/// +/// Remix owns the fallback chain — image, then label, then icon — and the +/// clipping; this recipe supplies the circle, the neutral surface behind it, +/// and the scale of whatever fallback shows through. +/// +/// The surface is `muted`, so an avatar with no image reads as a placeholder +/// rather than as a filled control. An image covers all of it, which is why +/// the fill only ever shows in the fallback case. The recipe sets no +/// alignment: Remix already centers whichever fallback it renders. +/// +/// The fallback takes `foreground`, not `mutedForeground`. Initials are the +/// content — they name a person — and `mutedForeground` on `muted` measures +/// 4.35:1 in the shipped light theme, under the 4.5:1 WCAG floor for text +/// this size. `mutedForeground` remains correct for the markers that pair +/// with it elsewhere; it is not a color to set names in. +/// +/// The shape is a full circle rather than the theme's control radius. An +/// avatar stands for a person or an organisation, and that is a circle in +/// every system this application is likely to sit beside; a theme that wants +/// squircles overrides `borderRadius` in one place. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +class VanillaAvatar extends StatelessWidget { + const VanillaAvatar({ + super.key, + this.style = const AvatarStyler.create(), + this.backgroundImage, + this.foregroundImage, + this.onBackgroundImageError, + this.onForegroundImageError, + this.child, + this.label, + this.labelBuilder, + this.icon, + this.iconBuilder, + }); + + final AvatarStyler style; + + final ImageProvider? backgroundImage; + + final ImageProvider? foregroundImage; + + final ImageErrorListener? onBackgroundImageError; + + final ImageErrorListener? onForegroundImageError; + + final Widget? child; + + final String? label; + + final RemixAvatarLabelBuilder? labelBuilder; + + final IconData? icon; + + final RemixAvatarIconBuilder? iconBuilder; + + @override + Widget build(BuildContext context) { + return RemixAvatar( + key: this.key, + style: vanillaAvatarStyle(style: this.style), + backgroundImage: this.backgroundImage, + foregroundImage: this.foregroundImage, + onBackgroundImageError: this.onBackgroundImageError, + onForegroundImageError: this.onForegroundImageError, + child: this.child, + label: this.label, + labelBuilder: this.labelBuilder, + icon: this.icon, + iconBuilder: this.iconBuilder, + ); + } +} diff --git a/registry_source/lib/src/default/components/badge.dart b/registry_source/lib/src/default/components/badge.dart new file mode 100644 index 000000000..eb33ebcfe --- /dev/null +++ b/registry_source/lib/src/default/components/badge.dart @@ -0,0 +1,89 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'badge.g.dart'; + +/// The visual weights this application offers for a badge. +enum VanillaBadgeVariant { + /// Highest emphasis: a solid `primary` fill. + primary, + + /// Medium emphasis: a solid `secondary` fill. + secondary, + + /// Low emphasis with a hairline `border` and no fill. + outline, + + /// Highest emphasis for a problem the reader must notice. + destructive, +} + +/// The application's Badge recipe. +/// +/// A badge is a static label: no interaction, no states. That is why this +/// recipe has no hover, focus, or disabled fragments — there is nothing to +/// report. +/// +/// It takes no size. A badge sits inline beside other content and reads at +/// one scale; a size axis would have to be threaded through every call site +/// for no gain. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. Because [variant] is a non-nullable +/// enum, the generator also emits one named constructor per enum value: +/// +/// ```dart +/// VanillaBadge.destructive(label: 'Failing') +/// ``` +@MixWidget(target: RemixBadge.new) +BadgeStyler vanillaBadgeStyle({ + VanillaBadgeVariant variant = .primary, + BadgeStyler style = const BadgeStyler.create(), +}) => _base().merge(_variantStyle(variant)).merge(style); + +/// Horizontal inset between the badge edge and its label. +const _paddingX = 8.0; + +/// Vertical inset between the badge edge and its label. +const _paddingY = 2.0; + +/// Label size, one step below body text so a badge reads as an annotation. +const _labelSize = 12.0; + +/// Width of the outline the `outline` variant draws. +const _borderWidth = 1.0; + +/// A fill that paints nothing, used by `outline`. +const _noFill = Color(0x00000000); + +/// Geometry and typography shared by every variant. +BadgeStyler _base() => BadgeStyler() + .padding(.symmetric(horizontal: _paddingX, vertical: _paddingY)) + .borderRadius(.all(VanillaTokens.radius())) + .label(.fontSize(_labelSize).fontWeight(FontWeight.w500)); + +BadgeStyler _variantStyle(VanillaBadgeVariant variant) => switch (variant) { + .primary => _filled( + fill: VanillaTokens.primary(), + foreground: VanillaTokens.primaryForeground(), + ), + .secondary => _filled( + fill: VanillaTokens.secondary(), + foreground: VanillaTokens.secondaryForeground(), + ), + .destructive => _filled( + fill: VanillaTokens.destructive(), + foreground: VanillaTokens.destructiveForeground(), + ), + .outline => _filled( + fill: _noFill, + foreground: VanillaTokens.foreground(), + ).border(.color(VanillaTokens.border()).width(_borderWidth)), +}; + +/// One surface and one content color. +BadgeStyler _filled({required Color fill, required Color foreground}) => + BadgeStyler().color(fill).label(.color(foreground)); diff --git a/registry_source/lib/src/default/components/badge.g.dart b/registry_source/lib/src/default/components/badge.g.dart new file mode 100644 index 000000000..cd24b5921 --- /dev/null +++ b/registry_source/lib/src/default/components/badge.g.dart @@ -0,0 +1,92 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'badge.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Badge recipe. +/// +/// A badge is a static label: no interaction, no states. That is why this +/// recipe has no hover, focus, or disabled fragments — there is nothing to +/// report. +/// +/// It takes no size. A badge sits inline beside other content and reads at +/// one scale; a size axis would have to be threaded through every call site +/// for no gain. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. Because [variant] is a non-nullable +/// enum, the generator also emits one named constructor per enum value: +/// +/// ```dart +/// VanillaBadge.destructive(label: 'Failing') +/// ``` +class VanillaBadge extends StatelessWidget { + const VanillaBadge({ + super.key, + this.variant = .primary, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }); + + /// Highest emphasis: a solid `primary` fill. + const VanillaBadge.primary({ + super.key, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = VanillaBadgeVariant.primary; + + /// Medium emphasis: a solid `secondary` fill. + const VanillaBadge.secondary({ + super.key, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = VanillaBadgeVariant.secondary; + + /// Low emphasis with a hairline `border` and no fill. + const VanillaBadge.outline({ + super.key, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = VanillaBadgeVariant.outline; + + /// Highest emphasis for a problem the reader must notice. + const VanillaBadge.destructive({ + super.key, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = VanillaBadgeVariant.destructive; + + final VanillaBadgeVariant variant; + + final BadgeStyler style; + + final String? label; + + final Widget? child; + + final RemixBadgeLabelBuilder? labelBuilder; + + @override + Widget build(BuildContext context) { + return RemixBadge( + key: this.key, + style: vanillaBadgeStyle(variant: this.variant, style: this.style), + label: this.label, + child: this.child, + labelBuilder: this.labelBuilder, + ); + } +} diff --git a/packages/remix_agent/example/lib/ui/components/button.dart b/registry_source/lib/src/default/components/button.dart similarity index 78% rename from packages/remix_agent/example/lib/ui/components/button.dart rename to registry_source/lib/src/default/components/button.dart index 6e066442b..cbea20238 100644 --- a/packages/remix_agent/example/lib/ui/components/button.dart +++ b/registry_source/lib/src/default/components/button.dart @@ -7,7 +7,7 @@ import '../theme/tokens.dart'; part 'button.g.dart'; /// The visual weights this application offers for a button. -enum UiButtonVariant { +enum VanillaButtonVariant { /// Highest emphasis: a solid `primary` fill. primary, @@ -28,7 +28,7 @@ enum UiButtonVariant { /// /// The 32/36/40px heights are compact, web-oriented defaults. A touch-first /// application should raise them to meet platform hit-target guidance. -enum UiButtonSize { +enum VanillaButtonSize { /// 32px minimum height. small, @@ -47,10 +47,10 @@ enum UiButtonSize { /// behavior, accessibility semantics, and the loading/disabled interaction /// rules — this recipe never reimplements any of that. /// -/// `@MixWidget(target: RemixButton.new)` generates `UiButton` into +/// `@MixWidget(target: RemixButton.new)` generates `VanillaButton` into /// `button.g.dart`: an adapter whose constructor is this function's /// parameters plus every safe `RemixButton` parameter, and whose `build` -/// calls `RemixButton(style: uiButtonStyle(...), ...)`. Because +/// calls `RemixButton(style: vanillaButtonStyle(...), ...)`. Because /// [variant] is a non-nullable enum, the generator also emits one named /// constructor per enum value. /// @@ -62,7 +62,7 @@ enum UiButtonSize { /// the resolved recipe without forking it: /// /// ```dart -/// UiButton.primary( +/// VanillaButton.primary( /// label: 'Publish', /// style: ButtonStyler().color(const Color(0xFF7C3AED)), /// onPressed: publish, @@ -73,9 +73,9 @@ enum UiButtonSize { /// the recipe's hover fill has to be declared as a hover fragment too /// (`ButtonStyler().onHovered(...)`). @MixWidget(target: RemixButton.new) -ButtonStyler uiButtonStyle({ - UiButtonVariant variant = .primary, - UiButtonSize size = .medium, +ButtonStyler vanillaButtonStyle({ + VanillaButtonVariant variant = .primary, + VanillaButtonSize size = .medium, ButtonStyler style = const ButtonStyler.create(), }) { return _base(_metricsFor(size)) @@ -93,7 +93,7 @@ const _pressedAlpha = 0.8; /// A fill derived from [source] at [alpha], resolved from the active scope. /// -/// The obvious spelling would be `UiTokens.primary().withValues(alpha: 0.9)`, +/// The obvious spelling would be `VanillaTokens.primary().withValues(alpha: 0.9)`, /// but that records a Mix *directive*, and directives accumulate through every /// later merge. A caller who replaced the hover fill would still get this /// recipe's alpha applied on top of their own color. A `ContextToken` does the @@ -108,13 +108,16 @@ ContextToken _dimmed(ColorToken source, double alpha) => (context) => source.resolve(context).withValues(alpha: alpha), ); -final _primaryHoverFill = _dimmed(UiTokens.primary, _hoverAlpha); -final _primaryPressedFill = _dimmed(UiTokens.primary, _pressedAlpha); -final _secondaryHoverFill = _dimmed(UiTokens.secondary, _hoverAlpha); -final _secondaryPressedFill = _dimmed(UiTokens.secondary, _pressedAlpha); -final _destructiveHoverFill = _dimmed(UiTokens.destructive, _hoverAlpha); -final _destructivePressedFill = _dimmed(UiTokens.destructive, _pressedAlpha); -final _accentPressedFill = _dimmed(UiTokens.accent, _pressedAlpha); +final _primaryHoverFill = _dimmed(VanillaTokens.primary, _hoverAlpha); +final _primaryPressedFill = _dimmed(VanillaTokens.primary, _pressedAlpha); +final _secondaryHoverFill = _dimmed(VanillaTokens.secondary, _hoverAlpha); +final _secondaryPressedFill = _dimmed(VanillaTokens.secondary, _pressedAlpha); +final _destructiveHoverFill = _dimmed(VanillaTokens.destructive, _hoverAlpha); +final _destructivePressedFill = _dimmed( + VanillaTokens.destructive, + _pressedAlpha, +); +final _accentPressedFill = _dimmed(VanillaTokens.accent, _pressedAlpha); /// Opacity of the loading spinner, so it reads as secondary to the label. const _spinnerOpacity = 0.65; @@ -134,8 +137,8 @@ const _disabledOpacity = 0.5; /// A fill that paints nothing, used by `outline` and `ghost`. const _noFill = Color(0x00000000); -/// Geometry and type scale for one [UiButtonSize]. -typedef _UiButtonMetrics = ({ +/// Geometry and type scale for one [VanillaButtonSize]. +typedef _VanillaButtonMetrics = ({ double minHeight, double paddingX, double gap, @@ -143,7 +146,7 @@ typedef _UiButtonMetrics = ({ double iconSize, }); -_UiButtonMetrics _metricsFor(UiButtonSize size) => switch (size) { +_VanillaButtonMetrics _metricsFor(VanillaButtonSize size) => switch (size) { .small => ( minHeight: 32.0, paddingX: 12.0, @@ -168,7 +171,7 @@ _UiButtonMetrics _metricsFor(UiButtonSize size) => switch (size) { }; /// Layout, typography, and spinner defaults shared by every variant. -ButtonStyler _base(_UiButtonMetrics metrics) => ButtonStyler() +ButtonStyler _base(_VanillaButtonMetrics metrics) => ButtonStyler() .direction(.horizontal) .mainAxisSize(.min) .mainAxisAlignment(.center) @@ -176,7 +179,7 @@ ButtonStyler _base(_UiButtonMetrics metrics) => ButtonStyler() .minHeight(metrics.minHeight) .padding(.horizontal(metrics.paddingX)) .spacing(metrics.gap) - .borderRadius(.all(UiTokens.radius())) + .borderRadius(.all(VanillaTokens.radius())) .label(.fontSize(metrics.labelSize).fontWeight(FontWeight.w500)) .icon(.size(metrics.iconSize)) .spinner( @@ -185,22 +188,22 @@ ButtonStyler _base(_UiButtonMetrics metrics) => ButtonStyler() ).opacity(_spinnerOpacity).duration(_spinnerDuration), ); -ButtonStyler _variantStyle(UiButtonVariant variant) => switch (variant) { +ButtonStyler _variantStyle(VanillaButtonVariant variant) => switch (variant) { .primary => _filled( - fill: UiTokens.primary(), - foreground: UiTokens.primaryForeground(), + fill: VanillaTokens.primary(), + foreground: VanillaTokens.primaryForeground(), hoverFill: _primaryHoverFill(), pressedFill: _primaryPressedFill(), ), .secondary => _filled( - fill: UiTokens.secondary(), - foreground: UiTokens.secondaryForeground(), + fill: VanillaTokens.secondary(), + foreground: VanillaTokens.secondaryForeground(), hoverFill: _secondaryHoverFill(), pressedFill: _secondaryPressedFill(), ), .destructive => _filled( - fill: UiTokens.destructive(), - foreground: UiTokens.destructiveForeground(), + fill: VanillaTokens.destructive(), + foreground: VanillaTokens.destructiveForeground(), hoverFill: _destructiveHoverFill(), pressedFill: _destructivePressedFill(), ), @@ -221,20 +224,26 @@ ButtonStyler _filled({ /// A transparent variant: `accent` is what makes interaction visible. ButtonStyler _quiet({required bool bordered}) { - var style = _content(.color(_noFill), UiTokens.foreground()); + var style = _content(.color(_noFill), VanillaTokens.foreground()); if (bordered) { - style = style.border(.color(UiTokens.border()).width(1)); + style = style.border(.color(VanillaTokens.border()).width(1)); } return style .onHovered( - _content(.color(UiTokens.accent()), UiTokens.accentForeground()), + _content( + .color(VanillaTokens.accent()), + VanillaTokens.accentForeground(), + ), ) // Content color is re-applied on press, not only on hover: a touch // device never reports hover, so a press that changed the fill alone // would paint the accent surface under the default foreground. .onPressed( - _content(.color(_accentPressedFill()), UiTokens.accentForeground()), + _content( + .color(_accentPressedFill()), + VanillaTokens.accentForeground(), + ), ); } @@ -252,7 +261,7 @@ ButtonStyler _content(ButtonStyler style, Color foreground) => style ButtonStyler _focusVisibleStyle() => ButtonStyler().containerEffects( .outline( .color( - UiTokens.focusRing(), + VanillaTokens.focusRing(), ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), ).outlineOffset(_focusRingOffset), ); diff --git a/packages/remix_agent/example/lib/ui/components/button.g.dart b/registry_source/lib/src/default/components/button.g.dart similarity index 90% rename from packages/remix_agent/example/lib/ui/components/button.g.dart rename to registry_source/lib/src/default/components/button.g.dart index cc6f29bfa..441879db4 100644 --- a/packages/remix_agent/example/lib/ui/components/button.g.dart +++ b/registry_source/lib/src/default/components/button.g.dart @@ -14,10 +14,10 @@ part of 'button.dart'; /// behavior, accessibility semantics, and the loading/disabled interaction /// rules — this recipe never reimplements any of that. /// -/// `@MixWidget(target: RemixButton.new)` generates `UiButton` into +/// `@MixWidget(target: RemixButton.new)` generates `VanillaButton` into /// `button.g.dart`: an adapter whose constructor is this function's /// parameters plus every safe `RemixButton` parameter, and whose `build` -/// calls `RemixButton(style: uiButtonStyle(...), ...)`. Because +/// calls `RemixButton(style: vanillaButtonStyle(...), ...)`. Because /// [variant] is a non-nullable enum, the generator also emits one named /// constructor per enum value. /// @@ -29,7 +29,7 @@ part of 'button.dart'; /// the resolved recipe without forking it: /// /// ```dart -/// UiButton.primary( +/// VanillaButton.primary( /// label: 'Publish', /// style: ButtonStyler().color(const Color(0xFF7C3AED)), /// onPressed: publish, @@ -39,8 +39,8 @@ part of 'button.dart'; /// State fragments merge by state, not by depth: an override that must beat /// the recipe's hover fill has to be declared as a hover fragment too /// (`ButtonStyler().onHovered(...)`). -class UiButton extends StatelessWidget { - const UiButton({ +class VanillaButton extends StatelessWidget { + const VanillaButton({ super.key, this.variant = .primary, this.size = .medium, @@ -66,7 +66,7 @@ class UiButton extends StatelessWidget { }); /// Highest emphasis: a solid `primary` fill. - const UiButton.primary({ + const VanillaButton.primary({ super.key, this.size = .medium, this.style = const ButtonStyler.create(), @@ -88,10 +88,10 @@ class UiButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiButtonVariant.primary; + }) : variant = VanillaButtonVariant.primary; /// Medium emphasis: a solid `secondary` fill. - const UiButton.secondary({ + const VanillaButton.secondary({ super.key, this.size = .medium, this.style = const ButtonStyler.create(), @@ -113,10 +113,10 @@ class UiButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiButtonVariant.secondary; + }) : variant = VanillaButtonVariant.secondary; /// Low emphasis with a hairline `border`. - const UiButton.outline({ + const VanillaButton.outline({ super.key, this.size = .medium, this.style = const ButtonStyler.create(), @@ -138,10 +138,10 @@ class UiButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiButtonVariant.outline; + }) : variant = VanillaButtonVariant.outline; /// Low emphasis with no fill and no border. - const UiButton.ghost({ + const VanillaButton.ghost({ super.key, this.size = .medium, this.style = const ButtonStyler.create(), @@ -163,10 +163,10 @@ class UiButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiButtonVariant.ghost; + }) : variant = VanillaButtonVariant.ghost; /// Highest emphasis for irreversible actions. - const UiButton.destructive({ + const VanillaButton.destructive({ super.key, this.size = .medium, this.style = const ButtonStyler.create(), @@ -188,11 +188,11 @@ class UiButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiButtonVariant.destructive; + }) : variant = VanillaButtonVariant.destructive; - final UiButtonVariant variant; + final VanillaButtonVariant variant; - final UiButtonSize size; + final VanillaButtonSize size; final ButtonStyler style; @@ -236,7 +236,7 @@ class UiButton extends StatelessWidget { Widget build(BuildContext context) { return RemixButton( key: this.key, - style: uiButtonStyle( + style: vanillaButtonStyle( variant: this.variant, size: this.size, style: this.style, diff --git a/registry_source/lib/src/default/components/callout.dart b/registry_source/lib/src/default/components/callout.dart new file mode 100644 index 000000000..1d297dafb --- /dev/null +++ b/registry_source/lib/src/default/components/callout.dart @@ -0,0 +1,124 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'callout.g.dart'; + +/// The tones this application offers for a callout. +/// +/// Two, not the usual four. `info`, `success`, and `warning` would each need a +/// color pair this theme does not have, and inventing three would triple the +/// token vocabulary to serve one component. An application that needs them +/// adds the tokens and one more enum value here. +enum VanillaCalloutVariant { + /// A neutral aside on a `muted` surface. + neutral, + + /// A problem the reader has to act on. + destructive, +} + +/// The application's Callout recipe. +/// +/// A callout is a block of text, usually with a leading icon, that says +/// something about the surrounding page. Remix owns the layout and the icon +/// slot; this recipe owns the surface, the outline, and the content colors. +/// +/// There are no interaction fragments. A callout is not a control — anything +/// actionable inside it is a separate button or link with its own recipe. +/// +/// The destructive tone paints no fill. A tinted danger surface would need a +/// `destructive`-derived background this theme does not define, and a solid +/// `destructive` fill would read as a pressed button rather than as a notice; +/// the outline and the icon carry the meaning instead. +/// +/// Both tones set their sentence in `foreground`, which is why the text color +/// lives in [_base] rather than in either tone. `destructive` is a fill color +/// chosen to sit under `destructiveForeground`, not a text color: on the dark +/// theme's page it measures 4.1:1, under the 4.5:1 WCAG floor for body copy. +/// The border and the glyph are non-text, where the floor is 3:1, so they are +/// where the tone shows. A theme that adds a dedicated danger *text* step +/// would move the text color back into [_variantStyle]. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. Because [variant] is a non-nullable +/// enum, the generator also emits one named constructor per enum value: +/// +/// ```dart +/// VanillaCallout.destructive( +/// icon: warningGlyph, +/// text: 'This deletes the workspace for everyone.', +/// ) +/// ``` +@MixWidget(target: RemixCallout.new) +CalloutStyler vanillaCalloutStyle({ + VanillaCalloutVariant variant = .neutral, + CalloutStyler style = const CalloutStyler.create(), +}) => _base().merge(_variantStyle(variant)).merge(style); + +/// Inset between the callout edge and its content. +const _paddingX = 16.0; + +/// See [_paddingX]. +const _paddingY = 12.0; + +/// Gap between the icon and the text. +const _gap = 8.0; + +/// Text size, matching body copy: a callout is prose, not a label. +const _textSize = 14.0; + +/// Size of the leading icon, one step up so it aligns with the first line. +const _iconSize = 16.0; + +/// Optical offset that aligns the icon with the first line's visible glyphs. +/// +/// The row stays top-aligned for multi-line prose. Font line boxes reserve +/// leading around their visible glyphs, so an icon at the line-box origin +/// looks high even though both layout bounds start together. +const _iconOffsetY = 2.0; + +/// Width of the callout outline. +const _borderWidth = 1.0; + +/// A fill that paints nothing, used by `destructive`. +const _noFill = Color(0x00000000); + +/// Layout and typography shared by both tones. +CalloutStyler _base() => CalloutStyler() + .direction(.horizontal) + .crossAxisAlignment(.start) + .padding(.symmetric(horizontal: _paddingX, vertical: _paddingY)) + .spacing(_gap) + .borderRadius(.all(VanillaTokens.radius())) + .text(.fontSize(_textSize).color(VanillaTokens.foreground())) + .icon(.size(_iconSize).wrap(.translate(x: 0, y: _iconOffsetY))); + +CalloutStyler _variantStyle(VanillaCalloutVariant variant) => switch (variant) { + .neutral => _toned( + fill: VanillaTokens.muted(), + outline: VanillaTokens.border(), + icon: VanillaTokens.mutedForeground(), + ), + .destructive => _toned( + fill: _noFill, + outline: VanillaTokens.destructive(), + icon: VanillaTokens.destructive(), + ), +}; + +/// One surface, one outline, and the glyph color. +/// +/// The icon carries the tone in both cases: the glyph is a marker and the +/// sentence is the message, so neutral dims the glyph while destructive +/// colors it. +CalloutStyler _toned({ + required Color fill, + required Color outline, + required Color icon, +}) => CalloutStyler() + .color(fill) + .border(.color(outline).width(_borderWidth)) + .icon(.color(icon)); diff --git a/registry_source/lib/src/default/components/callout.g.dart b/registry_source/lib/src/default/components/callout.g.dart new file mode 100644 index 000000000..8d67d67a1 --- /dev/null +++ b/registry_source/lib/src/default/components/callout.g.dart @@ -0,0 +1,89 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'callout.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Callout recipe. +/// +/// A callout is a block of text, usually with a leading icon, that says +/// something about the surrounding page. Remix owns the layout and the icon +/// slot; this recipe owns the surface, the outline, and the content colors. +/// +/// There are no interaction fragments. A callout is not a control — anything +/// actionable inside it is a separate button or link with its own recipe. +/// +/// The destructive tone paints no fill. A tinted danger surface would need a +/// `destructive`-derived background this theme does not define, and a solid +/// `destructive` fill would read as a pressed button rather than as a notice; +/// the outline and the icon carry the meaning instead. +/// +/// Both tones set their sentence in `foreground`, which is why the text color +/// lives in [_base] rather than in either tone. `destructive` is a fill color +/// chosen to sit under `destructiveForeground`, not a text color: on the dark +/// theme's page it measures 4.1:1, under the 4.5:1 WCAG floor for body copy. +/// The border and the glyph are non-text, where the floor is 3:1, so they are +/// where the tone shows. A theme that adds a dedicated danger *text* step +/// would move the text color back into [_variantStyle]. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. Because [variant] is a non-nullable +/// enum, the generator also emits one named constructor per enum value: +/// +/// ```dart +/// VanillaCallout.destructive( +/// icon: warningGlyph, +/// text: 'This deletes the workspace for everyone.', +/// ) +/// ``` +class VanillaCallout extends StatelessWidget { + const VanillaCallout({ + super.key, + this.variant = .neutral, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }); + + /// A neutral aside on a `muted` surface. + const VanillaCallout.neutral({ + super.key, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }) : variant = VanillaCalloutVariant.neutral; + + /// A problem the reader has to act on. + const VanillaCallout.destructive({ + super.key, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }) : variant = VanillaCalloutVariant.destructive; + + final VanillaCalloutVariant variant; + + final CalloutStyler style; + + final String? text; + + final IconData? icon; + + final Widget? child; + + @override + Widget build(BuildContext context) { + return RemixCallout( + key: this.key, + style: vanillaCalloutStyle(variant: this.variant, style: this.style), + text: this.text, + icon: this.icon, + child: this.child, + ); + } +} diff --git a/packages/remix_agent/example/lib/ui/components/card.dart b/registry_source/lib/src/default/components/card.dart similarity index 76% rename from packages/remix_agent/example/lib/ui/components/card.dart rename to registry_source/lib/src/default/components/card.dart index e55c4cecf..231ab0007 100644 --- a/packages/remix_agent/example/lib/ui/components/card.dart +++ b/registry_source/lib/src/default/components/card.dart @@ -18,24 +18,24 @@ part 'card.g.dart'; /// /// The fill is `background`, the same token the page uses, so a card is told /// apart by its outline rather than by a second surface color. That is -/// deliberate: it keeps the token vocabulary at fifteen names, and a theme +/// deliberate: it keeps the token vocabulary at twenty names, and a theme /// that wants a distinct card surface changes this one line. /// /// [style] is merged **last**, so a single call site can override any part of /// the resolved recipe without forking it: /// /// ```dart -/// UiCard( -/// style: CardStyler().color(UiTokens.muted()), +/// VanillaCard( +/// style: CardStyler().color(VanillaTokens.muted()), /// child: summary, /// ) /// ``` @MixWidget(target: RemixCard.new) -CardStyler uiCardStyle({CardStyler style = const CardStyler.create()}) => +CardStyler vanillaCardStyle({CardStyler style = const CardStyler.create()}) => CardStyler() - .color(UiTokens.background()) - .border(.color(UiTokens.border()).width(_borderWidth)) - .borderRadius(.all(UiTokens.radius())) + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .borderRadius(.all(VanillaTokens.radius())) .padding(.all(_padding)) .merge(style); diff --git a/packages/remix_agent/example/lib/ui/components/card.g.dart b/registry_source/lib/src/default/components/card.g.dart similarity index 78% rename from packages/remix_agent/example/lib/ui/components/card.g.dart rename to registry_source/lib/src/default/components/card.g.dart index e6e866068..12e2e31d0 100644 --- a/packages/remix_agent/example/lib/ui/components/card.g.dart +++ b/registry_source/lib/src/default/components/card.g.dart @@ -18,20 +18,24 @@ part of 'card.dart'; /// /// The fill is `background`, the same token the page uses, so a card is told /// apart by its outline rather than by a second surface color. That is -/// deliberate: it keeps the token vocabulary at fifteen names, and a theme +/// deliberate: it keeps the token vocabulary at twenty names, and a theme /// that wants a distinct card surface changes this one line. /// /// [style] is merged **last**, so a single call site can override any part of /// the resolved recipe without forking it: /// /// ```dart -/// UiCard( -/// style: CardStyler().color(UiTokens.muted()), +/// VanillaCard( +/// style: CardStyler().color(VanillaTokens.muted()), /// child: summary, /// ) /// ``` -class UiCard extends StatelessWidget { - const UiCard({super.key, this.style = const CardStyler.create(), this.child}); +class VanillaCard extends StatelessWidget { + const VanillaCard({ + super.key, + this.style = const CardStyler.create(), + this.child, + }); final CardStyler style; @@ -41,7 +45,7 @@ class UiCard extends StatelessWidget { Widget build(BuildContext context) { return RemixCard( key: this.key, - style: uiCardStyle(style: this.style), + style: vanillaCardStyle(style: this.style), child: this.child, ); } diff --git a/registry_source/lib/src/default/components/chart.dart b/registry_source/lib/src/default/components/chart.dart new file mode 100644 index 000000000..58fa3ac67 --- /dev/null +++ b/registry_source/lib/src/default/components/chart.dart @@ -0,0 +1,232 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:mix_chart/mix_chart.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'chart.g.dart'; + +const _defaultPaletteToken = ContextToken>( + resolveVanillaChartPalette, +); +const _tooltipBorderToken = ContextToken(_resolveTooltipBorder); +const _tooltipRadiusToken = ContextToken(_resolveTooltipRadius); +const _tooltipPaddingToken = ContextToken(_resolveTooltipPadding); +const _barRadiusToken = ContextToken(_resolveBarRadius); + +/// Returns the categorical palette shared by this application's charts. +/// +/// The colors are the theme's `chart1` through `chart5` tokens in series +/// order, so editing them in `VanillaThemeData` restyles every chart. +/// Pass `palette` to one recipe or generated widget for a local override. +List resolveVanillaChartPalette(BuildContext context) => + List.unmodifiable([ + for (final token in VanillaTokens.chart) token.resolve(context), + ]); + +/// The application's line and area chart recipe. +/// +/// `mix_chart` owns the data model, rendering, interaction, and semantics. +/// This file owns the palette, axes, grid, line, markers, and tooltip. Give the +/// generated [VanillaLineChart] a bounded height because charts have no +/// intrinsic height. +/// +/// [style] merges last, so one call site can replace any part of the recipe. +@MixWidget(target: LineChart.new) +LineChartStyler vanillaLineChartStyle({ + bool showMarkers = false, + List? palette, + LineChartStyler style = const LineChartStyler.create(), +}) => LineChartStyler() + .frame(_chartFrameStyle()) + .axis(_chartAxisStyle()) + .topAxis(_hiddenAxisStyle()) + .rightAxis(_hiddenAxisStyle()) + .grid(_chartGridStyle()) + .series( + LineSeriesStyler() + .curve(.curved) + .smoothness(0.18) + .preventCurveOvershooting(true) + .roundStrokeCap(true) + .roundStrokeJoin(true) + .stroke(ChartStrokeStyler().width(_lineWidth)) + .marker( + ChartMarkerStyler() + .show(showMarkers) + .radius(_markerRadius) + .borderColor(VanillaTokens.background()) + .borderWidth(_markerBorderWidth), + ), + ) + .tooltip(_chartTooltipStyle()) + .merge(LineChartStyler.create(palette: _paletteProp(palette))) + .merge(style); + +/// The application's grouped, stacked, and floating bar chart recipe. +/// +/// `mix_chart` owns the bar data and behavior. This recipe supplies the shared +/// visual treatment. Give the generated [VanillaBarChart] a bounded +/// height because charts have no intrinsic height. +/// +/// [style] merges last, so one call site can replace any part of the recipe. +@MixWidget(target: BarChart.new) +BarChartStyler vanillaBarChartStyle({ + List? palette, + BarChartStyler style = const BarChartStyler.create(), +}) => BarChartStyler() + .frame(_chartFrameStyle()) + .axis(_chartAxisStyle()) + .topAxis(_hiddenAxisStyle()) + .rightAxis(_hiddenAxisStyle()) + .grid(_chartGridStyle()) + .bar( + BarStyler.create( + borderRadius: Prop.token(_barRadiusToken), + ).width(_barWidth), + ) + .groupSpacing(_barGroupSpacing) + .barSpacing(_barSpacing) + .tooltip(_chartTooltipStyle()) + .merge(BarChartStyler.create(palette: _paletteProp(palette))) + .merge(style); + +/// The application's pie and donut chart recipe. +/// +/// A positive [centerRadius] creates a donut. Labels stay hidden by default; +/// a caller-owned legend keeps category names readable with any custom +/// palette. Give the generated [VanillaPieChart] a bounded width and +/// height because charts have no intrinsic size. +/// +/// [style] merges last, so one call site can replace any part of the recipe. +@MixWidget(target: PieChart.new) +PieChartStyler vanillaPieChartStyle({ + double centerRadius = 0, + bool showLabels = false, + List? palette, + PieChartStyler style = const PieChartStyler.create(), +}) => PieChartStyler() + .frame(_chartFrameStyle()) + .centerRadius(centerRadius) + .centerColor(VanillaTokens.background()) + .sliceSpacing(_sliceSpacing) + .selectedSliceRadiusOffset(_selectedSliceOffset) + .slice( + PieSliceStyler() + .showLabel(showLabels) + .cornerRadius(_sliceRadius) + .label( + TextStyler() + .fontSize(_labelSize) + .fontWeight(FontWeight.w600) + .color(VanillaTokens.background()), + ), + ) + .tooltip(_chartTooltipStyle()) + .merge(PieChartStyler.create(palette: _paletteProp(palette))) + .merge(style); + +/// Width of a line series. +const _lineWidth = 2.0; + +/// Radius of an optional line marker. +const _markerRadius = 3.0; + +/// Border that separates a line marker from the plot behind it. +const _markerBorderWidth = 2.0; + +/// Width of each bar before a caller override. +const _barWidth = 16.0; + +/// Space between bar groups. +const _barGroupSpacing = 12.0; + +/// Space between bars in one group. +const _barSpacing = 6.0; + +/// Gap between pie slices. +const _sliceSpacing = 2.0; + +/// Extra radius applied to a selected pie slice. +const _selectedSliceOffset = 4.0; + +/// Corner radius applied to each pie slice. +const _sliceRadius = 2.0; + +/// Axis, tooltip, and optional pie-label text size. +const _labelSize = 12.0; + +/// Maximum corner radius for bars. +const _maxBarRadius = 4.0; + +/// Maximum corner radius for the tooltip surface. +const _maxTooltipRadius = 12.0; + +Prop> _paletteProp(List? palette) => palette == null + ? Prop.token(_defaultPaletteToken) + : Prop.value(List.unmodifiable(palette)); + +ChartFrameStyler _chartFrameStyle() => ChartFrameStyler() + .backgroundColor(MixColors.transparent) + .showBorder(false) + .clip(true); + +ChartAxisStyler _chartAxisStyle() => ChartAxisStyler() + .showLabels(true) + .label( + TextStyler().fontSize(_labelSize).color(VanillaTokens.mutedForeground()), + ) + .labelSpace(8) + .fitInside(true) + .fitInsideDistance(4) + .drawBelowEverything(true); + +ChartAxisStyler _hiddenAxisStyle() => ChartAxisStyler().showLabels(false); + +ChartGridStyler _chartGridStyle() => ChartGridStyler() + .show(true) + .showHorizontal(true) + .showVertical(false) + .stroke(ChartStrokeStyler().color(VanillaTokens.border()).width(1)); + +ChartTooltipStyler _chartTooltipStyle() => + ChartTooltipStyler.create( + border: Prop.token(_tooltipBorderToken), + borderRadius: Prop.token(_tooltipRadiusToken), + padding: Prop.token(_tooltipPaddingToken), + ) + .backgroundColor(VanillaTokens.background()) + .margin(8) + .maxWidth(280) + .fitHorizontally(true) + .fitVertically(true) + .text( + TextStyler() + .fontSize(_labelSize) + .fontWeight(FontWeight.w500) + .color(VanillaTokens.foreground()), + ); + +BorderSide _resolveTooltipBorder(BuildContext context) => + BorderSide(color: VanillaTokens.border.resolve(context), width: 1); + +BorderRadius _resolveTooltipRadius(BuildContext context) => + BorderRadius.all(_clampedThemeRadius(context, _maxTooltipRadius)); + +EdgeInsets _resolveTooltipPadding(BuildContext context) => + const EdgeInsets.symmetric(horizontal: 12, vertical: 8); + +BorderRadius _resolveBarRadius(BuildContext context) => + BorderRadius.all(_clampedThemeRadius(context, _maxBarRadius)); + +Radius _clampedThemeRadius(BuildContext context, double maximum) { + final radius = VanillaTokens.radius.resolve(context); + + return Radius.elliptical( + math.min(radius.x, maximum), + math.min(radius.y, maximum), + ); +} diff --git a/registry_source/lib/src/default/components/chart.g.dart b/registry_source/lib/src/default/components/chart.g.dart new file mode 100644 index 000000000..075328bbb --- /dev/null +++ b/registry_source/lib/src/default/components/chart.g.dart @@ -0,0 +1,292 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chart.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's line and area chart recipe. +/// +/// `mix_chart` owns the data model, rendering, interaction, and semantics. +/// This file owns the palette, axes, grid, line, markers, and tooltip. Give the +/// generated [VanillaLineChart] a bounded height because charts have no +/// intrinsic height. +/// +/// [style] merges last, so one call site can replace any part of the recipe. +class VanillaLineChart extends StatelessWidget { + const VanillaLineChart({ + super.key, + this.showMarkers = false, + this.palette, + this.style = const LineChartStyler.create(), + required this.series, + this.xAxis, + this.yAxis, + this.topAxis, + this.rightAxis, + this.viewport, + this.dataTransition = ChartDataTransition.none, + this.selectedPoints = const {}, + this.onPointHover, + this.onPointTap, + this.onPointLongPress, + this.tooltipBuilder, + this.hitTestRadius = 10, + this.mouseCursorResolver, + this.semanticsLabel, + this.semanticsValue, + this.excludeFromSemantics = false, + }); + + final bool showMarkers; + + final List? palette; + + final LineChartStyler style; + + final List series; + + final ChartAxis? xAxis; + + final ChartAxis? yAxis; + + final ChartAxis? topAxis; + + final ChartAxis? rightAxis; + + final ChartViewport? viewport; + + final ChartDataTransition dataTransition; + + final Set selectedPoints; + + final ValueChanged? onPointHover; + + final ValueChanged? onPointTap; + + final ValueChanged? onPointLongPress; + + final ChartTooltipBuilder? tooltipBuilder; + + final double hitTestRadius; + + final ChartMouseCursorResolver? mouseCursorResolver; + + final String? semanticsLabel; + + final String? semanticsValue; + + final bool excludeFromSemantics; + + @override + Widget build(BuildContext context) { + return LineChart( + key: this.key, + style: vanillaLineChartStyle( + showMarkers: this.showMarkers, + palette: this.palette, + style: this.style, + ), + series: this.series, + xAxis: this.xAxis, + yAxis: this.yAxis, + topAxis: this.topAxis, + rightAxis: this.rightAxis, + viewport: this.viewport, + dataTransition: this.dataTransition, + selectedPoints: this.selectedPoints, + onPointHover: this.onPointHover, + onPointTap: this.onPointTap, + onPointLongPress: this.onPointLongPress, + tooltipBuilder: this.tooltipBuilder, + hitTestRadius: this.hitTestRadius, + mouseCursorResolver: this.mouseCursorResolver, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + excludeFromSemantics: this.excludeFromSemantics, + ); + } +} + +/// The application's grouped, stacked, and floating bar chart recipe. +/// +/// `mix_chart` owns the bar data and behavior. This recipe supplies the shared +/// visual treatment. Give the generated [VanillaBarChart] a bounded +/// height because charts have no intrinsic height. +/// +/// [style] merges last, so one call site can replace any part of the recipe. +class VanillaBarChart extends StatelessWidget { + const VanillaBarChart({ + super.key, + this.palette, + this.style = const BarChartStyler.create(), + required this.groups, + this.xAxis, + this.yAxis, + this.topAxis, + this.rightAxis, + this.viewport, + this.dataTransition = ChartDataTransition.none, + this.selectedItems = const {}, + this.onBarHover, + this.onBarTap, + this.onBarLongPress, + this.tooltipBuilder, + this.hitTestPadding = const EdgeInsets.all(4), + this.mouseCursorResolver, + this.semanticsLabel, + this.semanticsValue, + this.excludeFromSemantics = false, + }); + + final List? palette; + + final BarChartStyler style; + + final List groups; + + final ChartAxis? xAxis; + + final ChartAxis? yAxis; + + final ChartAxis? topAxis; + + final ChartAxis? rightAxis; + + final ChartViewport? viewport; + + final ChartDataTransition dataTransition; + + final Set selectedItems; + + final ValueChanged? onBarHover; + + final ValueChanged? onBarTap; + + final ValueChanged? onBarLongPress; + + final ChartTooltipBuilder? tooltipBuilder; + + final EdgeInsets hitTestPadding; + + final ChartMouseCursorResolver? mouseCursorResolver; + + final String? semanticsLabel; + + final String? semanticsValue; + + final bool excludeFromSemantics; + + @override + Widget build(BuildContext context) { + return BarChart( + key: this.key, + style: vanillaBarChartStyle(palette: this.palette, style: this.style), + groups: this.groups, + xAxis: this.xAxis, + yAxis: this.yAxis, + topAxis: this.topAxis, + rightAxis: this.rightAxis, + viewport: this.viewport, + dataTransition: this.dataTransition, + selectedItems: this.selectedItems, + onBarHover: this.onBarHover, + onBarTap: this.onBarTap, + onBarLongPress: this.onBarLongPress, + tooltipBuilder: this.tooltipBuilder, + hitTestPadding: this.hitTestPadding, + mouseCursorResolver: this.mouseCursorResolver, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + excludeFromSemantics: this.excludeFromSemantics, + ); + } +} + +/// The application's pie and donut chart recipe. +/// +/// A positive [centerRadius] creates a donut. Labels stay hidden by default; +/// a caller-owned legend keeps category names readable with any custom +/// palette. Give the generated [VanillaPieChart] a bounded width and +/// height because charts have no intrinsic size. +/// +/// [style] merges last, so one call site can replace any part of the recipe. +class VanillaPieChart extends StatelessWidget { + const VanillaPieChart({ + super.key, + this.centerRadius = 0, + this.showLabels = false, + this.palette, + this.style = const PieChartStyler.create(), + required this.slices, + this.dataTransition = ChartDataTransition.none, + this.selectedSliceIds = const {}, + this.onSliceHover, + this.onSliceTap, + this.onSliceLongPress, + this.tooltipBuilder, + this.mouseCursorResolver, + this.valueFormatter, + this.semanticsLabel, + this.semanticsValue, + this.excludeFromSemantics = false, + }); + + final double centerRadius; + + final bool showLabels; + + final List? palette; + + final PieChartStyler style; + + final List slices; + + final ChartDataTransition dataTransition; + + final Set selectedSliceIds; + + final ValueChanged? onSliceHover; + + final ValueChanged? onSliceTap; + + final ValueChanged? onSliceLongPress; + + final ChartTooltipBuilder? tooltipBuilder; + + final ChartMouseCursorResolver? mouseCursorResolver; + + final ChartAxisLabelFormatter? valueFormatter; + + final String? semanticsLabel; + + final String? semanticsValue; + + final bool excludeFromSemantics; + + @override + Widget build(BuildContext context) { + return PieChart( + key: this.key, + style: vanillaPieChartStyle( + centerRadius: this.centerRadius, + showLabels: this.showLabels, + palette: this.palette, + style: this.style, + ), + slices: this.slices, + dataTransition: this.dataTransition, + selectedSliceIds: this.selectedSliceIds, + onSliceHover: this.onSliceHover, + onSliceTap: this.onSliceTap, + onSliceLongPress: this.onSliceLongPress, + tooltipBuilder: this.tooltipBuilder, + mouseCursorResolver: this.mouseCursorResolver, + valueFormatter: this.valueFormatter, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + excludeFromSemantics: this.excludeFromSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/checkbox.dart b/registry_source/lib/src/default/components/checkbox.dart new file mode 100644 index 000000000..44e96015e --- /dev/null +++ b/registry_source/lib/src/default/components/checkbox.dart @@ -0,0 +1,210 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'checkbox.g.dart'; + +/// The application's Checkbox recipe. +/// +/// Everything visual about a checkbox lives in this function: the box +/// geometry, the indicator, the label, and the +/// hover/checked/indeterminate/focus/disabled fragments. Remix keeps +/// ownership of rendering, the tristate transition, pointer and keyboard +/// behavior, the minimum tap target, and the checkbox accessibility +/// semantics — this recipe never reimplements any of that. +/// +/// `@MixWidget(target: RemixCheckbox.new)` generates `VanillaCheckbox` +/// into `checkbox.g.dart`: an adapter whose constructor is this function's +/// parameters plus every safe `RemixCheckbox` parameter, and whose `build` +/// calls `RemixCheckbox(style: vanillaCheckboxStyle(...), ...)`. Unlike +/// Button there is no `variant` parameter, so the generator emits no named +/// constructors: a checkbox has one look, and its meaningful axes are the +/// runtime states below. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaCheckbox( +/// selected: subscribed, +/// label: 'Email me', +/// style: CheckboxStyler().onSelected( +/// CheckboxStyler().color(const Color(0xFF7C3AED)), +/// ), +/// onChanged: (value) => setState(() => subscribed = value), +/// ) +/// ``` +/// +/// State fragments merge by state, not by depth: an override that must beat +/// the recipe's checked fill has to be declared as a selected fragment too +/// (`CheckboxStyler().onSelected(...)`). +/// +/// There is deliberately no pressed fragment. A button needs one because +/// nothing else about it changes on tap; a checkbox flips its own state, and +/// that is the feedback. +@MixWidget(target: RemixCheckbox.new) +CheckboxStyler vanillaCheckboxStyle({ + CheckboxStyler style = const CheckboxStyler.create(), +}) { + // Built once and used for both fragments: an indeterminate checkbox is a + // checkbox that is *not unchecked*, so it carries the checked surface and + // only its glyph differs. Reusing the value also keeps the two fragments + // equal, which matters because stylers compare by value. + final checked = _checkedStyle(); + + return _base() + // The outline has to survive the hover fill, exactly as it does on the + // radio beside it: `accent` on `border` is 1.09:1 in the shipped light + // theme, so tinting the box alone leaves an unchecked checkbox with no + // visible edge while the pointer is on it. + .onHovered( + CheckboxStyler() + .color(VanillaTokens.accent()) + .border( + .color(VanillaTokens.mutedForeground()).width(_borderWidth), + ), + ) + .onSelected(checked) + .onIndeterminate(checked) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()) + .merge(style); +} + +/// The application's Checkbox recipe for one option in a checkbox group. +/// +/// `RemixCheckboxGroup` is behavioral: it owns the selected set and the +/// group-wide enabled and required configuration, and carries no styler of +/// its own. Without this second adapter every option in a group would need +/// the recipe attached by hand, and one missed option in a loop would render +/// unstyled beside its styled siblings. +/// +/// It delegates to [vanillaCheckboxStyle] rather than restating it: +/// a group option is the same checkbox, so editing the recipe above restyles +/// both. +@MixWidget(target: RemixCheckboxGroupItem.new) +CheckboxStyler vanillaCheckboxGroupItemStyle({ + CheckboxStyler style = const CheckboxStyler.create(), +}) => vanillaCheckboxStyle(style: style); + +/// Alpha applied to the checked fill while hovered. +const _hoverAlpha = 0.9; + +/// The checked fill, dimmed, resolved from the active scope. +/// +/// The obvious spelling would be `VanillaTokens.primary().withValues(alpha: 0.9)`, +/// but that records a Mix *directive*, and directives accumulate through every +/// later merge. A caller who replaced the hover fill would still get this +/// recipe's alpha applied on top of their own color. A `ContextToken` does the +/// arithmetic during resolution instead, so the state fragment holds one plain +/// color that a caller can replace outright. +/// +/// Declared as a top-level final because `ContextToken` equality is resolver +/// identity: rebuilding one per call would make two identical recipes compare +/// unequal. +final _primaryHoverFill = ContextToken( + (context) => + VanillaTokens.primary.resolve(context).withValues(alpha: _hoverAlpha), +); + +/// The largest corner radius a checkbox box may take. +/// +/// `VanillaTokens.radius` is authored for 32-40px controls. Applied +/// unclamped to a 16px box, a pill radius draws a circle, which reads as a +/// radio button. Clamping rather than hardcoding keeps the theme in charge in +/// the other direction, so `radius: Radius.zero` still yields square +/// checkboxes. +const _maxBoxRadius = 4.0; + +/// The theme's corner radius, clamped to [_maxBoxRadius]. See +/// [_primaryHoverFill] for why this is a top-level final rather than a +/// per-call closure. +final _boxRadius = ContextToken((context) { + final radius = VanillaTokens.radius.resolve(context); + + return Radius.elliptical( + math.min(radius.x, _maxBoxRadius), + math.min(radius.y, _maxBoxRadius), + ); +}); + +/// Width of the box outline, in every state. +const _borderWidth = 1.0; + +/// Width of the keyboard focus ring. +const _focusRingWidth = 2.0; + +/// Distance between the control edge and its focus ring. +const _focusRingOffset = 2.0; + +/// Opacity applied to the whole control while disabled. +const _disabledOpacity = 0.5; + +/// Side of the box, matching shadcn's `h-4 w-4`. +const _box = 16.0; + +/// Side of the check or dash drawn inside the box. +const _indicator = 10.0; + +/// Gap between the box and its label. +const _gap = 8.0; + +/// Label size, matching body copy. +const _labelSize = 14.0; + +/// The unchecked box, the indicator geometry, and the label. +/// +/// The indicator gets a size but no color here: Remix renders no indicator at +/// all while unchecked, so the only states that can show one set their own +/// content color. +CheckboxStyler _base() => CheckboxStyler() + .size(_box, _box) + .alignment(.center) + .borderRadius(.all(_boxRadius())) + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .indicator(.size(_indicator)) + .labelSpacing(_gap) + .label(.fontSize(_labelSize).color(VanillaTokens.foreground())); + +/// The checked and indeterminate surface. +/// +/// The border is repainted in the fill color rather than removed: dropping it +/// would shrink the painted box by two logical pixels at the moment of +/// checking, so the control would visibly twitch. +CheckboxStyler _checkedStyle() => _filled(VanillaTokens.primary()) + .indicator(.color(VanillaTokens.primaryForeground())) + // Declared as a hover fragment inside the checked fragment so a hovered, + // checked box dims its own fill. A top-level hover fragment could not do + // this: it does not know which fill it is dimming. + .onHovered(_filled(_primaryHoverFill())); + +/// One fill applied to both the box and its outline. +CheckboxStyler _filled(Color fill) => + CheckboxStyler().color(fill).border(.color(fill).width(_borderWidth)); + +/// The keyboard focus ring. +/// +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// box without taking layout space, so focusing a checkbox never reflows the +/// row it sits in. +CheckboxStyler _focusVisibleStyle() => CheckboxStyler().containerEffects( + .outline( + .color( + VanillaTokens.focusRing(), + ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), + ).outlineOffset(_focusRingOffset), +); + +/// Declared last so it wins over every other state fragment. +/// +/// A disabled checkbox keeps whatever surface its state gives it and simply +/// fades; the focus ring is cleared because a disabled control that still +/// draws a focus ring reads as actionable. +CheckboxStyler _disabledStyle() => CheckboxStyler() + .containerEffects(.outline(.style(.none))) + .wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/checkbox.g.dart b/registry_source/lib/src/default/components/checkbox.g.dart new file mode 100644 index 000000000..82ac617c0 --- /dev/null +++ b/registry_source/lib/src/default/components/checkbox.g.dart @@ -0,0 +1,190 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'checkbox.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Checkbox recipe. +/// +/// Everything visual about a checkbox lives in this function: the box +/// geometry, the indicator, the label, and the +/// hover/checked/indeterminate/focus/disabled fragments. Remix keeps +/// ownership of rendering, the tristate transition, pointer and keyboard +/// behavior, the minimum tap target, and the checkbox accessibility +/// semantics — this recipe never reimplements any of that. +/// +/// `@MixWidget(target: RemixCheckbox.new)` generates `VanillaCheckbox` +/// into `checkbox.g.dart`: an adapter whose constructor is this function's +/// parameters plus every safe `RemixCheckbox` parameter, and whose `build` +/// calls `RemixCheckbox(style: vanillaCheckboxStyle(...), ...)`. Unlike +/// Button there is no `variant` parameter, so the generator emits no named +/// constructors: a checkbox has one look, and its meaningful axes are the +/// runtime states below. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaCheckbox( +/// selected: subscribed, +/// label: 'Email me', +/// style: CheckboxStyler().onSelected( +/// CheckboxStyler().color(const Color(0xFF7C3AED)), +/// ), +/// onChanged: (value) => setState(() => subscribed = value), +/// ) +/// ``` +/// +/// State fragments merge by state, not by depth: an override that must beat +/// the recipe's checked fill has to be declared as a selected fragment too +/// (`CheckboxStyler().onSelected(...)`). +/// +/// There is deliberately no pressed fragment. A button needs one because +/// nothing else about it changes on tap; a checkbox flips its own state, and +/// that is the feedback. +class VanillaCheckbox extends StatelessWidget { + const VanillaCheckbox({ + super.key, + this.style = const CheckboxStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon, + this.uncheckedIcon, + this.indeterminateIcon, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.label, + this.semanticLabel, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }); + + final CheckboxStyler style; + + final bool? selected; + + final ValueChanged? onChanged; + + final bool enabled; + + final bool tristate; + + final IconData? checkedIcon; + + final IconData? uncheckedIcon; + + final IconData? indeterminateIcon; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool enableFeedback; + + final String? label; + + final String? semanticLabel; + + final Size minimumTapTargetSize; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixCheckbox( + key: this.key, + style: vanillaCheckboxStyle(style: this.style), + selected: this.selected, + onChanged: this.onChanged, + enabled: this.enabled, + tristate: this.tristate, + checkedIcon: this.checkedIcon, + uncheckedIcon: this.uncheckedIcon, + indeterminateIcon: this.indeterminateIcon, + focusNode: this.focusNode, + autofocus: this.autofocus, + enableFeedback: this.enableFeedback, + label: this.label, + semanticLabel: this.semanticLabel, + minimumTapTargetSize: this.minimumTapTargetSize, + mouseCursor: this.mouseCursor, + ); + } +} + +/// The application's Checkbox recipe for one option in a checkbox group. +/// +/// `RemixCheckboxGroup` is behavioral: it owns the selected set and the +/// group-wide enabled and required configuration, and carries no styler of +/// its own. Without this second adapter every option in a group would need +/// the recipe attached by hand, and one missed option in a loop would render +/// unstyled beside its styled siblings. +/// +/// It delegates to [vanillaCheckboxStyle] rather than restating it: +/// a group option is the same checkbox, so editing the recipe above restyles +/// both. +class VanillaCheckboxGroupItem extends StatelessWidget { + const VanillaCheckboxGroupItem({ + super.key, + this.style = const CheckboxStyler.create(), + required this.value, + required this.label, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.checkedIcon, + this.uncheckedIcon, + this.enableFeedback = true, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }); + + final CheckboxStyler style; + + final T value; + + final String label; + + final String? semanticLabel; + + final bool enabled; + + final FocusNode? focusNode; + + final bool autofocus; + + final IconData? checkedIcon; + + final IconData? uncheckedIcon; + + final bool enableFeedback; + + final Size minimumTapTargetSize; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixCheckboxGroupItem( + key: this.key, + style: vanillaCheckboxGroupItemStyle(style: this.style), + value: this.value, + label: this.label, + semanticLabel: this.semanticLabel, + enabled: this.enabled, + focusNode: this.focusNode, + autofocus: this.autofocus, + checkedIcon: this.checkedIcon, + uncheckedIcon: this.uncheckedIcon, + enableFeedback: this.enableFeedback, + minimumTapTargetSize: this.minimumTapTargetSize, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/registry_source/lib/src/default/components/data_list.dart b/registry_source/lib/src/default/components/data_list.dart new file mode 100644 index 000000000..d6564109a --- /dev/null +++ b/registry_source/lib/src/default/components/data_list.dart @@ -0,0 +1,63 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'data_list.g.dart'; + +/// The application's DataList recipe. +/// +/// A data list is a set of label/value pairs — the "Status: Active" block on a +/// detail page. Remix owns the rendering, the two layout orientations, the +/// label-column alignment, and the accessibility semantics; this recipe +/// supplies the two text roles and the spacing between them. +/// +/// The label is `mutedForeground` and the value is `foreground`, which is the +/// pairing that makes a list scannable: the eye lands on the answers, and the +/// questions stay legible without competing. +/// +/// It takes no size and no variant. A data list is typography and spacing, and +/// both are decided by the page it sits on — a caller who wants a denser block +/// overrides the spacings through [style]. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaDataList( +/// items: const [ +/// RemixDataListItem(label: 'Status', value: 'Active'), +/// RemixDataListItem(label: 'Plan', value: 'Pro'), +/// ], +/// ) +/// ``` +@MixWidget(target: RemixDataList.new) +DataListStyler vanillaDataListStyle({ + DataListStyler style = const DataListStyler.create(), +}) => DataListStyler() + .label(.fontSize(_textSize).color(VanillaTokens.mutedForeground())) + .value(.fontSize(_textSize).color(VanillaTokens.foreground())) + .rowSpacing(_rowSpacing) + .columnSpacing(_columnSpacing) + .labelValueSpacing(_labelValueSpacing) + // A floor rather than a fixed width: the labels line up into a column, + // but a long one is still allowed to be as long as it needs to be. + .minLabelWidth(_minLabelWidth) + .merge(style); + +/// Text size for both roles, matching body copy. +const _textSize = 14.0; + +/// Gap between one pair and the next. +const _rowSpacing = 12.0; + +/// Gap between two pairs sitting side by side. +const _columnSpacing = 24.0; + +/// Gap between a label and its own value. +const _labelValueSpacing = 8.0; + +/// The narrowest the label column gets, so short labels still line their +/// values up instead of leaving a ragged edge. +const _minLabelWidth = 96.0; diff --git a/registry_source/lib/src/default/components/data_list.g.dart b/registry_source/lib/src/default/components/data_list.g.dart new file mode 100644 index 000000000..c03836d92 --- /dev/null +++ b/registry_source/lib/src/default/components/data_list.g.dart @@ -0,0 +1,66 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_list.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's DataList recipe. +/// +/// A data list is a set of label/value pairs — the "Status: Active" block on a +/// detail page. Remix owns the rendering, the two layout orientations, the +/// label-column alignment, and the accessibility semantics; this recipe +/// supplies the two text roles and the spacing between them. +/// +/// The label is `mutedForeground` and the value is `foreground`, which is the +/// pairing that makes a list scannable: the eye lands on the answers, and the +/// questions stay legible without competing. +/// +/// It takes no size and no variant. A data list is typography and spacing, and +/// both are decided by the page it sits on — a caller who wants a denser block +/// overrides the spacings through [style]. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaDataList( +/// items: const [ +/// RemixDataListItem(label: 'Status', value: 'Active'), +/// RemixDataListItem(label: 'Plan', value: 'Pro'), +/// ], +/// ) +/// ``` +class VanillaDataList extends StatelessWidget { + const VanillaDataList({ + super.key, + this.style = const DataListStyler.create(), + required this.items, + this.orientation = Axis.horizontal, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final DataListStyler style; + + final List items; + + final Axis orientation; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixDataList( + key: this.key, + style: vanillaDataListStyle(style: this.style), + items: this.items, + orientation: this.orientation, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/data_table.dart b/registry_source/lib/src/default/components/data_table.dart new file mode 100644 index 000000000..4e6cc8e8a --- /dev/null +++ b/registry_source/lib/src/default/components/data_table.dart @@ -0,0 +1,168 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; +import 'checkbox.dart'; +import 'icon_button.dart'; +import 'select.dart'; + +part 'data_table.g.dart'; + +/// The application's DataTable recipe. +/// +/// Remix owns the rendering, sorting, selection, pagination, the empty state, +/// and the table accessibility semantics; this recipe supplies the frame, the +/// rows, the cells, and the three text roles. +/// +/// It is the one recipe in this layer that **depends on other items**, and it +/// is the reason its registry entry lists `checkbox`, `icon_button`, and +/// `select` beside `theme`. A table's selection column is a checkbox, its +/// pager is a pair of icon buttons, and its page-size control is a select — +/// literally, not by analogy. `DataTableSpec` takes each of those as a styler, +/// so the honest thing is to hand it the application's own recipes rather than +/// restate three components inside a fourth. Change the checkbox recipe and +/// this table's checkboxes change with it, which is what a reader expects. +/// +/// That is a deliberate exception to the rule that these files are +/// self-contained. It earns it: the alternative is either three duplicated +/// recipes that drift, or a table whose controls do not match the rest of the +/// application. +/// +/// The frame is the card's: `background` fill, `border` hairline, the theme +/// radius. Header and body rows are separated by the same hairline, and the +/// last body row drops it so the table does not draw a second line on top of +/// its own bottom edge. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +@MixWidget(target: RemixDataTable.new) +DataTableStyler vanillaDataTableStyle({ + DataTableStyler style = const DataTableStyler.create(), +}) => DataTableStyler() + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .borderRadius(.all(_frameRadius())) + .clipBehavior(Clip.antiAlias) + // The header sits on `muted` so a long table keeps its column names + // legible while the body scrolls under them. + .headerRow(.color(VanillaTokens.muted()).border(_rowRule())) + .bodyRow(.border(_rowRule())) + // Without this the bottom row's rule would double up with the frame's own + // edge, which reads as a two-pixel border on one side only. + .lastBodyRow(.border(.bottom(.style(.none)))) + .headerCell(_cell()) + .bodyCell(_cell()) + .selectionCell(_cell()) + .headerMinHeight(_headerHeight) + .rowMinHeight(_rowHeight) + .selectionColumnWidth(_selectionColumnWidth) + // `foreground`, not `mutedForeground`: the header already sits on its own + // surface, and `mutedForeground` on `muted` measures 4.35:1 — under the + // floor for text this size. The weight is what makes a column name read + // as a label rather than as data. + .headerLabel( + .fontSize( + _labelSize, + ).fontWeight(FontWeight.w500).color(VanillaTokens.foreground()), + ) + .cellText(.fontSize(_textSize).color(VanillaTokens.foreground())) + .footerLabel(.fontSize(_labelSize).color(VanillaTokens.mutedForeground())) + .sortIcon(.size(_iconSize).color(VanillaTokens.mutedForeground())) + .sortIconSpacing(_sortIconSpacing) + .footer( + FlexBoxStyler() + .direction(.horizontal) + .crossAxisAlignment(.center) + .mainAxisAlignment(.end) + .minHeight(_headerHeight) + .padding(.horizontal(_cellPaddingX)) + .spacing(_footerGap) + .border(.top(_rule())), + ) + // The application's own controls, not restatements of them. + .selectionCheckbox(vanillaCheckboxStyle()) + .pageButton(vanillaIconButtonStyle(variant: .ghost, size: .small)) + .pageSizeSelect(vanillaSelectStyle()) + .merge(style); + +/// Width of the frame, the row rules, and the footer rule. +const _borderWidth = 1.0; + +/// Horizontal inset inside every cell. +const _cellPaddingX = 12.0; + +/// Vertical inset inside every cell. +const _cellPaddingY = 8.0; + +/// Minimum height of the header row and the footer. +const _headerHeight = 40.0; + +/// Minimum height of a body row. +const _rowHeight = 44.0; + +/// Width of the column holding the selection checkboxes. +const _selectionColumnWidth = 44.0; + +/// Column-name size, one step below the data it names. +const _labelSize = 13.0; + +/// Cell text size, matching body copy. +const _textSize = 14.0; + +/// Size of the sort indicator. +const _iconSize = 14.0; + +/// Gap between a column name and its sort indicator. +const _sortIconSpacing = 4.0; + +/// Gap between the footer's controls. +const _footerGap = 12.0; + +/// The frame's corner radius, bounded so the clip cannot eat a corner cell. +/// +/// This is the one recipe here that both rounds its frame and clips to it. +/// The clip is not optional: the header sits on `muted` and would otherwise +/// square off the top two corners inside the rounded border. But the corner +/// arc is carved out of the first and last rows, and the leading cell of both +/// is the selection column — so past a certain radius the clip stops trimming +/// a fill and starts removing a checkbox. +/// +/// The bound is the geometry, not a taste: an arc of radius r has finished +/// turning r from the corner, so a radius no larger than half the header's +/// height is fully out of the way by the row's own content line. Rows are +/// taller than the header, so the header is what binds. +/// +/// Clamping rather than hardcoding leaves the theme in charge in the other +/// direction: `radius: Radius.zero` still gives a square table. The checkbox +/// recipe clamps for the same class of reason at a different scale. +const _maxFrameRadius = _headerHeight / 2; + +/// The theme's corner radius, clamped to [_maxFrameRadius]. +/// +/// A top-level final rather than a per-call closure because `ContextToken` +/// equality is resolver identity: rebuilding one per call would make two +/// identical recipes compare unequal. +final _frameRadius = ContextToken((context) { + final radius = VanillaTokens.radius.resolve(context); + + return Radius.elliptical( + math.min(radius.x, _maxFrameRadius), + math.min(radius.y, _maxFrameRadius), + ); +}); + +/// One hairline in the `border` token, shared by every rule the table draws. +BorderSideMix _rule() => + BorderSideMix(color: VanillaTokens.border(), width: _borderWidth); + +/// The rule under a row. +BoxBorderMix _rowRule() => .bottom(_rule()); + +/// One cell's padding, shared by the header, the body, and the selection +/// column so the columns line up regardless of what is in them. +BoxStyler _cell() => BoxStyler().padding( + .symmetric(horizontal: _cellPaddingX, vertical: _cellPaddingY), +); diff --git a/registry_source/lib/src/default/components/data_table.g.dart b/registry_source/lib/src/default/components/data_table.g.dart new file mode 100644 index 000000000..ef1896db3 --- /dev/null +++ b/registry_source/lib/src/default/components/data_table.g.dart @@ -0,0 +1,143 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_table.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's DataTable recipe. +/// +/// Remix owns the rendering, sorting, selection, pagination, the empty state, +/// and the table accessibility semantics; this recipe supplies the frame, the +/// rows, the cells, and the three text roles. +/// +/// It is the one recipe in this layer that **depends on other items**, and it +/// is the reason its registry entry lists `checkbox`, `icon_button`, and +/// `select` beside `theme`. A table's selection column is a checkbox, its +/// pager is a pair of icon buttons, and its page-size control is a select — +/// literally, not by analogy. `DataTableSpec` takes each of those as a styler, +/// so the honest thing is to hand it the application's own recipes rather than +/// restate three components inside a fourth. Change the checkbox recipe and +/// this table's checkboxes change with it, which is what a reader expects. +/// +/// That is a deliberate exception to the rule that these files are +/// self-contained. It earns it: the alternative is either three duplicated +/// recipes that drift, or a table whose controls do not match the rest of the +/// application. +/// +/// The frame is the card's: `background` fill, `border` hairline, the theme +/// radius. Header and body rows are separated by the same hairline, and the +/// last body row drops it so the table does not draw a second line on top of +/// its own bottom edge. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +class VanillaDataTable extends StatelessWidget { + const VanillaDataTable({ + super.key, + this.style = const DataTableStyler.create(), + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon, + this.sortAscendingIcon, + this.sortDescendingIcon, + this.previousPageIcon, + this.nextPageIcon, + }); + + final DataTableStyler style; + + final List rows; + + final List> columns; + + final String? semanticLabel; + + final RemixDataTableSort? sort; + + final ValueChanged? onSortChanged; + + final Object Function(T row)? rowId; + + final Set selectedRowIds; + + final ValueChanged>? onSelectionChanged; + + final int? totalRows; + + final int pageIndex; + + final int pageSize; + + final List pageSizeOptions; + + final ValueChanged? onPageChanged; + + final ValueChanged? onPageSizeChanged; + + final double minimumWidth; + + final WidgetBuilder? emptyBuilder; + + final RemixDataTableLabels labels; + + final RemixDataTablePageRangeFormatter pageRangeFormatter; + + final IconData? sortableIcon; + + final IconData? sortAscendingIcon; + + final IconData? sortDescendingIcon; + + final IconData? previousPageIcon; + + final IconData? nextPageIcon; + + @override + Widget build(BuildContext context) { + return RemixDataTable( + key: this.key, + style: vanillaDataTableStyle(style: this.style), + rows: this.rows, + columns: this.columns, + semanticLabel: this.semanticLabel, + sort: this.sort, + onSortChanged: this.onSortChanged, + rowId: this.rowId, + selectedRowIds: this.selectedRowIds, + onSelectionChanged: this.onSelectionChanged, + totalRows: this.totalRows, + pageIndex: this.pageIndex, + pageSize: this.pageSize, + pageSizeOptions: this.pageSizeOptions, + onPageChanged: this.onPageChanged, + onPageSizeChanged: this.onPageSizeChanged, + minimumWidth: this.minimumWidth, + emptyBuilder: this.emptyBuilder, + labels: this.labels, + pageRangeFormatter: this.pageRangeFormatter, + sortableIcon: this.sortableIcon, + sortAscendingIcon: this.sortAscendingIcon, + sortDescendingIcon: this.sortDescendingIcon, + previousPageIcon: this.previousPageIcon, + nextPageIcon: this.nextPageIcon, + ); + } +} diff --git a/registry_source/lib/src/default/components/dialog.dart b/registry_source/lib/src/default/components/dialog.dart new file mode 100644 index 000000000..e02efb8c3 --- /dev/null +++ b/registry_source/lib/src/default/components/dialog.dart @@ -0,0 +1,84 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'dialog.g.dart'; + +/// The application's Dialog recipe. +/// +/// Remix owns the rendering, the modal barrier, focus trapping, the escape +/// and barrier dismissal rules, and the dialog accessibility semantics; this +/// recipe supplies the panel, the two text roles, and the action row. +/// +/// It is a popover with a title: the same `background` fill, `border` +/// hairline, and lift, sized wider and padded more because a dialog holds a +/// decision rather than a control. The two are deliberately separate files — +/// they have separate update stories, and sharing a surface helper would make +/// every change to one a change to the other. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +@MixWidget(target: RemixDialog.new) +DialogStyler vanillaDialogStyle({ + DialogStyler style = const DialogStyler.create(), +}) => DialogStyler() + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .borderRadius(.all(VanillaTokens.radius())) + .padding(.all(_padding)) + .maxWidth(_maxWidth) + .shadow(_shadow) + .title( + .fontSize(_titleSize) + .fontWeight(FontWeight.w600) + .color(VanillaTokens.foreground()) + .wrap(.padding(.only(bottom: _titleDescriptionGap))), + ) + .description( + .fontSize(_descriptionSize).color(VanillaTokens.mutedForeground()), + ) + // The actions sit at the trailing edge, which is where a reader looks for + // the decision once they have read the description. + .actions( + FlexBoxStyler() + .direction(.horizontal) + .mainAxisAlignment(.end) + .spacing(_actionGap) + .margin(.top(_actionsMarginTop)), + ) + .merge(style); + +/// Width of the panel outline. +const _borderWidth = 1.0; + +/// Inset between the panel edge and its content. +const _padding = 24.0; + +/// The widest a dialog gets before its lines become hard to scan. +const _maxWidth = 420.0; + +/// Title size: the one thing in the dialog that has to be read first. +const _titleSize = 18.0; + +/// Description size, matching body copy. +const _descriptionSize = 14.0; + +/// Space between the title and its description. +const _titleDescriptionGap = 6.0; + +/// Space between the dialog body and its decisions. +const _actionsMarginTop = 16.0; + +/// Gap between the action buttons. +const _actionGap = 8.0; + +/// The lift that separates the panel from the page behind the barrier. +/// +/// Heavier than a popover's, because a dialog is meant to stop the reader. +final _shadow = BoxShadowMix( + color: const Color(0x26000000), + offset: const Offset(0, 8), + blurRadius: 24, +); diff --git a/registry_source/lib/src/default/components/dialog.g.dart b/registry_source/lib/src/default/components/dialog.g.dart new file mode 100644 index 000000000..6ab95de82 --- /dev/null +++ b/registry_source/lib/src/default/components/dialog.g.dart @@ -0,0 +1,66 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'dialog.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Dialog recipe. +/// +/// Remix owns the rendering, the modal barrier, focus trapping, the escape +/// and barrier dismissal rules, and the dialog accessibility semantics; this +/// recipe supplies the panel, the two text roles, and the action row. +/// +/// It is a popover with a title: the same `background` fill, `border` +/// hairline, and lift, sized wider and padded more because a dialog holds a +/// decision rather than a control. The two are deliberately separate files — +/// they have separate update stories, and sharing a surface helper would make +/// every change to one a change to the other. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +class VanillaDialog extends StatelessWidget { + const VanillaDialog({ + super.key, + this.style = const DialogStyler.create(), + this.child, + this.title, + this.description, + this.actions, + this.scrollable = false, + this.modal = true, + this.semanticLabel, + }); + + final DialogStyler style; + + final Widget? child; + + final String? title; + + final String? description; + + final List? actions; + + final bool scrollable; + + final bool modal; + + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + return RemixDialog( + key: this.key, + style: vanillaDialogStyle(style: this.style), + child: this.child, + title: this.title, + description: this.description, + actions: this.actions, + scrollable: this.scrollable, + modal: this.modal, + semanticLabel: this.semanticLabel, + ); + } +} diff --git a/registry_source/lib/src/default/components/disclosure.dart b/registry_source/lib/src/default/components/disclosure.dart new file mode 100644 index 000000000..f9aa9f7d7 --- /dev/null +++ b/registry_source/lib/src/default/components/disclosure.dart @@ -0,0 +1,148 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'disclosure.g.dart'; + +/// The application's Disclosure recipe. +/// +/// A single collapsible section: a trigger row and the content it reveals. +/// Remix owns the rendering, the expand and collapse animation, keyboard +/// activation, and the accessibility semantics — including announcing the +/// expanded state; this recipe supplies the trigger row, the content inset, +/// and the state fragments. +/// +/// It is deliberately frameless, unlike the card. The accordion is this +/// component's stacked sibling and draws a rule under each section because +/// its rows have neighbours to separate; a lone disclosure has none, so a +/// frame would only box in whatever the caller already placed it inside. +/// The trigger is styled as a self-contained row target instead — same +/// padding, radius, and hover treatment as a menu row, because behaviorally +/// that is what it is: a full-width thing you click. +/// +/// The spec carries plain boxes (`trigger`, `content`), not text: the caller +/// passes whole widgets for both, so their type belongs to the caller. The +/// hover and open fills are `accent` and `muted`, which in the shipped themes +/// are near-surface tints the `foreground` text keeps its contrast on. +/// +/// Two constructor parameters are deliberately not forwarded to the generated +/// `VanillaDisclosure`: `triggerBuilder` and `transitionBuilder`. Both +/// are typed by `package:naked_ui`, which this layer does not depend on. +/// Reach for `RemixDisclosure` directly on the rare call site that needs a +/// state-aware trigger or a custom transition. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, +/// not by depth: an override that must beat the open trigger's fill has to be +/// declared with `.onExpanded(...)` too. +@MixWidget( + target: RemixDisclosure.new, + widgetParameters: .only({ + 'trigger', + 'content', + 'expanded', + 'defaultExpanded', + 'onExpandedChanged', + 'enabled', + 'mouseCursor', + 'enableFeedback', + 'focusNode', + 'autofocus', + 'onFocusChange', + 'onHoverChange', + 'onPressChange', + 'semanticLabel', + 'semanticHint', + 'excludeSemantics', + 'animationStyle', + }), +) +DisclosureStyler vanillaDisclosureStyle({ + DisclosureStyler style = const DisclosureStyler.create(), +}) => DisclosureStyler() + // These are the forwarded box shorthand, so they land on `trigger`: the + // row a reader clicks to open the section. + .width(double.infinity) + .alignment(.centerLeft) + .minHeight(_triggerHeight) + .padding(.symmetric(horizontal: _paddingX, vertical: _paddingY)) + .borderRadius(.all(VanillaTokens.radius())) + // `content` has to be reached by name; a bare `.padding(...)` would inset + // the trigger instead. The horizontal inset matches the trigger's so the + // revealed content lines up under the trigger's own. + .content( + .padding( + .only( + left: _paddingX, + right: _paddingX, + top: _contentGap, + bottom: _contentGap, + ), + ), + ) + .onHovered(DisclosureStyler().color(VanillaTokens.accent())) + // The open trigger keeps a fill after the pointer leaves, so a reader + // scanning the page can tell an open section from a closed one without + // touching it. `muted` rather than `accent`: the two must differ, or + // hovering a closed section would look identical to one that is open. + .onExpanded(DisclosureStyler().color(VanillaTokens.muted())) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()) + .merge(style); + +/// Minimum height of the trigger row. +/// +/// The accordion's 44px, not the menu row's 32: this row stands alone on the +/// page rather than inside a dense list, so it keeps the full touch target. +const _triggerHeight = 44.0; + +/// Horizontal inset inside the trigger, matched by the content. +const _paddingX = 12.0; + +/// Vertical inset inside the trigger. +const _paddingY = 10.0; + +/// Inset above and below the revealed content. +/// +/// Both sides, not just the top. Without the bottom the last line of content +/// sits hard against the container's own edge, which is invisible while the +/// container is undecorated and obvious the moment it is not — the focus ring +/// is drawn there, and a consumer who gives the container a fill or a border +/// gets text touching it. +const _contentGap = 8.0; + +/// Width of the keyboard focus ring. +const _focusRingWidth = 2.0; + +/// Opacity applied to the whole section while disabled. +const _disabledOpacity = 0.5; + +/// The keyboard focus ring, drawn on the trigger alone. +/// +/// Not on the container, which is where the effects layer lives and where the +/// accordion puts its ring. Keyboard focus is on the trigger, and an expanded +/// disclosure's container is the trigger *plus* everything it revealed — a +/// ring there claims the content is focused too, and grows with it. +/// +/// `foregroundDecoration` is what makes a trigger-only ring possible: it +/// paints over the box's own bounds rather than beside them, so the ring +/// takes no layout space and opening the section never reflows the page. A +/// plain `.border(...)` would push the trigger's content in by two pixels the +/// moment it took focus. +DisclosureStyler _focusVisibleStyle() => DisclosureStyler().trigger( + .foregroundDecoration( + BoxDecorationMix.border( + .color(VanillaTokens.focusRing()).width(_focusRingWidth), + ).borderRadius(.all(VanillaTokens.radius())), + ), +); + +/// Declared last so it wins over every other state fragment. +/// +/// The ring is cleared as well as faded: a disabled control that still draws +/// a focus ring reads as reachable. +DisclosureStyler _disabledStyle() => DisclosureStyler() + .trigger(.foregroundDecoration(BoxDecorationMix.border(.style(.none)))) + .wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/disclosure.g.dart b/registry_source/lib/src/default/components/disclosure.g.dart new file mode 100644 index 000000000..88efe341a --- /dev/null +++ b/registry_source/lib/src/default/components/disclosure.g.dart @@ -0,0 +1,127 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'disclosure.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Disclosure recipe. +/// +/// A single collapsible section: a trigger row and the content it reveals. +/// Remix owns the rendering, the expand and collapse animation, keyboard +/// activation, and the accessibility semantics — including announcing the +/// expanded state; this recipe supplies the trigger row, the content inset, +/// and the state fragments. +/// +/// It is deliberately frameless, unlike the card. The accordion is this +/// component's stacked sibling and draws a rule under each section because +/// its rows have neighbours to separate; a lone disclosure has none, so a +/// frame would only box in whatever the caller already placed it inside. +/// The trigger is styled as a self-contained row target instead — same +/// padding, radius, and hover treatment as a menu row, because behaviorally +/// that is what it is: a full-width thing you click. +/// +/// The spec carries plain boxes (`trigger`, `content`), not text: the caller +/// passes whole widgets for both, so their type belongs to the caller. The +/// hover and open fills are `accent` and `muted`, which in the shipped themes +/// are near-surface tints the `foreground` text keeps its contrast on. +/// +/// Two constructor parameters are deliberately not forwarded to the generated +/// `VanillaDisclosure`: `triggerBuilder` and `transitionBuilder`. Both +/// are typed by `package:naked_ui`, which this layer does not depend on. +/// Reach for `RemixDisclosure` directly on the rare call site that needs a +/// state-aware trigger or a custom transition. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, +/// not by depth: an override that must beat the open trigger's fill has to be +/// declared with `.onExpanded(...)` too. +class VanillaDisclosure extends StatelessWidget { + const VanillaDisclosure({ + super.key, + this.style = const DisclosureStyler.create(), + required this.trigger, + required this.content, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.animationStyle = const AnimationStyle( + curve: Curves.ease, + duration: Duration(milliseconds: 200), + reverseDuration: Duration(milliseconds: 200), + ), + }); + + final DisclosureStyler style; + + final Widget trigger; + + final Widget content; + + final bool? expanded; + + final bool defaultExpanded; + + final ValueChanged? onExpandedChanged; + + final bool enabled; + + final MouseCursor mouseCursor; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final ValueChanged? onFocusChange; + + final ValueChanged? onHoverChange; + + final ValueChanged? onPressChange; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + final AnimationStyle animationStyle; + + @override + Widget build(BuildContext context) { + return RemixDisclosure( + key: this.key, + style: vanillaDisclosureStyle(style: this.style), + trigger: this.trigger, + content: this.content, + expanded: this.expanded, + defaultExpanded: this.defaultExpanded, + onExpandedChanged: this.onExpandedChanged, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + onFocusChange: this.onFocusChange, + onHoverChange: this.onHoverChange, + onPressChange: this.onPressChange, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + animationStyle: this.animationStyle, + ); + } +} diff --git a/registry_source/lib/src/default/components/divider.dart b/registry_source/lib/src/default/components/divider.dart new file mode 100644 index 000000000..6b8be2bc4 --- /dev/null +++ b/registry_source/lib/src/default/components/divider.dart @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'divider.g.dart'; + +/// The application's Divider recipe. +/// +/// A divider is a hairline in the `border` token, the same color every other +/// control outline uses, so a rule between two rows matches the edge of the +/// card around them. +/// +/// It always fills the axis it is laid along rather than offering length +/// presets: a rule that stops short of its container is a decision for the +/// layout that placed it, expressed with ordinary padding. +/// +/// [orientation] is a plain Flutter [Axis] rather than an enum of this +/// layer's own. There is nothing application-specific about which way a line +/// runs, and a local enum would only need mapping back at every call site. +/// It is also not named `variant`, so the generator emits no named +/// constructors for it — an axis is a value a caller computes, not a +/// hand-written choice. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaDivider( +/// orientation: Axis.vertical, +/// style: DividerStyler().color(VanillaTokens.muted()), +/// ) +/// ``` +@MixWidget(target: RemixDivider.new) +DividerStyler vanillaDividerStyle({ + Axis orientation = Axis.horizontal, + DividerStyler style = const DividerStyler.create(), +}) => DividerStyler() + .color(VanillaTokens.border()) + .merge(_extent(orientation)) + .merge(style); + +/// Thickness of the rule. +/// +/// One logical pixel, matching the hairline every bordered control draws, so +/// a divider and a card edge do not read as two different weights. +const _thickness = 1.0; + +/// Pins the cross-axis thickness and stretches along the main axis. +/// +/// A `FractionallySizedBox` rather than an explicit width or height: the +/// divider does not know how wide its parent is, and a fixed length would be +/// wrong the moment the layout changed. It centres on the cross axis by +/// itself, so it needs no `.align()` after it. +DividerStyler _extent(Axis orientation) => switch (orientation) { + .horizontal => + DividerStyler() + .height(_thickness) + .wrap(.fractionallySizedBox(widthFactor: 1)), + .vertical => + DividerStyler() + .width(_thickness) + .wrap(.fractionallySizedBox(heightFactor: 1)), +}; diff --git a/registry_source/lib/src/default/components/divider.g.dart b/registry_source/lib/src/default/components/divider.g.dart new file mode 100644 index 000000000..616e7e4d6 --- /dev/null +++ b/registry_source/lib/src/default/components/divider.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'divider.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Divider recipe. +/// +/// A divider is a hairline in the `border` token, the same color every other +/// control outline uses, so a rule between two rows matches the edge of the +/// card around them. +/// +/// It always fills the axis it is laid along rather than offering length +/// presets: a rule that stops short of its container is a decision for the +/// layout that placed it, expressed with ordinary padding. +/// +/// [orientation] is a plain Flutter [Axis] rather than an enum of this +/// layer's own. There is nothing application-specific about which way a line +/// runs, and a local enum would only need mapping back at every call site. +/// It is also not named `variant`, so the generator emits no named +/// constructors for it — an axis is a value a caller computes, not a +/// hand-written choice. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaDivider( +/// orientation: Axis.vertical, +/// style: DividerStyler().color(VanillaTokens.muted()), +/// ) +/// ``` +class VanillaDivider extends StatelessWidget { + const VanillaDivider({ + super.key, + this.orientation = Axis.horizontal, + this.style = const DividerStyler.create(), + }); + + final Axis orientation; + + final DividerStyler style; + + @override + Widget build(BuildContext context) { + return RemixDivider( + key: this.key, + style: vanillaDividerStyle( + orientation: this.orientation, + style: this.style, + ), + ); + } +} diff --git a/packages/remix_agent/example/lib/ui/components/icon_button.dart b/registry_source/lib/src/default/components/icon_button.dart similarity index 77% rename from packages/remix_agent/example/lib/ui/components/icon_button.dart rename to registry_source/lib/src/default/components/icon_button.dart index 9b9e3b7ac..b0e5217fc 100644 --- a/packages/remix_agent/example/lib/ui/components/icon_button.dart +++ b/registry_source/lib/src/default/components/icon_button.dart @@ -10,7 +10,7 @@ part 'icon_button.g.dart'; /// /// The same five the labelled button offers, so a toolbar can mix the two /// without the icon-only control looking like a different family. -enum UiIconButtonVariant { +enum VanillaIconButtonVariant { /// Highest emphasis: a solid `primary` fill. primary, @@ -32,7 +32,7 @@ enum UiIconButtonVariant { /// Square at the same 32/36/40px the labelled button is tall, so the two line /// up in a row. These are compact, web-oriented defaults; a touch-first /// application should raise them to meet platform hit-target guidance. -enum UiIconButtonSize { +enum VanillaIconButtonSize { /// A 32px square. small, @@ -69,9 +69,9 @@ enum UiIconButtonSize { /// the recipe's hover fill has to be declared as a hover fragment too /// (`IconButtonStyler().onHovered(...)`). @MixWidget(target: RemixIconButton.new) -IconButtonStyler uiIconButtonStyle({ - UiIconButtonVariant variant = .primary, - UiIconButtonSize size = .medium, +IconButtonStyler vanillaIconButtonStyle({ + VanillaIconButtonVariant variant = .primary, + VanillaIconButtonSize size = .medium, IconButtonStyler style = const IconButtonStyler.create(), }) { return _base(_metricsFor(size)) @@ -89,7 +89,7 @@ const _pressedAlpha = 0.8; /// A fill derived from [source] at [alpha], resolved from the active scope. /// -/// The obvious spelling would be `UiTokens.primary().withValues(alpha: 0.9)`, +/// The obvious spelling would be `VanillaTokens.primary().withValues(alpha: 0.9)`, /// but that records a Mix *directive*, and directives accumulate through every /// later merge. A caller who replaced the hover fill would still get this /// recipe's alpha applied on top of their own color. A `ContextToken` does the @@ -104,13 +104,16 @@ ContextToken _dimmed(ColorToken source, double alpha) => (context) => source.resolve(context).withValues(alpha: alpha), ); -final _primaryHoverFill = _dimmed(UiTokens.primary, _hoverAlpha); -final _primaryPressedFill = _dimmed(UiTokens.primary, _pressedAlpha); -final _secondaryHoverFill = _dimmed(UiTokens.secondary, _hoverAlpha); -final _secondaryPressedFill = _dimmed(UiTokens.secondary, _pressedAlpha); -final _destructiveHoverFill = _dimmed(UiTokens.destructive, _hoverAlpha); -final _destructivePressedFill = _dimmed(UiTokens.destructive, _pressedAlpha); -final _accentPressedFill = _dimmed(UiTokens.accent, _pressedAlpha); +final _primaryHoverFill = _dimmed(VanillaTokens.primary, _hoverAlpha); +final _primaryPressedFill = _dimmed(VanillaTokens.primary, _pressedAlpha); +final _secondaryHoverFill = _dimmed(VanillaTokens.secondary, _hoverAlpha); +final _secondaryPressedFill = _dimmed(VanillaTokens.secondary, _pressedAlpha); +final _destructiveHoverFill = _dimmed(VanillaTokens.destructive, _hoverAlpha); +final _destructivePressedFill = _dimmed( + VanillaTokens.destructive, + _pressedAlpha, +); +final _accentPressedFill = _dimmed(VanillaTokens.accent, _pressedAlpha); /// Opacity of the loading spinner, so it reads as secondary to the icon. const _spinnerOpacity = 0.65; @@ -133,23 +136,24 @@ const _disabledOpacity = 0.5; /// A fill that paints nothing, used by `outline` and `ghost`. const _noFill = Color(0x00000000); -/// Geometry for one [UiIconButtonSize]. -typedef _UiIconButtonMetrics = ({double edge, double iconSize}); +/// Geometry for one [VanillaIconButtonSize]. +typedef _VanillaIconButtonMetrics = ({double edge, double iconSize}); -_UiIconButtonMetrics _metricsFor(UiIconButtonSize size) => switch (size) { - .small => (edge: 32.0, iconSize: 16.0), - .medium => (edge: 36.0, iconSize: 16.0), - .large => (edge: 40.0, iconSize: 18.0), -}; +_VanillaIconButtonMetrics _metricsFor(VanillaIconButtonSize size) => + switch (size) { + .small => (edge: 32.0, iconSize: 16.0), + .medium => (edge: 36.0, iconSize: 16.0), + .large => (edge: 40.0, iconSize: 18.0), + }; /// Layout and spinner defaults shared by every variant. /// /// The box is square and centered, so the control's footprint does not change /// with the glyph inside it. -IconButtonStyler _base(_UiIconButtonMetrics metrics) => IconButtonStyler() +IconButtonStyler _base(_VanillaIconButtonMetrics metrics) => IconButtonStyler() .size(metrics.edge, metrics.edge) .alignment(.center) - .borderRadius(.all(UiTokens.radius())) + .borderRadius(.all(VanillaTokens.radius())) .icon(.size(metrics.iconSize)) .spinner( .size( @@ -157,23 +161,23 @@ IconButtonStyler _base(_UiIconButtonMetrics metrics) => IconButtonStyler() ).opacity(_spinnerOpacity).duration(_spinnerDuration), ); -IconButtonStyler _variantStyle(UiIconButtonVariant variant) => +IconButtonStyler _variantStyle(VanillaIconButtonVariant variant) => switch (variant) { .primary => _filled( - fill: UiTokens.primary(), - foreground: UiTokens.primaryForeground(), + fill: VanillaTokens.primary(), + foreground: VanillaTokens.primaryForeground(), hoverFill: _primaryHoverFill(), pressedFill: _primaryPressedFill(), ), .secondary => _filled( - fill: UiTokens.secondary(), - foreground: UiTokens.secondaryForeground(), + fill: VanillaTokens.secondary(), + foreground: VanillaTokens.secondaryForeground(), hoverFill: _secondaryHoverFill(), pressedFill: _secondaryPressedFill(), ), .destructive => _filled( - fill: UiTokens.destructive(), - foreground: UiTokens.destructiveForeground(), + fill: VanillaTokens.destructive(), + foreground: VanillaTokens.destructiveForeground(), hoverFill: _destructiveHoverFill(), pressedFill: _destructivePressedFill(), ), @@ -194,20 +198,26 @@ IconButtonStyler _filled({ /// A transparent variant: `accent` is what makes interaction visible. IconButtonStyler _quiet({required bool bordered}) { - var style = _content(.color(_noFill), UiTokens.foreground()); + var style = _content(.color(_noFill), VanillaTokens.foreground()); if (bordered) { - style = style.border(.color(UiTokens.border()).width(_borderWidth)); + style = style.border(.color(VanillaTokens.border()).width(_borderWidth)); } return style .onHovered( - _content(.color(UiTokens.accent()), UiTokens.accentForeground()), + _content( + .color(VanillaTokens.accent()), + VanillaTokens.accentForeground(), + ), ) // Content color is re-applied on press, not only on hover: a touch // device never reports hover, so a press that changed the fill alone // would paint the accent surface under the default foreground. .onPressed( - _content(.color(_accentPressedFill()), UiTokens.accentForeground()), + _content( + .color(_accentPressedFill()), + VanillaTokens.accentForeground(), + ), ); } @@ -223,7 +233,7 @@ IconButtonStyler _content(IconButtonStyler style, Color foreground) => IconButtonStyler _focusVisibleStyle() => IconButtonStyler().containerEffects( .outline( .color( - UiTokens.focusRing(), + VanillaTokens.focusRing(), ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), ).outlineOffset(_focusRingOffset), ); diff --git a/packages/remix_agent/example/lib/ui/components/icon_button.g.dart b/registry_source/lib/src/default/components/icon_button.g.dart similarity index 90% rename from packages/remix_agent/example/lib/ui/components/icon_button.g.dart rename to registry_source/lib/src/default/components/icon_button.g.dart index b850ff6ac..072fffc64 100644 --- a/packages/remix_agent/example/lib/ui/components/icon_button.g.dart +++ b/registry_source/lib/src/default/components/icon_button.g.dart @@ -31,8 +31,8 @@ part of 'icon_button.dart'; /// State fragments merge by state, not by depth: an override that must beat /// the recipe's hover fill has to be declared as a hover fragment too /// (`IconButtonStyler().onHovered(...)`). -class UiIconButton extends StatelessWidget { - const UiIconButton({ +class VanillaIconButton extends StatelessWidget { + const VanillaIconButton({ super.key, this.variant = .primary, this.size = .medium, @@ -54,7 +54,7 @@ class UiIconButton extends StatelessWidget { }); /// Highest emphasis: a solid `primary` fill. - const UiIconButton.primary({ + const VanillaIconButton.primary({ super.key, this.size = .medium, this.style = const IconButtonStyler.create(), @@ -72,10 +72,10 @@ class UiIconButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiIconButtonVariant.primary; + }) : variant = VanillaIconButtonVariant.primary; /// Medium emphasis: a solid `secondary` fill. - const UiIconButton.secondary({ + const VanillaIconButton.secondary({ super.key, this.size = .medium, this.style = const IconButtonStyler.create(), @@ -93,10 +93,10 @@ class UiIconButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiIconButtonVariant.secondary; + }) : variant = VanillaIconButtonVariant.secondary; /// Low emphasis with a hairline `border`. - const UiIconButton.outline({ + const VanillaIconButton.outline({ super.key, this.size = .medium, this.style = const IconButtonStyler.create(), @@ -114,10 +114,10 @@ class UiIconButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiIconButtonVariant.outline; + }) : variant = VanillaIconButtonVariant.outline; /// Low emphasis with no fill and no border. - const UiIconButton.ghost({ + const VanillaIconButton.ghost({ super.key, this.size = .medium, this.style = const IconButtonStyler.create(), @@ -135,10 +135,10 @@ class UiIconButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiIconButtonVariant.ghost; + }) : variant = VanillaIconButtonVariant.ghost; /// Highest emphasis for irreversible actions. - const UiIconButton.destructive({ + const VanillaIconButton.destructive({ super.key, this.size = .medium, this.style = const IconButtonStyler.create(), @@ -156,11 +156,11 @@ class UiIconButton extends StatelessWidget { this.semanticHint, this.excludeSemantics = false, this.mouseCursor = SystemMouseCursors.click, - }) : variant = UiIconButtonVariant.destructive; + }) : variant = VanillaIconButtonVariant.destructive; - final UiIconButtonVariant variant; + final VanillaIconButtonVariant variant; - final UiIconButtonSize size; + final VanillaIconButtonSize size; final IconButtonStyler style; @@ -196,7 +196,7 @@ class UiIconButton extends StatelessWidget { Widget build(BuildContext context) { return RemixIconButton( key: this.key, - style: uiIconButtonStyle( + style: vanillaIconButtonStyle( variant: this.variant, size: this.size, style: this.style, diff --git a/registry_source/lib/src/default/components/link.dart b/registry_source/lib/src/default/components/link.dart new file mode 100644 index 000000000..ac3313f3d --- /dev/null +++ b/registry_source/lib/src/default/components/link.dart @@ -0,0 +1,64 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'link.g.dart'; + +/// The application's Link recipe. +/// +/// Remix owns the link role, the destination, focus, activation, and the rule +/// that a link with no callback is a disabled link; this recipe supplies only +/// its color and its underline. +/// +/// It sets no font size on purpose. A link is inline text, so it should take +/// the size and weight of the paragraph around it — a fixed size here would +/// make a link inside a heading render at body scale. +/// +/// The color is `foreground`, not `primary`. This theme's `primary` is a +/// near-neutral fill color rather than a link hue, so a `primary` link would +/// read as body text with no affordance at all. Underlining is what marks it, +/// which also means the link is still identifiable without color — an +/// application that has a brand link color changes the two `.color(...)` calls +/// below. +/// +/// There is no pressed fragment. A link's press is over in the time it takes +/// to navigate, and the destination arriving is the feedback — the same reason +/// the checkbox has none, arrived at from the other direction: a checkbox +/// flips its own state, and a link replaces the page. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's hover color has to be +/// declared as a hover fragment too (`LinkStyler().onHovered(...)`). +@MixWidget(target: RemixLink.new) +LinkStyler vanillaLinkStyle({LinkStyler style = const LinkStyler.create()}) => + LinkStyler() + .label( + .color(VanillaTokens.foreground()) + .decoration(TextDecoration.underline) + .decorationColor(VanillaTokens.border()), + ) + // Hover and keyboard focus both promote the underline to full strength + // rather than adding one: an underline that appears on hover moves the + // text's baseline box on some platforms, and a link that is only + // underlined while hovered is invisible to a keyboard user. + .onHovered(_emphasized()) + .onFocusVisible(_emphasized()) + .onDisabled(_disabledStyle()) + .merge(style); + +/// Opacity applied to the whole link while disabled. +const _disabledOpacity = 0.5; + +/// The underline at full strength, in the text's own color. +LinkStyler _emphasized() => + LinkStyler().label(.decorationColor(VanillaTokens.foreground())); + +/// Declared last so it wins over every other state fragment. +/// +/// A link with no `onPressed` is disabled by Remix, which is the same meaning +/// `onPressed: null` carries on every other Flutter control, so this fragment +/// is also what a decorative link looks like. +LinkStyler _disabledStyle() => LinkStyler().wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/link.g.dart b/registry_source/lib/src/default/components/link.g.dart new file mode 100644 index 000000000..8f0ea4035 --- /dev/null +++ b/registry_source/lib/src/default/components/link.g.dart @@ -0,0 +1,98 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'link.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Link recipe. +/// +/// Remix owns the link role, the destination, focus, activation, and the rule +/// that a link with no callback is a disabled link; this recipe supplies only +/// its color and its underline. +/// +/// It sets no font size on purpose. A link is inline text, so it should take +/// the size and weight of the paragraph around it — a fixed size here would +/// make a link inside a heading render at body scale. +/// +/// The color is `foreground`, not `primary`. This theme's `primary` is a +/// near-neutral fill color rather than a link hue, so a `primary` link would +/// read as body text with no affordance at all. Underlining is what marks it, +/// which also means the link is still identifiable without color — an +/// application that has a brand link color changes the two `.color(...)` calls +/// below. +/// +/// There is no pressed fragment. A link's press is over in the time it takes +/// to navigate, and the destination arriving is the feedback — the same reason +/// the checkbox has none, arrived at from the other direction: a checkbox +/// flips its own state, and a link replaces the page. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's hover color has to be +/// declared as a hover fragment too (`LinkStyler().onHovered(...)`). +class VanillaLink extends StatelessWidget { + const VanillaLink({ + super.key, + this.style = const LinkStyler.create(), + this.label, + this.child, + this.onPressed, + this.enabled = true, + this.linkUrl, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }); + + final LinkStyler style; + + final String? label; + + final Widget? child; + + final VoidCallback? onPressed; + + final bool enabled; + + final Uri? linkUrl; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool enableFeedback; + + final MouseCursor mouseCursor; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixLink( + key: this.key, + style: vanillaLinkStyle(style: this.style), + label: this.label, + child: this.child, + onPressed: this.onPressed, + enabled: this.enabled, + linkUrl: this.linkUrl, + focusNode: this.focusNode, + autofocus: this.autofocus, + enableFeedback: this.enableFeedback, + mouseCursor: this.mouseCursor, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/menu.dart b/registry_source/lib/src/default/components/menu.dart new file mode 100644 index 000000000..519e41ddf --- /dev/null +++ b/registry_source/lib/src/default/components/menu.dart @@ -0,0 +1,176 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'menu.g.dart'; + +/// The application's Menu recipe. +/// +/// Remix owns the rendering, the overlay, the anchor positioning, keyboard +/// traversal, submenu timing, dismissal, and the menu accessibility +/// semantics; this recipe supplies the trigger, the floating panel, and every +/// kind of row inside it. +/// +/// One recipe covers all of them, because `MenuSpec` carries each row kind as +/// a field: `item` is the default, and `checkboxItem`, `radioItem`, and +/// `submenuItem` fall back to it unless a recipe says otherwise. Setting only +/// `item` is what keeps a menu looking like one list rather than four. +/// +/// The panel is the same `background` fill and `border` hairline the popover +/// uses. The two files are deliberately separate — the components have +/// separate update stories — but the values are meant to match, so a menu and +/// a popover anchored to adjacent buttons do not read as two systems. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat a row's hover fill has to be declared +/// as a hover fragment too. +@MixWidget(target: RemixMenu.new) +MenuStyler vanillaMenuStyle({MenuStyler style = const MenuStyler.create()}) => + MenuStyler() + .trigger(_triggerStyle()) + .overlay( + FlexBoxStyler() + .direction(.vertical) + .mainAxisSize(.min) + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .borderRadius(.all(VanillaTokens.radius())) + .padding(.all(_panelPadding)) + .minWidth(_panelMinWidth), + ) + .containerEffects(.behindContent(.shadows([_shadow]))) + .item(_itemStyle()) + .divider(_dividerStyle()) + .merge(style); + +/// Width of the panel and trigger outlines. +const _borderWidth = 1.0; + +/// Inset between the panel edge and its rows. +/// +/// Small: the rows carry their own padding, and this is only the gap that +/// keeps a hovered row's fill from touching the panel's outline. +const _panelPadding = 4.0; + +/// The narrowest a menu panel gets, so a one-word menu is still a target. +const _panelMinWidth = 160.0; + +/// Horizontal inset inside a row. +const _rowPaddingX = 8.0; + +/// Vertical inset inside a row. +const _rowPaddingY = 6.0; + +/// Minimum height of a row. +const _rowHeight = 32.0; + +/// Gap between a row's icons and its label. +const _rowGap = 8.0; + +/// Label size, matching body copy. +const _labelSize = 14.0; + +/// Size of a row's leading and trailing icons. +const _iconSize = 16.0; + +/// Vertical space a divider claims between two groups of rows. +const _dividerMargin = 4.0; + +/// Width of the keyboard focus ring on the trigger. +const _focusRingWidth = 2.0; + +/// Opacity applied to a trigger or a row the reader cannot use. +const _disabledOpacity = 0.5; + +/// The lift that separates the panel from whatever it covers. +/// +/// A shadow is nearly invisible on a dark page, so it is a *second* cue; the +/// panel's outline is what has to carry the boundary in both themes. +final _shadow = RemixBoxShadowMix( + color: const Color(0x1A000000), + offset: const Offset(0, 4), + blurRadius: 12, +); + +/// The control that opens the menu. +/// +/// It is deliberately quiet at rest — no fill, no outline — because the +/// trigger usually already wraps a button or an icon button with a recipe of +/// its own, and two competing surfaces would read as a control inside a +/// control. It still answers hover and focus, because a caller is equally +/// free to wrap a bare `Text`, and that caller must not end up with a +/// keyboard-reachable control that shows nothing when it is reached. +MenuTriggerStyler _triggerStyle() => MenuTriggerStyler() + .direction(.horizontal) + .mainAxisSize(.min) + .crossAxisAlignment(.center) + .minHeight(_rowHeight) + .padding(.symmetric(horizontal: _rowPaddingX, vertical: _rowPaddingY)) + .spacing(_rowGap) + .borderRadius(.all(VanillaTokens.radius())) + .label( + .fontSize( + _labelSize, + ).fontWeight(FontWeight.w500).color(VanillaTokens.foreground()), + ) + .icon(.size(_iconSize).color(VanillaTokens.foreground())) + .onHovered(.color(VanillaTokens.accent())) + // A trigger is keyboard-reachable whether or not it wraps a control that + // rings itself, so it rings too. A *foreground* decoration, because + // `MenuTriggerSpec` has no `containerEffects` layer and a real border + // would nudge the label. + .onFocusVisible( + .foregroundDecoration( + BoxDecorationMix( + border: .all( + .color( + VanillaTokens.focusRing(), + ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), + ), + borderRadius: .all(VanillaTokens.radius()), + ), + ), + ) + .onDisabled(MenuTriggerStyler().wrap(.opacity(_disabledOpacity))); + +/// One row, in every kind the menu can hold. +/// +/// `accent` is what makes the highlighted row visible, and it is applied on +/// hover *and* on focus: a menu is as often driven by the arrow keys as by +/// the pointer, and a keyboard user has to see the same row a mouse user +/// would. +MenuItemStyler _itemStyle() => MenuItemStyler() + .direction(.horizontal) + .crossAxisAlignment(.center) + .minHeight(_rowHeight) + .padding(.symmetric(horizontal: _rowPaddingX, vertical: _rowPaddingY)) + .spacing(_rowGap) + .borderRadius(.all(VanillaTokens.radius())) + .label(.fontSize(_labelSize).color(VanillaTokens.foreground())) + .leadingIcon(.size(_iconSize).color(VanillaTokens.mutedForeground())) + .trailingIcon(.size(_iconSize).color(VanillaTokens.mutedForeground())) + .indicator(.size(_iconSize).color(VanillaTokens.mutedForeground())) + .onHovered(_highlighted()) + .onFocused(_highlighted()) + .onDisabled(MenuItemStyler().wrap(.opacity(_disabledOpacity))); + +/// The row under the pointer or the keyboard cursor. +MenuItemStyler _highlighted() => MenuItemStyler() + .color(VanillaTokens.accent()) + .label(.color(VanillaTokens.accentForeground())) + .leadingIcon(.color(VanillaTokens.accentForeground())) + .trailingIcon(.color(VanillaTokens.accentForeground())) + .indicator(.color(VanillaTokens.accentForeground())); + +/// The rule between two groups of rows. +/// +/// It stops short of the panel's edge on both sides, so it reads as +/// separating the rows rather than cutting the panel in half. +DividerStyler _dividerStyle() => DividerStyler() + .color(VanillaTokens.border()) + .height(_borderWidth) + .margin(.symmetric(vertical: _dividerMargin)) + .wrap(.fractionallySizedBox(widthFactor: 1)); diff --git a/registry_source/lib/src/default/components/menu.g.dart b/registry_source/lib/src/default/components/menu.g.dart new file mode 100644 index 000000000..7ad635632 --- /dev/null +++ b/registry_source/lib/src/default/components/menu.g.dart @@ -0,0 +1,109 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'menu.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Menu recipe. +/// +/// Remix owns the rendering, the overlay, the anchor positioning, keyboard +/// traversal, submenu timing, dismissal, and the menu accessibility +/// semantics; this recipe supplies the trigger, the floating panel, and every +/// kind of row inside it. +/// +/// One recipe covers all of them, because `MenuSpec` carries each row kind as +/// a field: `item` is the default, and `checkboxItem`, `radioItem`, and +/// `submenuItem` fall back to it unless a recipe says otherwise. Setting only +/// `item` is what keeps a menu looking like one list rather than four. +/// +/// The panel is the same `background` fill and `border` hairline the popover +/// uses. The two files are deliberately separate — the components have +/// separate update stories — but the values are meant to match, so a menu and +/// a popover anchored to adjacent buttons do not read as two systems. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat a row's hover fill has to be declared +/// as a hover fragment too. +class VanillaMenu extends StatelessWidget { + const VanillaMenu({ + super.key, + this.style = const MenuStyler.create(), + required this.trigger, + required this.items, + this.controller, + this.onSelected, + this.onOpen, + this.onClose, + this.onCanceled, + this.onOpenRequested, + this.onCloseRequested, + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.closeOnClickOutside = true, + this.triggerFocusNode, + this.positioning = const OverlayPositionConfig(), + this.semanticLabel, + this.excludeSemantics = false, + }); + + final MenuStyler style; + + final RemixMenuTrigger trigger; + + final List> items; + + final MenuController? controller; + + final ValueChanged? onSelected; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final VoidCallback? onCanceled; + + final RawMenuAnchorOpenRequestedCallback? onOpenRequested; + + final RawMenuAnchorCloseRequestedCallback? onCloseRequested; + + final bool consumeOutsideTaps; + + final bool useRootOverlay; + + final bool closeOnClickOutside; + + final FocusNode? triggerFocusNode; + + final OverlayPositionConfig positioning; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixMenu( + key: this.key, + style: vanillaMenuStyle(style: this.style), + trigger: this.trigger, + items: this.items, + controller: this.controller, + onSelected: this.onSelected, + onOpen: this.onOpen, + onClose: this.onClose, + onCanceled: this.onCanceled, + onOpenRequested: this.onOpenRequested, + onCloseRequested: this.onCloseRequested, + consumeOutsideTaps: this.consumeOutsideTaps, + useRootOverlay: this.useRootOverlay, + closeOnClickOutside: this.closeOnClickOutside, + triggerFocusNode: this.triggerFocusNode, + positioning: this.positioning, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/popover.dart b/registry_source/lib/src/default/components/popover.dart new file mode 100644 index 000000000..257d7948f --- /dev/null +++ b/registry_source/lib/src/default/components/popover.dart @@ -0,0 +1,75 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'popover.g.dart'; + +/// The application's Popover recipe. +/// +/// Remix owns the rendering, the overlay, the anchor positioning, the +/// dismiss-on-outside-tap behavior, focus, and the popover accessibility +/// semantics; this recipe supplies only the floating panel's surface. +/// +/// A popover sits *over* arbitrary content, so its edge is doing real work: +/// it is what tells a reader where the panel stops and the page resumes. That +/// edge is a `border` hairline plus a soft drop shadow. The fill is +/// `background`, the same token the page uses, because this vocabulary has no +/// separate surface step — see the theme's own comments if you add one. +/// +/// One composition trap worth knowing: `RemixPopover` opens on a tap of its +/// own `child`, so a trigger that handles its own taps never lets the popover +/// see one. A `VanillaButton` with an `onPressed` is exactly that, and a +/// popover built the obvious way silently never opens. Drive it from a +/// `MenuController` when the trigger has to be a button: +/// +/// ```dart +/// final filters = MenuController(); +/// +/// VanillaPopover( +/// controller: filters, +/// popoverChild: const Text('Filters go here.'), +/// child: VanillaButton.outline( +/// label: 'Filter', +/// onPressed: () => filters.isOpen ? filters.close() : filters.open(), +/// ), +/// ) +/// ``` +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaPopover( +/// popoverChild: filters, +/// child: VanillaButton.outline(label: 'Filter'), +/// ) +/// ``` +@MixWidget(target: RemixPopover.new) +PopoverStyler vanillaPopoverStyle({ + PopoverStyler style = const PopoverStyler.create(), +}) => PopoverStyler() + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .borderRadius(.all(VanillaTokens.radius())) + .padding(.all(_padding)) + .shadow(_shadow) + .merge(style); + +/// Width of the panel outline. +const _borderWidth = 1.0; + +/// Inset between the panel edge and its content. +const _padding = 16.0; + +/// The lift that separates the panel from whatever it covers. +/// +/// Deliberately soft and untinted. A shadow is nearly invisible on a dark +/// page, so it is a *second* cue rather than the primary one; the outline +/// above is what has to carry the boundary in both themes. +final _shadow = BoxShadowMix( + color: const Color(0x1A000000), + offset: const Offset(0, 4), + blurRadius: 12, +); diff --git a/registry_source/lib/src/default/components/popover.g.dart b/registry_source/lib/src/default/components/popover.g.dart new file mode 100644 index 000000000..229928121 --- /dev/null +++ b/registry_source/lib/src/default/components/popover.g.dart @@ -0,0 +1,120 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'popover.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Popover recipe. +/// +/// Remix owns the rendering, the overlay, the anchor positioning, the +/// dismiss-on-outside-tap behavior, focus, and the popover accessibility +/// semantics; this recipe supplies only the floating panel's surface. +/// +/// A popover sits *over* arbitrary content, so its edge is doing real work: +/// it is what tells a reader where the panel stops and the page resumes. That +/// edge is a `border` hairline plus a soft drop shadow. The fill is +/// `background`, the same token the page uses, because this vocabulary has no +/// separate surface step — see the theme's own comments if you add one. +/// +/// One composition trap worth knowing: `RemixPopover` opens on a tap of its +/// own `child`, so a trigger that handles its own taps never lets the popover +/// see one. A `VanillaButton` with an `onPressed` is exactly that, and a +/// popover built the obvious way silently never opens. Drive it from a +/// `MenuController` when the trigger has to be a button: +/// +/// ```dart +/// final filters = MenuController(); +/// +/// VanillaPopover( +/// controller: filters, +/// popoverChild: const Text('Filters go here.'), +/// child: VanillaButton.outline( +/// label: 'Filter', +/// onPressed: () => filters.isOpen ? filters.close() : filters.open(), +/// ), +/// ) +/// ``` +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaPopover( +/// popoverChild: filters, +/// child: VanillaButton.outline(label: 'Filter'), +/// ) +/// ``` +class VanillaPopover extends StatelessWidget { + const VanillaPopover({ + super.key, + this.style = const PopoverStyler.create(), + required this.popoverChild, + required this.child, + this.positioning = const OverlayPositionConfig(), + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.openOnTap = true, + this.triggerFocusNode, + this.onOpen, + this.onClose, + this.onOpenRequested, + this.onCloseRequested, + this.controller, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final PopoverStyler style; + + final Widget popoverChild; + + final Widget child; + + final OverlayPositionConfig positioning; + + final bool consumeOutsideTaps; + + final bool useRootOverlay; + + final bool openOnTap; + + final FocusNode? triggerFocusNode; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final RawMenuAnchorOpenRequestedCallback? onOpenRequested; + + final RawMenuAnchorCloseRequestedCallback? onCloseRequested; + + final MenuController? controller; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixPopover( + key: this.key, + style: vanillaPopoverStyle(style: this.style), + popoverChild: this.popoverChild, + child: this.child, + positioning: this.positioning, + consumeOutsideTaps: this.consumeOutsideTaps, + useRootOverlay: this.useRootOverlay, + openOnTap: this.openOnTap, + triggerFocusNode: this.triggerFocusNode, + onOpen: this.onOpen, + onClose: this.onClose, + onOpenRequested: this.onOpenRequested, + onCloseRequested: this.onCloseRequested, + controller: this.controller, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/progress.dart b/registry_source/lib/src/default/components/progress.dart new file mode 100644 index 000000000..d23a52e8c --- /dev/null +++ b/registry_source/lib/src/default/components/progress.dart @@ -0,0 +1,61 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'progress.g.dart'; + +/// The application's Progress recipe. +/// +/// Remix owns the geometry that maps a 0-1 value onto the filled width, and +/// the progress semantics; this recipe supplies the track and the indicator. +/// +/// One weight, not a scale. A progress bar has no size relationship to the +/// controls around it — it spans its container and is read by length rather +/// than by height — so the sizes this recipe used to offer were three numbers +/// with nothing to anchor them. shadcn ships `h-2` and nothing else, and this +/// is that bar. A call site that wants a different weight sets `.height(...)` +/// through [style], which is one line and says what it means. +/// +/// The track is `muted` and the indicator is `primary`: the same pairing the +/// checked checkbox uses, so "how far along" reads in the accent the rest of +/// the application already uses for state. +/// +/// Both are fully rounded rather than sharing the theme's control radius. The +/// theme radius is authored for 32-40px controls; on an 8px bar anything +/// short of a full round reads as a rendering artifact. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaProgress( +/// value: 0.4, +/// style: ProgressStyler().indicatorColor(VanillaTokens.destructive()), +/// ) +/// ``` +@MixWidget(target: RemixProgress.new) +ProgressStyler vanillaProgressStyle({ + ProgressStyler style = const ProgressStyler.create(), +}) => ProgressStyler() + // The bar spans whatever it is given: progress is measured against the + // width of its container, so a shrink-wrapped bar would collapse to + // nothing. The clip is what rounds the indicator's leading edge as it + // grows past the track's corner. + .width(double.infinity) + .height(_thickness) + .borderRadius(.all(_radius)) + .clipBehavior(Clip.antiAlias) + .track(_bar().width(double.infinity).color(VanillaTokens.muted())) + .indicator(_bar().color(VanillaTokens.primary())) + .merge(style); + +/// The bar's weight, matching shadcn's `h-2`. +const _thickness = 8.0; + +/// Half of [_thickness], which is what makes each end a semicircle. +const _radius = Radius.circular(_thickness / 2); + +/// One rounded bar, used for both the track and the fill. +BoxStyler _bar() => BoxStyler().height(_thickness).borderRadius(.all(_radius)); diff --git a/registry_source/lib/src/default/components/progress.g.dart b/registry_source/lib/src/default/components/progress.g.dart new file mode 100644 index 000000000..3a3079786 --- /dev/null +++ b/registry_source/lib/src/default/components/progress.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'progress.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Progress recipe. +/// +/// Remix owns the geometry that maps a 0-1 value onto the filled width, and +/// the progress semantics; this recipe supplies the track and the indicator. +/// +/// One weight, not a scale. A progress bar has no size relationship to the +/// controls around it — it spans its container and is read by length rather +/// than by height — so the sizes this recipe used to offer were three numbers +/// with nothing to anchor them. shadcn ships `h-2` and nothing else, and this +/// is that bar. A call site that wants a different weight sets `.height(...)` +/// through [style], which is one line and says what it means. +/// +/// The track is `muted` and the indicator is `primary`: the same pairing the +/// checked checkbox uses, so "how far along" reads in the accent the rest of +/// the application already uses for state. +/// +/// Both are fully rounded rather than sharing the theme's control radius. The +/// theme radius is authored for 32-40px controls; on an 8px bar anything +/// short of a full round reads as a rendering artifact. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaProgress( +/// value: 0.4, +/// style: ProgressStyler().indicatorColor(VanillaTokens.destructive()), +/// ) +/// ``` +class VanillaProgress extends StatelessWidget { + const VanillaProgress({ + super.key, + this.style = const ProgressStyler.create(), + required this.value, + this.semanticsLabel, + this.semanticsValue, + }); + + final ProgressStyler style; + + final double value; + + final String? semanticsLabel; + + final String? semanticsValue; + + @override + Widget build(BuildContext context) { + return RemixProgress( + key: this.key, + style: vanillaProgressStyle(style: this.style), + value: this.value, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + ); + } +} diff --git a/registry_source/lib/src/default/components/radio.dart b/registry_source/lib/src/default/components/radio.dart new file mode 100644 index 000000000..d38e08591 --- /dev/null +++ b/registry_source/lib/src/default/components/radio.dart @@ -0,0 +1,156 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'radio.g.dart'; + +/// The application's Radio recipe. +/// +/// Remix owns the rendering, the single-selection behavior, arrow-key +/// traversal within the group, and the radio accessibility role; this recipe +/// supplies the circle, the dot, and the state fragments. +/// +/// `RemixRadioGroup` — the behavioral coordinator that owns `groupValue` and +/// the change callback — carries no styler and therefore no recipe. Compose it +/// directly around these: +/// +/// ```dart +/// RemixRadioGroup( +/// groupValue: plan, +/// onChanged: (value) => setState(() => plan = value), +/// child: Column(children: const [ +/// VanillaRadio(value: 'free', semanticLabel: 'Free'), +/// VanillaRadio(value: 'pro', semanticLabel: 'Pro'), +/// ]), +/// ) +/// ``` +/// +/// Unlike the checkbox, a radio draws no glyph: the mark is a filled dot +/// inside the ring, which is what tells the two controls apart at a glance +/// even before their shapes register. +/// +/// `RemixRadio` requires a `semanticLabel` because it renders no text of its +/// own — the visible label beside it belongs to the caller's layout. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's selected ring has to be +/// declared as a selected fragment too (`RadioStyler().onSelected(...)`). +@MixWidget(target: RemixRadio.new) +RadioStyler vanillaRadioStyle({ + RadioStyler style = const RadioStyler.create(), +}) { + return RadioStyler() + .size(_diameter, _diameter) + .alignment(.center) + .borderRadius(.all(_circular)) + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .indicator(BoxStyler().size(_dot, _dot).borderRadius(.all(_circular))) + // The ring has to survive the hover fill. `accent` on `border` is + // 1.09:1 in the shipped light theme, so tinting the disc alone erased + // the outline and left a hovered empty radio reading as a filled one — + // the opposite of what it means. Darkening the ring is what keeps the + // circle a circle. + .onHovered( + RadioStyler() + .color(VanillaTokens.accent()) + .border( + .color(VanillaTokens.mutedForeground()).width(_borderWidth), + ), + ) + .onSelected(_selectedStyle()) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()) + .merge(style); +} + +/// Alpha applied to the selected ring while hovered. +const _hoverAlpha = 0.9; + +/// The selected ring color, dimmed, resolved from the active scope. +/// +/// The obvious spelling would be `VanillaTokens.primary().withValues(alpha: 0.9)`, +/// but that records a Mix *directive*, and directives accumulate through every +/// later merge. A caller who replaced the hover color would still get this +/// recipe's alpha applied on top of their own. A `ContextToken` does the +/// arithmetic during resolution instead, so the state fragment holds one plain +/// color that a caller can replace outright. +/// +/// Declared as a top-level final because `ContextToken` equality is resolver +/// identity: rebuilding one per call would make two identical recipes compare +/// unequal. +final _primaryHover = ContextToken( + (context) => + VanillaTokens.primary.resolve(context).withValues(alpha: _hoverAlpha), +); + +/// A radius large enough to round any radio in this scale into a circle. +const _circular = Radius.circular(999); + +/// Width of the ring, in every state. +const _borderWidth = 1.0; + +/// Width of the ring once the option is chosen. +/// +/// Thicker than the resting ring so a selected radio reads at a glance even +/// where the dot is small. +const _selectedBorderWidth = 1.5; + +/// Width of the keyboard focus ring. +const _focusRingWidth = 2.0; + +/// Distance between the control edge and its focus ring. +const _focusRingOffset = 2.0; + +/// Opacity applied to the whole control while disabled. +const _disabledOpacity = 0.5; + +/// The circle's diameter, matching shadcn's `h-4 w-4` and the checkbox beside +/// it — the two are chosen from the same list and must not differ in weight. +const _diameter = 16.0; + +/// The chosen dot. +const _dot = 6.0; + +/// The chosen option: a `primary` ring around a `primary` dot. +/// +/// The surface stays `background` rather than filling with `primary`. A filled +/// circle would be a checkbox's mark; leaving the middle open is what makes +/// the dot the thing the eye lands on. +RadioStyler _selectedStyle() => RadioStyler() + .border(.color(VanillaTokens.primary()).width(_selectedBorderWidth)) + .indicatorColor(VanillaTokens.primary()) + // Declared inside the selected fragment so a hovered, chosen radio dims + // its own ring. The top-level hover fragment tints the *surface*, which is + // the right feedback while unchosen and the wrong one once the ring is + // carrying the meaning. + .onHovered( + RadioStyler() + .border(.color(_primaryHover()).width(_selectedBorderWidth)) + .indicatorColor(_primaryHover()), + ); + +/// The keyboard focus ring. +/// +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// circle without taking layout space, so focusing a radio never reflows the +/// row it sits in — and the recipe's own ring is already a border. +RadioStyler _focusVisibleStyle() => RadioStyler().containerEffects( + .outline( + .color( + VanillaTokens.focusRing(), + ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), + ).outlineOffset(_focusRingOffset), +); + +/// Declared last so it wins over every other state fragment. +/// +/// A disabled radio keeps whatever ring its state gives it and simply fades; +/// the focus ring is cleared because a disabled control that still draws a +/// focus ring reads as actionable. +RadioStyler _disabledStyle() => RadioStyler() + .containerEffects(.outline(.style(.none))) + .wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/radio.g.dart b/registry_source/lib/src/default/components/radio.g.dart new file mode 100644 index 000000000..9bd56f206 --- /dev/null +++ b/registry_source/lib/src/default/components/radio.g.dart @@ -0,0 +1,88 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'radio.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Radio recipe. +/// +/// Remix owns the rendering, the single-selection behavior, arrow-key +/// traversal within the group, and the radio accessibility role; this recipe +/// supplies the circle, the dot, and the state fragments. +/// +/// `RemixRadioGroup` — the behavioral coordinator that owns `groupValue` and +/// the change callback — carries no styler and therefore no recipe. Compose it +/// directly around these: +/// +/// ```dart +/// RemixRadioGroup( +/// groupValue: plan, +/// onChanged: (value) => setState(() => plan = value), +/// child: Column(children: const [ +/// VanillaRadio(value: 'free', semanticLabel: 'Free'), +/// VanillaRadio(value: 'pro', semanticLabel: 'Pro'), +/// ]), +/// ) +/// ``` +/// +/// Unlike the checkbox, a radio draws no glyph: the mark is a filled dot +/// inside the ring, which is what tells the two controls apart at a glance +/// even before their shapes register. +/// +/// `RemixRadio` requires a `semanticLabel` because it renders no text of its +/// own — the visible label beside it belongs to the caller's layout. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's selected ring has to be +/// declared as a selected fragment too (`RadioStyler().onSelected(...)`). +class VanillaRadio extends StatelessWidget { + const VanillaRadio({ + super.key, + this.style = const RadioStyler.create(), + required this.value, + required this.semanticLabel, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + }); + + final RadioStyler style; + + final T value; + + final String semanticLabel; + + final bool enabled; + + final bool toggleable; + + final MouseCursor? mouseCursor; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixRadio( + key: this.key, + style: vanillaRadioStyle(style: this.style), + value: this.value, + semanticLabel: this.semanticLabel, + enabled: this.enabled, + toggleable: this.toggleable, + mouseCursor: this.mouseCursor, + focusNode: this.focusNode, + autofocus: this.autofocus, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/segmented_control.dart b/registry_source/lib/src/default/components/segmented_control.dart new file mode 100644 index 000000000..be74ba7b7 --- /dev/null +++ b/registry_source/lib/src/default/components/segmented_control.dart @@ -0,0 +1,166 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'segmented_control.g.dart'; + +/// The application's SegmentedControl recipe. +/// +/// A segmented control is one control divided into parts, which is what +/// separates it from a toggle group: the segments share a track, exactly one +/// is chosen, and the chosen one is *lifted* out of the track rather than +/// tinted on top of it. Remix owns the rendering, the equal-width layout, the +/// roving focus, and the group accessibility semantics. +/// +/// One recipe covers the track and the segments, because +/// `SegmentedControlSpec` carries the segment's style as a field: the +/// control's `item` is the default every `RemixSegmentedControlItem` resolves +/// against, so a segment in a loop cannot be left unstyled. +/// +/// It takes no variant. The whole point of the component is one shape — a +/// recessed track with a raised current segment — and a second look would be +/// a toggle group wearing the wrong name. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +@MixWidget(target: RemixSegmentedControl.new) +SegmentedControlStyler vanillaSegmentedControlStyle({ + SegmentedControlStyler style = const SegmentedControlStyler.create(), +}) { + return SegmentedControlStyler() + // The track is `muted`, the recessed surface the segments sit in. The + // chosen segment is `background`, so it reads as sitting on top of the + // page rather than painted onto the track. + .color(VanillaTokens.muted()) + .borderRadius(.all(VanillaTokens.radius())) + .padding(.all(_trackInset)) + .mainAxisSize(.min) + .spacing(_segmentGap) + .item(_itemStyle()) + .merge(style); +} + +/// Gap between the track edge and its segments, on every side. +const _trackInset = 3.0; + +/// Gap between adjacent segments. +/// +/// Zero: the segments are parts of one control, and a visible gap would make +/// them read as separate buttons that happen to share a background. +const _segmentGap = 0.0; + +/// How much tighter a segment's corners are than the track's. +/// +/// A segment inset by [_trackInset] on every side needs a correspondingly +/// smaller radius, or its corners stand proud of the track's. +const _segmentRadiusInset = _trackInset; + +/// Width of the chosen segment's outline. +const _borderWidth = 1.0; + +/// Width of the keyboard focus ring. +/// +/// It carries no offset, unlike the button's. Segments sit flush against each +/// other inside a 3px track inset, so a ring pushed outward would cross into +/// the neighbouring segment. +const _focusRingWidth = 2.0; + +/// Opacity applied to a segment while disabled. +const _disabledOpacity = 0.5; + +/// A fill that paints nothing, used by an unchosen segment. +const _noFill = Color(0x00000000); + +/// A segment's height. +/// +/// One size, not a scale. Thirty is not arbitrary: a segment is inset inside +/// the track by [_trackInset] on both sides, so the *track* lands on 36 — the +/// same height as the button and field beside it. +const _minHeight = 30.0; + +/// Horizontal inset inside a segment. +const _paddingX = 12.0; + +/// Gap between a segment's icon and its label. +const _gap = 8.0; + +/// Label size, matching body copy. +const _labelSize = 14.0; + +/// Size of a segment's leading icon. +const _iconSize = 16.0; + +/// One segment: quiet until chosen, then lifted onto its own surface. +/// +/// Every segment's label is `foreground`, chosen or not. The tempting +/// spelling is `mutedForeground` until chosen, but that pairing measures +/// 4.35:1 on the `muted` track in the light theme — under the 4.5:1 WCAG +/// floor for text this size. The chosen segment is marked by its raised +/// surface and a heavier weight instead, and weight survives where a colour +/// difference would not. +SegmentedControlItemStyler _itemStyle() => _content(VanillaTokens.foreground()) + .color(_noFill) + .alignment(.center) + .minHeight(_minHeight) + .padding(.horizontal(_paddingX)) + .spacing(_gap) + .borderRadius(.all(_segmentRadius())) + .label(.fontSize(_labelSize).fontWeight(FontWeight.w400)) + .icon(.size(_iconSize)) + // Hover tints the surface rather than the label: the label is already at + // full strength, and a second text colour would compete with the chosen + // segment for "this is the current section". + .onHovered(.color(VanillaTokens.accent())) + // Three cues, because the fill alone is not one: `background` on a + // `muted` track measures 1.09:1 in the light theme, so a reader looking + // for "which section am I in" would be reading a 1.09:1 difference and a + // font weight. The outline is what actually draws the segment. + .onSelected( + SegmentedControlItemStyler() + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .label(.fontWeight(FontWeight.w500)), + ) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()); + +/// The track's radius, pulled in by the inset the segments sit behind. +/// +/// Declared as a top-level final because `ContextToken` equality is resolver +/// identity: rebuilding one per call would make two identical recipes compare +/// unequal. +final _segmentRadius = ContextToken((context) { + final radius = VanillaTokens.radius.resolve(context); + + return Radius.elliptical( + (radius.x - _segmentRadiusInset).clamp(0.0, double.infinity), + (radius.y - _segmentRadiusInset).clamp(0.0, double.infinity), + ); +}); + +/// Applies one content color to the label and the icons. +SegmentedControlItemStyler _content(Color foreground) => + SegmentedControlItemStyler() + .label(.color(foreground)) + .icon(.color(foreground)); + +/// The keyboard focus ring. +/// +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// segment without taking layout space, so focusing one never widens the +/// track. +SegmentedControlItemStyler _focusVisibleStyle() => + SegmentedControlItemStyler().containerEffects( + .outline( + .color( + VanillaTokens.focusRing(), + ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), + ), + ); + +/// Declared last so it wins over every other state fragment. +SegmentedControlItemStyler _disabledStyle() => SegmentedControlItemStyler() + .containerEffects(.outline(.style(.none))) + .wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/segmented_control.g.dart b/registry_source/lib/src/default/components/segmented_control.g.dart new file mode 100644 index 000000000..0a814295f --- /dev/null +++ b/registry_source/lib/src/default/components/segmented_control.g.dart @@ -0,0 +1,75 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'segmented_control.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's SegmentedControl recipe. +/// +/// A segmented control is one control divided into parts, which is what +/// separates it from a toggle group: the segments share a track, exactly one +/// is chosen, and the chosen one is *lifted* out of the track rather than +/// tinted on top of it. Remix owns the rendering, the equal-width layout, the +/// roving focus, and the group accessibility semantics. +/// +/// One recipe covers the track and the segments, because +/// `SegmentedControlSpec` carries the segment's style as a field: the +/// control's `item` is the default every `RemixSegmentedControlItem` resolves +/// against, so a segment in a loop cannot be left unstyled. +/// +/// It takes no variant. The whole point of the component is one shape — a +/// recessed track with a raised current segment — and a second look would be +/// a toggle group wearing the wrong name. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +class VanillaSegmentedControl extends StatelessWidget { + const VanillaSegmentedControl({ + super.key, + this.style = const SegmentedControlStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final SegmentedControlStyler style; + + final List> items; + + final T? selectedValue; + + final ValueChanged? onChanged; + + final bool enabled; + + final Axis orientation; + + final bool loop; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixSegmentedControl( + key: this.key, + style: vanillaSegmentedControlStyle(style: this.style), + items: this.items, + selectedValue: this.selectedValue, + onChanged: this.onChanged, + enabled: this.enabled, + orientation: this.orientation, + loop: this.loop, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/select.dart b/registry_source/lib/src/default/components/select.dart new file mode 100644 index 000000000..0bf9a7ff9 --- /dev/null +++ b/registry_source/lib/src/default/components/select.dart @@ -0,0 +1,194 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'select.g.dart'; + +/// The application's Select recipe. +/// +/// Remix owns the rendering, the overlay, keyboard traversal, the open and +/// close behavior, and the listbox accessibility semantics; this recipe +/// supplies the trigger, the floating panel, and the option rows. +/// +/// One recipe covers all three, because `SelectSpec` carries them as fields: +/// `trigger`, `content` with `menuContainer`, and `item`. An option in a loop +/// therefore cannot be left unstyled. +/// +/// The trigger is styled as a field rather than as a button — same border, +/// same radius, same heights as the text field — because that is what it is. +/// The panel matches the menu's, so a select and a menu opened side by side +/// do not read as two systems. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the trigger's focus ring has to be +/// declared as a focus fragment too. +@MixWidget(target: RemixSelect.new) +SelectStyler vanillaSelectStyle({ + SelectStyler style = const SelectStyler.create(), +}) { + return SelectStyler() + .trigger(_triggerStyle()) + .content(_contentStyle()) + .menuContainer(.direction(.vertical).mainAxisSize(.min)) + .item(_itemStyle()) + .merge(style); +} + +/// Width of the trigger and panel outlines. +const _borderWidth = 1.0; + +/// Horizontal inset inside the trigger. +/// +/// Flat across the sizes, matching the text field this trigger is styled +/// after — see that recipe for why a field's gutter does not grow the way a +/// button's padding does. +const _paddingX = 12.0; + +/// Horizontal inset inside an option row. +/// +/// The menu's row inset, not the trigger's. The trigger is a field and takes +/// a field's gutter; an option row is a row in a floating list, and this +/// recipe claims above that its panel matches the menu's. Sharing the +/// trigger's 12 here broke that claim in the only place a reader would +/// notice: a menu and a select opened side by side had their labels on +/// different left edges. +const _rowPaddingX = 8.0; + +/// Gap between a row's text and its icons. +const _gap = 8.0; + +/// Size of the trigger's chevron and an option's check mark. +const _iconSize = 16.0; + +/// Inset between the panel edge and its rows. +const _panelPadding = 4.0; + +/// The narrowest a panel gets, so a one-word list is still a target. +const _panelMinWidth = 160.0; + +/// The tallest a panel gets before it scrolls. +/// +/// Bounded on purpose: an unbounded list of options grows past the viewport +/// and takes its own dismissal affordances with it. +const _panelMaxHeight = 320.0; + +/// Minimum height of one option row. +const _rowHeight = 32.0; + +/// Vertical inset inside an option row. +const _rowPaddingY = 6.0; + +/// Opacity of the placeholder, on top of its `mutedForeground` color. +/// +/// Remix multiplies this into the placeholder's own color rather than +/// replacing it, which is why the recipe sets both. +const _placeholderOpacity = 1.0; + +/// Width of the keyboard focus ring. +const _focusRingWidth = 2.0; + +/// Distance between the trigger edge and its focus ring. +const _focusRingOffset = 2.0; + +/// Opacity applied to the whole control while disabled. +const _disabledOpacity = 0.5; + +/// The lift that separates the panel from whatever it covers. +final _shadow = RemixBoxShadowMix( + color: const Color(0x1A000000), + offset: const Offset(0, 4), + blurRadius: 12, +); + +/// The trigger's resting height, matching shadcn's `h-9` and the text field +/// this trigger is styled after. +/// +/// One size, not a scale. A call site that needs another sets +/// `.minHeight(...)` through [style]. +const _minHeight = 36.0; + +/// Label, placeholder and option size, matching body copy. +const _textSize = 14.0; + +/// The closed control: a field showing the current value and a chevron. +SelectTriggerStyler _triggerStyle() => SelectTriggerStyler() + .direction(.horizontal) + .crossAxisAlignment(.center) + .mainAxisAlignment(.spaceBetween) + .minHeight(_minHeight) + .padding(.horizontal(_paddingX)) + .spacing(_gap) + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .borderRadius(.all(VanillaTokens.radius())) + .label(.fontSize(_textSize).color(VanillaTokens.foreground())) + // The placeholder is not a value: it has to read as the quieter of + // the two, or a select with nothing chosen looks answered. + .placeholder(.fontSize(_textSize).color(VanillaTokens.mutedForeground())) + .placeholderOpacity(_placeholderOpacity) + .icon(.size(_iconSize).color(VanillaTokens.mutedForeground())) + .indicator(.size(_iconSize).color(VanillaTokens.mutedForeground())) + // The content moves with the surface. Tinting the box alone would + // leave the placeholder at `mutedForeground` on `accent`, which is + // 3.76:1 in the light theme — under the 4.5:1 floor for text this + // size, and only while the pointer is on it, which is the worst kind + // of contrast bug to notice. + .onHovered( + SelectTriggerStyler() + .color(VanillaTokens.accent()) + .label(.color(VanillaTokens.accentForeground())) + .placeholder(.color(VanillaTokens.accentForeground())) + .icon(.color(VanillaTokens.accentForeground())) + .indicator(.color(VanillaTokens.accentForeground())), + ) + .onFocusVisible( + .containerEffects( + .outline( + .color( + VanillaTokens.focusRing(), + ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), + ).outlineOffset(_focusRingOffset), + ), + ) + .onDisabled( + SelectTriggerStyler() + .containerEffects(.outline(.style(.none))) + .wrap(.opacity(_disabledOpacity)), + ); + +/// The floating panel the options live in. +SelectContentStyler _contentStyle() => SelectContentStyler() + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .borderRadius(.all(VanillaTokens.radius())) + .padding(.all(_panelPadding)) + .minWidth(_panelMinWidth) + .maxHeight(_panelMaxHeight) + .containerEffects(.behindContent(.shadows([_shadow]))); + +/// One option row. +/// +/// `accent` marks the row under the pointer *and* the row the arrow keys are +/// on, because a select is as often driven by the keyboard as by the mouse. +/// The chosen option is marked by its check icon, which Remix renders. +SelectMenuItemStyler _itemStyle() => SelectMenuItemStyler() + .direction(.horizontal) + .crossAxisAlignment(.center) + .minHeight(_rowHeight) + .padding(.symmetric(horizontal: _rowPaddingX, vertical: _rowPaddingY)) + .spacing(_gap) + .borderRadius(.all(VanillaTokens.radius())) + .label(.fontSize(_textSize).color(VanillaTokens.foreground())) + .icon(.size(_iconSize).color(VanillaTokens.foreground())) + .onHovered(_highlighted()) + .onFocused(_highlighted()) + .onDisabled(SelectMenuItemStyler().wrap(.opacity(_disabledOpacity))); + +/// The option under the pointer or the keyboard cursor. +SelectMenuItemStyler _highlighted() => SelectMenuItemStyler() + .color(VanillaTokens.accent()) + .label(.color(VanillaTokens.accentForeground())) + .icon(.color(VanillaTokens.accentForeground())); diff --git a/registry_source/lib/src/default/components/select.g.dart b/registry_source/lib/src/default/components/select.g.dart new file mode 100644 index 000000000..6e6372798 --- /dev/null +++ b/registry_source/lib/src/default/components/select.g.dart @@ -0,0 +1,94 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'select.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Select recipe. +/// +/// Remix owns the rendering, the overlay, keyboard traversal, the open and +/// close behavior, and the listbox accessibility semantics; this recipe +/// supplies the trigger, the floating panel, and the option rows. +/// +/// One recipe covers all three, because `SelectSpec` carries them as fields: +/// `trigger`, `content` with `menuContainer`, and `item`. An option in a loop +/// therefore cannot be left unstyled. +/// +/// The trigger is styled as a field rather than as a button — same border, +/// same radius, same heights as the text field — because that is what it is. +/// The panel matches the menu's, so a select and a menu opened side by side +/// do not read as two systems. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the trigger's focus ring has to be +/// declared as a focus fragment too. +class VanillaSelect extends StatelessWidget { + const VanillaSelect({ + super.key, + this.style = const SelectStyler.create(), + required this.trigger, + required this.items, + this.selectedValue, + this.positioning = const OverlayPositionConfig( + side: .bottom, + alignment: .center, + ), + this.onChanged, + this.onOpen, + this.onClose, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.closeOnSelect = true, + this.focusNode, + }); + + final SelectStyler style; + + final RemixSelectTrigger trigger; + + final List> items; + + final T? selectedValue; + + final OverlayPositionConfig positioning; + + final ValueChanged? onChanged; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final bool enabled; + + final MouseCursor mouseCursor; + + final String? semanticLabel; + + final bool closeOnSelect; + + final FocusNode? focusNode; + + @override + Widget build(BuildContext context) { + return RemixSelect( + key: this.key, + style: vanillaSelectStyle(style: this.style), + trigger: this.trigger, + items: this.items, + selectedValue: this.selectedValue, + positioning: this.positioning, + onChanged: this.onChanged, + onOpen: this.onOpen, + onClose: this.onClose, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + semanticLabel: this.semanticLabel, + closeOnSelect: this.closeOnSelect, + focusNode: this.focusNode, + ); + } +} diff --git a/registry_source/lib/src/default/components/sidebar.dart b/registry_source/lib/src/default/components/sidebar.dart new file mode 100644 index 000000000..c869856c3 --- /dev/null +++ b/registry_source/lib/src/default/components/sidebar.dart @@ -0,0 +1,134 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; +import 'toggle.dart'; +import 'tooltip.dart'; + +part 'sidebar.g.dart'; + +/// The application's Sidebar recipe. +/// +/// A sidebar is the navigation panel down one edge of an application shell. +/// Remix owns the rendering, the header/content/footer stacking, Tab traversal +/// across destinations, the selection semantics, and the navigation +/// landmark; this recipe owns the panel surface, the region insets, the +/// section rhythm, and the label type. +/// +/// It takes no variant and no size. There is one panel per shell, and the +/// thing that actually varies between applications — how wide it is — is not +/// the recipe's to decide: the host sizes the panel, because the same panel +/// is usually presented as a drawer at narrow widths and the drawer's width +/// is a layout decision. Nothing here sets a width, and nothing here pads the +/// header, whose metrics normally have to line up with an application top bar. +/// +/// This recipe **depends on the `toggle` and `tooltip` items**, which is why +/// its registry entry lists both beside `theme`. A destination is a toggle: it +/// is a control that stays pressed, and `SidebarSpec` takes its style as a +/// `ToggleStyler` field. Handing it the application's own ghost toggle recipe +/// is what keeps a selected destination and a selected toggle the same colour +/// without restating one component inside another. The same reasoning covers +/// the tooltip: when the host collapses the panel to an icon rail, each +/// destination's label appears in the application's own tooltip recipe. +/// +/// The panel fill is `background`, the same token the page uses, and the +/// trailing hairline in `border` is what separates the two — the same choice +/// the card recipe makes, for the same reason. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaSidebar( +/// style: SidebarStyler().width(_shellSidebarWidth), +/// sections: sections, +/// selectedValue: current, +/// onSelected: go, +/// ) +/// ``` +@MixWidget(target: RemixSidebar.new) +SidebarStyler vanillaSidebarStyle({ + SidebarStyler style = const SidebarStyler.create(), +}) => SidebarStyler( + container: FlexBoxStyler() + .color(VanillaTokens.background()) + .border(.end(.color(VanillaTokens.border()).width(_borderWidth))), + content: FlexBoxStyler() + .padding( + .symmetric(horizontal: _contentPaddingX, vertical: _contentPaddingY), + ) + .spacing(_sectionGap), + footer: BoxStyler() + .border(.top(.color(VanillaTokens.border()).width(_borderWidth))) + .padding(.all(_contentPaddingX)), + // A section label names the group below it. It is deliberately the + // quietest text in the panel: `mutedForeground` at the smallest size, so + // it reads as a heading for the destinations rather than as one of them. + sectionLabel: TextStyler() + .color(VanillaTokens.mutedForeground()) + .fontSize(_sectionLabelSize) + .fontWeight(FontWeight.w500) + .letterSpacing(_sectionLabelTracking) + .wrap( + .padding( + .symmetric( + horizontal: _sectionLabelPaddingX, + vertical: _sectionLabelPaddingY, + ), + ), + ), + destinations: FlexBoxStyler().spacing(_destinationGap), + // The application's own toggle, stretched to the panel width and pinned to + // a comfortable target height. `ghost` is the right weight here: a column + // of outlined destinations would draw more lines than the panel has room + // for, and selection already reads through the `accent` fill. + destination: vanillaToggleStyle(variant: .ghost, size: .medium) + .minHeight(_destinationMinHeight) + .container(.mainAxisSize(.max).mainAxisAlignment(.start)), + // A collapsed rail shows each destination's label in the application's own + // tooltip, so it reads like every other tooltip in the app. + tooltip: vanillaTooltipStyle(), +).merge(style); + +/// Width of the panel's trailing edge and the footer's top divider. +const _borderWidth = 1.0; + +/// Horizontal inset of the scrolling destination region. +const _contentPaddingX = 12.0; + +/// Vertical inset of the scrolling destination region. +const _contentPaddingY = 16.0; + +/// Gap between adjacent sections. +const _sectionGap = 16.0; + +/// Gap between adjacent destinations inside one section. +/// +/// Much tighter than [_sectionGap]: destinations in a section are one list, +/// and the whitespace is what tells a reader where that list ends. +const _destinationGap = 2.0; + +/// Type scale for a section label. +const _sectionLabelSize = 12.0; + +/// Extra tracking on a section label, which small uppercase-ish headings need +/// to stay legible. +const _sectionLabelTracking = 0.4; + +/// Horizontal inset of a section label. +/// +/// Two pixels narrower than the destination's own `padding`, so the label's +/// first glyph sits over the destination text below it rather than over the +/// destination's edge. +const _sectionLabelPaddingX = 10.0; + +/// Vertical inset of a section label. +const _sectionLabelPaddingY = 6.0; + +/// Minimum height of one destination. +/// +/// Taller than the 36px `medium` toggle it is built from: a destination is a +/// primary navigation target, often reached on a touch screen, and this is +/// the one place in this layer that meets the 48px guidance outright. +const _destinationMinHeight = 48.0; diff --git a/registry_source/lib/src/default/components/sidebar.g.dart b/registry_source/lib/src/default/components/sidebar.g.dart new file mode 100644 index 000000000..535216a9b --- /dev/null +++ b/registry_source/lib/src/default/components/sidebar.g.dart @@ -0,0 +1,119 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sidebar.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Sidebar recipe. +/// +/// A sidebar is the navigation panel down one edge of an application shell. +/// Remix owns the rendering, the header/content/footer stacking, Tab traversal +/// across destinations, the selection semantics, and the navigation +/// landmark; this recipe owns the panel surface, the region insets, the +/// section rhythm, and the label type. +/// +/// It takes no variant and no size. There is one panel per shell, and the +/// thing that actually varies between applications — how wide it is — is not +/// the recipe's to decide: the host sizes the panel, because the same panel +/// is usually presented as a drawer at narrow widths and the drawer's width +/// is a layout decision. Nothing here sets a width, and nothing here pads the +/// header, whose metrics normally have to line up with an application top bar. +/// +/// This recipe **depends on the `toggle` and `tooltip` items**, which is why +/// its registry entry lists both beside `theme`. A destination is a toggle: it +/// is a control that stays pressed, and `SidebarSpec` takes its style as a +/// `ToggleStyler` field. Handing it the application's own ghost toggle recipe +/// is what keeps a selected destination and a selected toggle the same colour +/// without restating one component inside another. The same reasoning covers +/// the tooltip: when the host collapses the panel to an icon rail, each +/// destination's label appears in the application's own tooltip recipe. +/// +/// The panel fill is `background`, the same token the page uses, and the +/// trailing hairline in `border` is what separates the two — the same choice +/// the card recipe makes, for the same reason. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it: +/// +/// ```dart +/// VanillaSidebar( +/// style: SidebarStyler().width(_shellSidebarWidth), +/// sections: sections, +/// selectedValue: current, +/// onSelected: go, +/// ) +/// ``` +class VanillaSidebar extends StatelessWidget { + const VanillaSidebar({ + super.key, + this.style = const SidebarStyler.create(), + this.header, + this.collapsed = false, + this.showTooltips = true, + this.tooltipPositioning, + this.expandedWidth, + this.collapsedWidth, + this.animationStyle = const AnimationStyle(), + required this.sections, + required this.selectedValue, + this.onSelected, + this.footer, + this.enabled = true, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final SidebarStyler style; + + final Widget? header; + + final bool collapsed; + + final bool showTooltips; + + final OverlayPositionConfig? tooltipPositioning; + + final double? expandedWidth; + + final double? collapsedWidth; + + final AnimationStyle animationStyle; + + final List> sections; + + final T? selectedValue; + + final ValueChanged? onSelected; + + final Widget? footer; + + final bool enabled; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixSidebar( + key: this.key, + style: vanillaSidebarStyle(style: this.style), + header: this.header, + collapsed: this.collapsed, + showTooltips: this.showTooltips, + tooltipPositioning: this.tooltipPositioning, + expandedWidth: this.expandedWidth, + collapsedWidth: this.collapsedWidth, + animationStyle: this.animationStyle, + sections: this.sections, + selectedValue: this.selectedValue, + onSelected: this.onSelected, + footer: this.footer, + enabled: this.enabled, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/sidebar_layout.dart b/registry_source/lib/src/default/components/sidebar_layout.dart new file mode 100644 index 000000000..9b6fb99ac --- /dev/null +++ b/registry_source/lib/src/default/components/sidebar_layout.dart @@ -0,0 +1,357 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +/// Accessible name for the compact navigation sheet's dialog barrier. +const _closeNavigationLabel = 'Close navigation'; + +/// Accessible name for the compact navigation sheet itself. +const _navigationSemanticLabel = 'Navigation'; + +/// A width, in logical pixels, reserved outside the compact sheet so its +/// scrim stays reachable on narrow screens. +const _compactSheetBarrierGutter = 56.0; + +/// Application shell layout pairing a [sidebar] with a [body]. +/// +/// A layout, not a styled component: it owns no `Spec`, ships no generated +/// adapter, and paints nothing of its own beyond the compact sheet's panel +/// surface. [sidebar] is expected to be an already-configured `Sidebar` +/// (or any widget) that renders its own collapsed/expanded content; this +/// widget only decides where that content sits. +/// +/// At or above [compactBreakpoint] logical pixels of available width, the +/// layout renders a row: [sidebar] at [collapsedWidth] or [sidebarWidth] +/// (matching [collapsed]), animated over 200ms with an ease-in-out curve — +/// the same timing `RemixSidebar` uses by default — next to an expanded +/// column holding the optional [header] above [body]. +/// +/// Below [compactBreakpoint], [sidebar] is hidden from the row entirely and +/// instead presented as a full-height sheet pinned to the layout's *start* +/// edge (end edge in RTL), opened and closed through +/// [VanillaSidebarLayoutScope]. The sheet is a [showRemixDialog] +/// route, which supplies the barrier, Escape-to-dismiss, and focus +/// containment; this widget only positions the sheet's content and supplies +/// its panel surface. +/// +/// [compactOpen] and [onCompactOpenChanged] make the sheet's open state +/// controlled. Leave [compactOpen] null to let the layout manage it, still +/// observing changes through [onCompactOpenChanged] if supplied. +/// +/// A controlled [compactOpen] is the single source of truth: a barrier tap, +/// Escape, or a back gesture requests closure through [onCompactOpenChanged]. +/// The host must set [compactOpen] to `false` to dismiss it. Crossing back +/// above [compactBreakpoint] hides the sheet and requests a closed state. +/// +/// ```dart +/// VanillaSidebarLayout( +/// sidebar: VanillaSidebar( +/// sections: sections, +/// selectedValue: page, +/// onSelected: (value) { +/// setState(() => page = value); +/// VanillaSidebarLayoutScope.of(context).closeCompact(); +/// }, +/// ), +/// header: const TopBar(), +/// body: PageBody(page: page), +/// ) +/// ``` +class VanillaSidebarLayout extends StatefulWidget { + const VanillaSidebarLayout({ + super.key, + required this.sidebar, + required this.body, + this.header, + this.compactBreakpoint = 720, + this.sidebarWidth = 256, + this.collapsedWidth = 72, + this.collapsed = false, + this.compactOpen, + this.onCompactOpenChanged, + }) : assert(compactBreakpoint > 0), + assert(sidebarWidth > 0), + assert(collapsedWidth > 0 && collapsedWidth <= sidebarWidth); + + /// The navigation panel. Rendered inline while wide, and inside the + /// compact sheet while narrow. + final Widget sidebar; + + /// The page content, always visible. + final Widget body; + + /// Optional fixed content above [body], in both presentations. + final Widget? header; + + /// The available-width threshold, in logical pixels, below which the + /// layout switches to its compact presentation. + final double compactBreakpoint; + + /// The wide-mode panel width when [collapsed] is false. + final double sidebarWidth; + + /// The wide-mode panel width when [collapsed] is true. + final double collapsedWidth; + + /// Whether the wide-mode panel renders at [collapsedWidth] instead of + /// [sidebarWidth]. The host toggles this; [sidebar] itself decides how its + /// own content responds. + final bool collapsed; + + /// Controlled compact-sheet visibility. Null lets the layout manage it. + final bool? compactOpen; + + /// Called when the user requests a different open state, or an + /// uncontrolled sheet changes state. Updating [compactOpen] itself does + /// not emit another callback. + final ValueChanged? onCompactOpenChanged; + + @override + State createState() => _VanillaSidebarLayoutState(); +} + +class _VanillaSidebarLayoutState extends State { + bool _selfOpen = false; + bool _sheetShowing = false; + + /// The layout's presentation as of its most recent build, so [_openCompact] + /// can no-op while wide even when called outside that build. + bool _isCompact = false; + + /// The route [showRemixDialog] pushed for the open sheet, captured from + /// inside its own builder via `ModalRoute.of` so [_removeSheetRoute] can + /// close exactly that route on the Navigator that actually owns it, + /// rather than popping whatever a bare `Navigator.of(context)` finds. + Route? _sheetRoute; + + bool get _effectiveOpen => widget.compactOpen ?? _selfOpen; + + void _setOpen(bool value) { + if (_effectiveOpen == value) return; + if (widget.compactOpen == null) { + setState(() => _selfOpen = value); + } + widget.onCompactOpenChanged?.call(value); + } + + // No-op while wide, so open state never carries over to the next compact + // presentation. + void _openCompact() { + if (!_isCompact) return; + _setOpen(true); + } + + void _closeCompact() => _setOpen(false); + + void _reconcileSheet(bool isCompact) { + _isCompact = isCompact; + final desiredOpen = isCompact && _effectiveOpen; + if (desiredOpen == _sheetShowing) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_isCompact && _effectiveOpen) { + if (!_sheetShowing) _pushSheet(); + } else if (_sheetShowing) { + _removeSheetRoute(); + } + }); + } + + void _removeSheetRoute() { + final route = _sheetRoute; + if (route == null || !route.isActive) return; + route.navigator?.removeRoute(route); + } + + Future _pushSheet() async { + _sheetShowing = true; + final reduceMotion = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + await showRemixDialog( + context: context, + barrierDismissible: true, + barrierLabel: _closeNavigationLabel, + transitionDuration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 250), + builder: (dialogContext) { + _sheetRoute = ModalRoute.of(dialogContext); + final available = MediaQuery.sizeOf(dialogContext).width; + final width = math.min( + widget.sidebarWidth, + math.max(0.0, available - _compactSheetBarrierGutter), + ); + return PopScope( + canPop: widget.compactOpen == null, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _closeCompact(); + }, + child: VanillaSidebarLayoutScope._( + isCompact: true, + isCompactOpen: true, + openCompact: _openCompact, + closeCompact: _closeCompact, + child: Align( + alignment: AlignmentDirectional.centerStart, + // A plain DecoratedBox paints the panel surface without + // affecting layout, unlike a Mix `Box`, whose border-box sizing + // would shrink `width` by the border's own stroke width. + child: DecoratedBox( + decoration: BoxDecoration( + color: MixScope.tokenOf( + VanillaTokens.background, + dialogContext, + ), + border: BorderDirectional( + end: BorderSide( + color: MixScope.tokenOf( + VanillaTokens.border, + dialogContext, + ), + ), + ), + ), + child: SizedBox( + width: width, + height: double.infinity, + child: RemixDialog( + semanticLabel: _navigationSemanticLabel, + child: widget.sidebar, + ), + ), + ), + ), + ), + ); + }, + ); + // Reached once, however the route completed: a user dismissal or + // _removeSheetRoute above. + _sheetRoute = null; + _sheetShowing = false; + if (mounted) _setOpen(false); + } + + @override + void dispose() { + final route = _sheetRoute; + if (route != null) { + // Navigator mutations must wait until the current tree update finishes. + WidgetsBinding.instance.addPostFrameCallback((_) { + final navigator = route.navigator; + if (navigator != null && navigator.mounted && route.isActive) { + navigator.removeRoute(route); + } + }); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < widget.compactBreakpoint; + _reconcileSheet(isCompact); + + return VanillaSidebarLayoutScope._( + isCompact: isCompact, + // Anded with isCompact so it can't read true while wide. + isCompactOpen: isCompact && _effectiveOpen, + openCompact: _openCompact, + closeCompact: _closeCompact, + child: isCompact ? _body() : _wideRow(), + ); + }, + ); + } + + Widget _wideRow() { + final reduceMotion = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedContainer( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: widget.collapsed ? widget.collapsedWidth : widget.sidebarWidth, + child: widget.sidebar, + ), + Expanded(child: _body()), + ], + ); + } + + Widget _body() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ?widget.header, + Expanded(child: widget.body), + ], + ); + } +} + +/// Reads the layout's compact state and drives its compact sheet. +/// +/// Available to both the layout's normal subtree (for example, a [header]'s +/// menu button) and the compact sheet's own subtree (for example, a +/// destination's `onSelected` callback closing the sheet after navigating), +/// since the layout re-provides this scope inside the sheet route. +class VanillaSidebarLayoutScope extends InheritedWidget { + const VanillaSidebarLayoutScope._({ + required this.isCompact, + required this.isCompactOpen, + required this._openCompact, + required this._closeCompact, + required super.child, + }); + + /// Whether the layout is currently in its compact presentation. + final bool isCompact; + + /// Whether the compact sheet is currently open. + /// + /// Always false outside a compact presentation. + final bool isCompactOpen; + + final VoidCallback _openCompact; + final VoidCallback _closeCompact; + + /// Opens the compact sheet. A no-op while wide. + void openCompact() => _openCompact(); + + /// Closes the compact sheet. A no-op when already closed. + void closeCompact() => _closeCompact(); + + /// Reads the nearest [VanillaSidebarLayoutScope]. + /// + /// Throws a [FlutterError] outside a [VanillaSidebarLayout]. + static VanillaSidebarLayoutScope of(BuildContext context) { + final scope = maybeOf(context); + if (scope == null) { + throw FlutterError( + 'VanillaSidebarLayoutScope.of requires a ' + 'VanillaSidebarLayout ancestor.', + ); + } + return scope; + } + + /// Reads the nearest [VanillaSidebarLayoutScope], or null outside a + /// [VanillaSidebarLayout]. + static VanillaSidebarLayoutScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(VanillaSidebarLayoutScope oldWidget) => + isCompact != oldWidget.isCompact || + isCompactOpen != oldWidget.isCompactOpen; +} diff --git a/registry_source/lib/src/default/components/skeleton.dart b/registry_source/lib/src/default/components/skeleton.dart new file mode 100644 index 000000000..3303ed79a --- /dev/null +++ b/registry_source/lib/src/default/components/skeleton.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'skeleton.g.dart'; + +/// The application's Skeleton recipe. +/// +/// A skeleton is a placeholder that keeps a layout the right shape while its +/// content loads. Remix owns the pulse animation, the semantics, and the rule +/// that a wrapped child keeps sizing the placeholder in both states; this +/// recipe supplies only the two colors and the tempo. +/// +/// It takes no size. A skeleton is measured by the content it stands in for — +/// either the child it wraps, or explicit constraints on the caller's own +/// [style]: +/// +/// ```dart +/// VanillaSkeleton( +/// style: SkeletonStyler().container(.size(160, 20)), +/// ) +/// ``` +/// +/// The pulse runs between `muted` and `accent`, the theme's two neutral +/// surfaces, so a loading block reads as scenery rather than as content. Both +/// tokens shift with light and dark, and a theme that wants a stronger pulse +/// only widens the gap between them. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +@MixWidget(target: RemixSkeleton.new) +SkeletonStyler vanillaSkeletonStyle({ + SkeletonStyler style = const SkeletonStyler.create(), +}) => SkeletonStyler() + .container( + BoxStyler() + .color(VanillaTokens.muted()) + .borderRadius(.all(VanillaTokens.radius())), + ) + .pulseColor(VanillaTokens.accent()) + .duration(_pulseDuration) + .merge(style); + +/// The length of one forward pulse leg; the reverse leg takes the same time. +/// +/// Slow on purpose. A placeholder that pulses at interaction speed competes +/// with the content arriving beside it. +const _pulseDuration = Duration(milliseconds: 1000); diff --git a/registry_source/lib/src/default/components/skeleton.g.dart b/registry_source/lib/src/default/components/skeleton.g.dart new file mode 100644 index 000000000..428f1aebc --- /dev/null +++ b/registry_source/lib/src/default/components/skeleton.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'skeleton.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Skeleton recipe. +/// +/// A skeleton is a placeholder that keeps a layout the right shape while its +/// content loads. Remix owns the pulse animation, the semantics, and the rule +/// that a wrapped child keeps sizing the placeholder in both states; this +/// recipe supplies only the two colors and the tempo. +/// +/// It takes no size. A skeleton is measured by the content it stands in for — +/// either the child it wraps, or explicit constraints on the caller's own +/// [style]: +/// +/// ```dart +/// VanillaSkeleton( +/// style: SkeletonStyler().container(.size(160, 20)), +/// ) +/// ``` +/// +/// The pulse runs between `muted` and `accent`, the theme's two neutral +/// surfaces, so a loading block reads as scenery rather than as content. Both +/// tokens shift with light and dark, and a theme that wants a stronger pulse +/// only widens the gap between them. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +class VanillaSkeleton extends StatelessWidget { + const VanillaSkeleton({ + super.key, + this.style = const SkeletonStyler.create(), + this.child, + this.loading = true, + }); + + final SkeletonStyler style; + + final Widget? child; + + final bool loading; + + @override + Widget build(BuildContext context) { + return RemixSkeleton( + key: this.key, + style: vanillaSkeletonStyle(style: this.style), + child: this.child, + loading: this.loading, + ); + } +} diff --git a/registry_source/lib/src/default/components/slider.dart b/registry_source/lib/src/default/components/slider.dart new file mode 100644 index 000000000..b3e9caa97 --- /dev/null +++ b/registry_source/lib/src/default/components/slider.dart @@ -0,0 +1,123 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'slider.g.dart'; + +/// The application's Slider recipe. +/// +/// Remix owns the rendering, the drag and keyboard behavior, the mapping from +/// a 0-1 value onto the filled range, and the slider accessibility semantics; +/// this recipe supplies the rail, the filled range, and the thumb. +/// +/// The rail is `muted` and the range is `primary`, the same pairing the +/// progress bar uses — a slider is a progress bar you can grab, and reading +/// them as one family is worth more than distinguishing them by color. +/// +/// `semanticFormatterCallback` is deliberately not forwarded to the generated +/// `VanillaSlider`. Its type is +/// `NakedSliderSemanticFormatterCallback`, which comes from +/// `package:naked_ui` — a package this layer does not depend on. Reach for +/// `RemixSlider` directly on the rare call site that needs to reword the +/// announced value. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's hover thumb has to be +/// declared as a hover fragment too (`SliderStyler().onHovered(...)`). +@MixWidget( + target: RemixSlider.new, + widgetParameters: .only({ + 'value', + 'onChanged', + 'onChangeStart', + 'onChangeEnd', + 'min', + 'max', + 'enabled', + 'enableFeedback', + 'focusNode', + 'autofocus', + 'snapDivisions', + 'semanticLabel', + 'excludeSemantics', + }), +) +SliderStyler vanillaSliderStyle({ + SliderStyler style = const SliderStyler.create(), +}) { + return SliderStyler() + .thickness(_rail) + .trackColor(VanillaTokens.muted()) + .rangeColor(VanillaTokens.primary()) + .thumbSize(const Size.square(_thumb)) + .thumbColor(VanillaTokens.background()) + .thumb( + BoxStyler() + .borderRadius(.all(_circular)) + // The thumb is a light disc on a light rail, so its own outline is + // what separates it from the range it sits on. + .border(.color(VanillaTokens.primary()).width(_thumbBorderWidth)), + ) + // A thumb is a grab target, so it answers the pointer. The outline + // keeps identifying it; only the fill moves, which is why hovering does + // not make the thumb harder to find on a light rail. + .onHovered(SliderStyler().thumbColor(VanillaTokens.accent())) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()) + .merge(style); +} + +/// A radius large enough to round any thumb in this scale into a circle. +const _circular = Radius.circular(999); + +/// Width of the thumb's outline. +const _thumbBorderWidth = 2.0; + +/// Width of the keyboard focus ring. +const _focusRingWidth = 2.0; + +/// Distance between the thumb edge and its focus ring. +const _focusRingOffset = 2.0; + +/// Opacity applied to the whole control while disabled. +const _disabledOpacity = 0.5; + +/// The rail's thickness, matching shadcn's `h-1.5`. +/// +/// One size, not a scale. A call site that needs another sets `.thickness(...)` +/// through [style]. +const _rail = 6.0; + +/// Thumb diameter as a multiple of the rail thickness. +/// +/// Derived rather than stated, so the grab target keeps its relationship to +/// the rail if the rail is ever changed. +const _thumbRatio = 2.5; + +/// The thumb's diameter. +const _thumb = _rail * _thumbRatio; + +/// The keyboard focus ring, drawn around the thumb. +/// +/// `thumbFocusEffects` rather than `thumbEffects`: Remix paints the former +/// only while the slider has visible focus, which is the state a ring is for. +SliderStyler _focusVisibleStyle() => SliderStyler().thumbFocusEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: VanillaTokens.focusRing(), + width: _focusRingWidth, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: _focusRingOffset, + ), +); + +/// Declared last so it wins over every other state fragment. +/// +/// A disabled slider keeps its rail and range and simply fades; there is no +/// ring to clear because the focus effects are already conditional on focus. +SliderStyler _disabledStyle() => + SliderStyler().wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/slider.g.dart b/registry_source/lib/src/default/components/slider.g.dart new file mode 100644 index 000000000..cafe44024 --- /dev/null +++ b/registry_source/lib/src/default/components/slider.g.dart @@ -0,0 +1,97 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'slider.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Slider recipe. +/// +/// Remix owns the rendering, the drag and keyboard behavior, the mapping from +/// a 0-1 value onto the filled range, and the slider accessibility semantics; +/// this recipe supplies the rail, the filled range, and the thumb. +/// +/// The rail is `muted` and the range is `primary`, the same pairing the +/// progress bar uses — a slider is a progress bar you can grab, and reading +/// them as one family is worth more than distinguishing them by color. +/// +/// `semanticFormatterCallback` is deliberately not forwarded to the generated +/// `VanillaSlider`. Its type is +/// `NakedSliderSemanticFormatterCallback`, which comes from +/// `package:naked_ui` — a package this layer does not depend on. Reach for +/// `RemixSlider` directly on the rare call site that needs to reword the +/// announced value. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's hover thumb has to be +/// declared as a hover fragment too (`SliderStyler().onHovered(...)`). +class VanillaSlider extends StatelessWidget { + const VanillaSlider({ + super.key, + this.style = const SliderStyler.create(), + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final SliderStyler style; + + final double value; + + final ValueChanged? onChanged; + + final ValueChanged? onChangeStart; + + final ValueChanged? onChangeEnd; + + final double min; + + final double max; + + final bool enabled; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final int? snapDivisions; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixSlider( + key: this.key, + style: vanillaSliderStyle(style: this.style), + value: this.value, + onChanged: this.onChanged, + onChangeStart: this.onChangeStart, + onChangeEnd: this.onChangeEnd, + min: this.min, + max: this.max, + enabled: this.enabled, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + snapDivisions: this.snapDivisions, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/spinner.dart b/registry_source/lib/src/default/components/spinner.dart new file mode 100644 index 000000000..5ebca8daf --- /dev/null +++ b/registry_source/lib/src/default/components/spinner.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'spinner.g.dart'; + +/// The application's Spinner recipe. +/// +/// The spinner is the one component here that is pure motion: Remix owns the +/// eight-leaf geometry, the animation, and the progress semantics, and this +/// recipe supplies only its size, color, and tempo. +/// +/// The content color is `foreground` rather than `primary`. A spinner most +/// often replaces text while something loads, so it should read at the same +/// weight as the text it stands in for. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +@MixWidget(target: RemixSpinner.new) +SpinnerStyler vanillaSpinnerStyle({ + SpinnerStyler style = const SpinnerStyler.create(), +}) => SpinnerStyler() + .size(_diameter) + .color(VanillaTokens.foreground()) + .duration(_duration) + .merge(style); + +/// One full revolution. +const _duration = Duration(milliseconds: 800); + +/// The spinner's diameter. +/// +/// One size, not a scale. A button draws its own spinner from the button +/// recipe, so this is the standalone case — inline beside a label, or centred +/// in a panel — and 20 reads at the weight of the text it stands in for. A +/// call site that needs another sets `.size(...)` through [style]. +const _diameter = 20.0; diff --git a/registry_source/lib/src/default/components/spinner.g.dart b/registry_source/lib/src/default/components/spinner.g.dart new file mode 100644 index 000000000..ad4567d50 --- /dev/null +++ b/registry_source/lib/src/default/components/spinner.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spinner.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Spinner recipe. +/// +/// The spinner is the one component here that is pure motion: Remix owns the +/// eight-leaf geometry, the animation, and the progress semantics, and this +/// recipe supplies only its size, color, and tempo. +/// +/// The content color is `foreground` rather than `primary`. A spinner most +/// often replaces text while something loads, so it should read at the same +/// weight as the text it stands in for. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +class VanillaSpinner extends StatelessWidget { + const VanillaSpinner({ + super.key, + this.style = const SpinnerStyler.create(), + this.semanticsLabel, + this.semanticsValue, + }); + + final SpinnerStyler style; + + final String? semanticsLabel; + + final String? semanticsValue; + + @override + Widget build(BuildContext context) { + return RemixSpinner( + key: this.key, + style: vanillaSpinnerStyle(style: this.style), + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + ); + } +} diff --git a/registry_source/lib/src/default/components/switch.dart b/registry_source/lib/src/default/components/switch.dart new file mode 100644 index 000000000..b05198885 --- /dev/null +++ b/registry_source/lib/src/default/components/switch.dart @@ -0,0 +1,112 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'switch.g.dart'; + +/// The application's Switch recipe. +/// +/// Remix owns the rendering, the toggle behavior, the switch accessibility +/// role, and — importantly — the thumb's travel: it aligns the thumb to the +/// leading edge when off and the trailing edge when on. This recipe supplies +/// only the two boxes' geometry and their colors. +/// +/// `RemixSwitch` requires a `semanticLabel` because a switch has no visible +/// text of its own. That is a Remix rule, not a recipe choice. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's on-track has to be +/// declared as a selected fragment too (`SwitchStyler().onSelected(...)`). +@MixWidget(target: RemixSwitch.new) +SwitchStyler vanillaSwitchStyle({ + SwitchStyler style = const SwitchStyler.create(), +}) { + return SwitchStyler() + .size(_trackHeight * _trackRatio, _trackHeight) + .padding(.all(_thumbInset)) + .borderRadius(.all(_pill)) + .trackColor(VanillaTokens.muted()) + // Both boxes are outlined, and neither outline is decoration. `muted` + // on `background` measures 1.09:1 in the light theme and `background` + // on `muted` is the same pair inverted — so with no edge, an off switch + // is a pale shape on a pale page holding an invisible thumb. The `on` + // track does not need the help (`primary` on `background` is 17.9:1), + // but keeping the outline in both states is what stops the control + // changing size when it flips. + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .thumb( + BoxStyler() + .size(_thumbSize, _thumbSize) + .borderRadius(.all(_pill)) + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)), + ) + .onSelected(SwitchStyler().trackColor(VanillaTokens.primary())) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()) + .merge(style); +} + +/// Track width as a multiple of its height. +/// +/// Under 2 the thumb has nowhere to travel and the control stops reading as a +/// switch; well over 2 it reads as a slider. +const _trackRatio = 1.8; + +/// Gap between the track edge and the thumb, on every side. +const _thumbInset = 2.0; + +/// A radius large enough to round any track or thumb in this scale. +const _pill = Radius.circular(999); + +/// Width of the track and thumb outlines. +const _borderWidth = 1.0; + +/// Width of the keyboard focus ring. +const _focusRingWidth = 2.0; + +/// Distance between the track edge and its focus ring. +const _focusRingOffset = 2.0; + +/// Opacity applied to the whole control while disabled. +const _disabledOpacity = 0.5; + +/// The track's height, matching shadcn's `h-5`. +/// +/// One size, not a scale. A call site that needs another sets `.size(...)` +/// through [style]. +const _trackHeight = 20.0; + +/// The thumb, sized so it sits flush inside the track. +/// +/// Derived rather than stated: it is the track height minus the inset on both +/// sides, so the two cannot drift apart. +const _thumbSize = _trackHeight - _thumbInset * 2; + +/// The keyboard focus ring. +/// +/// An outline rather than a border: `RemixBoxEffects` paints it outside the +/// track without taking layout space, so focusing a switch never reflows the +/// row it sits in. +SwitchStyler _focusVisibleStyle() => SwitchStyler().trackEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: VanillaTokens.focusRing(), + width: _focusRingWidth, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: _focusRingOffset, + ), +); + +/// Declared last so it wins over every other state fragment. +/// +/// A disabled switch keeps whatever track its state gives it and simply +/// fades; the focus ring is cleared because a disabled control that still +/// draws a focus ring reads as actionable. +SwitchStyler _disabledStyle() => SwitchStyler() + .trackEffects(RemixBoxEffectsMix.outline(.style(.none))) + .wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/switch.g.dart b/registry_source/lib/src/default/components/switch.g.dart new file mode 100644 index 000000000..97abf2e6d --- /dev/null +++ b/registry_source/lib/src/default/components/switch.g.dart @@ -0,0 +1,74 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'switch.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Switch recipe. +/// +/// Remix owns the rendering, the toggle behavior, the switch accessibility +/// role, and — importantly — the thumb's travel: it aligns the thumb to the +/// leading edge when off and the trailing edge when on. This recipe supplies +/// only the two boxes' geometry and their colors. +/// +/// `RemixSwitch` requires a `semanticLabel` because a switch has no visible +/// text of its own. That is a Remix rule, not a recipe choice. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's on-track has to be +/// declared as a selected fragment too (`SwitchStyler().onSelected(...)`). +class VanillaSwitch extends StatelessWidget { + const VanillaSwitch({ + super.key, + this.style = const SwitchStyler.create(), + required this.selected, + required this.semanticLabel, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + final SwitchStyler style; + + final bool selected; + + final String semanticLabel; + + final ValueChanged? onChanged; + + final bool enabled; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixSwitch( + key: this.key, + style: vanillaSwitchStyle(style: this.style), + selected: this.selected, + semanticLabel: this.semanticLabel, + onChanged: this.onChanged, + enabled: this.enabled, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/registry_source/lib/src/default/components/tabs.dart b/registry_source/lib/src/default/components/tabs.dart new file mode 100644 index 000000000..30e62fc26 --- /dev/null +++ b/registry_source/lib/src/default/components/tabs.dart @@ -0,0 +1,220 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'tabs.g.dart'; + +/// The application's tab-strip recipe. +/// +/// The strip is the rule the tabs sit on: one hairline along its bottom edge, +/// in the same `border` token every other control outline uses. It spans its +/// container rather than hugging the tabs, so the rule lines up with the card +/// or page edge beside it. +/// +/// The strip does not scroll. Tabs wider than the container are a layout +/// decision, and the scroll view belongs **outside** the bar: +/// +/// ```dart +/// SingleChildScrollView( +/// scrollDirection: Axis.horizontal, +/// child: VanillaTabBar(child: Row(children: tabs)), +/// ) +/// ``` +/// +/// Not inside it. Flutter's tab-bar semantics role requires every direct +/// semantics child of the bar to be a tab, and a scroll view inserted between +/// them adds a node of its own, which trips that assertion at runtime. +/// +/// `RemixTabs` — the behavioral root that owns selection, roving focus, and +/// arrow-key traversal — carries no styler and therefore no recipe. Compose it +/// directly around this bar: +/// +/// ```dart +/// RemixTabs( +/// selectedTabId: tab, +/// onChanged: (id) => setState(() => tab = id), +/// child: Column( +/// children: [ +/// VanillaTabBar( +/// child: Row(children: [ +/// VanillaTab(tabId: 'account', label: 'Account'), +/// VanillaTab(tabId: 'billing', label: 'Billing'), +/// ]), +/// ), +/// VanillaTabView(tabId: 'account', child: accountPanel), +/// VanillaTabView(tabId: 'billing', child: billingPanel), +/// ], +/// ), +/// ) +/// ``` +@MixWidget(target: RemixTabBar.new) +TabBarStyler vanillaTabBarStyle({ + TabBarStyler style = const TabBarStyler.create(), +}) => TabBarStyler() + .direction(.horizontal) + .mainAxisSize(.max) + .crossAxisAlignment(.end) + .border(.bottom(.color(VanillaTokens.border()).width(_barBorderWidth))) + .merge(style); + +/// The application's Tab recipe. +/// +/// Everything visual about one tab lives in this function: geometry, +/// typography, and the hover/selected/focus/disabled fragments. Remix keeps +/// ownership of rendering, selection, keyboard traversal, and the tab +/// accessibility semantics — this recipe never reimplements any of that. +/// +/// The selected tab is marked by its trailing edge. That edge is present in +/// every state and merely transparent when unselected, so selecting a tab +/// paints two pixels instead of reflowing the whole strip. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's selected underline has to +/// be declared as a selected fragment too (`TabStyler().onSelected(...)`). +/// +/// `builder` is deliberately not forwarded to the generated +/// `VanillaTab`. Its type is `ValueWidgetBuilder`, and +/// `NakedTabState` comes from `package:naked_ui`, which this layer does not +/// depend on. Pass a `child` for custom content, or reach for `RemixTab` +/// directly on the rare call site that needs the raw state. +@MixWidget( + target: RemixTab.new, + widgetParameters: .only({ + 'tabId', + 'child', + 'label', + 'icon', + 'enabled', + 'mouseCursor', + 'enableFeedback', + 'focusNode', + 'autofocus', + 'onFocusChange', + 'onHoverChange', + 'onPressChange', + 'semanticLabel', + }), +) +TabStyler vanillaTabStyle({TabStyler style = const TabStyler.create()}) { + return _base() + .onHovered(_activeContent().color(VanillaTokens.accent())) + .onSelected(_selectedStyle()) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()) + .merge(style); +} + +/// The application's recipe for the panel a tab reveals. +/// +/// It exists so the panel carries the application's prefix and has one place +/// to edit, and it earns that by owning the gap between the strip and the +/// content: without it the panel's first line sits directly on the hairline. +@MixWidget(target: RemixTabView.new) +TabViewStyler vanillaTabViewStyle({ + TabViewStyler style = const TabViewStyler.create(), +}) => TabViewStyler().padding(.top(_panelGap)).merge(style); + +/// Width of the strip's hairline. +const _barBorderWidth = 1.0; + +/// Width of the edge that marks the selected tab. +/// +/// Twice the strip's hairline so the mark reads as a deliberate indicator +/// rather than a thicker piece of the same rule. +const _selectedEdgeWidth = 2.0; + +/// An edge that paints nothing, holding the selected mark's space. +const _noEdge = Color(0x00000000); + +/// Gap between the strip and the panel it reveals. +const _panelGap = 16.0; + +/// Width of the keyboard focus ring. +const _focusRingWidth = 2.0; + +/// Opacity applied to the whole tab while disabled. +const _disabledOpacity = 0.5; + +/// The tab's resting height, matching shadcn's `h-9` on its tab list and the +/// button beside it. +/// +/// One size, not a scale. A call site that needs another sets `.minHeight(...)` +/// through [style]. +const _minHeight = 36.0; + +/// Horizontal inset inside a tab. +const _paddingX = 12.0; + +/// Gap between a tab's icon and its label. +const _gap = 8.0; + +/// Label size, matching body copy. +const _labelSize = 14.0; + +/// Size of a tab's leading icon. +const _iconSize = 16.0; + +/// Layout, typography, and the unselected content color. +/// +/// An unselected tab is a destination, not the current one, so it uses +/// `mutedForeground`; hover and selection both promote it to `foreground`. +TabStyler _base() => _content(VanillaTokens.mutedForeground()) + .direction(.horizontal) + .mainAxisSize(.min) + .mainAxisAlignment(.center) + .crossAxisAlignment(.center) + .minHeight(_minHeight) + .padding(.horizontal(_paddingX)) + .spacing(_gap) + .border(.bottom(.color(_noEdge).width(_selectedEdgeWidth))) + .label(.fontSize(_labelSize).fontWeight(FontWeight.w500)) + .icon(.size(_iconSize)); + +/// The selected tab: full-strength content and the `primary` edge. +/// +/// It sets no fill on purpose. Variant fragments apply in declaration order +/// and only overwrite what they name, so leaving `color` alone here is what +/// lets a hovered selected tab keep the hover fill *and* the selected edge. +TabStyler _selectedStyle() => _activeContent().border( + .bottom(.color(VanillaTokens.primary()).width(_selectedEdgeWidth)), +); + +/// The content color shared by the hovered and selected tabs. +TabStyler _activeContent() => _content(VanillaTokens.foreground()); + +/// Applies one content color to the label and the icons. +TabStyler _content(Color foreground) => + TabStyler().label(.color(foreground)).icon(.color(foreground)); + +/// The keyboard focus ring. +/// +/// A *foreground* decoration rather than the box border: `TabSpec` has no +/// `containerEffects` layer to paint an outline into, and Flutter insets a +/// container's content by its border widths — so adding a real border on +/// focus would nudge the label. A foreground decoration paints over the tab +/// and takes no layout space, which is what a ring needs. +TabStyler _focusVisibleStyle() => TabStyler().foregroundDecoration( + BoxDecorationMix.border( + .all( + BorderSideMix( + color: VanillaTokens.focusRing(), + width: _focusRingWidth, + // Inset, so the ring stays inside the tab's own bounds instead of + // overlapping its neighbours in the strip. + strokeAlign: BorderSide.strokeAlignInside, + ), + ), + ), +); + +/// Declared last so it wins over every other state fragment. +/// +/// A disabled tab keeps whatever surface its state gives it and simply fades; +/// the focus ring is cleared because a disabled tab that still draws a focus +/// ring reads as reachable. +TabStyler _disabledStyle() => TabStyler() + .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/tabs.g.dart b/registry_source/lib/src/default/components/tabs.g.dart new file mode 100644 index 000000000..0a61df20f --- /dev/null +++ b/registry_source/lib/src/default/components/tabs.g.dart @@ -0,0 +1,195 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tabs.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's tab-strip recipe. +/// +/// The strip is the rule the tabs sit on: one hairline along its bottom edge, +/// in the same `border` token every other control outline uses. It spans its +/// container rather than hugging the tabs, so the rule lines up with the card +/// or page edge beside it. +/// +/// The strip does not scroll. Tabs wider than the container are a layout +/// decision, and the scroll view belongs **outside** the bar: +/// +/// ```dart +/// SingleChildScrollView( +/// scrollDirection: Axis.horizontal, +/// child: VanillaTabBar(child: Row(children: tabs)), +/// ) +/// ``` +/// +/// Not inside it. Flutter's tab-bar semantics role requires every direct +/// semantics child of the bar to be a tab, and a scroll view inserted between +/// them adds a node of its own, which trips that assertion at runtime. +/// +/// `RemixTabs` — the behavioral root that owns selection, roving focus, and +/// arrow-key traversal — carries no styler and therefore no recipe. Compose it +/// directly around this bar: +/// +/// ```dart +/// RemixTabs( +/// selectedTabId: tab, +/// onChanged: (id) => setState(() => tab = id), +/// child: Column( +/// children: [ +/// VanillaTabBar( +/// child: Row(children: [ +/// VanillaTab(tabId: 'account', label: 'Account'), +/// VanillaTab(tabId: 'billing', label: 'Billing'), +/// ]), +/// ), +/// VanillaTabView(tabId: 'account', child: accountPanel), +/// VanillaTabView(tabId: 'billing', child: billingPanel), +/// ], +/// ), +/// ) +/// ``` +class VanillaTabBar extends StatelessWidget { + const VanillaTabBar({ + super.key, + this.style = const TabBarStyler.create(), + required this.child, + }); + + final TabBarStyler style; + + final Widget child; + + @override + Widget build(BuildContext context) { + return RemixTabBar( + key: this.key, + style: vanillaTabBarStyle(style: this.style), + child: this.child, + ); + } +} + +/// The application's Tab recipe. +/// +/// Everything visual about one tab lives in this function: geometry, +/// typography, and the hover/selected/focus/disabled fragments. Remix keeps +/// ownership of rendering, selection, keyboard traversal, and the tab +/// accessibility semantics — this recipe never reimplements any of that. +/// +/// The selected tab is marked by its trailing edge. That edge is present in +/// every state and merely transparent when unselected, so selecting a tab +/// paints two pixels instead of reflowing the whole strip. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. State fragments merge by state, not +/// by depth: an override that must beat the recipe's selected underline has to +/// be declared as a selected fragment too (`TabStyler().onSelected(...)`). +/// +/// `builder` is deliberately not forwarded to the generated +/// `VanillaTab`. Its type is `ValueWidgetBuilder`, and +/// `NakedTabState` comes from `package:naked_ui`, which this layer does not +/// depend on. Pass a `child` for custom content, or reach for `RemixTab` +/// directly on the rare call site that needs the raw state. +class VanillaTab extends StatelessWidget { + const VanillaTab({ + super.key, + this.style = const TabStyler.create(), + required this.tabId, + this.child, + this.label, + this.icon, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + }); + + final TabStyler style; + + final String tabId; + + final Widget? child; + + final String? label; + + final IconData? icon; + + final bool enabled; + + final MouseCursor mouseCursor; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final ValueChanged? onFocusChange; + + final ValueChanged? onHoverChange; + + final ValueChanged? onPressChange; + + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + return RemixTab( + key: this.key, + style: vanillaTabStyle(style: this.style), + tabId: this.tabId, + child: this.child, + label: this.label, + icon: this.icon, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + onFocusChange: this.onFocusChange, + onHoverChange: this.onHoverChange, + onPressChange: this.onPressChange, + semanticLabel: this.semanticLabel, + ); + } +} + +/// The application's recipe for the panel a tab reveals. +/// +/// It exists so the panel carries the application's prefix and has one place +/// to edit, and it earns that by owning the gap between the strip and the +/// content: without it the panel's first line sits directly on the hairline. +class VanillaTabView extends StatelessWidget { + const VanillaTabView({ + super.key, + this.style = const TabViewStyler.create(), + required this.tabId, + required this.child, + this.maintainState = true, + }); + + final TabViewStyler style; + + final String tabId; + + final Widget child; + + final bool maintainState; + + @override + Widget build(BuildContext context) { + return RemixTabView( + key: this.key, + style: vanillaTabViewStyle(style: this.style), + tabId: this.tabId, + child: this.child, + maintainState: this.maintainState, + ); + } +} diff --git a/packages/remix_agent/example/lib/ui/components/textfield.dart b/registry_source/lib/src/default/components/textfield.dart similarity index 89% rename from packages/remix_agent/example/lib/ui/components/textfield.dart rename to registry_source/lib/src/default/components/textfield.dart index 2cff80dec..5c86b31ff 100644 --- a/packages/remix_agent/example/lib/ui/components/textfield.dart +++ b/registry_source/lib/src/default/components/textfield.dart @@ -35,7 +35,7 @@ part 'textfield.g.dart'; /// by depth: an override that must beat the recipe's error outline has to be /// declared as an error fragment too. @MixWidget(target: RemixTextField.new) -TextFieldStyler uiTextFieldStyle({ +TextFieldStyler vanillaTextFieldStyle({ TextFieldStyler style = const TextFieldStyler.create(), }) { return _base() @@ -55,7 +55,7 @@ TextFieldStyler uiTextFieldStyle({ /// /// [style] is merged **last**, exactly as it is for the single-line field. @MixWidget(target: RemixTextArea.new) -TextFieldStyler uiTextAreaStyle({ +TextFieldStyler vanillaTextAreaStyle({ TextFieldStyler style = const TextFieldStyler.create(), }) { return _base() @@ -120,22 +120,22 @@ const _textSize = 14.0; /// Both recipes share this whole body; only the box's height and the /// accessory alignment differ between them. TextFieldStyler _base() => TextFieldStyler() - .color(UiTokens.background()) - .border(.color(UiTokens.border()).width(_borderWidth)) - .borderRadius(.all(UiTokens.radius())) + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .borderRadius(.all(VanillaTokens.radius())) .padding(.horizontal(_paddingX)) .spacing(_accessoryGap) - .text(.fontSize(_textSize).color(UiTokens.foreground())) + .text(.fontSize(_textSize).color(VanillaTokens.foreground())) // The placeholder is not the value: it has to read as the quieter of the // two, or an empty field looks filled in. - .hintText(.fontSize(_textSize).color(UiTokens.mutedForeground())) - .cursorColor(UiTokens.foreground()) + .hintText(.fontSize(_textSize).color(VanillaTokens.mutedForeground())) + .cursorColor(VanillaTokens.foreground()) .label( .fontSize( _labelSize, - ).fontWeight(FontWeight.w500).color(UiTokens.foreground()), + ).fontWeight(FontWeight.w500).color(VanillaTokens.foreground()), ) - .helperText(.fontSize(_labelSize).color(UiTokens.mutedForeground())) + .helperText(.fontSize(_labelSize).color(VanillaTokens.mutedForeground())) .layout(.direction(.vertical).spacing(_stackGap)) .onFocusVisible(_focusVisibleStyle()) .merge(_errorStyle()) @@ -149,7 +149,7 @@ TextFieldStyler _base() => TextFieldStyler() TextFieldStyler _focusVisibleStyle() => TextFieldStyler().containerEffects( .outline( .color( - UiTokens.focusRing(), + VanillaTokens.focusRing(), ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), ).outlineOffset(_focusRingOffset), ); @@ -168,12 +168,14 @@ TextFieldStyler _focusVisibleStyle() => TextFieldStyler().containerEffects( /// assistive technology either way. /// /// A theme with a dedicated danger *text* step would put it on the helper -/// line here; this vocabulary has fifteen tokens and no such step. +/// line here; this vocabulary has twenty tokens and no such step. TextFieldStyler _errorStyle() => TextFieldStyler().variant( ContextVariant.widgetState(.error), TextFieldStyler() - .border(.color(UiTokens.destructive()).width(_borderWidth)) - .helperText(.color(UiTokens.foreground()).fontWeight(FontWeight.w500)), + .border(.color(VanillaTokens.destructive()).width(_borderWidth)) + .helperText( + .color(VanillaTokens.foreground()).fontWeight(FontWeight.w500), + ), ); /// Declared last so it wins over every other state fragment. diff --git a/packages/remix_agent/example/lib/ui/components/textfield.g.dart b/registry_source/lib/src/default/components/textfield.g.dart similarity index 98% rename from packages/remix_agent/example/lib/ui/components/textfield.g.dart rename to registry_source/lib/src/default/components/textfield.g.dart index 4c97a689e..f9cdbd286 100644 --- a/packages/remix_agent/example/lib/ui/components/textfield.g.dart +++ b/registry_source/lib/src/default/components/textfield.g.dart @@ -29,8 +29,8 @@ part of 'textfield.dart'; /// the resolved recipe without forking it. State fragments merge by state, not /// by depth: an override that must beat the recipe's error outline has to be /// declared as an error fragment too. -class UiTextField extends StatelessWidget { - const UiTextField({ +class VanillaTextField extends StatelessWidget { + const VanillaTextField({ super.key, this.style = const TextFieldStyler.create(), this.controller, @@ -212,7 +212,7 @@ class UiTextField extends StatelessWidget { Widget build(BuildContext context) { return RemixTextField( key: this.key, - style: uiTextFieldStyle(style: this.style), + style: vanillaTextFieldStyle(style: this.style), controller: this.controller, focusNode: this.focusNode, label: this.label, @@ -282,8 +282,8 @@ class UiTextField extends StatelessWidget { /// in the middle of a growing one. /// /// [style] is merged **last**, exactly as it is for the single-line field. -class UiTextArea extends StatelessWidget { - const UiTextArea({ +class VanillaTextArea extends StatelessWidget { + const VanillaTextArea({ super.key, this.style = const TextFieldStyler.create(), this.controller, @@ -456,7 +456,7 @@ class UiTextArea extends StatelessWidget { Widget build(BuildContext context) { return RemixTextArea( key: this.key, - style: uiTextAreaStyle(style: this.style), + style: vanillaTextAreaStyle(style: this.style), controller: this.controller, focusNode: this.focusNode, label: this.label, diff --git a/registry_source/lib/src/default/components/toast.dart b/registry_source/lib/src/default/components/toast.dart new file mode 100644 index 000000000..1a2b82d0e --- /dev/null +++ b/registry_source/lib/src/default/components/toast.dart @@ -0,0 +1,121 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; +import 'button.dart'; +import 'icon_button.dart'; + +part 'toast.g.dart'; + +/// The tones this application offers for a toast. +enum VanillaToastVariant { + /// An ordinary confirmation or notice. + neutral, + + /// A failure the reader should notice. + /// + /// Visual only. Pair it with `RemixToastPriority.assertive` when the message + /// must interrupt a screen reader; a red outline alone announces nothing. + destructive, +} + +/// The application's Toast recipe. +/// +/// Remix owns the queue, the timers, focus, and the announcement through +/// `RemixToastScope`; this recipe owns the surface, the type, and the colors. +/// Hand it to the scope once, above the app's `Navigator` so every route, +/// including dialogs, can reach it: +/// +/// ```dart +/// MaterialApp( +/// builder: (context, child) => Overlay.wrap( +/// child: RemixToastScope(style: vanillaToastStyle(), child: child!), +/// ), +/// ) +/// ``` +/// +/// One toast can switch tone through `RemixToastData.style`, which merges over +/// the scope's style: +/// +/// ```dart +/// showRemixToast( +/// context, +/// RemixToastData( +/// title: 'Upload failed', +/// priority: RemixToastPriority.assertive, +/// style: vanillaToastStyle(variant: .destructive), +/// ), +/// ); +/// ``` +/// +/// The action and the close button reuse this application's Button and +/// IconButton recipes, so they keep their own hover, focus, and press states. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +@MixWidget(target: RemixToast.new) +ToastStyler vanillaToastStyle({ + VanillaToastVariant variant = .neutral, + ToastStyler style = const ToastStyler.create(), +}) => _base().merge(_variantStyle(variant)).merge(style); + +/// Inset between the toast edge and its content. +const _padding = 16.0; + +/// Gap between the icon, the message, and the controls. +const _gap = 12.0; + +/// Gap between the title and the description. +const _textGap = 4.0; + +/// Widest a toast grows. Narrow screens shrink it further. +const _maxWidth = 360.0; + +const _titleSize = 14.0; + +const _descriptionSize = 13.0; + +const _iconSize = 16.0; + +const _borderWidth = 1.0; + +/// A toast floats over content that keeps scrolling beneath it, so it gets +/// the same lift as a dialog. +final _shadow = BoxShadowMix( + color: const Color(0x26000000), + offset: const Offset(0, 8), + blurRadius: 24, +); + +/// Surface, layout, and typography shared by both tones. +ToastStyler _base() => ToastStyler() + .color(VanillaTokens.background()) + .border(.color(VanillaTokens.border()).width(_borderWidth)) + .borderRadius(.all(VanillaTokens.radius())) + .padding(.all(_padding)) + .maxWidth(_maxWidth) + .shadow(_shadow) + .spacing(_gap) + .content(FlexBoxStyler().spacing(_textGap)) + .title( + .fontSize( + _titleSize, + ).fontWeight(FontWeight.w600).color(VanillaTokens.foreground()), + ) + .description( + .fontSize(_descriptionSize).color(VanillaTokens.mutedForeground()), + ) + .icon(.size(_iconSize)) + .action(vanillaButtonStyle(variant: .outline, size: .small)) + .closeButton(vanillaIconButtonStyle(variant: .ghost, size: .small)); + +/// The tone shows in the glyph and, for `destructive`, the outline. The +/// sentence stays in `foreground` for contrast. +ToastStyler _variantStyle(VanillaToastVariant variant) => switch (variant) { + .neutral => ToastStyler().icon(.color(VanillaTokens.mutedForeground())), + .destructive => + ToastStyler() + .border(.color(VanillaTokens.destructive())) + .icon(.color(VanillaTokens.destructive())), +}; diff --git a/registry_source/lib/src/default/components/toast.g.dart b/registry_source/lib/src/default/components/toast.g.dart new file mode 100644 index 000000000..7de0eff9c --- /dev/null +++ b/registry_source/lib/src/default/components/toast.g.dart @@ -0,0 +1,118 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toast.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Toast recipe. +/// +/// Remix owns the queue, the timers, focus, and the announcement through +/// `RemixToastScope`; this recipe owns the surface, the type, and the colors. +/// Hand it to the scope once, above the app's `Navigator` so every route, +/// including dialogs, can reach it: +/// +/// ```dart +/// MaterialApp( +/// builder: (context, child) => Overlay.wrap( +/// child: RemixToastScope(style: vanillaToastStyle(), child: child!), +/// ), +/// ) +/// ``` +/// +/// One toast can switch tone through `RemixToastData.style`, which merges over +/// the scope's style: +/// +/// ```dart +/// showRemixToast( +/// context, +/// RemixToastData( +/// title: 'Upload failed', +/// priority: RemixToastPriority.assertive, +/// style: vanillaToastStyle(variant: .destructive), +/// ), +/// ); +/// ``` +/// +/// The action and the close button reuse this application's Button and +/// IconButton recipes, so they keep their own hover, focus, and press states. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +class VanillaToast extends StatelessWidget { + const VanillaToast({ + super.key, + this.variant = .neutral, + this.style = const ToastStyler.create(), + required this.title, + this.description, + this.icon, + this.action, + this.onDismiss, + this.dismissLabel, + this.excludeMessageSemantics = false, + }); + + /// An ordinary confirmation or notice. + const VanillaToast.neutral({ + super.key, + this.style = const ToastStyler.create(), + required this.title, + this.description, + this.icon, + this.action, + this.onDismiss, + this.dismissLabel, + this.excludeMessageSemantics = false, + }) : variant = VanillaToastVariant.neutral; + + /// A failure the reader should notice. + /// + /// Visual only. Pair it with `RemixToastPriority.assertive` when the message + /// must interrupt a screen reader; a red outline alone announces nothing. + const VanillaToast.destructive({ + super.key, + this.style = const ToastStyler.create(), + required this.title, + this.description, + this.icon, + this.action, + this.onDismiss, + this.dismissLabel, + this.excludeMessageSemantics = false, + }) : variant = VanillaToastVariant.destructive; + + final VanillaToastVariant variant; + + final ToastStyler style; + + final String title; + + final String? description; + + final IconData? icon; + + final RemixToastAction? action; + + final VoidCallback? onDismiss; + + final String? dismissLabel; + + final bool excludeMessageSemantics; + + @override + Widget build(BuildContext context) { + return RemixToast( + key: this.key, + style: vanillaToastStyle(variant: this.variant, style: this.style), + title: this.title, + description: this.description, + icon: this.icon, + action: this.action, + onDismiss: this.onDismiss, + dismissLabel: this.dismissLabel, + excludeMessageSemantics: this.excludeMessageSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/toggle.dart b/registry_source/lib/src/default/components/toggle.dart new file mode 100644 index 000000000..46421fd67 --- /dev/null +++ b/registry_source/lib/src/default/components/toggle.dart @@ -0,0 +1,184 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'toggle.g.dart'; + +/// The visual weights this application offers for a toggle. +enum VanillaToggleVariant { + /// No fill and no border until the toggle is hovered or on. + ghost, + + /// A hairline `border`, so the control is visible while off. + outline, +} + +/// The control densities this application offers for a toggle. +/// +/// The same 32/36/40px heights the button uses, because a toggle usually sits +/// in a row beside one. +/// +/// These are compact, web-oriented defaults. A touch-first application should +/// raise them to meet platform hit-target guidance. +enum VanillaToggleSize { + /// 32px minimum height. + small, + + /// 36px minimum height. The default. + medium, + + /// 40px minimum height. + large, +} + +/// The application's Toggle recipe. +/// +/// A toggle is a button that stays pressed. Remix owns the rendering, the +/// pointer and keyboard behavior, and the on/off semantics; this recipe owns +/// the geometry and the off/hover/on/focus/disabled fragments. +/// +/// The on state is `accent`, the token whose whole job is "this transparent +/// control is doing something", while hover is the quieter `muted`. Keeping +/// them different is what lets a reader tell a toggle they are pointing at +/// from one that is switched on. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. Because [variant] is a non-nullable +/// enum, the generator also emits one named constructor per enum value. +/// +/// State fragments merge by state, not by depth: an override that must beat +/// the recipe's on fill has to be declared as a selected fragment too +/// (`ToggleStyler().onSelected(...)`). +@MixWidget(target: RemixToggle.new) +ToggleStyler vanillaToggleStyle({ + VanillaToggleVariant variant = .ghost, + VanillaToggleSize size = .medium, + ToggleStyler style = const ToggleStyler.create(), +}) { + return _base(_metricsFor(size)) + .merge(_variantStyle(variant)) + .onHovered(.color(VanillaTokens.muted())) + .onSelected( + _content(VanillaTokens.accentForeground()) + .color(VanillaTokens.accent()) + // The outline, not the fill, is what says "on". `muted` and + // `accent` are 1.155:1 apart in the light theme, so hover and on + // would otherwise be the same shade to most readers — and a state + // told apart by colour alone is one a lot of people cannot read. + .border(.all(_edge(VanillaTokens.primary()))), + ) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()) + .merge(style); +} + +/// Width of the outline every toggle draws, in every state. +const _borderWidth = 1.0; + +/// Width of the keyboard focus ring. +const _focusRingWidth = 2.0; + +/// Opacity applied to the whole control while disabled. +const _disabledOpacity = 0.5; + +/// A fill that paints nothing, used while the toggle is off. +const _noFill = Color(0x00000000); + +/// Geometry and type scale for one [VanillaToggleSize]. +typedef _VanillaToggleMetrics = ({ + double minHeight, + double paddingX, + double gap, + double labelSize, + double iconSize, +}); + +_VanillaToggleMetrics _metricsFor(VanillaToggleSize size) => switch (size) { + .small => ( + minHeight: 32.0, + paddingX: 10.0, + gap: 6.0, + labelSize: 14.0, + iconSize: 16.0, + ), + .medium => ( + minHeight: 36.0, + paddingX: 12.0, + gap: 8.0, + labelSize: 14.0, + iconSize: 16.0, + ), + .large => ( + minHeight: 40.0, + paddingX: 16.0, + gap: 8.0, + labelSize: 16.0, + iconSize: 18.0, + ), +}; + +/// Layout, typography, and the off appearance shared by both variants. +ToggleStyler _base(_VanillaToggleMetrics metrics) => + _content(VanillaTokens.foreground()) + .color(_noFill) + .direction(.horizontal) + .mainAxisSize(.min) + .mainAxisAlignment(.center) + .crossAxisAlignment(.center) + .minHeight(metrics.minHeight) + .padding(.horizontal(metrics.paddingX)) + .spacing(metrics.gap) + .borderRadius(.all(VanillaTokens.radius())) + .label(.fontSize(metrics.labelSize).fontWeight(FontWeight.w500)) + .icon(.size(metrics.iconSize)); + +/// The outline is present in every state and every variant, and only its +/// colour changes: Flutter insets a container's content by its border widths, +/// so an outline that appeared on selection would nudge the label sideways. +/// `ghost` simply paints its copy in nothing. +ToggleStyler _variantStyle(VanillaToggleVariant variant) => + ToggleStyler().border( + .all( + _edge(switch (variant) { + .ghost => _noFill, + .outline => VanillaTokens.border(), + }), + ), + ); + +/// One outline side, at the width every state shares. +BorderSideMix _edge(Color color) => + BorderSideMix(color: color, width: _borderWidth); + +/// Applies one content color to the label and the icons. +ToggleStyler _content(Color foreground) => + ToggleStyler().label(.color(foreground)).icon(.color(foreground)); + +/// The keyboard focus ring. +/// +/// A *foreground* decoration rather than the box border: `ToggleSpec` has no +/// `containerEffects` layer to paint an outline into, and Flutter insets a +/// container's content by its border widths — so adding a real border on +/// focus would nudge the label. A foreground decoration paints over the +/// control and takes no layout space, which is what a ring needs. +ToggleStyler _focusVisibleStyle() => ToggleStyler().foregroundDecoration( + BoxDecorationMix( + border: .all( + .color( + VanillaTokens.focusRing(), + ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), + ), + borderRadius: .all(VanillaTokens.radius()), + ), +); + +/// Declared last so it wins over every other state fragment. +/// +/// A disabled toggle keeps whatever surface its state gives it and simply +/// fades; the focus ring is cleared because a disabled control that still +/// draws a focus ring reads as actionable. +ToggleStyler _disabledStyle() => ToggleStyler() + .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/toggle.g.dart b/registry_source/lib/src/default/components/toggle.g.dart new file mode 100644 index 000000000..ffeb7dfbc --- /dev/null +++ b/registry_source/lib/src/default/components/toggle.g.dart @@ -0,0 +1,132 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toggle.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Toggle recipe. +/// +/// A toggle is a button that stays pressed. Remix owns the rendering, the +/// pointer and keyboard behavior, and the on/off semantics; this recipe owns +/// the geometry and the off/hover/on/focus/disabled fragments. +/// +/// The on state is `accent`, the token whose whole job is "this transparent +/// control is doing something", while hover is the quieter `muted`. Keeping +/// them different is what lets a reader tell a toggle they are pointing at +/// from one that is switched on. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. Because [variant] is a non-nullable +/// enum, the generator also emits one named constructor per enum value. +/// +/// State fragments merge by state, not by depth: an override that must beat +/// the recipe's on fill has to be declared as a selected fragment too +/// (`ToggleStyler().onSelected(...)`). +class VanillaToggle extends StatelessWidget { + const VanillaToggle({ + super.key, + this.variant = .ghost, + this.size = .medium, + this.style = const ToggleStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.label, + this.icon, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + /// No fill and no border until the toggle is hovered or on. + const VanillaToggle.ghost({ + super.key, + this.size = .medium, + this.style = const ToggleStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.label, + this.icon, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = VanillaToggleVariant.ghost; + + /// A hairline `border`, so the control is visible while off. + const VanillaToggle.outline({ + super.key, + this.size = .medium, + this.style = const ToggleStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.label, + this.icon, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = VanillaToggleVariant.outline; + + final VanillaToggleVariant variant; + + final VanillaToggleSize size; + + final ToggleStyler style; + + final bool selected; + + final ValueChanged? onChanged; + + final bool enabled; + + final String? label; + + final IconData? icon; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final String? semanticLabel; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixToggle( + key: this.key, + style: vanillaToggleStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + selected: this.selected, + onChanged: this.onChanged, + enabled: this.enabled, + label: this.label, + icon: this.icon, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/registry_source/lib/src/default/components/toggle_group.dart b/registry_source/lib/src/default/components/toggle_group.dart new file mode 100644 index 000000000..86b8ef59e --- /dev/null +++ b/registry_source/lib/src/default/components/toggle_group.dart @@ -0,0 +1,188 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'toggle_group.g.dart'; + +/// The visual weights this application offers for a toggle group. +/// +/// The same two the single toggle offers, because a group is a row of them. +enum VanillaToggleGroupVariant { + /// No fill and no border until an option is hovered or on. + ghost, + + /// A hairline `border` around every option, so the set is visible while off. + outline, +} + +/// The control densities this application offers for a toggle group. +/// +/// These are compact, web-oriented defaults. A touch-first application should +/// raise them to meet platform hit-target guidance. +enum VanillaToggleGroupSize { + /// 32px minimum height. + small, + + /// 36px minimum height. The default. + medium, + + /// 40px minimum height. + large, +} + +/// The application's ToggleGroup recipe. +/// +/// A toggle group is a set of toggles that share one selection. Remix owns the +/// rendering, the roving focus and arrow-key traversal, the single- or +/// multi-select rules, and the group accessibility semantics; this recipe +/// owns the strip's layout and every option's appearance. +/// +/// One recipe covers both, because `ToggleGroupSpec` carries the option's +/// style as a field: the group's `item` is the default every +/// `RemixToggleGroupItem` resolves against. That is what makes an option in a +/// loop impossible to leave unstyled, and it is why this file has one +/// `@MixWidget` rather than two. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. Because [variant] is a non-nullable +/// enum, the generator also emits one named constructor per enum value. +@MixWidget(target: RemixToggleGroup.new) +ToggleGroupStyler vanillaToggleGroupStyle({ + VanillaToggleGroupVariant variant = .ghost, + VanillaToggleGroupSize size = .medium, + ToggleGroupStyler style = const ToggleGroupStyler.create(), +}) => ToggleGroupStyler() + .direction(.horizontal) + .mainAxisSize(.min) + .spacing(_gap) + .item(_itemStyle(_metricsFor(size), variant)) + .merge(style); + +/// Gap between adjacent options. +/// +/// Present rather than zero: the options are separate controls that happen to +/// sit together, not segments of one control. A segmented control is the +/// component for the latter. +const _gap = 4.0; + +/// Width of the outline every option draws, in every state. +const _borderWidth = 1.0; + +/// Width of the keyboard focus ring. +const _focusRingWidth = 2.0; + +/// Opacity applied to an option while disabled. +const _disabledOpacity = 0.5; + +/// A colour that paints nothing, used for the `ghost` outline and for the +/// resting fill. +const _noFill = Color(0x00000000); + +/// Geometry and type scale for one [VanillaToggleGroupSize]. +typedef _VanillaToggleGroupMetrics = ({ + double minHeight, + double paddingX, + double gap, + double labelSize, + double iconSize, +}); + +_VanillaToggleGroupMetrics _metricsFor(VanillaToggleGroupSize size) => + switch (size) { + .small => ( + minHeight: 32.0, + paddingX: 10.0, + gap: 6.0, + labelSize: 14.0, + iconSize: 16.0, + ), + .medium => ( + minHeight: 36.0, + paddingX: 12.0, + gap: 8.0, + labelSize: 14.0, + iconSize: 16.0, + ), + .large => ( + minHeight: 40.0, + paddingX: 16.0, + gap: 8.0, + labelSize: 16.0, + iconSize: 18.0, + ), + }; + +/// One option: the same off/hover/on/focus/disabled story a lone toggle tells. +ToggleGroupItemStyler _itemStyle( + _VanillaToggleGroupMetrics metrics, + VanillaToggleGroupVariant variant, +) { + return _content(VanillaTokens.foreground()) + .color(_noFill) + .direction(.horizontal) + .mainAxisSize(.min) + .mainAxisAlignment(.center) + .crossAxisAlignment(.center) + .minHeight(metrics.minHeight) + .padding(.horizontal(metrics.paddingX)) + .spacing(metrics.gap) + .borderRadius(.all(VanillaTokens.radius())) + .label(.fontSize(metrics.labelSize).fontWeight(FontWeight.w500)) + .icon(.size(metrics.iconSize)) + // The outline is present in every state and every variant, and only its + // colour changes: Flutter insets a container's content by its border + // widths, so an outline that appeared on selection would nudge the label + // sideways. `ghost` simply paints its copy in nothing. + .border(.all(_edge(_variantEdge(variant)))) + .onHovered(.color(VanillaTokens.muted())) + .onSelected( + _content(VanillaTokens.accentForeground()) + .color(VanillaTokens.accent()) + // The outline, not the fill, is what says "on". `muted` and + // `accent` are 1.155:1 apart in the light theme, so hover and on + // would otherwise be the same shade to most readers — and a state + // told apart by colour alone is one a lot of people cannot read. + .border(.all(_edge(VanillaTokens.primary()))), + ) + .onFocusVisible(_focusVisibleStyle()) + .onDisabled(_disabledStyle()); +} + +/// The resting outline colour for one variant. +Color _variantEdge(VanillaToggleGroupVariant variant) => switch (variant) { + .ghost => _noFill, + .outline => VanillaTokens.border(), +}; + +/// One outline side, at the width every state shares. +BorderSideMix _edge(Color color) => + BorderSideMix(color: color, width: _borderWidth); + +/// Applies one content color to the label and the icons. +ToggleGroupItemStyler _content(Color foreground) => + ToggleGroupItemStyler().label(.color(foreground)).icon(.color(foreground)); + +/// The keyboard focus ring. +/// +/// A *foreground* decoration rather than the box border: `ToggleGroupItemSpec` +/// has no `containerEffects` layer to paint an outline into, and Flutter +/// insets a container's content by its border widths — so adding a real border +/// on focus would nudge the label. +ToggleGroupItemStyler _focusVisibleStyle() => + ToggleGroupItemStyler().foregroundDecoration( + BoxDecorationMix( + border: .all( + .color( + VanillaTokens.focusRing(), + ).width(_focusRingWidth).strokeAlign(BorderSide.strokeAlignInside), + ), + borderRadius: .all(VanillaTokens.radius()), + ), + ); + +/// Declared last so it wins over every other state fragment. +ToggleGroupItemStyler _disabledStyle() => ToggleGroupItemStyler() + .foregroundDecoration(BoxDecorationMix.border(.style(.none))) + .wrap(.opacity(_disabledOpacity)); diff --git a/registry_source/lib/src/default/components/toggle_group.g.dart b/registry_source/lib/src/default/components/toggle_group.g.dart new file mode 100644 index 000000000..bc86a9160 --- /dev/null +++ b/registry_source/lib/src/default/components/toggle_group.g.dart @@ -0,0 +1,112 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toggle_group.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's ToggleGroup recipe. +/// +/// A toggle group is a set of toggles that share one selection. Remix owns the +/// rendering, the roving focus and arrow-key traversal, the single- or +/// multi-select rules, and the group accessibility semantics; this recipe +/// owns the strip's layout and every option's appearance. +/// +/// One recipe covers both, because `ToggleGroupSpec` carries the option's +/// style as a field: the group's `item` is the default every +/// `RemixToggleGroupItem` resolves against. That is what makes an option in a +/// loop impossible to leave unstyled, and it is why this file has one +/// `@MixWidget` rather than two. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. Because [variant] is a non-nullable +/// enum, the generator also emits one named constructor per enum value. +class VanillaToggleGroup extends StatelessWidget { + const VanillaToggleGroup({ + super.key, + this.variant = .ghost, + this.size = .medium, + this.style = const ToggleGroupStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }); + + /// No fill and no border until an option is hovered or on. + const VanillaToggleGroup.ghost({ + super.key, + this.size = .medium, + this.style = const ToggleGroupStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = VanillaToggleGroupVariant.ghost; + + /// A hairline `border` around every option, so the set is visible while off. + const VanillaToggleGroup.outline({ + super.key, + this.size = .medium, + this.style = const ToggleGroupStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = VanillaToggleGroupVariant.outline; + + final VanillaToggleGroupVariant variant; + + final VanillaToggleGroupSize size; + + final ToggleGroupStyler style; + + final List> items; + + final T? selectedValue; + + final ValueChanged? onChanged; + + final bool enabled; + + final Axis orientation; + + final bool loop; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixToggleGroup( + key: this.key, + style: vanillaToggleGroupStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + items: this.items, + selectedValue: this.selectedValue, + onChanged: this.onChanged, + enabled: this.enabled, + orientation: this.orientation, + loop: this.loop, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/default/components/tooltip.dart b/registry_source/lib/src/default/components/tooltip.dart new file mode 100644 index 000000000..ec8ec2470 --- /dev/null +++ b/registry_source/lib/src/default/components/tooltip.dart @@ -0,0 +1,69 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/tokens.dart'; + +part 'tooltip.g.dart'; + +/// The application's Tooltip recipe. +/// +/// Remix owns the rendering, the overlay, the anchor positioning, and the +/// hover and focus timing; this recipe supplies the bubble and the three +/// durations that decide when it appears and how long it stays. +/// +/// It is the one floating surface here that does *not* use `background`. A +/// tooltip is a transient label, not a panel a reader can act in, and +/// inverting it — `foreground` fill, `background` text — is what makes that +/// difference legible at a glance without a second token. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +@MixWidget(target: RemixTooltip.new) +TooltipStyler vanillaTooltipStyle({ + TooltipStyler style = const TooltipStyler.create(), +}) => TooltipStyler() + .color(VanillaTokens.foreground()) + .borderRadius(.all(VanillaTokens.radius())) + .padding(.symmetric(horizontal: _paddingX, vertical: _paddingY)) + .label(.fontSize(_labelSize).color(VanillaTokens.background())) + .waitDuration(_waitDuration) + .showDuration(_showDuration) + .dismissDuration(_dismissDuration) + .merge(style); + +/// Horizontal inset between the bubble edge and its label. +/// +/// Twelve, matching shadcn's `px-3`. A bubble narrower than that crowds a +/// short label against its own corner radius. +const _paddingX = 12.0; + +/// Vertical inset between the bubble edge and its label. +/// +/// Six, matching the vertical inset a menu and select row already use, and +/// the only value here that had been off the layer's four-pixel grid. +const _paddingY = 6.0; + +/// Label size, one step below body text: a tooltip annotates, it does not +/// narrate. +const _labelSize = 12.0; + +/// How long the pointer must rest before the tooltip appears. +/// +/// Long enough that crossing a toolbar does not strobe a row of bubbles. +/// Remix passes this straight through as Naked UI's `hoverDelay`. +const _waitDuration = Duration(milliseconds: 500); + +/// How long a *touch*-triggered tooltip stays up after the press ends. +/// +/// Remix maps this onto Naked UI's `touchDelay`, so despite the name it has +/// nothing to say about hovering: a finger has no hover state, and this is +/// the whole time a touch user gets to read the bubble. +const _showDuration = Duration(milliseconds: 1500); + +/// The grace period between the pointer leaving and the bubble closing. +/// +/// Remix maps this onto Naked UI's `dismissDelay`. Short, but not zero: a +/// pointer that clips the anchor's edge on its way somewhere else should not +/// snap the bubble shut mid-sentence. +const _dismissDuration = Duration(milliseconds: 100); diff --git a/registry_source/lib/src/default/components/tooltip.g.dart b/registry_source/lib/src/default/components/tooltip.g.dart new file mode 100644 index 000000000..7be4453e8 --- /dev/null +++ b/registry_source/lib/src/default/components/tooltip.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tooltip.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// The application's Tooltip recipe. +/// +/// Remix owns the rendering, the overlay, the anchor positioning, and the +/// hover and focus timing; this recipe supplies the bubble and the three +/// durations that decide when it appears and how long it stays. +/// +/// It is the one floating surface here that does *not* use `background`. A +/// tooltip is a transient label, not a panel a reader can act in, and +/// inverting it — `foreground` fill, `background` text — is what makes that +/// difference legible at a glance without a second token. +/// +/// [style] is merged **last**, so a single call site can override any part of +/// the resolved recipe without forking it. +class VanillaTooltip extends StatelessWidget { + const VanillaTooltip({ + super.key, + this.style = const TooltipStyler.create(), + required this.tooltipChild, + required this.child, + this.open, + this.onOpenChanged, + this.tooltipSemantics, + this.positioning = const OverlayPositionConfig(), + }); + + final TooltipStyler style; + + final Widget tooltipChild; + + final Widget child; + + final bool? open; + + final ValueChanged? onOpenChanged; + + final String? tooltipSemantics; + + final OverlayPositionConfig positioning; + + @override + Widget build(BuildContext context) { + return RemixTooltip( + key: this.key, + style: vanillaTooltipStyle(style: this.style), + tooltipChild: this.tooltipChild, + child: this.child, + open: this.open, + onOpenChanged: this.onOpenChanged, + tooltipSemantics: this.tooltipSemantics, + positioning: this.positioning, + ); + } +} diff --git a/registry_source/lib/src/default/icons.dart b/registry_source/lib/src/default/icons.dart new file mode 100644 index 000000000..c50edaf85 --- /dev/null +++ b/registry_source/lib/src/default/icons.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix_ui_icons/remix_ui_icons.dart'; + +/// Application-owned aliases for the icons used by this UI layer. +/// +/// The complete 318-glyph catalog remains available through [RemixIcons]. +/// Add, rename, or remove aliases here as the application vocabulary evolves. +abstract final class VanillaIcons { + /// Confirms a successful or selected action. + static const IconData check = RemixIcons.check; + + /// Dismisses, clears, or marks a failed action. + static const IconData cross = RemixIcons.cross2; + + /// Opens content positioned below the current control. + static const IconData chevronDown = RemixIcons.chevronDown; +} diff --git a/registry_source/lib/src/default/recipes/activity_recipe.dart b/registry_source/lib/src/default/recipes/activity_recipe.dart new file mode 100644 index 000000000..cc154b5a9 --- /dev/null +++ b/registry_source/lib/src/default/recipes/activity_recipe.dart @@ -0,0 +1,47 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/activity.dart'; + +import '../components/disclosure.dart'; +import '../theme/tokens.dart'; + +@immutable +final class VanillaAgentActivityRecipe { + const VanillaAgentActivityRecipe({ + required this.style, + required this.disclosureStyle, + }); + final AgentActivityStyler style; + final DisclosureStyler disclosureStyle; +} + +VanillaAgentActivityRecipe vanillaAgentActivityRecipe({ + AgentActivityStyler style = const AgentActivityStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => VanillaAgentActivityRecipe( + style: AgentActivityStyler( + viewport: BoxStyler().maxHeight(200), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color(VanillaTokens.foreground()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color(VanillaTokens.foreground()).fontSize(14), + itemDetail: TextStyler() + .color(VanillaTokens.mutedForeground()) + .fontSize(12), + count: TextStyler() + .color(VanillaTokens.mutedForeground()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color(VanillaTokens.foreground()).size(16), + pendingStatus: IconStyler().color(VanillaTokens.mutedForeground()).size(12), + activeStatus: IconStyler().color(VanillaTokens.primary()).size(12), + completedStatus: IconStyler().color(VanillaTokens.primary()).size(12), + ).merge(style), + disclosureStyle: vanillaDisclosureStyle( + style: DisclosureStyler() + .content(BoxStyler().padding(.all(0))) + .merge(disclosureStyle), + ), +); diff --git a/registry_source/lib/src/default/recipes/answer_recipe.dart b/registry_source/lib/src/default/recipes/answer_recipe.dart new file mode 100644 index 000000000..d2c4bdf06 --- /dev/null +++ b/registry_source/lib/src/default/recipes/answer_recipe.dart @@ -0,0 +1,52 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/answer.dart'; + +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/icon_button.dart'; +import '../theme/tokens.dart'; + +@immutable +final class VanillaAgentAnswerRecipe { + const VanillaAgentAnswerRecipe({ + required this.style, + required this.surfaceStyle, + required this.sourcesStyle, + required this.copyStyle, + required this.retryStyle, + }); + final AgentAnswerStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +VanillaAgentAnswerRecipe vanillaAgentAnswerRecipe({ + AgentAnswerStyler style = const AgentAnswerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler sourcesStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => VanillaAgentAnswerRecipe( + style: AgentAnswerStyler( + body: BoxStyler(), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + feedback: BoxStyler().padding(.only(top: 6)), + sourcesLabel: TextStyler().color(VanillaTokens.foreground()).fontSize(13), + indicator: IconStyler().color(VanillaTokens.foreground()).size(16), + ).merge(style), + surfaceStyle: vanillaCardStyle(style: surfaceStyle), + sourcesStyle: vanillaDisclosureStyle(style: sourcesStyle), + copyStyle: vanillaIconButtonStyle( + variant: .ghost, + size: .small, + style: copyStyle, + ), + retryStyle: vanillaIconButtonStyle( + variant: .ghost, + size: .small, + style: retryStyle, + ), +); diff --git a/registry_source/lib/src/default/recipes/composer_recipe.dart b/registry_source/lib/src/default/recipes/composer_recipe.dart new file mode 100644 index 000000000..e646fa0b0 --- /dev/null +++ b/registry_source/lib/src/default/recipes/composer_recipe.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/composer.dart'; + +import '../components/card.dart'; +import '../components/icon_button.dart'; +import '../components/textfield.dart'; + +@immutable +final class VanillaAgentComposerRecipe { + const VanillaAgentComposerRecipe({ + required this.style, + required this.surfaceStyle, + required this.fieldStyle, + required this.submitStyle, + required this.stopStyle, + }); + final AgentComposerStyler style; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; +} + +VanillaAgentComposerRecipe vanillaAgentComposerRecipe({ + AgentComposerStyler style = const AgentComposerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + TextFieldStyler fieldStyle = const TextFieldStyler.create(), + IconButtonStyler submitStyle = const IconButtonStyler.create(), + IconButtonStyler stopStyle = const IconButtonStyler.create(), +}) => VanillaAgentComposerRecipe( + style: AgentComposerStyler( + toolbar: FlexBoxStyler() + .direction(.horizontal) + .mainAxisSize(.max) + .crossAxisAlignment(.center) + .spacing(8) + .padding(.only(top: 8)), + ).merge(style), + surfaceStyle: vanillaCardStyle( + style: CardStyler().padding(.all(12)).merge(surfaceStyle), + ), + fieldStyle: vanillaTextAreaStyle( + style: TextFieldStyler() + .color(const Color(0x00000000)) + .border(.style(.none)) + .minHeight(56) + .padding(.all(4)) + .merge(fieldStyle), + ), + submitStyle: vanillaIconButtonStyle( + size: .small, + style: IconButtonStyler().size(48, 48).merge(submitStyle), + ), + stopStyle: vanillaIconButtonStyle( + variant: .destructive, + size: .small, + style: IconButtonStyler().size(48, 48).merge(stopStyle), + ), +); diff --git a/registry_source/lib/src/default/recipes/execution_recipe.dart b/registry_source/lib/src/default/recipes/execution_recipe.dart new file mode 100644 index 000000000..442cc67ff --- /dev/null +++ b/registry_source/lib/src/default/recipes/execution_recipe.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/execution.dart'; + +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/icon_button.dart'; +import '../theme/tokens.dart'; + +@immutable +final class VanillaAgentExecutionRecipe { + const VanillaAgentExecutionRecipe({ + required this.style, + required this.surfaceStyle, + required this.disclosureStyle, + required this.copyStyle, + required this.retryStyle, + }); + final AgentExecutionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +VanillaAgentExecutionRecipe vanillaAgentExecutionRecipe({ + AgentExecutionStyler style = const AgentExecutionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => VanillaAgentExecutionRecipe( + style: AgentExecutionStyler( + header: FlexBoxStyler().spacing(8), + output: BoxStyler() + .color(VanillaTokens.muted()) + .borderRadius(.circular(6)) + .padding(.all(12)), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + tool: TextStyler().color(VanillaTokens.mutedForeground()).fontSize(12), + title: TextStyler() + .color(VanillaTokens.foreground()) + .fontWeight(FontWeight.w600), + meta: TextStyler().color(VanillaTokens.mutedForeground()).fontSize(12), + status: TextStyler().color(VanillaTokens.mutedForeground()).fontSize(12), + toolIcon: IconStyler().color(VanillaTokens.foreground()).size(16), + statusIcon: IconStyler().color(VanillaTokens.primary()).size(12), + indicator: IconStyler().color(VanillaTokens.foreground()).size(16), + ).merge(style), + surfaceStyle: vanillaCardStyle(style: surfaceStyle), + disclosureStyle: vanillaDisclosureStyle(style: disclosureStyle), + copyStyle: vanillaIconButtonStyle( + variant: .ghost, + size: .small, + style: copyStyle, + ), + retryStyle: vanillaIconButtonStyle( + variant: .ghost, + size: .small, + style: retryStyle, + ), +); diff --git a/registry_source/lib/src/default/recipes/message_recipe.dart b/registry_source/lib/src/default/recipes/message_recipe.dart new file mode 100644 index 000000000..9e95cdf82 --- /dev/null +++ b/registry_source/lib/src/default/recipes/message_recipe.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/message.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; + +@immutable +final class VanillaAgentMessageRecipe { + const VanillaAgentMessageRecipe({ + required this.style, + required this.surfaceStyle, + required this.collapsibleStyle, + required this.toggleStyle, + }); + final AgentMessageStyler style; + final CardStyler surfaceStyle; + final AgentMessageCollapsibleStyler collapsibleStyle; + final ButtonStyler toggleStyle; +} + +VanillaAgentMessageRecipe vanillaAgentMessageRecipe({ + AgentMessageStyler style = const AgentMessageStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + AgentMessageCollapsibleStyler collapsibleStyle = + const AgentMessageCollapsibleStyler.create(), + ButtonStyler toggleStyle = const ButtonStyler.create(), +}) => VanillaAgentMessageRecipe( + style: AgentMessageStyler( + row: FlexBoxStyler().mainAxisSize(.max).spacing(8), + avatar: BoxStyler().size(28, 28), + header: BoxStyler().padding(.only(bottom: 6)), + body: BoxStyler(), + footer: BoxStyler().padding(.only(top: 4)), + maxWidth: 640, + ).merge(style), + surfaceStyle: vanillaCardStyle(style: surfaceStyle), + collapsibleStyle: AgentMessageCollapsibleStyler( + collapsedHeight: 72, + container: BoxStyler(), + clipped: BoxStyler(), + ).merge(collapsibleStyle), + toggleStyle: vanillaButtonStyle( + variant: .ghost, + size: .small, + style: toggleStyle, + ), +); diff --git a/registry_source/lib/src/default/recipes/permission_recipe.dart b/registry_source/lib/src/default/recipes/permission_recipe.dart new file mode 100644 index 000000000..2fc9fa275 --- /dev/null +++ b/registry_source/lib/src/default/recipes/permission_recipe.dart @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/permission.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/data_list.dart'; +import '../components/disclosure.dart'; +import '../theme/tokens.dart'; + +@immutable +final class VanillaAgentPermissionRecipe { + const VanillaAgentPermissionRecipe({ + required this.style, + required this.surfaceStyle, + required this.detailsStyle, + required this.parametersStyle, + required this.allowOnceStyle, + required this.alwaysAllowStyle, + required this.denyStyle, + }); + final AgentPermissionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; +} + +VanillaAgentPermissionRecipe vanillaAgentPermissionRecipe({ + AgentPermissionStyler style = const AgentPermissionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler detailsStyle = const DisclosureStyler.create(), + DataListStyler parametersStyle = const DataListStyler.create(), + ButtonStyler allowOnceStyle = const ButtonStyler.create(), + ButtonStyler alwaysAllowStyle = const ButtonStyler.create(), + ButtonStyler denyStyle = const ButtonStyler.create(), +}) => VanillaAgentPermissionRecipe( + style: AgentPermissionStyler( + header: FlexBoxStyler().spacing(8), + actions: FlexBoxStyler().spacing(8).padding(.only(top: 8)), + title: TextStyler() + .color(VanillaTokens.foreground()) + .fontWeight(FontWeight.w600), + tool: TextStyler().color(VanillaTokens.mutedForeground()).fontSize(12), + description: TextStyler() + .color(VanillaTokens.mutedForeground()) + .wrap(.padding(.symmetric(vertical: 8))), + status: TextStyler().color(VanillaTokens.mutedForeground()).fontSize(12), + detailsLabel: TextStyler().color(VanillaTokens.foreground()).fontSize(13), + toolIcon: IconStyler().color(VanillaTokens.foreground()).size(16), + statusIcon: IconStyler().color(VanillaTokens.primary()).size(12), + indicator: IconStyler().color(VanillaTokens.foreground()).size(16), + ).merge(style), + surfaceStyle: vanillaCardStyle(style: surfaceStyle), + detailsStyle: vanillaDisclosureStyle(style: detailsStyle), + parametersStyle: vanillaDataListStyle(style: parametersStyle), + allowOnceStyle: vanillaButtonStyle(style: allowOnceStyle), + alwaysAllowStyle: vanillaButtonStyle( + variant: .outline, + style: alwaysAllowStyle, + ), + denyStyle: vanillaButtonStyle(variant: .ghost, style: denyStyle), +); diff --git a/registry_source/lib/src/default/recipes/plan_recipe.dart b/registry_source/lib/src/default/recipes/plan_recipe.dart new file mode 100644 index 000000000..f55ec6b8a --- /dev/null +++ b/registry_source/lib/src/default/recipes/plan_recipe.dart @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/plan.dart'; + +import '../components/disclosure.dart'; +import '../theme/tokens.dart'; + +@immutable +final class VanillaAgentPlanRecipe { + const VanillaAgentPlanRecipe({ + required this.style, + required this.disclosureStyle, + }); + final AgentPlanStyler style; + final DisclosureStyler disclosureStyle; +} + +VanillaAgentPlanRecipe vanillaAgentPlanRecipe({ + AgentPlanStyler style = const AgentPlanStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => VanillaAgentPlanRecipe( + style: AgentPlanStyler( + viewport: BoxStyler().maxHeight(220), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color(VanillaTokens.foreground()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color(VanillaTokens.foreground()).fontSize(14), + itemDetail: TextStyler() + .color(VanillaTokens.mutedForeground()) + .fontSize(12), + count: TextStyler() + .color(VanillaTokens.mutedForeground()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color(VanillaTokens.foreground()).size(16), + pendingStatus: IconStyler().color(VanillaTokens.mutedForeground()).size(18), + activeStatus: IconStyler().color(VanillaTokens.primary()).size(18), + completedStatus: IconStyler().color(VanillaTokens.primary()).size(18), + cancelledStatus: IconStyler() + .color(VanillaTokens.mutedForeground()) + .size(18), + ).merge(style), + disclosureStyle: vanillaDisclosureStyle(style: disclosureStyle), +); diff --git a/registry_source/lib/src/default/recipes/transcript_recipe.dart b/registry_source/lib/src/default/recipes/transcript_recipe.dart new file mode 100644 index 000000000..452eabb7d --- /dev/null +++ b/registry_source/lib/src/default/recipes/transcript_recipe.dart @@ -0,0 +1,19 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/transcript.dart'; + +@immutable +final class VanillaAgentTranscriptRecipe { + const VanillaAgentTranscriptRecipe({required this.style}); + final AgentTranscriptStyler style; +} + +VanillaAgentTranscriptRecipe vanillaAgentTranscriptRecipe({ + AgentTranscriptStyler style = const AgentTranscriptStyler.create(), +}) => VanillaAgentTranscriptRecipe( + style: AgentTranscriptStyler( + viewport: BoxStyler().padding(.only(right: 12)), + item: BoxStyler(), + spacing: 16, + ).merge(style), +); diff --git a/packages/remix_agent/example/lib/ui/theme/theme_data.dart b/registry_source/lib/src/default/theme/theme_data.dart similarity index 71% rename from packages/remix_agent/example/lib/ui/theme/theme_data.dart rename to registry_source/lib/src/default/theme/theme_data.dart index 787675a1f..fcaaf10ea 100644 --- a/packages/remix_agent/example/lib/ui/theme/theme_data.dart +++ b/registry_source/lib/src/default/theme/theme_data.dart @@ -4,15 +4,15 @@ import 'package:remix/remix.dart'; import 'tokens.dart'; -/// The concrete values behind [UiTokens] for one brightness. +/// The concrete values behind [VanillaTokens] for one brightness. /// /// This is application-owned data: change a hex value, add a field, or drop -/// one, and only this layer moves. `UiThemeScope` turns an instance into the +/// one, and only this layer moves. `VanillaThemeScope` turns an instance into the /// `MixScope` token map that every recipe resolves against. @immutable -class UiThemeData { +class VanillaThemeData { /// Creates a theme with an explicit value for every token. - const UiThemeData({ + const VanillaThemeData({ required this.background, required this.foreground, required this.primary, @@ -36,7 +36,7 @@ class UiThemeData { }); /// The neutral light theme. - const UiThemeData.light() + const VanillaThemeData.light() : background = const Color(0xFFFFFFFF), foreground = const Color(0xFF171717), primary = const Color(0xFF171717), @@ -59,7 +59,7 @@ class UiThemeData { radius = const Radius.circular(8); /// The neutral dark theme. - const UiThemeData.dark() + const VanillaThemeData.dark() : background = const Color(0xFF0A0A0A), foreground = const Color(0xFFFAFAFA), primary = const Color(0xFFFAFAFA), @@ -81,64 +81,64 @@ class UiThemeData { chart5 = const Color(0xFFFB7185), radius = const Radius.circular(8); - /// Value for [UiTokens.background]. + /// Value for [VanillaTokens.background]. final Color background; - /// Value for [UiTokens.foreground]. + /// Value for [VanillaTokens.foreground]. final Color foreground; - /// Value for [UiTokens.primary]. + /// Value for [VanillaTokens.primary]. final Color primary; - /// Value for [UiTokens.primaryForeground]. + /// Value for [VanillaTokens.primaryForeground]. final Color primaryForeground; - /// Value for [UiTokens.secondary]. + /// Value for [VanillaTokens.secondary]. final Color secondary; - /// Value for [UiTokens.secondaryForeground]. + /// Value for [VanillaTokens.secondaryForeground]. final Color secondaryForeground; - /// Value for [UiTokens.muted]. + /// Value for [VanillaTokens.muted]. final Color muted; - /// Value for [UiTokens.mutedForeground]. + /// Value for [VanillaTokens.mutedForeground]. final Color mutedForeground; - /// Value for [UiTokens.accent]. + /// Value for [VanillaTokens.accent]. final Color accent; - /// Value for [UiTokens.accentForeground]. + /// Value for [VanillaTokens.accentForeground]. final Color accentForeground; - /// Value for [UiTokens.destructive]. + /// Value for [VanillaTokens.destructive]. final Color destructive; - /// Value for [UiTokens.destructiveForeground]. + /// Value for [VanillaTokens.destructiveForeground]. final Color destructiveForeground; - /// Value for [UiTokens.border]. + /// Value for [VanillaTokens.border]. final Color border; - /// Value for [UiTokens.focusRing]. + /// Value for [VanillaTokens.focusRing]. final Color focusRing; - /// Value for [UiTokens.chart1]. + /// Value for [VanillaTokens.chart1]. final Color chart1; - /// Value for [UiTokens.chart2]. + /// Value for [VanillaTokens.chart2]. final Color chart2; - /// Value for [UiTokens.chart3]. + /// Value for [VanillaTokens.chart3]. final Color chart3; - /// Value for [UiTokens.chart4]. + /// Value for [VanillaTokens.chart4]. final Color chart4; - /// Value for [UiTokens.chart5]. + /// Value for [VanillaTokens.chart5]. final Color chart5; - /// Value for [UiTokens.radius]. + /// Value for [VanillaTokens.radius]. final Radius radius; /// This theme's values keyed by the token that resolves them. @@ -147,30 +147,30 @@ class UiThemeData { /// already read from; use [copyWith] to derive a changed theme instead. Map, Object> get tokens => Map, Object>.unmodifiable(, Object>{ - UiTokens.background: background, - UiTokens.foreground: foreground, - UiTokens.primary: primary, - UiTokens.primaryForeground: primaryForeground, - UiTokens.secondary: secondary, - UiTokens.secondaryForeground: secondaryForeground, - UiTokens.muted: muted, - UiTokens.mutedForeground: mutedForeground, - UiTokens.accent: accent, - UiTokens.accentForeground: accentForeground, - UiTokens.destructive: destructive, - UiTokens.destructiveForeground: destructiveForeground, - UiTokens.border: border, - UiTokens.focusRing: focusRing, - UiTokens.chart1: chart1, - UiTokens.chart2: chart2, - UiTokens.chart3: chart3, - UiTokens.chart4: chart4, - UiTokens.chart5: chart5, - UiTokens.radius: radius, + VanillaTokens.background: background, + VanillaTokens.foreground: foreground, + VanillaTokens.primary: primary, + VanillaTokens.primaryForeground: primaryForeground, + VanillaTokens.secondary: secondary, + VanillaTokens.secondaryForeground: secondaryForeground, + VanillaTokens.muted: muted, + VanillaTokens.mutedForeground: mutedForeground, + VanillaTokens.accent: accent, + VanillaTokens.accentForeground: accentForeground, + VanillaTokens.destructive: destructive, + VanillaTokens.destructiveForeground: destructiveForeground, + VanillaTokens.border: border, + VanillaTokens.focusRing: focusRing, + VanillaTokens.chart1: chart1, + VanillaTokens.chart2: chart2, + VanillaTokens.chart3: chart3, + VanillaTokens.chart4: chart4, + VanillaTokens.chart5: chart5, + VanillaTokens.radius: radius, }); /// Returns a copy of this theme with the given values replaced. - UiThemeData copyWith({ + VanillaThemeData copyWith({ Color? background, Color? foreground, Color? primary, @@ -191,7 +191,7 @@ class UiThemeData { Color? chart4, Color? chart5, Radius? radius, - }) => UiThemeData( + }) => VanillaThemeData( background: background ?? this.background, foreground: foreground ?? this.foreground, primary: primary ?? this.primary, @@ -240,11 +240,12 @@ class UiThemeData { @override bool operator ==(Object other) => identical(this, other) || - other is UiThemeData && listEquals(other._fields, _fields); + other is VanillaThemeData && listEquals(other._fields, _fields); @override int get hashCode => Object.hashAll(_fields); @override - String toString() => 'UiThemeData(background: $background, radius: $radius)'; + String toString() => + 'VanillaThemeData(background: $background, radius: $radius)'; } diff --git a/packages/remix_agent/example/lib/ui/theme/theme_scope.dart b/registry_source/lib/src/default/theme/theme_scope.dart similarity index 58% rename from packages/remix_agent/example/lib/ui/theme/theme_scope.dart rename to registry_source/lib/src/default/theme/theme_scope.dart index d88de27fb..013e5e722 100644 --- a/packages/remix_agent/example/lib/ui/theme/theme_scope.dart +++ b/registry_source/lib/src/default/theme/theme_scope.dart @@ -3,67 +3,67 @@ import 'package:remix/remix.dart'; import 'theme_data.dart'; -/// Installs a [UiThemeData] for a subtree. +/// Installs a [VanillaThemeData] for a subtree. /// /// Two things are installed together on purpose: /// -/// * [UiTheme], so application code can read the raw values through -/// [UiTheme.of]; -/// * a `MixScope` carrying the same values keyed by `UiTokens`, so every Mix +/// * [VanillaTheme], so application code can read the raw values through +/// [VanillaTheme.of]; +/// * a `MixScope` carrying the same values keyed by `VanillaTokens`, so every Mix /// styler resolved below this point sees them. /// /// Nesting a scope replaces the values for its subtree; nothing merges with /// the ancestor, which keeps "what does this token resolve to here?" a /// single-lookup question. -class UiThemeScope extends StatelessWidget { +class VanillaThemeScope extends StatelessWidget { /// Creates a scope that provides [data] to [child]. - const UiThemeScope({super.key, required this.data, required this.child}); + const VanillaThemeScope({super.key, required this.data, required this.child}); /// The theme installed for [child]. - final UiThemeData data; + final VanillaThemeData data; /// The subtree that resolves against [data]. final Widget child; @override Widget build(BuildContext context) { - return UiTheme( + return VanillaTheme( data: data, child: MixScope(tokens: data.tokens, child: child), ); } } -/// The inherited half of [UiThemeScope]. +/// The inherited half of [VanillaThemeScope]. /// -/// Prefer [UiThemeScope]; this is public because `UiTheme.of` is how widgets +/// Prefer [VanillaThemeScope]; this is public because `VanillaTheme.of` is how widgets /// read theme values that are not expressed as Mix styles, and because /// `InheritedTheme.wrap` has to be able to rebuild it across a route /// boundary. -class UiTheme extends InheritedTheme { +class VanillaTheme extends InheritedTheme { /// Creates the inherited theme holding [data]. - const UiTheme({super.key, required this.data, required super.child}); + const VanillaTheme({super.key, required this.data, required super.child}); /// The theme values available to [child]. - final UiThemeData data; + final VanillaThemeData data; - /// The closest [UiThemeData], or `null` when no scope is installed. - static UiThemeData? maybeOf(BuildContext context) => - context.dependOnInheritedWidgetOfExactType()?.data; + /// The closest [VanillaThemeData], or `null` when no scope is installed. + static VanillaThemeData? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.data; - /// The closest [UiThemeData]. + /// The closest [VanillaThemeData]. /// - /// Throws when no [UiThemeScope] is installed above [context]; use + /// Throws when no [VanillaThemeScope] is installed above [context]; use /// [maybeOf] when absence is a valid state. - static UiThemeData of(BuildContext context) { + static VanillaThemeData of(BuildContext context) { final data = maybeOf(context); if (data != null) return data; throw FlutterError.fromParts([ - ErrorSummary('No UiTheme found.'), + ErrorSummary('No VanillaTheme found.'), ErrorDescription( '${context.widget.runtimeType} tried to read the UI theme, but no ' - 'UiThemeScope was found above it.', + 'VanillaThemeScope was found above it.', ), context.describeElement('The context used was'), ]); @@ -77,12 +77,12 @@ class UiTheme extends InheritedTheme { /// token values that recipes actually resolve. @override Widget wrap(BuildContext context, Widget child) { - return UiTheme( + return VanillaTheme( data: data, child: MixScope(tokens: data.tokens, child: child), ); } @override - bool updateShouldNotify(UiTheme oldWidget) => data != oldWidget.data; + bool updateShouldNotify(VanillaTheme oldWidget) => data != oldWidget.data; } diff --git a/packages/remix_agent/example/lib/ui/theme/tokens.dart b/registry_source/lib/src/default/theme/tokens.dart similarity index 64% rename from packages/remix_agent/example/lib/ui/theme/tokens.dart rename to registry_source/lib/src/default/theme/tokens.dart index 279b4ea67..434e0e1b7 100644 --- a/packages/remix_agent/example/lib/ui/theme/tokens.dart +++ b/registry_source/lib/src/default/theme/tokens.dart @@ -4,36 +4,38 @@ import 'package:remix/remix.dart'; /// /// Remix ships no theme, so the names below are the application's vocabulary, /// not a Remix contract. A [MixToken] is only an identity: the concrete value -/// comes from whichever `MixScope` is active, which `UiThemeScope` installs -/// from a `UiThemeData`. Editing, renaming, or adding a token here is a +/// comes from whichever `MixScope` is active, which `VanillaThemeScope` installs +/// from a `VanillaThemeData`. Editing, renaming, or adding a token here is a /// local change — nothing in Remix reads these names. /// /// ```dart -/// ButtonStyler().color(UiTokens.primary()); +/// ButtonStyler().color(VanillaTokens.primary()); /// ``` -abstract final class UiTokens { +abstract final class VanillaTokens { /// Page background the application paints behind its content. - static const background = ColorToken('ui.color.background'); + static const background = ColorToken('vanilla.color.background'); /// Default content color used on top of [background]. - static const foreground = ColorToken('ui.color.foreground'); + static const foreground = ColorToken('vanilla.color.foreground'); /// Highest-emphasis fill. - static const primary = ColorToken('ui.color.primary'); + static const primary = ColorToken('vanilla.color.primary'); /// Content color used on top of [primary]. - static const primaryForeground = ColorToken('ui.color.primary-foreground'); + static const primaryForeground = ColorToken( + 'vanilla.color.primary-foreground', + ); /// Medium-emphasis fill. - static const secondary = ColorToken('ui.color.secondary'); + static const secondary = ColorToken('vanilla.color.secondary'); /// Content color used on top of [secondary]. static const secondaryForeground = ColorToken( - 'ui.color.secondary-foreground', + 'vanilla.color.secondary-foreground', ); /// De-emphasized surface. - static const muted = ColorToken('ui.color.muted'); + static const muted = ColorToken('vanilla.color.muted'); /// De-emphasized content color. /// @@ -42,24 +44,24 @@ abstract final class UiTokens { /// it for text on [background], and for glyphs and other non-text marks /// anywhere; text that lands on a `muted` surface takes [foreground]. Raise /// this value here and that restriction goes away everywhere at once. - static const mutedForeground = ColorToken('ui.color.muted-foreground'); + static const mutedForeground = ColorToken('vanilla.color.muted-foreground'); /// Interaction surface for otherwise transparent controls. - static const accent = ColorToken('ui.color.accent'); + static const accent = ColorToken('vanilla.color.accent'); /// Content color used on top of [accent]. - static const accentForeground = ColorToken('ui.color.accent-foreground'); + static const accentForeground = ColorToken('vanilla.color.accent-foreground'); /// Destructive fill for irreversible actions. - static const destructive = ColorToken('ui.color.destructive'); + static const destructive = ColorToken('vanilla.color.destructive'); /// Content color used on top of [destructive]. static const destructiveForeground = ColorToken( - 'ui.color.destructive-foreground', + 'vanilla.color.destructive-foreground', ); /// Hairline separator and control outline color. - static const border = ColorToken('ui.color.border'); + static const border = ColorToken('vanilla.color.border'); /// Focus ring color drawn for keyboard focus. /// @@ -68,7 +70,7 @@ abstract final class UiTokens { /// talking rather than the brand, and it clears the 3:1 non-text floor on /// both pages. Give it a brand color here and every control's focus ring /// follows; nothing else reads this token. - static const focusRing = ColorToken('ui.color.focus-ring'); + static const focusRing = ColorToken('vanilla.color.focus-ring'); /// First categorical chart series color. /// @@ -76,30 +78,30 @@ abstract final class UiTokens { /// The shipped themes keep one hue per series in both brightnesses, and /// every value clears 4.5:1 against [background]. That also keeps pie labels, /// which are drawn in [background], readable on their slice. - static const chart1 = ColorToken('ui.color.chart-1'); + static const chart1 = ColorToken('vanilla.color.chart-1'); /// Second categorical chart series color. See [chart1]. - static const chart2 = ColorToken('ui.color.chart-2'); + static const chart2 = ColorToken('vanilla.color.chart-2'); /// Third categorical chart series color. See [chart1]. - static const chart3 = ColorToken('ui.color.chart-3'); + static const chart3 = ColorToken('vanilla.color.chart-3'); /// Fourth categorical chart series color. See [chart1]. - static const chart4 = ColorToken('ui.color.chart-4'); + static const chart4 = ColorToken('vanilla.color.chart-4'); /// Fifth categorical chart series color. See [chart1]. - static const chart5 = ColorToken('ui.color.chart-5'); + static const chart5 = ColorToken('vanilla.color.chart-5'); /// Corner radius shared by the application's controls. - static const radius = RadiusToken('ui.radius'); + static const radius = RadiusToken('vanilla.radius'); /// The chart series colors in the order charts assign them. static const chart = [chart1, chart2, chart3, chart4, chart5]; /// Every color token this layer defines, in declaration order. /// - /// `UiThemeData` builds its scope map from this list, so a token added here - /// and to `UiThemeData` cannot be forgotten in the scope. + /// `VanillaThemeData` builds its scope map from this list, so a token added here + /// and to `VanillaThemeData` cannot be forgotten in the scope. static const colors = [ background, foreground, diff --git a/registry_source/lib/src/fortal/components/accordion.dart b/registry_source/lib/src/fortal/components/accordion.dart new file mode 100644 index 000000000..03dc9763b --- /dev/null +++ b/registry_source/lib/src/fortal/components/accordion.dart @@ -0,0 +1,166 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'accordion.g.dart'; + +/// Fortal accordion size presets. +enum FortalAccordionSize { size1, size2, size3 } + +/// Fortal accordion color variants. +enum FortalAccordionVariant { surface, soft } + +/// Fortal-themed preset for [RemixAccordion]. +@MixWidget(target: RemixAccordion.new) +AccordionStyler fortalAccordionStyle({ + FortalAccordionVariant variant = .surface, + FortalAccordionSize size = .size2, + AccordionStyler style = const AccordionStyler.create(), +}) { + return (switch (variant) { + .surface => _fortalAccordionSurfaceStyler(size), + .soft => _fortalAccordionSoftStyler(size), + }).merge(style); +} + +// Panel anatomy follows the mapped Table family (see data_table.dart): +// `container` alone owns radius, frame, fill, and clipping, while trigger and +// content stay flat rectangles that simply get cropped to its rounded shape. +// The frame and divider are foreground borders so edge-to-edge child fills +// cannot partially cover their antialiased edges. +AccordionStyler _fortalAccordionBaseStyler(FortalAccordionSize size) { + return AccordionStyler() + .trigger(.direction(.horizontal)) + .leadingIcon(.color(FortalTokens.gray11())) + .title( + .fontWeight( + FortalTokens.fontWeightMedium(), + ).color(FortalTokens.gray12()), + ) + .trailingIcon(.color(FortalTokens.gray11())) + .content(.width(.infinity)) + .merge(_fortalAccordionSizeStyler(size)); +} + +AccordionStyler _fortalAccordionFocusStyler() { + return AccordionStyler().trigger(FlexBoxStyler().fortalFocusRing()); +} + +AccordionStyler _fortalAccordionDisabledStyler() { + return AccordionStyler() + .trigger(.color(FortalTokens.grayA3())) + .leadingIcon(.color(FortalTokens.gray8())) + .title(.color(FortalTokens.gray8())) + .trailingIcon(.color(FortalTokens.gray8())); +} + +AccordionStyler _fortalAccordionSurfaceStyler([ + FortalAccordionSize size = .size2, +]) { + return _fortalAccordionBaseStyler(size) + .container( + fortalSurfaceFrame( + fillColor: FortalTokens.gray2(), + borderColor: FortalTokens.gray6(), + borderWidth: FortalTokens.borderWidth1(), + radius: _fortalAccordionRadius(size), + ), + ) + .trigger(.color(FortalTokens.gray1())) + .content( + BoxStyler() + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.top( + _fortalAccordionBorderSide(FortalTokens.gray6()), + ), + ), + ) + .wrap(_fortalAccordionContentTypography(FortalTokens.gray12())), + ) + .onHovered(.trigger(.color(FortalTokens.gray2()))) + .onPressed(.trigger(.color(FortalTokens.gray3()))) + .onFocusVisible(_fortalAccordionFocusStyler()) + .onDisabled(_fortalAccordionDisabledStyler()); +} + +AccordionStyler _fortalAccordionSoftStyler([ + FortalAccordionSize size = .size2, +]) { + return _fortalAccordionBaseStyler(size) + .container( + fortalSurfaceFrame( + fillColor: FortalTokens.accent2(), + borderColor: FortalTokens.accent6(), + borderWidth: FortalTokens.borderWidth1(), + radius: _fortalAccordionRadius(size), + ), + ) + .trigger(.color(FortalTokens.accent2())) + .title(.color(FortalTokens.accent12())) + .trailingIcon(.color(FortalTokens.accent11())) + .content( + BoxStyler() + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.top( + _fortalAccordionBorderSide(FortalTokens.accent6()), + ), + ), + ) + .wrap(_fortalAccordionContentTypography(FortalTokens.accent12())), + ) + .onHovered(.trigger(.color(FortalTokens.accent3()))) + .onPressed(.trigger(.color(FortalTokens.accent4()))) + .onFocusVisible(_fortalAccordionFocusStyler()) + .onDisabled(_fortalAccordionDisabledStyler()); +} + +/// The 1px edge shared by the panel's outer border and the trigger/content +/// divider, so the seam reads as a continuation of the frame rather than an +/// unrelated line. +BorderSideMix _fortalAccordionBorderSide(Color color) => + BorderSideMix(color: color, width: FortalTokens.borderWidth1()); + +/// Pins bare [Text] accordion content to the 14px type-scale step (`text2`) +/// regardless of accordion size, so content never renders larger than its own +/// trigger's title (measured 14/15/16px at size1/size2/size3). Fortal text +/// children pin their own run. [color] supplies the variant's own content tint. +WidgetModifierConfig _fortalAccordionContentTypography(Color color) => + WidgetModifierConfig.defaultTextStyle( + style: FortalTokens.text2.mix(), + ).defaultTextStyle(style: TextStyleMix().color(color)); + +AccordionStyler _fortalAccordionSizeStyler(FortalAccordionSize size) { + return switch (size) { + .size1 => AccordionStyler( + trigger: FlexBoxStyler().padding(.all(FortalTokens.space2())), + leadingIcon: .size(FortalTokens.space4()), + title: .style(FortalTokens.text2.mix()), + trailingIcon: .size(FortalTokens.space4()), + content: .padding(.all(FortalTokens.space2())), + ), + .size2 => AccordionStyler( + trigger: FlexBoxStyler().padding(.all(FortalTokens.space3())), + leadingIcon: .size(FortalTokens.spinnerSize3()), + title: .style(FortalTokens.accordionText2.mix()), + trailingIcon: .size(FortalTokens.spinnerSize3()), + content: .padding(.all(FortalTokens.space3())), + ), + .size3 => AccordionStyler( + trigger: FlexBoxStyler().padding(.all(FortalTokens.space4())), + leadingIcon: .size(FortalTokens.space5()), + title: .style(FortalTokens.text3.mix()), + trailingIcon: .size(FortalTokens.space5()), + content: .padding(.all(FortalTokens.space4())), + ), + }; +} + +Radius _fortalAccordionRadius(FortalAccordionSize size) => switch (size) { + .size1 => FortalTokens.radius3(), + .size2 => FortalTokens.radius4(), + .size3 => FortalTokens.radius5(), +}; diff --git a/registry_source/lib/src/fortal/components/accordion.g.dart b/registry_source/lib/src/fortal/components/accordion.g.dart new file mode 100644 index 000000000..c7fd70b7f --- /dev/null +++ b/registry_source/lib/src/fortal/components/accordion.g.dart @@ -0,0 +1,143 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'accordion.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixAccordion]. +class FortalAccordion extends StatelessWidget { + const FortalAccordion({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const AccordionStyler.create(), + required this.value, + required this.child, + this.title, + this.leadingIcon, + this.trailingIcon, + this.builder, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.transitionBuilder, + }); + + const FortalAccordion.surface({ + super.key, + this.size = .size2, + this.style = const AccordionStyler.create(), + required this.value, + required this.child, + this.title, + this.leadingIcon, + this.trailingIcon, + this.builder, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.transitionBuilder, + }) : variant = FortalAccordionVariant.surface; + + const FortalAccordion.soft({ + super.key, + this.size = .size2, + this.style = const AccordionStyler.create(), + required this.value, + required this.child, + this.title, + this.leadingIcon, + this.trailingIcon, + this.builder, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.autofocus = false, + this.focusNode, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.transitionBuilder, + }) : variant = FortalAccordionVariant.soft; + + final FortalAccordionVariant variant; + + final FortalAccordionSize size; + + final AccordionStyler style; + + final T value; + + final Widget child; + + final String? title; + + final IconData? leadingIcon; + + final IconData? trailingIcon; + + final NakedAccordionTriggerBuilder? builder; + + final bool enabled; + + final MouseCursor mouseCursor; + + final bool enableFeedback; + + final bool autofocus; + + final FocusNode? focusNode; + + final ValueChanged? onFocusChange; + + final ValueChanged? onHoverChange; + + final ValueChanged? onPressChange; + + final String? semanticLabel; + + final Widget Function(Widget, Animation)? transitionBuilder; + + @override + Widget build(BuildContext context) { + return RemixAccordion( + key: this.key, + style: fortalAccordionStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + value: this.value, + child: this.child, + title: this.title, + leadingIcon: this.leadingIcon, + trailingIcon: this.trailingIcon, + builder: this.builder, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + enableFeedback: this.enableFeedback, + autofocus: this.autofocus, + focusNode: this.focusNode, + onFocusChange: this.onFocusChange, + onHoverChange: this.onHoverChange, + onPressChange: this.onPressChange, + semanticLabel: this.semanticLabel, + transitionBuilder: this.transitionBuilder, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/avatar.dart b/registry_source/lib/src/fortal/components/avatar.dart new file mode 100644 index 000000000..5982f36fc --- /dev/null +++ b/registry_source/lib/src/fortal/components/avatar.dart @@ -0,0 +1,127 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'avatar.g.dart'; + +/// Radix Themes Avatar size presets. +enum FortalAvatarSize { + size1, + size2, + size3, + size4, + size5, + size6, + size7, + size8, + size9, +} + +/// Radix Themes Avatar variants. +enum FortalAvatarVariant { soft, solid } + +/// Fortal-themed Avatar with the Radix size, variant, and override contract. +/// +/// [fallbackLength] selects the pinned one- or two-character fallback +/// typography. Pass `2` when [RemixAvatar.label] contains two initials. +@MixWidget(target: RemixAvatar.new) +AvatarStyler fortalAvatarStyle({ + FortalAvatarVariant variant = .soft, + FortalAvatarSize size = .size3, + bool highContrast = false, + int fallbackLength = 1, + AvatarStyler style = const AvatarStyler.create(), +}) { + final base = _fortalAvatarBaseStyler(size, fallbackLength: fallbackLength); + final softContent = highContrast + ? FortalTokens.accent12() + : FortalTokens.accentA11(); + final solidContent = highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(); + return (switch (variant) { + .soft => + base + .color(FortalTokens.accentA3()) + .labelColor(softContent) + .iconColor(softContent), + .solid => + base + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent9(), + ) + .labelColor(solidContent) + .iconColor(solidContent), + }).merge(style); +} + +AvatarStyler _fortalAvatarBaseStyler( + FortalAvatarSize size, { + required int fallbackLength, +}) { + final fallbackText = _fortalAvatarFallbackText(size, fallbackLength); + final dimension = _fortalAvatarDimension(size); + return AvatarStyler() + .clipBehavior(.hardEdge) + .label( + TextStyler( + style: fallbackText.mix(), + ).fontWeight(FortalTokens.fontWeightMedium()), + ) + .icon(.size(_fortalAvatarIconSize(size)).color(FortalTokens.accentA11())) + .size(dimension, dimension) + .borderRadius(.all(_fortalAvatarRadius(size))); +} + +double _fortalAvatarDimension(FortalAvatarSize size) => switch (size) { + .size1 => FortalTokens.space5(), + .size2 => FortalTokens.space6(), + .size3 => FortalTokens.space7(), + .size4 => FortalTokens.space8(), + .size5 => FortalTokens.space9(), + .size6 => FortalTokens.avatarSize6(), + .size7 => FortalTokens.avatarSize7(), + .size8 => FortalTokens.avatarSize8(), + .size9 => FortalTokens.avatarSize9(), +}; + +double _fortalAvatarIconSize(FortalAvatarSize size) => switch (size) { + .size1 => FortalTokens.avatarIconSize1(), + .size2 => FortalTokens.avatarIconSize2(), + .size3 => FortalTokens.avatarIconSize3(), + .size4 => FortalTokens.avatarIconSize4(), + .size5 => FortalTokens.avatarIconSize5(), + .size6 => FortalTokens.avatarIconSize6(), + .size7 => FortalTokens.avatarIconSize7(), + .size8 => FortalTokens.avatarIconSize8(), + .size9 => FortalTokens.avatarIconSize9(), +}; + +Radius _fortalAvatarRadius(FortalAvatarSize size) => switch (size) { + .size1 || .size2 => FortalTokens.radius2OrFull(), + .size3 || .size4 => FortalTokens.radius3OrFull(), + .size5 => FortalTokens.radius4OrFull(), + .size6 || .size7 => FortalTokens.radius5OrFull(), + .size8 || .size9 => FortalTokens.radius6OrFull(), +}; + +TextStyleToken _fortalAvatarFallbackText( + FortalAvatarSize size, + int fallbackLength, +) => switch ((size, fallbackLength == 2)) { + (.size1, false) => FortalTokens.avatarFallback1One, + (.size1, true) => FortalTokens.avatarFallback1Two, + (.size2, false) => FortalTokens.avatarFallback2One, + (.size2, true) => FortalTokens.avatarFallback2Two, + (.size3, false) => FortalTokens.avatarFallback3One, + (.size3, true) => FortalTokens.avatarFallback3Two, + (.size4, false) => FortalTokens.avatarFallback4One, + (.size4, true) => FortalTokens.avatarFallback4Two, + (.size5, _) => FortalTokens.avatarFallback5, + (.size6, _) => FortalTokens.avatarFallback6, + (.size7, _) => FortalTokens.avatarFallback7, + (.size8, _) => FortalTokens.avatarFallback8, + (.size9, _) => FortalTokens.avatarFallback9, +}; diff --git a/registry_source/lib/src/fortal/components/avatar.g.dart b/registry_source/lib/src/fortal/components/avatar.g.dart new file mode 100644 index 000000000..e6057d0e3 --- /dev/null +++ b/registry_source/lib/src/fortal/components/avatar.g.dart @@ -0,0 +1,116 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'avatar.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed Avatar with the Radix size, variant, and override contract. +/// +/// [fallbackLength] selects the pinned one- or two-character fallback +/// typography. Pass `2` when [RemixAvatar.label] contains two initials. +class FortalAvatar extends StatelessWidget { + const FortalAvatar({ + super.key, + this.variant = .soft, + this.size = .size3, + this.highContrast = false, + this.fallbackLength = 1, + this.style = const AvatarStyler.create(), + this.backgroundImage, + this.foregroundImage, + this.onBackgroundImageError, + this.onForegroundImageError, + this.child, + this.label, + this.labelBuilder, + this.icon, + this.iconBuilder, + }); + + const FortalAvatar.soft({ + super.key, + this.size = .size3, + this.highContrast = false, + this.fallbackLength = 1, + this.style = const AvatarStyler.create(), + this.backgroundImage, + this.foregroundImage, + this.onBackgroundImageError, + this.onForegroundImageError, + this.child, + this.label, + this.labelBuilder, + this.icon, + this.iconBuilder, + }) : variant = FortalAvatarVariant.soft; + + const FortalAvatar.solid({ + super.key, + this.size = .size3, + this.highContrast = false, + this.fallbackLength = 1, + this.style = const AvatarStyler.create(), + this.backgroundImage, + this.foregroundImage, + this.onBackgroundImageError, + this.onForegroundImageError, + this.child, + this.label, + this.labelBuilder, + this.icon, + this.iconBuilder, + }) : variant = FortalAvatarVariant.solid; + + final FortalAvatarVariant variant; + + final FortalAvatarSize size; + + final bool highContrast; + + final int fallbackLength; + + final AvatarStyler style; + + final ImageProvider? backgroundImage; + + final ImageProvider? foregroundImage; + + final ImageErrorListener? onBackgroundImageError; + + final ImageErrorListener? onForegroundImageError; + + final Widget? child; + + final String? label; + + final RemixAvatarLabelBuilder? labelBuilder; + + final IconData? icon; + + final RemixAvatarIconBuilder? iconBuilder; + + @override + Widget build(BuildContext context) { + return RemixAvatar( + key: this.key, + style: fortalAvatarStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + fallbackLength: this.fallbackLength, + style: this.style, + ), + backgroundImage: this.backgroundImage, + foregroundImage: this.foregroundImage, + onBackgroundImageError: this.onBackgroundImageError, + onForegroundImageError: this.onForegroundImageError, + child: this.child, + label: this.label, + labelBuilder: this.labelBuilder, + icon: this.icon, + iconBuilder: this.iconBuilder, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/badge.dart b/registry_source/lib/src/fortal/components/badge.dart new file mode 100644 index 000000000..329dcae79 --- /dev/null +++ b/registry_source/lib/src/fortal/components/badge.dart @@ -0,0 +1,108 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'badge.g.dart'; + +/// Radix Themes Badge size presets. +enum FortalBadgeSize { size1, size2, size3 } + +/// Radix Themes Badge variants. +enum FortalBadgeVariant { solid, soft, surface, outline } + +/// Fortal-themed Badge with the Radix size, variant, and override contract. +@MixWidget(target: RemixBadge.new) +BadgeStyler fortalBadgeStyle({ + FortalBadgeVariant variant = .soft, + FortalBadgeSize size = .size1, + bool highContrast = false, + BadgeStyler style = const BadgeStyler.create(), +}) { + final base = _fortalBadgeBaseStyler(size); + return (switch (variant) { + .solid => + base + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent9(), + ) + .labelColor( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + .soft => + base + .color(FortalTokens.accentA3()) + .labelColor( + // Step 11 is Radix low-contrast text, not WCAG AA 4.5:1 on + // accentA3 over colorPanelSolid. highContrast promotes accent12. + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ), + .surface => + base + .color(FortalTokens.accentSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.accentA6()]), + ), + ) + .labelColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ), + .outline => + base + .containerEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface( + strokes: [ + highContrast + ? FortalTokens.accentA7() + : FortalTokens.accentA8(), + if (highContrast) FortalTokens.grayA11(), + ], + ), + ), + ) + .labelColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ), + }).merge(style); +} + +BadgeStyler _fortalBadgeBaseStyler(FortalBadgeSize size) { + final radius = _fortalBadgeRadius(size); + return BadgeStyler( + container: .padding(_fortalBadgePadding(size)), + label: .style( + _fortalBadgeText(size).mix(), + ).fontWeight(FortalTokens.fontWeightMedium()), + ).borderRadius(.all(radius)); +} + +TextStyleToken _fortalBadgeText(FortalBadgeSize size) => switch (size) { + .size1 || .size2 => FortalTokens.text1, + .size3 => FortalTokens.text2, +}; + +EdgeInsetsGeometryMix _fortalBadgePadding(FortalBadgeSize size) => + switch (size) { + .size1 => EdgeInsetsGeometryMix.symmetric( + horizontal: FortalTokens.badgePaddingX1(), + vertical: FortalTokens.badgePaddingY1(), + ), + .size2 => EdgeInsetsGeometryMix.symmetric( + horizontal: FortalTokens.space2(), + vertical: FortalTokens.space1(), + ), + .size3 => EdgeInsetsGeometryMix.symmetric( + horizontal: FortalTokens.badgePaddingX3(), + vertical: FortalTokens.space1(), + ), + }; + +Radius _fortalBadgeRadius(FortalBadgeSize size) => switch (size) { + .size1 => FortalTokens.radius1OrFull(), + .size2 || .size3 => FortalTokens.radius2OrFull(), +}; diff --git a/registry_source/lib/src/fortal/components/badge.g.dart b/registry_source/lib/src/fortal/components/badge.g.dart new file mode 100644 index 000000000..08d466b21 --- /dev/null +++ b/registry_source/lib/src/fortal/components/badge.g.dart @@ -0,0 +1,91 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'badge.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed Badge with the Radix size, variant, and override contract. +class FortalBadge extends StatelessWidget { + const FortalBadge({ + super.key, + this.variant = .soft, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }); + + const FortalBadge.solid({ + super.key, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = FortalBadgeVariant.solid; + + const FortalBadge.soft({ + super.key, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = FortalBadgeVariant.soft; + + const FortalBadge.surface({ + super.key, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = FortalBadgeVariant.surface; + + const FortalBadge.outline({ + super.key, + this.size = .size1, + this.highContrast = false, + this.style = const BadgeStyler.create(), + this.label, + this.child, + this.labelBuilder, + }) : variant = FortalBadgeVariant.outline; + + final FortalBadgeVariant variant; + + final FortalBadgeSize size; + + final bool highContrast; + + final BadgeStyler style; + + final String? label; + + final Widget? child; + + final RemixBadgeLabelBuilder? labelBuilder; + + @override + Widget build(BuildContext context) { + return RemixBadge( + key: this.key, + style: fortalBadgeStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + label: this.label, + child: this.child, + labelBuilder: this.labelBuilder, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/base_button.dart b/registry_source/lib/src/fortal/components/base_button.dart new file mode 100644 index 000000000..0e2d244bf --- /dev/null +++ b/registry_source/lib/src/fortal/components/base_button.dart @@ -0,0 +1,555 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +/// The Radix BaseButton size scale shared by Button and IconButton. +/// +/// Deliberate: this mirrors [FortalBaseButtonVariant]. Button and IconButton +/// each own a size enum, so the shared metrics need a common type — passing the +/// raw `size.index + 1` instead would drop every switch below to a wildcard and +/// defer an unknown size to a runtime throw. +enum FortalBaseButtonSize { size1, size2, size3, size4 } + +/// Shared Radix BaseButton metrics used by Button and IconButton recipes. +({ + double height, + double paddingX, + double gap, + Radius radius, + TextStyleToken text, + double spinnerSize, +}) +fortalBaseButtonMetrics(FortalBaseButtonSize size) => switch (size) { + .size1 => ( + height: FortalTokens.space5(), + paddingX: FortalTokens.space2(), + gap: FortalTokens.space1(), + radius: FortalTokens.radius1OrFull(), + text: FortalTokens.text1, + spinnerSize: FortalTokens.space3(), + ), + .size2 => ( + height: FortalTokens.space6(), + paddingX: FortalTokens.space3(), + gap: FortalTokens.space2(), + radius: FortalTokens.radius2OrFull(), + text: FortalTokens.text2, + spinnerSize: FortalTokens.space4(), + ), + .size3 => ( + height: FortalTokens.space7(), + paddingX: FortalTokens.space4(), + gap: FortalTokens.space3(), + radius: FortalTokens.radius3OrFull(), + text: FortalTokens.text3, + spinnerSize: FortalTokens.space4(), + ), + .size4 => ( + height: FortalTokens.space8(), + paddingX: FortalTokens.space5(), + gap: FortalTokens.space3(), + radius: FortalTokens.radius4OrFull(), + text: FortalTokens.text4, + spinnerSize: FortalTokens.spinnerSize3(), + ), +}; + +/// Default icon dimensions shared by text and icon-only button presets. +double fortalBaseButtonIconSize(FortalBaseButtonSize size) => switch (size) { + .size1 => FortalTokens.space3(), + .size2 => FortalTokens.space4(), + .size3 => FortalTokens.spinnerSize3(), + .size4 => FortalTokens.space5(), +}; + +/// Content-box metrics for the ghost BaseButton variant. +({double paddingX, double paddingY, double marginX, double marginY, double gap}) +fortalBaseButtonGhostMetrics(FortalBaseButtonSize size) => switch (size) { + .size1 => ( + paddingX: FortalTokens.space2(), + paddingY: FortalTokens.space1(), + marginX: FortalTokens.baseButtonGhostMarginX12(), + marginY: FortalTokens.baseButtonGhostMarginY12(), + gap: FortalTokens.space1(), + ), + .size2 => ( + paddingX: FortalTokens.space2(), + paddingY: FortalTokens.space1(), + marginX: FortalTokens.baseButtonGhostMarginX12(), + marginY: FortalTokens.baseButtonGhostMarginY12(), + gap: FortalTokens.space1(), + ), + .size3 => ( + paddingX: FortalTokens.space3(), + paddingY: FortalTokens.baseButtonGhostPaddingY3(), + marginX: FortalTokens.baseButtonGhostMarginX3(), + marginY: FortalTokens.baseButtonGhostMarginY3(), + gap: FortalTokens.space2(), + ), + .size4 => ( + paddingX: FortalTokens.space4(), + paddingY: FortalTokens.space2(), + marginX: FortalTokens.baseButtonGhostMarginX4(), + marginY: FortalTokens.baseButtonGhostMarginY4(), + gap: FortalTokens.space2(), + ), +}; + +/// Content-box metrics for the ghost IconButton variant. +({double padding, double margin}) fortalIconButtonGhostMetrics( + FortalBaseButtonSize size, +) => switch (size) { + .size1 => ( + padding: FortalTokens.space1(), + margin: FortalTokens.iconButtonGhostMargin1(), + ), + .size2 => ( + padding: FortalTokens.iconButtonGhostPadding2(), + margin: FortalTokens.iconButtonGhostMargin2(), + ), + .size3 => ( + padding: FortalTokens.space2(), + margin: FortalTokens.iconButtonGhostMargin3(), + ), + .size4 => ( + padding: FortalTokens.space3(), + margin: FortalTokens.iconButtonGhostMargin4(), + ), +}; + +/// Resolves a mode-aware ordered CSS filter at the component build context. +WidgetModifierConfig fortalModeAwareFilter({ + required List light, + required List dark, +}) => WidgetModifierConfig.modifier( + _FortalModeAwareFilterMix(light: light, dark: dark), +); + +/// Explicit identity filter used to clear a higher-priority state filter. +WidgetModifierConfig fortalClearFilter() => + fortalModeAwareFilter(light: const [], dark: const []); + +/// Exact classic BaseButton surface for a visual state. +RemixBoxEffectLayerMix fortalClassicBaseButtonSurface({ + required bool highContrast, + bool hovered = false, + bool pressed = false, + bool disabled = false, +}) { + final inset = FortalTokens.baseButtonClassicAfterInset(); + if (disabled) { + return RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [ + FortalTokens.blackA1(), + const Color(0x00000000), + FortalTokens.whiteA1(), + ], + stops: const [-0.2, 0.4, 1], + ), + RemixLinearGradientMix( + colors: [FortalTokens.grayA2(), FortalTokens.grayA2()], + ), + ], + gradientInsets: [inset, inset], + shadowToken: FortalTokens.baseButtonClassicDisabledShadows, + ); + } + + final baseColor = highContrast + ? FortalTokens.accent12() + : FortalTokens.accent9(); + final afterColor = hovered && !highContrast + ? FortalTokens.accent10() + : baseColor; + final pseudoGradient = RemixLinearGradientMix( + colors: [ + highContrast + ? hovered || pressed + ? FortalTokens.blackA5() + : FortalTokens.blackA3() + : hovered + ? FortalTokens.blackA2() + : pressed + ? FortalTokens.blackA2() + : FortalTokens.blackA1(), + const Color(0x00000000), + highContrast + ? pressed + ? FortalTokens.whiteA3() + : FortalTokens.whiteA2() + : hovered || pressed + ? FortalTokens.whiteA3() + : FortalTokens.whiteA2(), + ], + stops: hovered && !highContrast + ? const [-0.15, 0.425, 1] + : const [0, 0.5, 1], + ); + final gradients = [ + pseudoGradient, + RemixLinearGradientMix(colors: [afterColor, afterColor]), + if (pressed) + RemixLinearGradientMix( + colors: [FortalTokens.blackA1(), const Color(0x00000000)], + ) + else ...[ + RemixLinearGradientMix( + colors: [ + const Color(0x00000000), + const Color(0x00000000), + FortalTokens.grayA4(), + FortalTokens.grayA4(), + ], + stops: const [0, 0.5, 0.5, 1], + ), + RemixLinearGradientMix( + colors: [ + const Color(0x00000000), + const Color(0x00000000), + baseColor, + baseColor, + ], + stops: const [0, 0.5, 0.8, 1], + ), + ], + ]; + return RemixBoxEffectLayerMix( + gradients: gradients, + gradientInsets: [inset, inset, ...List.filled(gradients.length - 2, 0)], + shadowToken: pressed + ? highContrast + ? FortalTokens.baseButtonClassicActiveHighContrastShadows + : FortalTokens.baseButtonClassicActiveShadows + : highContrast + ? FortalTokens.baseButtonClassicHighContrastShadows + : FortalTokens.baseButtonClassicShadows, + ); +} + +final class _FortalModeAwareFilterMix + extends ModifierMix { + const _FortalModeAwareFilterMix({required this.light, required this.dark}); + + final List light; + final List dark; + + @override + RemixOrderedColorFilterModifier resolve(BuildContext context) => + RemixOrderedColorFilterModifier( + FortalTheme.of(context).isDark ? dark : light, + ); + + @override + _FortalModeAwareFilterMix merge(_FortalModeAwareFilterMix? other) => + other ?? this; + + @override + List get props => [light, dark]; +} + +/// Shared Radix BaseButton variants implemented by Button and IconButton. +enum FortalBaseButtonVariant { classic, solid, soft, surface, outline, ghost } + +/// One visual-state style fragment from the shared BaseButton recipe. +final class FortalBaseButtonStateStyle { + const FortalBaseButtonStateStyle({ + this.foreground, + this.background, + this.effects, + this.modifier, + this.spinnerOpacity, + }); + + final Color? foreground; + final Color? background; + final RemixBoxEffectsMix? effects; + final WidgetModifierConfig? modifier; + final double? spinnerOpacity; +} + +/// Visual state styles shared by the concrete Button and IconButton stylers. +final class FortalBaseButtonStateStyles { + const FortalBaseButtonStateStyles({ + required this.idle, + required this.hovered, + required this.pressed, + required this.disabled, + required this.focusVisible, + required this.disabledFocus, + }); + + final FortalBaseButtonStateStyle idle; + final FortalBaseButtonStateStyle hovered; + final FortalBaseButtonStateStyle pressed; + final FortalBaseButtonStateStyle disabled; + final FortalBaseButtonStateStyle focusVisible; + final FortalBaseButtonStateStyle disabledFocus; +} + +FortalBaseButtonStateStyles fortalBaseButtonStateStyles({ + required FortalBaseButtonVariant variant, + required bool highContrast, +}) { + final states = switch (variant) { + .classic => _classicStateStyles(highContrast: highContrast), + .solid => _solidStateStyles(highContrast: highContrast), + .soft => _softStateStyles(highContrast: highContrast), + .surface => _surfaceStateStyles(highContrast: highContrast), + .outline => _outlineStateStyles(highContrast: highContrast), + .ghost => _ghostStateStyles(highContrast: highContrast), + }; + final focusColor = switch (variant) { + .soft => FortalTokens.accent8(), + .classic || + .solid || + .surface || + .outline || + .ghost => FortalTokens.focus8(), + }; + final focusOffset = switch (variant) { + .classic || .solid => 2.0, + .soft || .surface || .outline || .ghost => -1.0, + }; + + return FortalBaseButtonStateStyles( + idle: states.idle, + hovered: states.hovered, + pressed: states.pressed, + disabled: states.disabled, + focusVisible: FortalBaseButtonStateStyle( + effects: fortalFocusOutline(focusColor, offset: focusOffset), + ), + disabledFocus: FortalBaseButtonStateStyle( + effects: RemixBoxEffectsMix.outline( + BorderSideMix(style: BorderStyle.none), + ), + ), + ); +} + +typedef _InteractionStateStyles = ({ + FortalBaseButtonStateStyle idle, + FortalBaseButtonStateStyle hovered, + FortalBaseButtonStateStyle pressed, + FortalBaseButtonStateStyle disabled, +}); + +_InteractionStateStyles _classicStateStyles({required bool highContrast}) { + final foreground = highContrast + ? FortalTokens.gray1() + : FortalTokens.accentContrast(); + + return ( + idle: FortalBaseButtonStateStyle( + foreground: foreground, + background: highContrast + ? FortalTokens.accent12() + : FortalTokens.accent9(), + effects: RemixBoxEffectsMix.behindContent( + fortalClassicBaseButtonSurface(highContrast: highContrast), + ), + ), + hovered: FortalBaseButtonStateStyle( + effects: RemixBoxEffectsMix.behindContent( + fortalClassicBaseButtonSurface( + highContrast: highContrast, + hovered: true, + ), + ), + modifier: _hoverFilter(highContrast, classic: true), + ), + pressed: FortalBaseButtonStateStyle( + effects: RemixBoxEffectsMix.behindContent( + fortalClassicBaseButtonSurface( + highContrast: highContrast, + pressed: true, + ), + ), + modifier: _pressedFilter(highContrast), + ), + disabled: FortalBaseButtonStateStyle( + foreground: FortalTokens.grayA8(), + background: FortalTokens.gray2(), + effects: RemixBoxEffectsMix.behindContent( + fortalClassicBaseButtonSurface(highContrast: false, disabled: true), + ), + spinnerOpacity: 1, + modifier: fortalClearFilter(), + ), + ); +} + +_InteractionStateStyles _solidStateStyles({required bool highContrast}) { + final foreground = highContrast + ? FortalTokens.gray1() + : FortalTokens.accentContrast(); + + return ( + idle: FortalBaseButtonStateStyle( + foreground: foreground, + background: highContrast + ? FortalTokens.accent12() + : FortalTokens.accent9(), + ), + hovered: FortalBaseButtonStateStyle( + background: highContrast + ? FortalTokens.accent12() + : FortalTokens.accent10(), + modifier: _hoverFilter(highContrast, classic: false), + ), + pressed: FortalBaseButtonStateStyle( + background: highContrast + ? FortalTokens.accent12() + : FortalTokens.accent10(), + modifier: _pressedFilter(highContrast), + ), + disabled: FortalBaseButtonStateStyle( + foreground: FortalTokens.grayA8(), + background: FortalTokens.grayA3(), + spinnerOpacity: 1, + modifier: fortalClearFilter(), + ), + ); +} + +_InteractionStateStyles _softStateStyles({required bool highContrast}) => ( + idle: FortalBaseButtonStateStyle( + foreground: highContrast + ? FortalTokens.accent12() + : FortalTokens.accentA11(), + background: FortalTokens.accentA3(), + ), + hovered: FortalBaseButtonStateStyle(background: FortalTokens.accentA4()), + pressed: FortalBaseButtonStateStyle(background: FortalTokens.accentA5()), + disabled: FortalBaseButtonStateStyle( + foreground: FortalTokens.grayA8(), + background: FortalTokens.grayA3(), + spinnerOpacity: 1, + ), +); + +_InteractionStateStyles _surfaceStateStyles({required bool highContrast}) => ( + idle: FortalBaseButtonStateStyle( + foreground: highContrast + ? FortalTokens.accent12() + : FortalTokens.accentA11(), + background: FortalTokens.accentSurface(), + effects: RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.accentA7()]), + ), + ), + hovered: FortalBaseButtonStateStyle( + background: FortalTokens.accentSurface(), + effects: RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.accentA8()]), + ), + ), + pressed: FortalBaseButtonStateStyle( + background: FortalTokens.accentA3(), + effects: RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.accentA8()]), + ), + ), + disabled: FortalBaseButtonStateStyle( + foreground: FortalTokens.grayA8(), + background: FortalTokens.grayA2(), + effects: RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.grayA6()]), + ), + spinnerOpacity: 1, + ), +); + +_InteractionStateStyles _outlineStateStyles({required bool highContrast}) { + final strokes = highContrast + ? [FortalTokens.accentA7(), FortalTokens.grayA11()] + : [FortalTokens.accentA8()]; + final effects = RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: strokes), + ); + + return ( + idle: FortalBaseButtonStateStyle( + foreground: highContrast + ? FortalTokens.accent12() + : FortalTokens.accentA11(), + effects: effects, + ), + hovered: FortalBaseButtonStateStyle( + background: FortalTokens.accentA2(), + effects: effects, + ), + pressed: FortalBaseButtonStateStyle( + background: FortalTokens.accentA3(), + effects: effects, + ), + disabled: FortalBaseButtonStateStyle( + foreground: FortalTokens.grayA8(), + background: const Color(0x00000000), + effects: RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.grayA7()]), + ), + spinnerOpacity: 1, + ), + ); +} + +_InteractionStateStyles _ghostStateStyles({required bool highContrast}) => ( + idle: FortalBaseButtonStateStyle( + foreground: highContrast + ? FortalTokens.accent12() + : FortalTokens.accentA11(), + background: const Color(0x00000000), + ), + hovered: FortalBaseButtonStateStyle(background: FortalTokens.accentA3()), + pressed: FortalBaseButtonStateStyle(background: FortalTokens.accentA4()), + disabled: FortalBaseButtonStateStyle( + foreground: FortalTokens.grayA8(), + background: const Color(0x00000000), + spinnerOpacity: 1, + ), +); + +WidgetModifierConfig _hoverFilter(bool highContrast, {required bool classic}) { + if (!highContrast) return fortalClearFilter(); + + return fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.88), + RemixCssColorFilterOperation.saturate(1.1), + RemixCssColorFilterOperation.brightness(1.1), + ], + dark: [ + const RemixCssColorFilterOperation.contrast(0.88), + const RemixCssColorFilterOperation.saturate(1.3), + RemixCssColorFilterOperation.brightness(classic ? 1.14 : 1.18), + ], + ); +} + +WidgetModifierConfig _pressedFilter(bool highContrast) { + if (highContrast) { + return fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.contrast(0.82), + RemixCssColorFilterOperation.saturate(1.2), + RemixCssColorFilterOperation.brightness(1.16), + ], + dark: const [ + RemixCssColorFilterOperation.brightness(0.95), + RemixCssColorFilterOperation.saturate(1.2), + ], + ); + } + + return fortalModeAwareFilter( + light: const [ + RemixCssColorFilterOperation.brightness(0.92), + RemixCssColorFilterOperation.saturate(1.1), + ], + dark: const [RemixCssColorFilterOperation.brightness(1.08)], + ); +} diff --git a/registry_source/lib/src/fortal/components/button.dart b/registry_source/lib/src/fortal/components/button.dart new file mode 100644 index 000000000..71e41ca13 --- /dev/null +++ b/registry_source/lib/src/fortal/components/button.dart @@ -0,0 +1,141 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import 'base_button.dart'; +import '../theme/theme.dart'; + +part 'button.g.dart'; + +/// Radix Themes Button size presets. +enum FortalButtonSize { size1, size2, size3, size4 } + +/// Radix Themes Button variants. +enum FortalButtonVariant { classic, solid, soft, surface, outline, ghost } + +/// Fortal-themed Button with the Radix size, variant, and override contract. +/// +/// Default icon slots use the preset's icon size, not the ambient IconTheme. +/// An explicit icon size in [style] overrides that default. +@MixWidget(target: RemixButton.new) +ButtonStyler fortalButtonStyle({ + FortalButtonVariant variant = .solid, + FortalButtonSize size = .size2, + bool highContrast = false, + ButtonStyler style = const ButtonStyler.create(), +}) { + final base = _fortalButtonBaseStyler(variant, _fortalBaseButtonSize(size)); + final stateStyles = fortalBaseButtonStateStyles( + variant: _fortalBaseButtonVariant(variant), + highContrast: highContrast, + ); + + return _applyFortalButtonStateStyles( + base, + stateStyles, + pressedPaddingTop: variant == .classic ? (size == .size1 ? 1 : 2) : null, + ).merge(style); +} + +ButtonStyler _fortalButtonBaseStyler( + FortalButtonVariant variant, + FortalBaseButtonSize size, +) { + final metrics = fortalBaseButtonMetrics(size); + var style = ButtonStyler( + icon: .size(fortalBaseButtonIconSize(size)), + container: .direction(.horizontal).mainAxisSize(.min).spacing(metrics.gap), + label: .style(metrics.text.mix()).fontWeight( + variant == .ghost + ? FortalTokens.fontWeightRegular() + : FortalTokens.fontWeightMedium(), + ), + spinner: .size(metrics.spinnerSize) + .opacity(0.65) + .leafRadius(FortalTokens.radius1()) + .duration(const Duration(milliseconds: 800)), + ).borderRadius(.all(metrics.radius)); + + if (variant == .ghost) { + final ghost = fortalBaseButtonGhostMetrics(size); + style = style + .spacing(ghost.gap) + .padding( + .symmetric(horizontal: ghost.paddingX, vertical: ghost.paddingY), + ) + .margin(.symmetric(horizontal: ghost.marginX, vertical: ghost.marginY)); + } else { + style = style + .minHeight(metrics.height) + .padding(.horizontal(metrics.paddingX)) + .icon(.opacity(0.9)); + } + return style; +} + +FortalBaseButtonVariant _fortalBaseButtonVariant(FortalButtonVariant variant) => + switch (variant) { + .classic => .classic, + .solid => .solid, + .soft => .soft, + .surface => .surface, + .outline => .outline, + .ghost => .ghost, + }; + +FortalBaseButtonSize _fortalBaseButtonSize(FortalButtonSize size) => + switch (size) { + .size1 => .size1, + .size2 => .size2, + .size3 => .size3, + .size4 => .size4, + }; + +ButtonStyler _applyFortalButtonStateStyles( + ButtonStyler base, + FortalBaseButtonStateStyles stateStyles, { + required double? pressedPaddingTop, +}) { + var pressed = _applyFortalButtonState(ButtonStyler(), stateStyles.pressed); + if (pressedPaddingTop != null) { + pressed = pressed.padding(.top(pressedPaddingTop)); + } + + return _applyFortalButtonState(base, stateStyles.idle) + .onHovered(_applyFortalButtonState(ButtonStyler(), stateStyles.hovered)) + .onPressed(pressed) + .onDisabled(_applyFortalButtonState(ButtonStyler(), stateStyles.disabled)) + .onFocusVisible( + _applyFortalButtonState(ButtonStyler(), stateStyles.focusVisible), + ) + .onDisabled( + _applyFortalButtonState(ButtonStyler(), stateStyles.disabledFocus), + ); +} + +ButtonStyler _applyFortalButtonState( + ButtonStyler style, + FortalBaseButtonStateStyle state, +) { + var result = style; + final foreground = state.foreground; + if (foreground != null) { + result = result + .label(.color(foreground)) + .icon(.color(foreground)) + .spinner(.color(foreground)); + } + if (state.background != null) { + result = result.color(state.background!); + } + if (state.effects != null) { + result = result.containerEffects(state.effects!); + } + if (state.spinnerOpacity != null) { + result = result.spinner(.opacity(state.spinnerOpacity!)); + } + if (state.modifier != null) { + result = result.wrap(state.modifier!); + } + return result; +} diff --git a/registry_source/lib/src/fortal/components/button.g.dart b/registry_source/lib/src/fortal/components/button.g.dart new file mode 100644 index 000000000..6b2c50df2 --- /dev/null +++ b/registry_source/lib/src/fortal/components/button.g.dart @@ -0,0 +1,264 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'button.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed Button with the Radix size, variant, and override contract. +/// +/// Default icon slots use the preset's icon size, not the ambient IconTheme. +/// An explicit icon size in [style] overrides that default. +class FortalButton extends StatelessWidget { + const FortalButton({ + super.key, + this.variant = .solid, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + const FortalButton.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalButtonVariant.classic; + + const FortalButton.solid({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalButtonVariant.solid; + + const FortalButton.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalButtonVariant.soft; + + const FortalButton.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalButtonVariant.surface; + + const FortalButton.outline({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalButtonVariant.outline; + + const FortalButton.ghost({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ButtonStyler.create(), + required this.label, + this.leadingIcon, + this.trailingIcon, + this.textBuilder, + this.leadingIconBuilder, + this.trailingIconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalButtonVariant.ghost; + + final FortalButtonVariant variant; + + final FortalButtonSize size; + + final bool highContrast; + + final ButtonStyler style; + + final String label; + + final IconData? leadingIcon; + + final IconData? trailingIcon; + + final RemixButtonTextBuilder? textBuilder; + + final RemixButtonIconBuilder? leadingIconBuilder; + + final RemixButtonIconBuilder? trailingIconBuilder; + + final RemixButtonLoadingBuilder? loadingBuilder; + + final bool loading; + + final bool enabled; + + final VoidCallback? onPressed; + + final VoidCallback? onLongPress; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool enableFeedback; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixButton( + key: this.key, + style: fortalButtonStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + label: this.label, + leadingIcon: this.leadingIcon, + trailingIcon: this.trailingIcon, + textBuilder: this.textBuilder, + leadingIconBuilder: this.leadingIconBuilder, + trailingIconBuilder: this.trailingIconBuilder, + loadingBuilder: this.loadingBuilder, + loading: this.loading, + enabled: this.enabled, + onPressed: this.onPressed, + onLongPress: this.onLongPress, + focusNode: this.focusNode, + autofocus: this.autofocus, + enableFeedback: this.enableFeedback, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/callout.dart b/registry_source/lib/src/fortal/components/callout.dart new file mode 100644 index 000000000..dee4a40e5 --- /dev/null +++ b/registry_source/lib/src/fortal/components/callout.dart @@ -0,0 +1,86 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'callout.g.dart'; + +/// Radix Themes Callout size presets. +enum FortalCalloutSize { size1, size2, size3 } + +/// Radix Themes Callout variants. +enum FortalCalloutVariant { soft, surface, outline } + +/// Fortal-themed Callout with the Radix size, variant, and override contract. +@MixWidget(target: RemixCallout.new) +CalloutStyler fortalCalloutStyle({ + FortalCalloutVariant variant = .soft, + FortalCalloutSize size = .size2, + bool highContrast = false, + CalloutStyler style = const CalloutStyler.create(), +}) { + final contentColor = highContrast + ? FortalTokens.accent12() + : FortalTokens.accentA11(); + final base = _fortalCalloutBaseStyler( + size, + ).iconColor(contentColor).textColor(contentColor); + return (switch (variant) { + .soft => base.color(FortalTokens.accentA3()), + .surface => + base + .color(FortalTokens.accentA2()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.accentA6()]), + ), + ), + .outline => base.containerEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.accentA7()]), + ), + ), + }).merge(style); +} + +CalloutStyler _fortalCalloutBaseStyler(FortalCalloutSize size) { + final radius = _fortalCalloutRadius(size); + return CalloutStyler( + container: .direction(.horizontal) + .mainAxisSize(.min) + .crossAxisAlignment(.start) + .spacing(_fortalCalloutGap(size)) + .padding(EdgeInsetsGeometryMix.all(_fortalCalloutPadding(size))), + text: .style(_fortalCalloutText(size).mix()), + icon: .size(_fortalCalloutIconSize(size)), + ).borderRadius(.all(radius)); +} + +double _fortalCalloutPadding(FortalCalloutSize size) => switch (size) { + .size1 => FortalTokens.space3(), + .size2 => FortalTokens.space4(), + .size3 => FortalTokens.space5(), +}; + +double _fortalCalloutGap(FortalCalloutSize size) => switch (size) { + .size1 => FortalTokens.space2(), + .size2 => FortalTokens.space3(), + .size3 => FortalTokens.space4(), +}; + +TextStyleToken _fortalCalloutText(FortalCalloutSize size) => switch (size) { + .size1 || .size2 => FortalTokens.text2, + .size3 => FortalTokens.text3, +}; + +double _fortalCalloutIconSize(FortalCalloutSize size) => switch (size) { + .size1 || .size2 => FortalTokens.space4(), + .size3 => FortalTokens.spinnerSize3(), +}; + +Radius _fortalCalloutRadius(FortalCalloutSize size) => switch (size) { + .size1 => FortalTokens.radius3(), + .size2 => FortalTokens.radius4(), + .size3 => FortalTokens.radius5(), +}; diff --git a/registry_source/lib/src/fortal/components/callout.g.dart b/registry_source/lib/src/fortal/components/callout.g.dart new file mode 100644 index 000000000..b3583c443 --- /dev/null +++ b/registry_source/lib/src/fortal/components/callout.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'callout.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed Callout with the Radix size, variant, and override contract. +class FortalCallout extends StatelessWidget { + const FortalCallout({ + super.key, + this.variant = .soft, + this.size = .size2, + this.highContrast = false, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }); + + const FortalCallout.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }) : variant = FortalCalloutVariant.soft; + + const FortalCallout.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }) : variant = FortalCalloutVariant.surface; + + const FortalCallout.outline({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CalloutStyler.create(), + this.text, + this.icon, + this.child, + }) : variant = FortalCalloutVariant.outline; + + final FortalCalloutVariant variant; + + final FortalCalloutSize size; + + final bool highContrast; + + final CalloutStyler style; + + final String? text; + + final IconData? icon; + + final Widget? child; + + @override + Widget build(BuildContext context) { + return RemixCallout( + key: this.key, + style: fortalCalloutStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + text: this.text, + icon: this.icon, + child: this.child, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/card.dart b/registry_source/lib/src/fortal/components/card.dart new file mode 100644 index 000000000..8f691bc50 --- /dev/null +++ b/registry_source/lib/src/fortal/components/card.dart @@ -0,0 +1,223 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'card.g.dart'; + +/// Radix Themes Card size presets. +enum FortalCardSize { size1, size2, size3, size4, size5 } + +/// Radix Themes Card variants. +enum FortalCardVariant { surface, classic, ghost } + +/// Fortal-themed Card with the Radix size and variant contract. +@MixWidget(target: RemixCard.new) +CardStyler fortalCardStyle({ + FortalCardVariant variant = .surface, + FortalCardSize size = .size1, + CardStyler style = const CardStyler.create(), +}) { + final metrics = _fortalCardMetrics(size); + final base = CardStyler() + .padding(.all(metrics.padding)) + .borderRadius(.all(metrics.radius)) + .clipBehavior(Clip.antiAlias) + .onFocusVisible( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: -1, + ), + ), + ); + + return (switch (variant) { + .surface => _fortalCardSurface(base), + .classic => _fortalCardClassic(base), + .ghost => _fortalCardGhost(base, metrics.ghostMargin), + }).merge(style); +} + +({double padding, double ghostMargin, Radius radius}) _fortalCardMetrics( + FortalCardSize size, +) => switch (size) { + .size1 => ( + padding: FortalTokens.space3(), + ghostMargin: FortalTokens.cardGhostMargin1(), + radius: FortalTokens.radius4(), + ), + .size2 => ( + padding: FortalTokens.space4(), + ghostMargin: FortalTokens.cardGhostMargin2(), + radius: FortalTokens.radius4(), + ), + .size3 => ( + padding: FortalTokens.space5(), + ghostMargin: FortalTokens.cardGhostMargin3(), + radius: FortalTokens.radius5(), + ), + .size4 => ( + padding: FortalTokens.space6(), + ghostMargin: FortalTokens.cardGhostMargin4(), + radius: FortalTokens.radius5(), + ), + .size5 => ( + padding: FortalTokens.space8(), + ghostMargin: FortalTokens.cardGhostMargin5(), + radius: FortalTokens.radius6(), + ), +}; + +CardStyler _fortalCardSurface(CardStyler base) { + base = base.containerEffects( + RemixBoxEffectsMix.backdropBlur(FortalTokens.panelBlur()), + ); + final open = CardStyler() + .containerEffects(RemixBoxEffectsMix.behindContent(_fortalCardPanel())) + .containerEffects( + RemixBoxEffectsMix.overContent( + _fortalCardSurfaceStroke(FortalTokens.grayStroke7()), + ), + ); + final activeFocus = CardStyler() + .containerEffects( + RemixBoxEffectsMix.behindContent(_fortalCardActiveFocus()), + ) + .onSelected(open); + final pressed = CardStyler() + .containerEffects( + RemixBoxEffectsMix.overContent( + _fortalCardSurfaceStroke(FortalTokens.grayStroke6()), + ), + ) + .onFocusVisible(activeFocus) + .onSelected(open); + + return base + .containerEffects(RemixBoxEffectsMix.behindContent(_fortalCardPanel())) + .containerEffects( + RemixBoxEffectsMix.overContent( + _fortalCardSurfaceStroke(FortalTokens.grayStroke5()), + ), + ) + .onHovered(open) + .onPressed(pressed) + .onSelected(open.onPressed(open)); +} + +CardStyler _fortalCardClassic(CardStyler base) { + base = base.containerEffects( + RemixBoxEffectsMix.backdropBlur(FortalTokens.panelBlur()), + ); + final open = CardStyler() + .animate(AnimationConfig.ease(const Duration(milliseconds: 40))) + .containerEffects( + RemixBoxEffectsMix.behindContent( + _fortalCardPanel( + shadowToken: FortalTokens.cardClassicHoverOuterShadows, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadowToken: FortalTokens.cardClassicHoverInnerShadows, + ), + ), + ); + final pressed = CardStyler() + .animate(AnimationConfig.ease(const Duration(milliseconds: 40))) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + shadowToken: FortalTokens.cardClassicActiveOuterShadows, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadowToken: FortalTokens.cardClassicActiveInnerShadows, + ), + ), + ) + .onFocusVisible( + .containerEffects( + RemixBoxEffectsMix.behindContent(_fortalCardActiveFocus()), + ).onSelected(open), + ) + .onSelected(open); + + return base + .animate(AnimationConfig.ease(const Duration(milliseconds: 120))) + .containerEffects( + RemixBoxEffectsMix.behindContent( + _fortalCardPanel(shadowToken: FortalTokens.cardClassicOuterShadows), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadowToken: FortalTokens.cardClassicInnerShadows, + ), + ), + ) + .onHovered(open) + .onPressed(pressed) + .onSelected(open.onPressed(open)); +} + +CardStyler _fortalCardGhost(CardStyler base, double ghostMargin) { + final focused = CardStyler().color(FortalTokens.accentA2()); + final open = CardStyler() + .color(FortalTokens.grayA3()) + .onFocusVisible(focused); + final pressed = CardStyler() + .color(FortalTokens.grayA4()) + .onFocusVisible(focused) + .onSelected(open); + + return base + .margin(.all(ghostMargin)) + .color(const Color(0x00000000)) + .onHovered(open) + .onPressed(pressed) + .onSelected(open.onPressed(open)); +} + +RemixBoxEffectLayerMix _fortalCardPanel({ + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.colorPanel(), FortalTokens.colorPanel()], + ), + ], + gradientInsets: const [1], + shadowToken: shadowToken, +); + +RemixBoxEffectLayerMix _fortalCardActiveFocus() => RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.accentA2(), FortalTokens.accentA2()], + ), + RemixLinearGradientMix( + colors: [FortalTokens.colorPanel(), FortalTokens.colorPanel()], + ), + ], + gradientInsets: const [1, 1], +); + +RemixBoxEffectLayerMix _fortalCardSurfaceStroke(Color color) => + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix(color: color, spreadRadius: 1, shapeInset: 1), + ], + ); diff --git a/registry_source/lib/src/fortal/components/card.g.dart b/registry_source/lib/src/fortal/components/card.g.dart new file mode 100644 index 000000000..cc14c3ee7 --- /dev/null +++ b/registry_source/lib/src/fortal/components/card.g.dart @@ -0,0 +1,60 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'card.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed Card with the Radix size and variant contract. +class FortalCard extends StatelessWidget { + const FortalCard({ + super.key, + this.variant = .surface, + this.size = .size1, + this.style = const CardStyler.create(), + this.child, + }); + + const FortalCard.surface({ + super.key, + this.size = .size1, + this.style = const CardStyler.create(), + this.child, + }) : variant = FortalCardVariant.surface; + + const FortalCard.classic({ + super.key, + this.size = .size1, + this.style = const CardStyler.create(), + this.child, + }) : variant = FortalCardVariant.classic; + + const FortalCard.ghost({ + super.key, + this.size = .size1, + this.style = const CardStyler.create(), + this.child, + }) : variant = FortalCardVariant.ghost; + + final FortalCardVariant variant; + + final FortalCardSize size; + + final CardStyler style; + + final Widget? child; + + @override + Widget build(BuildContext context) { + return RemixCard( + key: this.key, + style: fortalCardStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + child: this.child, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/chart.dart b/registry_source/lib/src/fortal/components/chart.dart new file mode 100644 index 000000000..2f5ada379 --- /dev/null +++ b/registry_source/lib/src/fortal/components/chart.dart @@ -0,0 +1,255 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:mix_chart/mix_chart.dart'; +import 'package:remix/remix.dart'; + +import '../theme/radix_colors.dart' + show amber, blue, cyan, green, orange, ruby, violet; +import '../theme/theme.dart'; + +part 'chart.g.dart'; + +const _standardPaletteToken = ContextToken>( + _resolveStandardPalette, +); +const _highContrastPaletteToken = ContextToken>( + _resolveHighContrastPalette, +); +const _tooltipBorderToken = ContextToken(_resolveTooltipBorder); +const _tooltipRadiusToken = ContextToken(_resolveTooltipRadius); +const _tooltipPaddingToken = ContextToken(_resolveTooltipPadding); +const _barRadiusToken = ContextToken(_resolveBarRadius); +const _lineWidthToken = ContextToken(_resolveLineWidth); +const _highContrastLineWidthToken = ContextToken( + _resolveHighContrastLineWidth, +); + +/// Returns the categorical palette used by Fortal charts in the current scope. +/// +/// The first entry follows the configured Fortal accent. Remaining entries use +/// Radix color families selected for clear categorical separation. Set +/// [highContrast] to use step 12 instead of the standard solid-color step 9. +List resolveFortalChartPalette( + BuildContext context, { + bool highContrast = false, +}) { + final theme = FortalTheme.of(context); + final colors = resolveFortalTokens(theme); + final step = highContrast ? 12 : 9; + final candidates = [ + colors.accent.scale.step(step), + for (final family in [cyan, orange, ruby, green, violet, amber, blue]) + (theme.isDark ? family.dark : family.light).scale.step(step), + ]; + + return List.unmodifiable(candidates.toSet()); +} + +/// Fortal presentation for a Mix line or area chart. +/// +/// Generates [FortalLineChart] through `mix_generator`. The plot remains +/// transparent so callers can compose it inside any Fortal surface. +@MixWidget(target: LineChart.new) +LineChartStyler fortalLineChartStyle({ + bool highContrast = false, + bool showMarkers = false, + List? palette, + LineChartStyler style = const LineChartStyler.create(), +}) { + final recipe = LineChartStyler() + .frame(_fortalFrameStyle()) + .axis(_fortalAxisStyle()) + .topAxis(_hiddenAxisStyle()) + .rightAxis(_hiddenAxisStyle()) + .grid(_fortalGridStyle()) + .series( + LineSeriesStyler() + .curve(.curved) + .smoothness(0.18) + .preventCurveOvershooting(true) + .roundStrokeCap(true) + .roundStrokeJoin(true) + .stroke( + ChartStrokeStyler().width( + highContrast + ? _highContrastLineWidthToken() + : _lineWidthToken(), + ), + ) + .marker( + ChartMarkerStyler() + .show(showMarkers) + .radius(FortalTokens.space1()) + .borderColor(FortalTokens.colorPanel()) + .borderWidth(FortalTokens.borderWidth2()), + ), + ) + .tooltip(_fortalTooltipStyle()); + + return recipe + .merge( + LineChartStyler.create( + palette: _paletteProp(highContrast: highContrast, palette: palette), + ), + ) + .merge(style); +} + +/// Fortal presentation for a Mix grouped, stacked, or floating bar chart. +/// +/// Generates [FortalBarChart] through `mix_generator`. +@MixWidget(target: BarChart.new) +BarChartStyler fortalBarChartStyle({ + bool highContrast = false, + List? palette, + BarChartStyler style = const BarChartStyler.create(), +}) { + final bar = BarStyler.create( + borderRadius: Prop.token(_barRadiusToken), + ).width(FortalTokens.space4()); + final recipe = BarChartStyler() + .frame(_fortalFrameStyle()) + .axis(_fortalAxisStyle()) + .topAxis(_hiddenAxisStyle()) + .rightAxis(_hiddenAxisStyle()) + .grid(_fortalGridStyle()) + .bar(bar) + .groupSpacing(FortalTokens.space4()) + .barSpacing(FortalTokens.space2()) + .tooltip(_fortalTooltipStyle()); + + return recipe + .merge( + BarChartStyler.create( + palette: _paletteProp(highContrast: highContrast, palette: palette), + ), + ) + .merge(style); +} + +/// Fortal presentation for a Mix pie or donut chart. +/// +/// A positive [centerRadius] renders a donut. Labels are hidden by default so +/// category names can be presented in a caller-owned legend without forcing +/// low-contrast text onto arbitrary categorical colors. Generates +/// [FortalPieChart] through `mix_generator`. For advanced chart-level geometry, +/// pass this recipe directly to [PieChart.style] and merge a [PieSliceStyler]. +@MixWidget(target: PieChart.new) +PieChartStyler fortalPieChartStyle({ + bool highContrast = false, + double centerRadius = 0, + bool showLabels = false, + List? palette, + PieChartStyler style = const PieChartStyler.create(), +}) { + final recipe = PieChartStyler() + .frame(_fortalFrameStyle()) + .centerRadius(centerRadius) + .centerColor(FortalTokens.colorPanel()) + .sliceSpacing(FortalTokens.borderWidth2()) + .selectedSliceRadiusOffset(FortalTokens.space2()) + .slice( + PieSliceStyler() + .showLabel(showLabels) + .cornerRadius(FortalTokens.borderWidth2()) + .label( + TextStyler() + .style(FortalTokens.text1.mix()) + .fontWeight(.w600) + .color(FortalTokens.accentContrast()), + ), + ) + .tooltip(_fortalTooltipStyle()); + + return recipe + .merge( + PieChartStyler.create( + palette: _paletteProp(highContrast: highContrast, palette: palette), + ), + ) + .merge(style); +} + +Prop> _paletteProp({ + required bool highContrast, + required List? palette, +}) { + if (palette != null) return Prop.value(List.unmodifiable(palette)); + + return Prop.token( + highContrast ? _highContrastPaletteToken : _standardPaletteToken, + ); +} + +ChartFrameStyler _fortalFrameStyle() => ChartFrameStyler() + .backgroundColor(MixColors.transparent) + .showBorder(false) + .clip(true); + +ChartAxisStyler _fortalAxisStyle() => ChartAxisStyler() + .showLabels(true) + .label( + TextStyler().style(FortalTokens.text1.mix()).color(FortalTokens.gray11()), + ) + .labelSpace(FortalTokens.space2()) + .fitInside(true) + .fitInsideDistance(FortalTokens.space1()) + .drawBelowEverything(true); + +ChartAxisStyler _hiddenAxisStyle() => ChartAxisStyler().showLabels(false); + +ChartGridStyler _fortalGridStyle() => ChartGridStyler() + .show(true) + .showHorizontal(true) + .showVertical(false) + .stroke( + ChartStrokeStyler() + .color(FortalTokens.grayA5()) + .width(FortalTokens.borderWidth1()), + ); + +ChartTooltipStyler _fortalTooltipStyle() => + ChartTooltipStyler.create( + border: Prop.token(_tooltipBorderToken), + borderRadius: Prop.token(_tooltipRadiusToken), + padding: Prop.token(_tooltipPaddingToken), + ) + .backgroundColor(FortalTokens.colorPanel()) + .margin(FortalTokens.space2()) + .maxWidth(280) + .fitHorizontally(true) + .fitVertically(true) + .text( + TextStyler() + .style(FortalTokens.text1.mix()) + .fontWeight(.w500) + .color(FortalTokens.gray12()), + ); + +List _resolveStandardPalette(BuildContext context) => + resolveFortalChartPalette(context); + +List _resolveHighContrastPalette(BuildContext context) => + resolveFortalChartPalette(context, highContrast: true); + +BorderSide _resolveTooltipBorder(BuildContext context) => BorderSide( + color: FortalTokens.grayStroke6.resolve(context), + width: FortalTokens.borderWidth1.resolve(context), +); + +BorderRadius _resolveTooltipRadius(BuildContext context) => + BorderRadius.all(FortalTokens.radius3.resolve(context)); + +EdgeInsets _resolveTooltipPadding(BuildContext context) => EdgeInsets.symmetric( + horizontal: FortalTokens.space3.resolve(context), + vertical: FortalTokens.space2.resolve(context), +); + +BorderRadius _resolveBarRadius(BuildContext context) => + BorderRadius.all(FortalTokens.radius2.resolve(context)); + +double _resolveLineWidth(BuildContext context) => + 2 * FortalTheme.of(context).scaling.factor; + +double _resolveHighContrastLineWidth(BuildContext context) => + 3 * FortalTheme.of(context).scaling.factor; diff --git a/registry_source/lib/src/fortal/components/chart.g.dart b/registry_source/lib/src/fortal/components/chart.g.dart new file mode 100644 index 000000000..9b36c0469 --- /dev/null +++ b/registry_source/lib/src/fortal/components/chart.g.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chart.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal presentation for a Mix line or area chart. +/// +/// Generates [FortalLineChart] through `mix_generator`. The plot remains +/// transparent so callers can compose it inside any Fortal surface. +class FortalLineChart extends StatelessWidget { + const FortalLineChart({ + super.key, + this.highContrast = false, + this.showMarkers = false, + this.palette, + this.style = const LineChartStyler.create(), + required this.series, + this.xAxis, + this.yAxis, + this.topAxis, + this.rightAxis, + this.viewport, + this.dataTransition = ChartDataTransition.none, + this.selectedPoints = const {}, + this.onPointHover, + this.onPointTap, + this.onPointLongPress, + this.tooltipBuilder, + this.hitTestRadius = 10, + this.mouseCursorResolver, + this.semanticsLabel, + this.semanticsValue, + this.excludeFromSemantics = false, + }); + + final bool highContrast; + + final bool showMarkers; + + final List? palette; + + final LineChartStyler style; + + final List series; + + final ChartAxis? xAxis; + + final ChartAxis? yAxis; + + final ChartAxis? topAxis; + + final ChartAxis? rightAxis; + + final ChartViewport? viewport; + + final ChartDataTransition dataTransition; + + final Set selectedPoints; + + final ValueChanged? onPointHover; + + final ValueChanged? onPointTap; + + final ValueChanged? onPointLongPress; + + final ChartTooltipBuilder? tooltipBuilder; + + final double hitTestRadius; + + final ChartMouseCursorResolver? mouseCursorResolver; + + final String? semanticsLabel; + + final String? semanticsValue; + + final bool excludeFromSemantics; + + @override + Widget build(BuildContext context) { + return LineChart( + key: this.key, + style: fortalLineChartStyle( + highContrast: this.highContrast, + showMarkers: this.showMarkers, + palette: this.palette, + style: this.style, + ), + series: this.series, + xAxis: this.xAxis, + yAxis: this.yAxis, + topAxis: this.topAxis, + rightAxis: this.rightAxis, + viewport: this.viewport, + dataTransition: this.dataTransition, + selectedPoints: this.selectedPoints, + onPointHover: this.onPointHover, + onPointTap: this.onPointTap, + onPointLongPress: this.onPointLongPress, + tooltipBuilder: this.tooltipBuilder, + hitTestRadius: this.hitTestRadius, + mouseCursorResolver: this.mouseCursorResolver, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + excludeFromSemantics: this.excludeFromSemantics, + ); + } +} + +/// Fortal presentation for a Mix grouped, stacked, or floating bar chart. +/// +/// Generates [FortalBarChart] through `mix_generator`. +class FortalBarChart extends StatelessWidget { + const FortalBarChart({ + super.key, + this.highContrast = false, + this.palette, + this.style = const BarChartStyler.create(), + required this.groups, + this.xAxis, + this.yAxis, + this.topAxis, + this.rightAxis, + this.viewport, + this.dataTransition = ChartDataTransition.none, + this.selectedItems = const {}, + this.onBarHover, + this.onBarTap, + this.onBarLongPress, + this.tooltipBuilder, + this.hitTestPadding = const EdgeInsets.all(4), + this.mouseCursorResolver, + this.semanticsLabel, + this.semanticsValue, + this.excludeFromSemantics = false, + }); + + final bool highContrast; + + final List? palette; + + final BarChartStyler style; + + final List groups; + + final ChartAxis? xAxis; + + final ChartAxis? yAxis; + + final ChartAxis? topAxis; + + final ChartAxis? rightAxis; + + final ChartViewport? viewport; + + final ChartDataTransition dataTransition; + + final Set selectedItems; + + final ValueChanged? onBarHover; + + final ValueChanged? onBarTap; + + final ValueChanged? onBarLongPress; + + final ChartTooltipBuilder? tooltipBuilder; + + final EdgeInsets hitTestPadding; + + final ChartMouseCursorResolver? mouseCursorResolver; + + final String? semanticsLabel; + + final String? semanticsValue; + + final bool excludeFromSemantics; + + @override + Widget build(BuildContext context) { + return BarChart( + key: this.key, + style: fortalBarChartStyle( + highContrast: this.highContrast, + palette: this.palette, + style: this.style, + ), + groups: this.groups, + xAxis: this.xAxis, + yAxis: this.yAxis, + topAxis: this.topAxis, + rightAxis: this.rightAxis, + viewport: this.viewport, + dataTransition: this.dataTransition, + selectedItems: this.selectedItems, + onBarHover: this.onBarHover, + onBarTap: this.onBarTap, + onBarLongPress: this.onBarLongPress, + tooltipBuilder: this.tooltipBuilder, + hitTestPadding: this.hitTestPadding, + mouseCursorResolver: this.mouseCursorResolver, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + excludeFromSemantics: this.excludeFromSemantics, + ); + } +} + +/// Fortal presentation for a Mix pie or donut chart. +/// +/// A positive [centerRadius] renders a donut. Labels are hidden by default so +/// category names can be presented in a caller-owned legend without forcing +/// low-contrast text onto arbitrary categorical colors. Generates +/// [FortalPieChart] through `mix_generator`. For advanced chart-level geometry, +/// pass this recipe directly to [PieChart.style] and merge a [PieSliceStyler]. +class FortalPieChart extends StatelessWidget { + const FortalPieChart({ + super.key, + this.highContrast = false, + this.centerRadius = 0, + this.showLabels = false, + this.palette, + this.style = const PieChartStyler.create(), + required this.slices, + this.dataTransition = ChartDataTransition.none, + this.selectedSliceIds = const {}, + this.onSliceHover, + this.onSliceTap, + this.onSliceLongPress, + this.tooltipBuilder, + this.mouseCursorResolver, + this.valueFormatter, + this.semanticsLabel, + this.semanticsValue, + this.excludeFromSemantics = false, + }); + + final bool highContrast; + + final double centerRadius; + + final bool showLabels; + + final List? palette; + + final PieChartStyler style; + + final List slices; + + final ChartDataTransition dataTransition; + + final Set selectedSliceIds; + + final ValueChanged? onSliceHover; + + final ValueChanged? onSliceTap; + + final ValueChanged? onSliceLongPress; + + final ChartTooltipBuilder? tooltipBuilder; + + final ChartMouseCursorResolver? mouseCursorResolver; + + final ChartAxisLabelFormatter? valueFormatter; + + final String? semanticsLabel; + + final String? semanticsValue; + + final bool excludeFromSemantics; + + @override + Widget build(BuildContext context) { + return PieChart( + key: this.key, + style: fortalPieChartStyle( + highContrast: this.highContrast, + centerRadius: this.centerRadius, + showLabels: this.showLabels, + palette: this.palette, + style: this.style, + ), + slices: this.slices, + dataTransition: this.dataTransition, + selectedSliceIds: this.selectedSliceIds, + onSliceHover: this.onSliceHover, + onSliceTap: this.onSliceTap, + onSliceLongPress: this.onSliceLongPress, + tooltipBuilder: this.tooltipBuilder, + mouseCursorResolver: this.mouseCursorResolver, + valueFormatter: this.valueFormatter, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + excludeFromSemantics: this.excludeFromSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/checkbox.dart b/registry_source/lib/src/fortal/components/checkbox.dart new file mode 100644 index 000000000..0de1973d3 --- /dev/null +++ b/registry_source/lib/src/fortal/components/checkbox.dart @@ -0,0 +1,253 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'checkbox.g.dart'; + +/// Radix Themes Checkbox size presets. +enum FortalCheckboxSize { size1, size2, size3 } + +/// Radix Themes Checkbox variants. +enum FortalCheckboxVariant { classic, surface, soft } + +/// Fortal recipe for [RemixCheckbox]. +@MixWidget(target: RemixCheckbox.new) +CheckboxStyler fortalCheckboxStyle({ + FortalCheckboxVariant variant = .surface, + FortalCheckboxSize size = .size2, + bool highContrast = false, + CheckboxStyler style = const CheckboxStyler.create(), +}) { + final metrics = _fortalCheckboxMetrics(size); + final base = + CheckboxStyler( + container: .size( + metrics.size, + metrics.size, + ).alignment(.center).borderRadius(.all(metrics.radius)), + indicator: .size(metrics.indicatorSize), + containerEffects: RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix(), + overContent: RemixBoxEffectLayerMix(), + ), + ).onFocusVisible( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ); + + return (switch (variant) { + .classic => _fortalCheckboxClassic(base, highContrast), + .surface => _fortalCheckboxSurface(base, highContrast), + .soft => _fortalCheckboxSoft(base, highContrast), + }).merge(style); +} + +/// Fortal recipe for [RemixCheckboxGroupItem]. +/// +/// Combines the mapped checkbox recipe with Radix's size-linked item label +/// typography and `0.5em` label gap. The behavioral group remains layout +/// transparent, so callers continue to own root direction and spacing. +/// +/// It exists because `RemixCheckboxGroup` is behavioral and carries no styler, +/// so unlike every other Remix item (menu, select, segmented control, toggle +/// group) there is no parent recipe to push item styling down. Without this, +/// callers hand-attach a styler to each item and a missed one in a loop renders +/// unstyled beside its styled siblings. +@MixWidget(target: RemixCheckboxGroupItem.new) +CheckboxStyler fortalCheckboxGroupItemStyle({ + FortalCheckboxVariant variant = .surface, + FortalCheckboxSize size = .size2, + bool highContrast = false, + CheckboxStyler style = const CheckboxStyler.create(), +}) { + final checkbox = fortalCheckboxStyle( + variant: variant, + size: size, + highContrast: highContrast, + ); + + return (switch (size) { + .size1 => + checkbox + .label(.style(FortalTokens.text1.mix())) + .labelSpacing(FortalTokens.checkboxGroupItemGap1()), + .size2 => + checkbox + .label(.style(FortalTokens.text2.mix())) + .labelSpacing(FortalTokens.checkboxGroupItemGap2()), + .size3 => + checkbox + .label(.style(FortalTokens.text3.mix())) + .labelSpacing(FortalTokens.checkboxGroupItemGap3()), + }).merge(style); +} + +({double size, double indicatorSize, Radius radius}) _fortalCheckboxMetrics( + FortalCheckboxSize size, +) => switch (size) { + .size1 => ( + size: FortalTokens.checkboxSize1(), + indicatorSize: FortalTokens.checkboxIndicatorSize1(), + radius: FortalTokens.checkboxRadius1(), + ), + .size2 => ( + size: FortalTokens.space4(), + indicatorSize: FortalTokens.checkboxIndicatorSize2(), + radius: FortalTokens.radius1(), + ), + .size3 => ( + size: FortalTokens.checkboxSize3(), + indicatorSize: FortalTokens.checkboxIndicatorSize3(), + radius: FortalTokens.checkboxRadius3(), + ), +}; + +CheckboxStyler _fortalCheckboxSurface(CheckboxStyler base, bool highContrast) { + final selected = CheckboxStyler() + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accentIndicator(), + ) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor( + highContrast ? FortalTokens.accent1() : FortalTokens.accentContrast(), + ); + + return base + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + fortalInsetSurface(strokes: [FortalTokens.grayA7()]), + ), + ) + .onSelected(selected) + .onIndeterminate(selected) + .onDisabled( + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + fortalInsetSurface(strokes: [FortalTokens.grayA6()]), + ), + ) + .indicatorColor(FortalTokens.grayA8()), + ); +} + +CheckboxStyler _fortalCheckboxClassic(CheckboxStyler base, bool highContrast) { + final selected = CheckboxStyler() + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accentIndicator(), + ) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [ + FortalTokens.whiteA3(), + const Color(0x00000000), + FortalTokens.blackA1(), + ], + ), + ], + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.whiteA4(), + offset: const Offset(0, 0.5), + blurRadius: 0.5, + ), + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.blackA4(), + offset: const Offset(0, -0.5), + blurRadius: 0.5, + ), + ], + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor( + highContrast ? FortalTokens.accent1() : FortalTokens.accentContrast(), + ); + + return base + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadowToken: FortalTokens.shadow1Layers), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + fortalInsetSurface(strokes: [FortalTokens.grayA3()]), + ), + ) + .onSelected(selected) + .onIndeterminate(selected) + .onDisabled( + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: const [], + shadowToken: FortalTokens.shadow1Layers, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor(FortalTokens.grayA8()), + ); +} + +CheckboxStyler _fortalCheckboxSoft(CheckboxStyler base, bool highContrast) { + final selected = CheckboxStyler().indicatorColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), + ); + + return base + .color(FortalTokens.accentA5()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .onSelected(selected) + .onIndeterminate(selected) + .onDisabled( + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .indicatorColor(FortalTokens.grayA8()), + ); +} diff --git a/registry_source/lib/src/fortal/components/checkbox.g.dart b/registry_source/lib/src/fortal/components/checkbox.g.dart new file mode 100644 index 000000000..dd60aa9f9 --- /dev/null +++ b/registry_source/lib/src/fortal/components/checkbox.g.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'checkbox.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal recipe for [RemixCheckbox]. +class FortalCheckbox extends StatelessWidget { + const FortalCheckbox({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon, + this.uncheckedIcon, + this.indeterminateIcon, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.label, + this.semanticLabel, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }); + + const FortalCheckbox.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon, + this.uncheckedIcon, + this.indeterminateIcon, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.label, + this.semanticLabel, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalCheckboxVariant.classic; + + const FortalCheckbox.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon, + this.uncheckedIcon, + this.indeterminateIcon, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.label, + this.semanticLabel, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalCheckboxVariant.surface; + + const FortalCheckbox.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.tristate = false, + this.checkedIcon, + this.uncheckedIcon, + this.indeterminateIcon, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.label, + this.semanticLabel, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalCheckboxVariant.soft; + + final FortalCheckboxVariant variant; + + final FortalCheckboxSize size; + + final bool highContrast; + + final CheckboxStyler style; + + final bool? selected; + + final ValueChanged? onChanged; + + final bool enabled; + + final bool tristate; + + final IconData? checkedIcon; + + final IconData? uncheckedIcon; + + final IconData? indeterminateIcon; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool enableFeedback; + + final String? label; + + final String? semanticLabel; + + final Size minimumTapTargetSize; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixCheckbox( + key: this.key, + style: fortalCheckboxStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + selected: this.selected, + onChanged: this.onChanged, + enabled: this.enabled, + tristate: this.tristate, + checkedIcon: this.checkedIcon, + uncheckedIcon: this.uncheckedIcon, + indeterminateIcon: this.indeterminateIcon, + focusNode: this.focusNode, + autofocus: this.autofocus, + enableFeedback: this.enableFeedback, + label: this.label, + semanticLabel: this.semanticLabel, + minimumTapTargetSize: this.minimumTapTargetSize, + mouseCursor: this.mouseCursor, + ); + } +} + +/// Fortal recipe for [RemixCheckboxGroupItem]. +/// +/// Combines the mapped checkbox recipe with Radix's size-linked item label +/// typography and `0.5em` label gap. The behavioral group remains layout +/// transparent, so callers continue to own root direction and spacing. +/// +/// It exists because `RemixCheckboxGroup` is behavioral and carries no styler, +/// so unlike every other Remix item (menu, select, segmented control, toggle +/// group) there is no parent recipe to push item styling down. Without this, +/// callers hand-attach a styler to each item and a missed one in a loop renders +/// unstyled beside its styled siblings. +class FortalCheckboxGroupItem extends StatelessWidget { + const FortalCheckboxGroupItem({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.value, + required this.label, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.checkedIcon, + this.uncheckedIcon, + this.enableFeedback = true, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }); + + const FortalCheckboxGroupItem.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.value, + required this.label, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.checkedIcon, + this.uncheckedIcon, + this.enableFeedback = true, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalCheckboxVariant.classic; + + const FortalCheckboxGroupItem.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.value, + required this.label, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.checkedIcon, + this.uncheckedIcon, + this.enableFeedback = true, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalCheckboxVariant.surface; + + const FortalCheckboxGroupItem.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const CheckboxStyler.create(), + required this.value, + required this.label, + this.semanticLabel, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.checkedIcon, + this.uncheckedIcon, + this.enableFeedback = true, + this.minimumTapTargetSize = const Size.square(48), + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalCheckboxVariant.soft; + + final FortalCheckboxVariant variant; + + final FortalCheckboxSize size; + + final bool highContrast; + + final CheckboxStyler style; + + final T value; + + final String label; + + final String? semanticLabel; + + final bool enabled; + + final FocusNode? focusNode; + + final bool autofocus; + + final IconData? checkedIcon; + + final IconData? uncheckedIcon; + + final bool enableFeedback; + + final Size minimumTapTargetSize; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixCheckboxGroupItem( + key: this.key, + style: fortalCheckboxGroupItemStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + value: this.value, + label: this.label, + semanticLabel: this.semanticLabel, + enabled: this.enabled, + focusNode: this.focusNode, + autofocus: this.autofocus, + checkedIcon: this.checkedIcon, + uncheckedIcon: this.uncheckedIcon, + enableFeedback: this.enableFeedback, + minimumTapTargetSize: this.minimumTapTargetSize, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/code.dart b/registry_source/lib/src/fortal/components/code.dart new file mode 100644 index 000000000..88b7db1c3 --- /dev/null +++ b/registry_source/lib/src/fortal/components/code.dart @@ -0,0 +1,300 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +/// Radix Themes Code variants. +enum FortalCodeVariant { solid, soft, outline, ghost } + +/// Fortal-themed inline code on the Radix nine-step scale. +/// +/// Geometry is em-relative to the resolved font size, so this recipe takes a +/// [context]. An omitted [size] anchors to the root `text3` token — not the +/// ambient `DefaultTextStyle` — while keeping upstream's separate unsized +/// factors, so a host text run cannot change Code's geometry. +BadgeStyler fortalCodeStyle( + BuildContext context, { + FortalTextSize? size, + FortalCodeVariant variant = .soft, + FortalTextWeight? weight, + bool softWrap = true, + bool truncate = false, + bool accent = false, + bool highContrast = false, + BadgeStyler style = const BadgeStyler.create(), +}) { + final base = fortalResolveTextToken(context, size ?? FortalTextSize.size3); + final baseFontSize = base.fontSize!; + + // Radix nests two adjustments: --code-font-size-adjust is 0.95, and + // --code-variant-font-size-adjust multiplies it by 0.95 again for every + // variant except ghost, which keeps the outer value. + final decorated = variant != .ghost; + final fontSize = baseFontSize * (decorated ? 0.95 * 0.95 : 0.95); + // An explicit size keeps its token's absolute line box; the unsized path + // uses the pinned unitless 1.25. + final lineHeight = size == null + ? 1.25 + : (baseFontSize * (base.height ?? 1)) / fontSize; + final letterSpacing = (base.letterSpacing ?? 0) - (0.007 * fontSize); + + var textStyle = TextStyler() + .fontFamily('Menlo') + .fontFamilyFallback(const [ + 'Consolas', + 'Bitstream Vera Sans Mono', + 'monospace', + 'Apple Color Emoji', + 'Segoe UI Emoji', + ]) + .fontSize(fontSize) + .height(lineHeight) + .letterSpacing(letterSpacing) + .inherit(false); + if (weight != null) { + textStyle = textStyle.fontWeight(fortalTextWeightToken(weight)()); + } + textStyle = fortalApplyTextFlow( + textStyle, + softWrap: softWrap, + truncate: truncate, + ); + + Color? fill; + Color? foreground; + final accent1 = fortalResolveColor(context, FortalTokens.accent1); + final accent12 = fortalResolveColor(context, FortalTokens.accent12); + final accentA3 = fortalResolveColor(context, FortalTokens.accentA3); + final accentA9 = fortalResolveColor(context, FortalTokens.accentA9); + final accentA11 = fortalResolveColor(context, FortalTokens.accentA11); + final accentContrast = fortalResolveColor( + context, + FortalTokens.accentContrast, + ); + switch (variant) { + case .solid: + fill = highContrast ? accent12 : accentA9; + foreground = highContrast ? accent1 : accentContrast; + case .soft: + fill = accentA3; + foreground = highContrast ? accent12 : accentA11; + case .outline: + foreground = highContrast ? accent12 : accentA11; + case .ghost: + // Ghost is transparent and inherits the ambient colour unless the caller + // opts into the local accent. Apply only that intended ambient field + // after Mix composition so an explicit recipe colour or foreground can + // override it without creating an invalid Flutter TextStyle. + if (accent) { + foreground = highContrast ? accent12 : accentA11; + } else { + textStyle = textStyle.merge( + TextStyler.create( + style: Prop.directives([ + _AmbientCodeForegroundDirective( + DefaultTextStyle.of(context).style, + ), + ]), + ), + ); + } + } + if (foreground != null) textStyle = textStyle.color(foreground); + + var recipe = BadgeStyler() + .label(textStyle) + .borderRadius( + BorderRadiusGeometryMix.circular( + (0.5 + 0.2 * fontSize) * fortalRadiusFactor(context), + ), + ); + if (decorated) { + recipe = recipe.padding( + EdgeInsetsGeometryMix.symmetric( + horizontal: 0.25 * fontSize, + vertical: 0.10 * fontSize, + ), + ); + } + if (fill != null) recipe = recipe.color(fill); + + if (variant == .outline) { + final ringWidth = math.max(1.0, 0.033 * fontSize); + recipe = recipe.containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: fortalResolveColor( + context, + highContrast ? FortalTokens.accentA7 : FortalTokens.accentA8, + ), + spreadRadius: ringWidth, + ), + if (highContrast) + RemixBoxShadowMix( + kind: .inset, + color: fortalResolveColor(context, FortalTokens.grayA11), + spreadRadius: ringWidth, + ), + ], + ), + ), + ); + } + + return recipe.merge(style); +} + +final class _AmbientCodeForegroundDirective extends Directive { + _AmbientCodeForegroundDirective(TextStyle ambient) + : color = ambient.color, + foreground = ambient.foreground; + + final Color? color; + final Paint? foreground; + + @override + String get key => 'fortal_code_ambient_foreground'; + + @override + TextStyle apply(TextStyle style) { + final hasAmbientFallback = _ambientCodeForegroundFallbacks[style] ?? false; + if (!hasAmbientFallback && + (style.color != null || style.foreground != null)) { + return style; + } + if (color == null && foreground == null) return style; + + late final TextStyle result; + if (foreground case final paint?) { + result = style.copyWith(foreground: paint); + } else if (style.foreground != null) { + // Mix concatenates directives when recipes merge. If an earlier fallback + // supplied a Paint, copyWith cannot clear it in favour of a Color. Keep + // the equivalent Paint representation so the later recipe still wins + // without producing an invalid TextStyle(color:, foreground:). + result = style.copyWith(foreground: Paint()..color = color!); + } else { + result = style.copyWith(color: color); + } + + // Expando keeps provenance out of TextStyle's visual and diagnostic + // fields, works with assertions disabled, and does not retain resolved + // styles after Mix finishes applying the directive list. + _ambientCodeForegroundFallbacks[result] = true; + return result; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _AmbientCodeForegroundDirective && + other.color == color && + other.foreground == foreground; + + @override + int get hashCode => Object.hash(color, foreground); +} + +final _ambientCodeForegroundFallbacks = Expando( + 'fortal_code_ambient_foreground', +); + +/// Token-backed standalone code text with the Radix Code variants. +/// +/// Code carries no accessibility role: Flutter has no code semantics, and +/// inventing one would misreport the content. +class FortalCode extends StatelessWidget { + const FortalCode( + this.text, { + super.key, + this.size, + this.variant = FortalCodeVariant.soft, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : assert(text != ''); + + const FortalCode.solid( + this.text, { + super.key, + this.size, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : variant = FortalCodeVariant.solid, + assert(text != ''); + + const FortalCode.soft( + this.text, { + super.key, + this.size, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : variant = FortalCodeVariant.soft, + assert(text != ''); + + const FortalCode.outline( + this.text, { + super.key, + this.size, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : variant = FortalCodeVariant.outline, + assert(text != ''); + + const FortalCode.ghost( + this.text, { + super.key, + this.size, + this.weight, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const BadgeStyler.create(), + }) : variant = FortalCodeVariant.ghost, + assert(text != ''); + + final String text; + final FortalTextSize? size; + final FortalCodeVariant variant; + final FortalTextWeight? weight; + final bool softWrap; + final bool truncate; + final bool accent; + final bool highContrast; + final BadgeStyler style; + + @override + Widget build(BuildContext context) => fortalCodeStyle( + context, + size: size, + variant: variant, + weight: weight, + softWrap: softWrap, + truncate: truncate, + accent: accent, + highContrast: highContrast, + style: style, + )(label: text); +} diff --git a/registry_source/lib/src/fortal/components/data_list.dart b/registry_source/lib/src/fortal/components/data_list.dart new file mode 100644 index 000000000..e90eaf2a8 --- /dev/null +++ b/registry_source/lib/src/fortal/components/data_list.dart @@ -0,0 +1,52 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'data_list.g.dart'; + +/// Radix Themes DataList size presets. +enum FortalDataListSize { size1, size2, size3 } + +/// Fortal recipe for [RemixDataList]. +@MixWidget(target: RemixDataList.new) +DataListStyler fortalDataListStyle({ + FortalDataListSize size = .size2, + bool highContrast = false, + DataListStyler style = const DataListStyler.create(), +}) { + final metrics = _fortalDataListMetrics(size); + + return DataListStyler() + .label( + TextStyler() + .style(metrics.text.mix()) + .fontWeight(FortalTokens.fontWeightRegular()) + .color( + highContrast ? FortalTokens.gray12() : FortalTokens.grayA11(), + ), + ) + .value( + TextStyler() + .style(metrics.text.mix()) + .fontWeight(FortalTokens.fontWeightRegular()) + .color(FortalTokens.gray12()), + ) + .rowSpacing(metrics.rowSpacing) + .columnSpacing(metrics.rowSpacing) + .labelValueSpacing(FortalTokens.space1()) + .minLabelWidth(FortalTokens.dataListLabelMinWidth()) + .merge(style); +} + +({TextStyleToken text, double rowSpacing}) _fortalDataListMetrics( + FortalDataListSize size, +) => switch (size) { + .size1 => (text: FortalTokens.text1, rowSpacing: FortalTokens.space3()), + .size2 => (text: FortalTokens.text2, rowSpacing: FortalTokens.space4()), + .size3 => ( + text: FortalTokens.text3, + rowSpacing: FortalTokens.dataListRowGap3(), + ), +}; diff --git a/registry_source/lib/src/fortal/components/data_list.g.dart b/registry_source/lib/src/fortal/components/data_list.g.dart new file mode 100644 index 000000000..5788311d6 --- /dev/null +++ b/registry_source/lib/src/fortal/components/data_list.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_list.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal recipe for [RemixDataList]. +class FortalDataList extends StatelessWidget { + const FortalDataList({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const DataListStyler.create(), + required this.items, + this.orientation = Axis.horizontal, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final FortalDataListSize size; + + final bool highContrast; + + final DataListStyler style; + + final List items; + + final Axis orientation; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixDataList( + key: this.key, + style: fortalDataListStyle( + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + items: this.items, + orientation: this.orientation, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/data_table.dart b/registry_source/lib/src/fortal/components/data_table.dart new file mode 100644 index 000000000..793fceb31 --- /dev/null +++ b/registry_source/lib/src/fortal/components/data_table.dart @@ -0,0 +1,173 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'checkbox.dart'; +import 'icon_button.dart'; +import 'select.dart'; + +part 'data_table.g.dart'; + +/// Radix Themes Table size presets. +enum FortalDataTableSize { size1, size2, size3 } + +/// Radix Themes Table variants. +enum FortalDataTableVariant { surface, ghost } + +/// Resolved Radix `table.css` metrics for one size step. +typedef _FortalDataTableMetrics = ({ + double paddingX, + double paddingY, + double minHeight, + double sortIconSize, + Radius radius, + TextStyleToken text, +}); + +/// Fortal recipe for [RemixDataTable]. +/// +/// Sizes and variants map `@radix-ui/themes@3.3.0` `table.css` exactly: cell +/// padding, minimum cell height, typography, radius, the `gray-a5` row +/// divider, bold column headers, the surface panel/border, the `gray-a2` +/// header background, and the suppressed divider under a surface table's last +/// row. +/// +/// Sorting, selection, pagination, and row hover have no Radix counterpart — +/// Radix's Table is a passive layout. They are Fortal extensions built from +/// existing accent/gray control tokens and are recorded as extensions in the +/// parity manifest. +@MixWidget(target: RemixDataTable.new) +DataTableStyler fortalDataTableStyle({ + FortalDataTableSize size = .size2, + FortalDataTableVariant variant = .ghost, + DataTableStyler style = const DataTableStyler.create(), +}) { + final metrics = _fortalDataTableMetrics(size); + final base = DataTableStyler() + .cellText( + TextStyler(style: metrics.text.mix()).color(FortalTokens.gray12()), + ) + .headerLabel( + TextStyler(style: metrics.text.mix()) + .fontWeight(FortalTokens.fontWeightBold()) + .color(FortalTokens.gray12()), + ) + .footerLabel( + TextStyler(style: FortalTokens.text1.mix()) + .fontWeight(FortalTokens.fontWeightRegular()) + .color(FortalTokens.gray11()), + ) + .headerCell(_fortalDataTableCell(metrics)) + .bodyCell(_fortalDataTableCell(metrics)) + // The selection column is a Fortal extension with no Radix counterpart. + // It carries no padding of its own, so the composed checkbox's + // interaction target — sized to this cell — spans the whole column and + // the full row height instead of being inset from both. + .selectionCell(BoxStyler().alignment(Alignment.center)) + .headerMinHeight(metrics.minHeight) + .rowMinHeight(metrics.minHeight) + .selectionColumnWidth(FortalTokens.space8()) + .sortIconSpacing(FortalTokens.space1()) + .sortIcon( + IconStyler(color: FortalTokens.gray11(), size: metrics.sortIconSize), + ) + .headerRow(_fortalDataTableRowDivider()) + .bodyRow( + _fortalDataTableRowDivider() + .color(const Color(0x00000000)) + // Hover and selection are Fortal extensions. Both are pure color + // layers, so a row never changes geometry when either applies. + .onHovered(.color(FortalTokens.grayA3())) + .onSelected( + .color( + FortalTokens.accentA3(), + ).onHovered(.color(FortalTokens.accentA4())), + ), + ) + .footer(_fortalDataTableFooter()) + .selectionCheckbox(fortalCheckboxStyle(size: .size1)) + .pageButton(fortalIconButtonStyle(variant: .ghost, size: .size1)) + .pageSizeSelect(fortalSelectStyle(variant: .ghost, size: .size1)); + + return (switch (variant) { + .surface => _fortalDataTableSurface(base, metrics.radius), + .ghost => base.color(const Color(0x00000000)), + }).merge(style); +} + +_FortalDataTableMetrics _fortalDataTableMetrics(FortalDataTableSize size) => + switch (size) { + .size1 => ( + paddingX: FortalTokens.space2(), + paddingY: FortalTokens.space2(), + minHeight: FortalTokens.dataTableRowHeight1(), + sortIconSize: 14.0, + radius: FortalTokens.radius3(), + text: FortalTokens.text2, + ), + .size2 => ( + paddingX: FortalTokens.space3(), + paddingY: FortalTokens.space3(), + minHeight: FortalTokens.dataTableRowHeight2(), + sortIconSize: 16.0, + radius: FortalTokens.radius4(), + text: FortalTokens.text2, + ), + .size3 => ( + paddingX: FortalTokens.space4(), + paddingY: FortalTokens.space3(), + minHeight: FortalTokens.space8(), + sortIconSize: 18.0, + radius: FortalTokens.radius4(), + text: FortalTokens.text3, + ), + }; + +BoxStyler _fortalDataTableCell(_FortalDataTableMetrics metrics) => BoxStyler() + .padding(.horizontal(metrics.paddingX)) + .padding(.vertical(metrics.paddingY)); + +/// Radix draws the row divider as `inset 0 -1px var(--gray-a5)`, which paints +/// over the cell without reserving layout space. A foreground border is the +/// Flutter equivalent; a regular border would inset the cell content by 1px. +BoxStyler _fortalDataTableRowDivider() => BoxStyler().foregroundDecoration( + BoxDecorationMix(border: BoxBorderMix.bottom(_fortalDataTableDividerSide())), +); + +/// The `gray-a5` 1px edge shared by the row divider and the footer's top +/// border, so the footer reads as a continuation of the last row's divider. +BorderSideMix _fortalDataTableDividerSide() => + BorderSideMix(color: FortalTokens.grayA5(), width: 1); + +FlexBoxStyler _fortalDataTableFooter() => FlexBoxStyler() + .direction(.horizontal) + .spacing(FortalTokens.space2()) + .padding(.horizontal(FortalTokens.space4())) + .padding(.vertical(FortalTokens.space2())) + .foregroundDecoration( + BoxDecorationMix(border: BoxBorderMix.top(_fortalDataTableDividerSide())), + ); + +DataTableStyler _fortalDataTableSurface(DataTableStyler base, Radius radius) { + return base + .container( + fortalSurfaceFrame( + fillColor: FortalTokens.colorPanel(), + borderColor: FortalTokens.dataTableBorder(), + borderWidth: FortalTokens.borderWidth1(), + radius: radius, + ), + ) + .containerEffects( + RemixBoxEffectsMix.backdropBlur(FortalTokens.panelBlur()), + ) + .headerRow(.color(FortalTokens.grayA2())) + // Radix clears `--table-row-box-shadow` on the surface variant's last + // row so its divider never doubles up with the panel border. + .lastBodyRow( + BoxStyler().foregroundDecoration( + BoxDecorationMix(border: BoxBorderMix.bottom(BorderSideMix.none)), + ), + ); +} diff --git a/registry_source/lib/src/fortal/components/data_table.g.dart b/registry_source/lib/src/fortal/components/data_table.g.dart new file mode 100644 index 000000000..15063563a --- /dev/null +++ b/registry_source/lib/src/fortal/components/data_table.g.dart @@ -0,0 +1,196 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_table.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal recipe for [RemixDataTable]. +/// +/// Sizes and variants map `@radix-ui/themes@3.3.0` `table.css` exactly: cell +/// padding, minimum cell height, typography, radius, the `gray-a5` row +/// divider, bold column headers, the surface panel/border, the `gray-a2` +/// header background, and the suppressed divider under a surface table's last +/// row. +/// +/// Sorting, selection, pagination, and row hover have no Radix counterpart — +/// Radix's Table is a passive layout. They are Fortal extensions built from +/// existing accent/gray control tokens and are recorded as extensions in the +/// parity manifest. +class FortalDataTable extends StatelessWidget { + const FortalDataTable({ + super.key, + this.size = .size2, + this.variant = .ghost, + this.style = const DataTableStyler.create(), + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon, + this.sortAscendingIcon, + this.sortDescendingIcon, + this.previousPageIcon, + this.nextPageIcon, + }); + + const FortalDataTable.surface({ + super.key, + this.size = .size2, + this.style = const DataTableStyler.create(), + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon, + this.sortAscendingIcon, + this.sortDescendingIcon, + this.previousPageIcon, + this.nextPageIcon, + }) : variant = FortalDataTableVariant.surface; + + const FortalDataTable.ghost({ + super.key, + this.size = .size2, + this.style = const DataTableStyler.create(), + required this.rows, + required this.columns, + this.semanticLabel, + this.sort, + this.onSortChanged, + this.rowId, + this.selectedRowIds = const {}, + this.onSelectionChanged, + this.totalRows, + this.pageIndex = 0, + this.pageSize = 10, + this.pageSizeOptions = const [10, 20, 50], + this.onPageChanged, + this.onPageSizeChanged, + this.minimumWidth = 0, + this.emptyBuilder, + this.labels = const RemixDataTableLabels(), + this.pageRangeFormatter = remixDefaultDataTablePageRangeFormatter, + this.sortableIcon, + this.sortAscendingIcon, + this.sortDescendingIcon, + this.previousPageIcon, + this.nextPageIcon, + }) : variant = FortalDataTableVariant.ghost; + + final FortalDataTableSize size; + + final FortalDataTableVariant variant; + + final DataTableStyler style; + + final List rows; + + final List> columns; + + final String? semanticLabel; + + final RemixDataTableSort? sort; + + final ValueChanged? onSortChanged; + + final Object Function(T row)? rowId; + + final Set selectedRowIds; + + final ValueChanged>? onSelectionChanged; + + final int? totalRows; + + final int pageIndex; + + final int pageSize; + + final List pageSizeOptions; + + final ValueChanged? onPageChanged; + + final ValueChanged? onPageSizeChanged; + + final double minimumWidth; + + final WidgetBuilder? emptyBuilder; + + final RemixDataTableLabels labels; + + final RemixDataTablePageRangeFormatter pageRangeFormatter; + + final IconData? sortableIcon; + + final IconData? sortAscendingIcon; + + final IconData? sortDescendingIcon; + + final IconData? previousPageIcon; + + final IconData? nextPageIcon; + + @override + Widget build(BuildContext context) { + return RemixDataTable( + key: this.key, + style: fortalDataTableStyle( + size: this.size, + variant: this.variant, + style: this.style, + ), + rows: this.rows, + columns: this.columns, + semanticLabel: this.semanticLabel, + sort: this.sort, + onSortChanged: this.onSortChanged, + rowId: this.rowId, + selectedRowIds: this.selectedRowIds, + onSelectionChanged: this.onSelectionChanged, + totalRows: this.totalRows, + pageIndex: this.pageIndex, + pageSize: this.pageSize, + pageSizeOptions: this.pageSizeOptions, + onPageChanged: this.onPageChanged, + onPageSizeChanged: this.onPageSizeChanged, + minimumWidth: this.minimumWidth, + emptyBuilder: this.emptyBuilder, + labels: this.labels, + pageRangeFormatter: this.pageRangeFormatter, + sortableIcon: this.sortableIcon, + sortAscendingIcon: this.sortAscendingIcon, + sortDescendingIcon: this.sortDescendingIcon, + previousPageIcon: this.previousPageIcon, + nextPageIcon: this.nextPageIcon, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/dialog.dart b/registry_source/lib/src/fortal/components/dialog.dart new file mode 100644 index 000000000..36dd3699c --- /dev/null +++ b/registry_source/lib/src/fortal/components/dialog.dart @@ -0,0 +1,96 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'dialog.g.dart'; + +/// Fortal dialog size presets matching Radix Themes 3.3.0. +enum FortalDialogSize { size1, size2, size3, size4 } + +/// Fortal dialog vertical alignment matching Radix Themes 3.3.0. +enum FortalDialogAlign { start, center } + +final _dialogViewportInsets = ContextToken((context) { + final safeArea = MediaQuery.paddingOf(context); + final viewportHeight = MediaQuery.sizeOf(context).height; + final horizontal = FortalTokens.space4.resolve(context); + final vertical = FortalTokens.space6.resolve(context); + + return EdgeInsets.fromLTRB( + math.max(safeArea.left, horizontal), + math.max(safeArea.top, vertical), + math.max(safeArea.right, horizontal), + math.max(safeArea.bottom, math.max(vertical, viewportHeight * 0.06)), + ); +}); + +/// Fortal-themed preset for [RemixDialog]. +/// +/// The generated [FortalDialog] defaults to [FortalDialogSize.size3], +/// [FortalDialogAlign.center], fills up to 600 logical pixels, preserves safe +/// viewport insets, and is modal. +@MixWidget(target: RemixDialog.new) +DialogStyler fortalDialogStyle({ + FortalDialogSize size = FortalDialogSize.size3, + FortalDialogAlign align = FortalDialogAlign.center, + DialogStyler style = const DialogStyler.create(), +}) { + final radius = switch (size) { + FortalDialogSize.size1 || FortalDialogSize.size2 => FortalTokens.radius4(), + FortalDialogSize.size3 || FortalDialogSize.size4 => FortalTokens.radius5(), + }; + final padding = switch (size) { + FortalDialogSize.size1 => FortalTokens.space3(), + FortalDialogSize.size2 => FortalTokens.space4(), + FortalDialogSize.size3 => FortalTokens.space5(), + FortalDialogSize.size4 => FortalTokens.space6(), + }; + final alignment = switch (align) { + FortalDialogAlign.start => Alignment.topCenter, + FortalDialogAlign.center => Alignment.center, + }; + + return DialogStyler() + .wrap( + .modifier( + PaddingModifierMix.create(padding: Prop.token(_dialogViewportInsets)), + ).align(alignment: alignment).orderOfModifiers([ + PaddingModifier, + AlignModifier, + ]), + ) + .title( + .style(FortalTokens.text5.mix()) + .fontWeight(FortalTokens.fontWeightBold()) + .color(FortalTokens.gray12()) + .wrap( + .padding(EdgeInsetsMix.fromLTRB(0, 0, 0, FortalTokens.space3())), + ), + ) + .description( + TextStyler( + style: FortalTokens.text3.mix(), + ).color(FortalTokens.gray12()), + ) + .actions( + FlexBoxStyler() + .mainAxisAlignment(.end) + .spacing(FortalTokens.space3()) + .margin(.top(FortalTokens.space5())), + ) + .width(600) + .padding(.all(padding)) + .borderRadius(.all(radius)) + .color(FortalTokens.colorPanel()) + .decoration( + BoxDecorationMix.create(boxShadow: FortalTokens.shadow6.mix()), + ) + .containerEffects( + RemixBoxEffectsMix.backdropBlur(FortalTokens.panelBlur()), + ) + .merge(style); +} diff --git a/registry_source/lib/src/fortal/components/dialog.g.dart b/registry_source/lib/src/fortal/components/dialog.g.dart new file mode 100644 index 000000000..16d3dcefd --- /dev/null +++ b/registry_source/lib/src/fortal/components/dialog.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'dialog.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixDialog]. +/// +/// The generated [FortalDialog] defaults to [FortalDialogSize.size3], +/// [FortalDialogAlign.center], fills up to 600 logical pixels, preserves safe +/// viewport insets, and is modal. +class FortalDialog extends StatelessWidget { + const FortalDialog({ + super.key, + this.size = FortalDialogSize.size3, + this.align = FortalDialogAlign.center, + this.style = const DialogStyler.create(), + this.child, + this.title, + this.description, + this.actions, + this.scrollable = false, + this.modal = true, + this.semanticLabel, + }); + + final FortalDialogSize size; + + final FortalDialogAlign align; + + final DialogStyler style; + + final Widget? child; + + final String? title; + + final String? description; + + final List? actions; + + final bool scrollable; + + final bool modal; + + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + return RemixDialog( + key: this.key, + style: fortalDialogStyle( + size: this.size, + align: this.align, + style: this.style, + ), + child: this.child, + title: this.title, + description: this.description, + actions: this.actions, + scrollable: this.scrollable, + modal: this.modal, + semanticLabel: this.semanticLabel, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/disclosure.dart b/registry_source/lib/src/fortal/components/disclosure.dart new file mode 100644 index 000000000..7b30df01d --- /dev/null +++ b/registry_source/lib/src/fortal/components/disclosure.dart @@ -0,0 +1,194 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'disclosure.g.dart'; + +/// Fortal disclosure size presets. +enum FortalDisclosureSize { size1, size2, size3 } + +/// Fortal disclosure color variants. +enum FortalDisclosureVariant { surface, soft } + +/// Fortal-themed preset for [RemixDisclosure]. +@MixWidget(target: RemixDisclosure.new) +DisclosureStyler fortalDisclosureStyle({ + FortalDisclosureVariant variant = .surface, + FortalDisclosureSize size = .size2, + DisclosureStyler style = const DisclosureStyler.create(), +}) { + return (switch (variant) { + .surface => _fortalDisclosureSurfaceStyler(size), + .soft => _fortalDisclosureSoftStyler(size), + }).merge(style); +} + +// Panel anatomy follows the mapped Table family (see data_table.dart): +// `container` alone owns radius, frame, fill, and clipping, while trigger and +// content stay flat rectangles that simply get cropped to its rounded shape. +// The frame and divider are foreground borders so edge-to-edge child fills +// cannot partially cover their antialiased edges. +DisclosureStyler _fortalDisclosureBaseStyler(FortalDisclosureSize size) { + final metrics = _fortalDisclosureMetrics(size); + + return DisclosureStyler() + .trigger( + BoxStyler() + .width(.infinity) + .alignment(.centerLeft) + .padding(.all(metrics.padding)) + .wrap( + _fortalDisclosureTypography( + style: metrics.triggerText, + color: FortalTokens.gray12(), + iconColor: FortalTokens.gray11(), + iconSize: metrics.iconSize, + ), + ), + ) + .content( + BoxStyler() + .width(.infinity) + .padding(.all(metrics.padding)) + .wrap( + _fortalDisclosureTypography( + style: FortalTokens.text2, + color: FortalTokens.gray12(), + iconColor: FortalTokens.gray11(), + iconSize: metrics.iconSize, + ), + ), + ); +} + +DisclosureStyler _fortalDisclosureSurfaceStyler(FortalDisclosureSize size) { + final metrics = _fortalDisclosureMetrics(size); + return _fortalDisclosureBaseStyler(size) + .container( + fortalSurfaceFrame( + fillColor: FortalTokens.gray2(), + borderColor: FortalTokens.gray6(), + borderWidth: FortalTokens.borderWidth1(), + radius: metrics.radius, + ), + ) + .trigger(.color(FortalTokens.gray1())) + .content( + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.top( + _fortalDisclosureBorderSide(FortalTokens.gray6()), + ), + ), + ), + ) + .onHovered(.trigger(.color(FortalTokens.gray2()))) + .onPressed(.trigger(.color(FortalTokens.gray3()))) + .onFocusVisible(DisclosureStyler().fortalFocusRing()) + .onDisabled(_fortalDisclosureDisabledStyler()); +} + +DisclosureStyler _fortalDisclosureSoftStyler(FortalDisclosureSize size) { + final metrics = _fortalDisclosureMetrics(size); + return _fortalDisclosureBaseStyler(size) + .container( + fortalSurfaceFrame( + fillColor: FortalTokens.accent2(), + borderColor: FortalTokens.accent6(), + borderWidth: FortalTokens.borderWidth1(), + radius: metrics.radius, + ), + ) + .trigger( + BoxStyler() + .color(FortalTokens.accent2()) + .wrap( + _fortalDisclosureForeground( + color: FortalTokens.accent12(), + iconColor: FortalTokens.accent11(), + ), + ), + ) + .content( + BoxStyler() + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.top( + _fortalDisclosureBorderSide(FortalTokens.accent6()), + ), + ), + ) + .wrap( + _fortalDisclosureForeground( + color: FortalTokens.accent12(), + iconColor: FortalTokens.accent11(), + ), + ), + ) + .onHovered(.trigger(.color(FortalTokens.accent3()))) + .onPressed(.trigger(.color(FortalTokens.accent4()))) + .onFocusVisible(DisclosureStyler().fortalFocusRing()) + .onDisabled(_fortalDisclosureDisabledStyler()); +} + +DisclosureStyler _fortalDisclosureDisabledStyler() { + return DisclosureStyler().trigger( + BoxStyler() + .color(FortalTokens.grayA3()) + .wrap( + _fortalDisclosureForeground( + color: FortalTokens.gray8(), + iconColor: FortalTokens.gray8(), + ), + ), + ); +} + +BorderSideMix _fortalDisclosureBorderSide(Color color) => + BorderSideMix(color: color, width: FortalTokens.borderWidth1()); + +WidgetModifierConfig _fortalDisclosureTypography({ + required TextStyleToken style, + required Color color, + required Color iconColor, + required double iconSize, +}) { + return WidgetModifierConfig.defaultTextStyle(style: style.mix()) + .defaultTextStyle(style: TextStyleMix().color(color)) + .merge(WidgetModifierConfig.iconTheme(color: iconColor, size: iconSize)); +} + +WidgetModifierConfig _fortalDisclosureForeground({ + required Color color, + required Color iconColor, +}) { + return WidgetModifierConfig.defaultTextStyle( + style: TextStyleMix().color(color), + ).merge(WidgetModifierConfig.iconTheme(color: iconColor)); +} + +({double padding, Radius radius, TextStyleToken triggerText, double iconSize}) +_fortalDisclosureMetrics(FortalDisclosureSize size) { + return switch (size) { + .size1 => ( + padding: FortalTokens.space2(), + radius: FortalTokens.radius3(), + triggerText: FortalTokens.text2, + iconSize: FortalTokens.space4(), + ), + .size2 => ( + padding: FortalTokens.space3(), + radius: FortalTokens.radius4(), + triggerText: FortalTokens.accordionText2, + iconSize: FortalTokens.spinnerSize3(), + ), + .size3 => ( + padding: FortalTokens.space4(), + radius: FortalTokens.radius5(), + triggerText: FortalTokens.text3, + iconSize: FortalTokens.space5(), + ), + }; +} diff --git a/registry_source/lib/src/fortal/components/disclosure.g.dart b/registry_source/lib/src/fortal/components/disclosure.g.dart new file mode 100644 index 000000000..331df50a4 --- /dev/null +++ b/registry_source/lib/src/fortal/components/disclosure.g.dart @@ -0,0 +1,173 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'disclosure.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixDisclosure]. +class FortalDisclosure extends StatelessWidget { + const FortalDisclosure({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const DisclosureStyler.create(), + required this.trigger, + required this.content, + this.triggerBuilder, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.transitionBuilder, + this.animationStyle = const AnimationStyle( + curve: Curves.ease, + duration: Duration(milliseconds: 200), + reverseDuration: Duration(milliseconds: 200), + ), + }); + + const FortalDisclosure.surface({ + super.key, + this.size = .size2, + this.style = const DisclosureStyler.create(), + required this.trigger, + required this.content, + this.triggerBuilder, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.transitionBuilder, + this.animationStyle = const AnimationStyle( + curve: Curves.ease, + duration: Duration(milliseconds: 200), + reverseDuration: Duration(milliseconds: 200), + ), + }) : variant = FortalDisclosureVariant.surface; + + const FortalDisclosure.soft({ + super.key, + this.size = .size2, + this.style = const DisclosureStyler.create(), + required this.trigger, + required this.content, + this.triggerBuilder, + this.expanded, + this.defaultExpanded = false, + this.onExpandedChanged, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.transitionBuilder, + this.animationStyle = const AnimationStyle( + curve: Curves.ease, + duration: Duration(milliseconds: 200), + reverseDuration: Duration(milliseconds: 200), + ), + }) : variant = FortalDisclosureVariant.soft; + + final FortalDisclosureVariant variant; + + final FortalDisclosureSize size; + + final DisclosureStyler style; + + final Widget trigger; + + final Widget content; + + final ValueWidgetBuilder? triggerBuilder; + + final bool? expanded; + + final bool defaultExpanded; + + final ValueChanged? onExpandedChanged; + + final bool enabled; + + final MouseCursor mouseCursor; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final ValueChanged? onFocusChange; + + final ValueChanged? onHoverChange; + + final ValueChanged? onPressChange; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + final NakedDisclosureTransitionBuilder? transitionBuilder; + + final AnimationStyle animationStyle; + + @override + Widget build(BuildContext context) { + return RemixDisclosure( + key: this.key, + style: fortalDisclosureStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + trigger: this.trigger, + content: this.content, + triggerBuilder: this.triggerBuilder, + expanded: this.expanded, + defaultExpanded: this.defaultExpanded, + onExpandedChanged: this.onExpandedChanged, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + onFocusChange: this.onFocusChange, + onHoverChange: this.onHoverChange, + onPressChange: this.onPressChange, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + transitionBuilder: this.transitionBuilder, + animationStyle: this.animationStyle, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/divider.dart b/registry_source/lib/src/fortal/components/divider.dart new file mode 100644 index 000000000..7223874c6 --- /dev/null +++ b/registry_source/lib/src/fortal/components/divider.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'divider.g.dart'; + +/// Fortal divider length presets: 16, 32, 64, or the available axis extent. +enum FortalDividerSize { size1, size2, size3, size4 } + +/// Fortal-themed preset for [RemixDivider]. +@MixWidget(target: RemixDivider.new) +DividerStyler fortalDividerStyle({ + FortalDividerSize size = .size1, + Axis orientation = Axis.horizontal, + DividerStyler style = const DividerStyler.create(), +}) { + return DividerStyler() + .color(FortalTokens.gray6()) + .merge(_fortalDividerSizeStyler(size, orientation)) + .merge(style); +} + +DividerStyler _fortalDividerSizeStyler( + FortalDividerSize size, + Axis orientation, +) { + final length = switch (size) { + .size1 => FortalTokens.space4(), + .size2 => FortalTokens.space6(), + .size3 => FortalTokens.space9(), + .size4 => null, + }; + if (orientation == Axis.horizontal) { + final style = DividerStyler().height(FortalTokens.borderWidth1()); + return length == null + ? style.wrap( + WidgetModifierConfig.fractionallySizedBox(widthFactor: 1).align(), + ) + : style.width(length); + } + final style = DividerStyler().width(FortalTokens.borderWidth1()); + return length == null + ? style.wrap( + WidgetModifierConfig.fractionallySizedBox(heightFactor: 1).align(), + ) + : style.height(length); +} diff --git a/registry_source/lib/src/fortal/components/divider.g.dart b/registry_source/lib/src/fortal/components/divider.g.dart new file mode 100644 index 000000000..6b22ed351 --- /dev/null +++ b/registry_source/lib/src/fortal/components/divider.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'divider.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixDivider]. +class FortalDivider extends StatelessWidget { + const FortalDivider({ + super.key, + this.size = .size1, + this.orientation = Axis.horizontal, + this.style = const DividerStyler.create(), + }); + + final FortalDividerSize size; + + final Axis orientation; + + final DividerStyler style; + + @override + Widget build(BuildContext context) { + return RemixDivider( + key: this.key, + style: fortalDividerStyle( + size: this.size, + orientation: this.orientation, + style: this.style, + ), + ); + } +} diff --git a/registry_source/lib/src/fortal/components/heading.dart b/registry_source/lib/src/fortal/components/heading.dart new file mode 100644 index 000000000..606288e67 --- /dev/null +++ b/registry_source/lib/src/fortal/components/heading.dart @@ -0,0 +1,116 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +/// Fortal-themed heading style on the Radix nine-step scale. +/// +/// Radix's `--heading-font-size-adjust` is `1`, so headings use the raw token +/// size; only the line box differs from body text. Each ratio below is the +/// pinned Radix heading line height over its font size, so both scale together +/// and the ratio stays constant across theme scaling. +/// +/// This is a plain recipe rather than a `@MixWidget`: a generated widget only +/// renders the styler, and [FortalHeading] must additionally publish a native +/// heading node that generation cannot supply. +TextStyler fortalHeadingStyle({ + FortalTextSize size = .size6, + FortalTextWeight weight = .bold, + TextAlign? align, + bool softWrap = true, + bool truncate = false, + bool accent = false, + bool highContrast = false, + TextStyler style = const TextStyler.create(), +}) { + final lineHeight = switch (size) { + .size1 => 16.0 / 12.0, + .size2 => 18.0 / 14.0, + .size3 => 22.0 / 16.0, + .size4 => 24.0 / 18.0, + .size5 => 26.0 / 20.0, + .size6 => 30.0 / 24.0, + .size7 => 36.0 / 28.0, + .size8 => 40.0 / 35.0, + .size9 => 1.0, + }; + + var recipe = TextStyler( + style: fortalTextSizeToken(size).mix(), + ).height(lineHeight).fontWeight(fortalTextWeightToken(weight)()); + // Neutral headings pin `gray12` from the tokens rather than inheriting the + // ambient foreground, matching fortalTextStyle's token-default contract. + recipe = accent + ? fortalAccentForeground(recipe, highContrast: highContrast) + : recipe.color(FortalTokens.gray12()); + recipe = recipe.inherit(false); + + return fortalApplyTextFlow( + recipe, + align: align, + softWrap: softWrap, + truncate: truncate, + ).merge(style); +} + +/// Token-backed visual heading with an independent native heading level. +/// +/// [headingLevel] drives the accessibility level only; changing it never +/// changes the visual [size], matching Radix. +class FortalHeading extends StatelessWidget { + const FortalHeading( + this.text, { + super.key, + this.headingLevel = 1, + this.size = FortalTextSize.size6, + this.weight = FortalTextWeight.bold, + this.align, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const TextStyler.create(), + }) : assert(text != ''), + assert(headingLevel >= 1 && headingLevel <= 6), + assert(semanticLabel == null || semanticLabel != ''); + + final String text; + final int headingLevel; + final FortalTextSize size; + final FortalTextWeight weight; + final TextAlign? align; + final bool softWrap; + final bool truncate; + final bool accent; + final bool highContrast; + final String? semanticLabel; + final bool excludeSemantics; + final TextStyler style; + + @override + Widget build(BuildContext context) { + final content = fortalHeadingStyle( + size: size, + weight: weight, + align: align, + softWrap: softWrap, + truncate: truncate, + accent: accent, + highContrast: highContrast, + style: style, + )(text); + + if (excludeSemantics) return ExcludeSemantics(child: content); + + return Semantics( + header: true, + headingLevel: headingLevel, + label: semanticLabel ?? text, + excludeSemantics: true, + child: content, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/icon_button.dart b/registry_source/lib/src/fortal/components/icon_button.dart new file mode 100644 index 000000000..e47d9bbac --- /dev/null +++ b/registry_source/lib/src/fortal/components/icon_button.dart @@ -0,0 +1,141 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import 'base_button.dart'; +import '../theme/theme.dart'; + +part 'icon_button.g.dart'; + +/// Radix Themes IconButton size presets. +enum FortalIconButtonSize { size1, size2, size3, size4 } + +/// Radix Themes IconButton variants. +enum FortalIconButtonVariant { classic, solid, soft, surface, outline, ghost } + +/// Fortal-themed IconButton with the Radix size, variant, and override contract. +@MixWidget(target: RemixIconButton.new) +IconButtonStyler fortalIconButtonStyle({ + FortalIconButtonVariant variant = .solid, + FortalIconButtonSize size = .size2, + bool highContrast = false, + IconButtonStyler style = const IconButtonStyler.create(), +}) { + final base = _fortalIconButtonBaseStyler( + variant, + _fortalBaseButtonSize(size), + ); + final stateStyles = fortalBaseButtonStateStyles( + variant: _fortalBaseButtonVariant(variant), + highContrast: highContrast, + ); + + return _applyFortalIconButtonStateStyles( + base, + stateStyles, + pressedPaddingTop: variant == .classic ? (size == .size1 ? 1 : 2) : null, + ).merge(style); +} + +IconButtonStyler _fortalIconButtonBaseStyler( + FortalIconButtonVariant variant, + FortalBaseButtonSize size, +) { + final metrics = fortalBaseButtonMetrics(size); + var style = IconButtonStyler( + icon: .size(fortalBaseButtonIconSize(size)), + spinner: .size(metrics.spinnerSize) + .opacity(0.65) + .leafRadius(FortalTokens.radius1()) + .duration(const Duration(milliseconds: 800)), + ).borderRadius(.all(metrics.radius)); + + if (variant == .ghost) { + final ghost = fortalIconButtonGhostMetrics(size); + style = style.padding(.all(ghost.padding)).margin(.all(ghost.margin)); + } else { + style = style + .container(.alignment(.center)) + .width(metrics.height) + .height(metrics.height); + } + return style; +} + +FortalBaseButtonVariant _fortalBaseButtonVariant( + FortalIconButtonVariant variant, +) => switch (variant) { + .classic => .classic, + .solid => .solid, + .soft => .soft, + .surface => .surface, + .outline => .outline, + .ghost => .ghost, +}; + +FortalBaseButtonSize _fortalBaseButtonSize(FortalIconButtonSize size) => + switch (size) { + .size1 => .size1, + .size2 => .size2, + .size3 => .size3, + .size4 => .size4, + }; + +IconButtonStyler _applyFortalIconButtonStateStyles( + IconButtonStyler base, + FortalBaseButtonStateStyles stateStyles, { + required double? pressedPaddingTop, +}) { + var pressed = _applyFortalIconButtonState( + IconButtonStyler(), + stateStyles.pressed, + ); + if (pressedPaddingTop != null) { + pressed = pressed.padding(.top(pressedPaddingTop)); + } + + return _applyFortalIconButtonState(base, stateStyles.idle) + .onHovered( + _applyFortalIconButtonState(IconButtonStyler(), stateStyles.hovered), + ) + .onPressed(pressed) + .onDisabled( + _applyFortalIconButtonState(IconButtonStyler(), stateStyles.disabled), + ) + .onFocusVisible( + _applyFortalIconButtonState( + IconButtonStyler(), + stateStyles.focusVisible, + ), + ) + .onDisabled( + _applyFortalIconButtonState( + IconButtonStyler(), + stateStyles.disabledFocus, + ), + ); +} + +IconButtonStyler _applyFortalIconButtonState( + IconButtonStyler style, + FortalBaseButtonStateStyle state, +) { + var result = style; + final foreground = state.foreground; + if (foreground != null) { + result = result.icon(.color(foreground)).spinner(.color(foreground)); + } + if (state.background != null) { + result = result.color(state.background!); + } + if (state.effects != null) { + result = result.containerEffects(state.effects!); + } + if (state.spinnerOpacity != null) { + result = result.spinner(.opacity(state.spinnerOpacity!)); + } + if (state.modifier != null) { + result = result.wrap(state.modifier!); + } + return result; +} diff --git a/registry_source/lib/src/fortal/components/icon_button.g.dart b/registry_source/lib/src/fortal/components/icon_button.g.dart new file mode 100644 index 000000000..530f5292c --- /dev/null +++ b/registry_source/lib/src/fortal/components/icon_button.g.dart @@ -0,0 +1,221 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'icon_button.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed IconButton with the Radix size, variant, and override contract. +class FortalIconButton extends StatelessWidget { + const FortalIconButton({ + super.key, + this.variant = .solid, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + const FortalIconButton.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalIconButtonVariant.classic; + + const FortalIconButton.solid({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalIconButtonVariant.solid; + + const FortalIconButton.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalIconButtonVariant.soft; + + const FortalIconButton.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalIconButtonVariant.surface; + + const FortalIconButton.outline({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalIconButtonVariant.outline; + + const FortalIconButton.ghost({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const IconButtonStyler.create(), + required this.icon, + required this.semanticLabel, + this.iconBuilder, + this.loadingBuilder, + this.loading = false, + this.enabled = true, + this.enableFeedback = true, + this.onPressed, + this.onLongPress, + this.focusNode, + this.autofocus = false, + this.semanticHint, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalIconButtonVariant.ghost; + + final FortalIconButtonVariant variant; + + final FortalIconButtonSize size; + + final bool highContrast; + + final IconButtonStyler style; + + final IconData? icon; + + final String semanticLabel; + + final RemixIconButtonIconBuilder? iconBuilder; + + final RemixIconButtonLoadingBuilder? loadingBuilder; + + final bool loading; + + final bool enabled; + + final bool enableFeedback; + + final VoidCallback? onPressed; + + final VoidCallback? onLongPress; + + final FocusNode? focusNode; + + final bool autofocus; + + final String? semanticHint; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixIconButton( + key: this.key, + style: fortalIconButtonStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + icon: this.icon, + semanticLabel: this.semanticLabel, + iconBuilder: this.iconBuilder, + loadingBuilder: this.loadingBuilder, + loading: this.loading, + enabled: this.enabled, + enableFeedback: this.enableFeedback, + onPressed: this.onPressed, + onLongPress: this.onLongPress, + focusNode: this.focusNode, + autofocus: this.autofocus, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/kbd.dart b/registry_source/lib/src/fortal/components/kbd.dart new file mode 100644 index 000000000..c9de34a15 --- /dev/null +++ b/registry_source/lib/src/fortal/components/kbd.dart @@ -0,0 +1,207 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +/// Radix Themes Kbd variants. +enum FortalKbdVariant { classic, soft } + +/// Fortal-themed keyboard key. +/// +/// Like Code, the geometry is em-relative to the resolved font size, so this +/// recipe takes a [context]. Radix uses two different type-scale factors: an +/// explicit size multiplies its token by `0.8`, while an omitted one keeps +/// upstream's unsized `0.75em` — anchored to the root `text3` token rather +/// than the ambient `DefaultTextStyle`, so a host text run cannot resize the +/// key cap. +/// The resolved token supplies the font family and fallback families; Kbd +/// retains its own weight, spacing, and line box. +BadgeStyler fortalKbdStyle( + BuildContext context, { + FortalTextSize? size, + FortalKbdVariant variant = .classic, + BadgeStyler style = const BadgeStyler.create(), +}) { + final base = fortalResolveTextToken(context, size ?? FortalTextSize.size3); + final fontSize = base.fontSize! * (size == null ? 0.75 : 0.8); + // Upstream `--letter-spacing-N` is em-relative, so an explicit size resolves + // it against Kbd's own `0.8em` rather than the token's own font size. The + // unsized path keeps the token's letter spacing unscaled, matching the + // resolved value upstream's `0.75em` run would carry. + final letterSpacing = (base.letterSpacing ?? 0) * (size == null ? 1 : 0.8); + + // Kbd pins its own weight and line box regardless of the surrounding style, + // so it stays a key cap rather than following surrounding copy. + final textStyle = TextStyler() + .style( + TextStyleMix( + fontFamily: base.fontFamily, + fontFamilyFallback: base.fontFamilyFallback, + ), + ) + .fontSize(fontSize) + .fontWeight(FortalTokens.fontWeightRegular()) + .height(1.7) + .letterSpacing(letterSpacing) + .wordSpacing(-0.1 * fontSize) + .textAlign(TextAlign.center) + .softWrap(false) + .maxLines(1) + .color(FortalTokens.gray12()) + .inherit(false); + + var recipe = BadgeStyler() + .label(textStyle) + .minWidth(1.75 * fontSize) + .padding( + EdgeInsetsGeometryMix.only( + left: 0.5 * fontSize, + right: 0.5 * fontSize, + bottom: 0.05 * fontSize, + ), + ) + .borderRadius( + BorderRadiusGeometryMix.circular( + 0.35 * fontSize * fortalRadiusFactor(context), + ), + ) + .color(switch (variant) { + .classic => fortalResolveColor(context, FortalTokens.gray1), + .soft => fortalResolveColor(context, FortalTokens.grayA3), + }); + + if (variant == .classic) { + recipe = recipe.containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadows: _fortalKbdShadows(context, fontSize)), + ), + ); + } + + return recipe.merge(style); +} + +/// The pinned six-layer classic key-cap stack, in upstream paint order. +/// +/// Radix's `-0.03em` visual top nudge is deliberately skipped; a transform +/// wrapper for a sub-pixel baseline tweak is recorded as a measured visual +/// approximation instead. +List _fortalKbdShadows(BuildContext context, double em) { + final isDark = FortalTheme.of(context).isDark; + + return [ + RemixBoxShadowMix( + kind: .inset, + color: fortalResolveColor( + context, + isDark ? FortalTokens.grayA3 : FortalTokens.grayA2, + ), + offset: Offset(0, -0.05 * em), + blurRadius: 0.5 * em, + ), + RemixBoxShadowMix( + kind: .inset, + color: fortalResolveColor( + context, + isDark ? FortalTokens.grayA11 : FortalTokens.whiteA12, + ), + offset: Offset(0, 0.05 * em), + ), + RemixBoxShadowMix( + kind: .inset, + color: fortalResolveColor(context, FortalTokens.grayA2), + offset: Offset(0, 0.25 * em), + blurRadius: 0.5 * em, + ), + RemixBoxShadowMix( + kind: .inset, + color: fortalResolveColor( + context, + isDark ? FortalTokens.blackA11 : FortalTokens.grayA6, + ), + offset: Offset(0, (isDark ? -0.1 : -0.05) * em), + ), + RemixBoxShadowMix( + color: fortalResolveColor( + context, + isDark ? FortalTokens.grayA7 : FortalTokens.grayA5, + ), + spreadRadius: (isDark ? 0.075 : 0.05) * em, + ), + RemixBoxShadowMix( + color: fortalResolveColor( + context, + isDark ? FortalTokens.blackA12 : FortalTokens.grayA7, + ), + offset: Offset(0, 0.08 * em), + blurRadius: 0.17 * em, + ), + ]; +} + +/// Token-backed representation of one keyboard key or shortcut. +/// +/// Publishes a single native `keyboardKey` node and no tap action; Kbd is inert +/// upstream, so the hover/pressed selectors that apply only when it is nested +/// in an actionable element are deliberately absent. +class FortalKbd extends StatelessWidget { + const FortalKbd( + this.text, { + super.key, + this.size, + this.variant = FortalKbdVariant.classic, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const BadgeStyler.create(), + }) : assert(text != ''), + assert(semanticLabel == null || semanticLabel != ''); + + const FortalKbd.classic( + this.text, { + super.key, + this.size, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const BadgeStyler.create(), + }) : variant = FortalKbdVariant.classic, + assert(text != ''), + assert(semanticLabel == null || semanticLabel != ''); + + const FortalKbd.soft( + this.text, { + super.key, + this.size, + this.semanticLabel, + this.excludeSemantics = false, + this.style = const BadgeStyler.create(), + }) : variant = FortalKbdVariant.soft, + assert(text != ''), + assert(semanticLabel == null || semanticLabel != ''); + + final String text; + final FortalTextSize? size; + final FortalKbdVariant variant; + final String? semanticLabel; + final bool excludeSemantics; + final BadgeStyler style; + + @override + Widget build(BuildContext context) { + final content = fortalKbdStyle( + context, + size: size, + variant: variant, + style: style, + )(label: text); + + if (excludeSemantics) return ExcludeSemantics(child: content); + + return Semantics( + keyboardKey: true, + label: semanticLabel ?? text, + excludeSemantics: true, + child: content, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/link.dart b/registry_source/lib/src/fortal/components/link.dart new file mode 100644 index 000000000..ebd29e432 --- /dev/null +++ b/registry_source/lib/src/fortal/components/link.dart @@ -0,0 +1,244 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +/// Underline visibility for [FortalLink]. +enum FortalLinkUnderline { auto, always, hover, none } + +/// Fortal-themed link style. +/// +/// Takes a [context] because the focus outline's radius is em-relative to the +/// resolved font size. +/// +/// [actionable] gates every state-dependent rule, matching upstream's +/// `:where(:any-link, button)`. A non-actionable link carries no hover or +/// focus-visible variant at all, so it stays plain accent text no matter what +/// widget states are resolved around it. +LinkStyler fortalLinkStyle( + BuildContext context, { + FortalTextSize? size, + FortalTextWeight? weight, + FortalLinkUnderline underline = .auto, + bool softWrap = true, + bool truncate = false, + bool highContrast = false, + required bool actionable, + LinkStyler style = const LinkStyler.create(), +}) { + LinkStyler styleFor({bool hovered = false, bool focused = false}) => + _fortalLinkStateStyle( + context, + size: size, + weight: weight, + underline: underline, + softWrap: softWrap, + truncate: truncate, + highContrast: highContrast, + actionable: actionable, + hovered: hovered, + focused: focused, + ); + + if (!actionable) return styleFor().merge(style); + + // The focus-visible snapshot already drops the underline via `focused`; the + // explicit `none` also clears any decoration inherited through the merge. + final focusVisible = styleFor( + focused: true, + ).label(.decoration(TextDecoration.none)); + + return styleFor() + .onHovered(styleFor(hovered: true)) + .onFocusVisible(focusVisible) + .merge(style); +} + +/// Resolves one point in the link's state space. +/// +/// Separate from [fortalLinkStyle] because the public recipe returns a style +/// carrying Mix variants, and building those variants needs the flat snapshots +/// they are built from. +LinkStyler _fortalLinkStateStyle( + BuildContext context, { + required FortalTextSize? size, + required FortalTextWeight? weight, + required FortalLinkUnderline underline, + required bool softWrap, + required bool truncate, + required bool highContrast, + required bool actionable, + required bool hovered, + required bool focused, +}) { + var textStyle = fortalAccentForeground( + TextStyler(), + highContrast: highContrast, + ); + // An omitted size anchors to the root `text3` token rather than the ambient + // `DefaultTextStyle`, so a host text run cannot change the link's metrics or + // its em-relative underline geometry. + textStyle = textStyle.style( + fortalTextSizeToken(size ?? FortalTextSize.size3).mix(), + ); + if (weight != null) { + textStyle = textStyle.fontWeight(fortalTextWeightToken(weight)()); + } + textStyle = textStyle.inherit(false); + + final effectiveText = fortalResolveTextToken( + context, + size ?? FortalTextSize.size3, + ); + final fontSize = effectiveText.fontSize!; + + // Every upstream underline rule is gated behind `:where(:any-link, button)`, + // so a link with no callback stays plain accent-coloured text. A focus-visible + // outline replaces the underline rather than stacking both. + final underlined = + actionable && + !focused && + switch (underline) { + .always => true, + .hover => hovered, + .auto => highContrast || hovered, + .none => false, + }; + if (underlined) { + // Radix declares the decoration colour twice for this selector and the + // later rule wins: + // text-decoration-color: color-mix(in oklab, var(--accent-aN), var(--gray-a6)) + // Using the accent alpha alone leaves the underline noticeably fainter, so + // blend it. Color.lerp is an sRGB approximation of the oklab mix. + final accentStep = underline == FortalLinkUnderline.auto && highContrast + ? FortalTokens.accentA6 + : FortalTokens.accentA5; + final decorationColor = Color.lerp( + fortalResolveColor(context, accentStep), + fortalResolveColor(context, FortalTokens.grayA6), + 0.5, + )!; + textStyle = textStyle + .decoration(TextDecoration.underline) + .decorationStyle(TextDecorationStyle.solid) + .decorationColor(decorationColor) + // Upstream is `min(2px, max(1px, 0.05em))`. Flutter reads + // decorationThickness as a multiple of the font's own underline + // thickness rather than a length, so the pinned 1–2 range lands as a + // 1×–2× stroke instead of exact pixels; the em breakpoints still fall + // where Radix puts them. + .decorationThickness(math.min(2, math.max(1, 0.05 * fontSize))); + } + textStyle = fortalApplyTextFlow( + textStyle, + softWrap: softWrap, + truncate: truncate, + ); + + var style = LinkStyler() + .label(textStyle) + .borderRadius( + BorderRadiusGeometryMix.circular( + 0.07 * fontSize * fortalRadiusFactor(context), + ), + ); + if (focused) { + style = style.containerEffects( + fortalFocusOutline( + fortalResolveColor(context, FortalTokens.focus8), + offset: 2, + ), + ); + } + + return style; +} + +/// Token-backed text that becomes an accessible link only when actionable. +/// +/// A null [onPressed] disables the link just as [enabled] `false` does: accent +/// text with no focus stop, link role, or activation, and never underlined. +/// Reach for `FortalText(accent: true)` when the text was never meant to +/// navigate. +/// +/// `linkUrl` is assistive metadata only and is never launched; navigation stays +/// the caller's responsibility in [onPressed]. +/// +/// An actionable link activates on pointer and Enter. Space belongs to the +/// Button role and is deliberately left unclaimed. +class FortalLink extends StatelessWidget { + const FortalLink( + this.text, { + super.key, + this.size, + this.weight, + this.underline = FortalLinkUnderline.auto, + this.softWrap = true, + this.truncate = false, + this.highContrast = false, + this.onPressed, + this.enabled = true, + this.linkUrl, + this.focusNode, + this.autofocus = false, + this.enableFeedback = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + this.style = const LinkStyler.create(), + }) : assert(text != ''), + assert(semanticLabel == null || semanticLabel != ''), + assert(semanticHint == null || semanticHint != ''), + assert(linkUrl == null || onPressed != null); + + final String text; + final FortalTextSize? size; + final FortalTextWeight? weight; + final FortalLinkUnderline underline; + final bool softWrap; + final bool truncate; + final bool highContrast; + final VoidCallback? onPressed; + final bool enabled; + final Uri? linkUrl; + final FocusNode? focusNode; + final bool autofocus; + final bool enableFeedback; + final MouseCursor mouseCursor; + final String? semanticLabel; + final String? semanticHint; + final bool excludeSemantics; + final LinkStyler style; + + @override + Widget build(BuildContext context) { + return RemixLink( + label: text, + onPressed: onPressed, + enabled: enabled, + linkUrl: linkUrl, + focusNode: focusNode, + autofocus: autofocus, + enableFeedback: enableFeedback, + mouseCursor: mouseCursor, + semanticLabel: semanticLabel, + semanticHint: semanticHint, + excludeSemantics: excludeSemantics, + style: fortalLinkStyle( + context, + size: size, + weight: weight, + underline: underline, + softWrap: softWrap, + truncate: truncate, + highContrast: highContrast, + actionable: enabled && onPressed != null, + style: style, + ), + ); + } +} diff --git a/registry_source/lib/src/fortal/components/menu.dart b/registry_source/lib/src/fortal/components/menu.dart new file mode 100644 index 000000000..a8d38754e --- /dev/null +++ b/registry_source/lib/src/fortal/components/menu.dart @@ -0,0 +1,224 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'menu.g.dart'; + +/// Radix Themes menu content sizes. +enum FortalMenuSize { size1, size2 } + +/// Radix Themes menu content variants. +enum FortalMenuVariant { solid, soft } + +/// Fortal menu content with Radix-owned size, variant, and contrast behavior. +@MixWidget(target: RemixMenu.new) +MenuStyler fortalMenuStyle({ + FortalMenuVariant variant = .solid, + FortalMenuSize size = .size2, + bool highContrast = false, + MenuStyler style = const MenuStyler.create(), +}) { + final metrics = _fortalMenuMetrics(size); + final base = MenuStyler() + .trigger(_fortalMenuTriggerStyler(metrics)) + .overlay( + FlexBoxStyler() + .padding(.all(metrics.contentPadding)) + .borderRadius(.all(metrics.contentRadius)) + // Radix pins menus to the solid panel with no backdrop blur, + // even when the theme panel background is translucent. + .color(FortalTokens.colorPanelSolid()) + .decoration( + BoxDecorationMix.create(boxShadow: FortalTokens.shadow5.mix()), + ) + .clipBehavior(Clip.antiAlias), + ) + .item(_fortalMenuItemStyler(variant, metrics, highContrast: highContrast)) + .submenuItem( + _fortalMenuSubmenuItemStyler( + variant, + metrics, + highContrast: highContrast, + ), + ) + .divider(_fortalMenuDividerStyler(metrics)); + + return base.merge(style); +} + +/// Fortal item recipe for per-item style overrides. +MenuItemStyler fortalMenuItemStyle({ + FortalMenuVariant variant = .solid, + FortalMenuSize size = .size2, + bool highContrast = false, +}) => _fortalMenuItemStyler( + variant, + _fortalMenuMetrics(size), + highContrast: highContrast, +); + +/// Radix has no menu-owned trigger; this mirrors the base Radix button +/// content treatment (gap, text token, icon) without button chrome. +MenuTriggerStyler _fortalMenuTriggerStyler(_FortalMenuMetrics metrics) => + MenuTriggerStyler() + .spacing(metrics.triggerGap) + .label(.style(metrics.text.mix()).color(FortalTokens.gray12())) + .icon(.color(FortalTokens.gray12()).size(metrics.contentIconSize)); + +MenuItemStyler _fortalMenuItemStyler( + FortalMenuVariant variant, + _FortalMenuMetrics metrics, { + required bool highContrast, +}) { + final base = MenuItemStyler() + .direction(.horizontal) + .spacing(FortalTokens.space2()) + .height(metrics.itemHeight) + .padding(.horizontal(metrics.leadingInset)) + .borderRadius(.all(metrics.itemRadius)) + .label(.style(metrics.text.mix()).color(FortalTokens.gray12())) + // Radix pins only indicator/subtrigger icons (8/10px); content icons + // follow the repo-wide text-matched sizes used by tabs and toggles. + .leadingIcon(.color(FortalTokens.gray12()).size(metrics.contentIconSize)) + .trailingIcon( + .color(FortalTokens.grayA11()).size(metrics.contentIconSize), + ) + .indicator(.color(FortalTokens.gray12()).size(metrics.indicatorSize)); + final highlighted = _fortalMenuHighlightedItemStyler( + variant, + highContrast: highContrast, + ); + final disabled = MenuItemStyler() + .color(const Color(0x00000000)) + .label(.color(FortalTokens.grayA8())) + .leadingIcon(.color(FortalTokens.grayA8())) + .trailingIcon(.color(FortalTokens.grayA8())) + .indicator(.color(FortalTokens.grayA8())); + + // Naked's focused item is Radix's roving `data-highlighted` item, not a + // CSS focus ring, so this intentionally follows raw focus. + return base + .onHovered(highlighted) + .onFocused(highlighted) + .onPressed(highlighted) + .onDisabled(disabled); +} + +MenuItemStyler _fortalMenuHighlightedItemStyler( + FortalMenuVariant variant, { + required bool highContrast, +}) { + final solidForeground = highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(); + + return switch (variant) { + .solid => + MenuItemStyler() + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent9(), + ) + .label(.color(solidForeground)) + .leadingIcon(.color(solidForeground)) + .trailingIcon(.color(solidForeground)) + .indicator(.color(solidForeground)), + .soft => + MenuItemStyler() + .color(FortalTokens.accentA4()) + .trailingIcon(.color(FortalTokens.gray12())), + }; +} + +MenuItemStyler _fortalMenuSubmenuItemStyler( + FortalMenuVariant variant, + _FortalMenuMetrics metrics, { + required bool highContrast, +}) { + final highlighted = _fortalMenuHighlightedItemStyler( + variant, + highContrast: highContrast, + ); + final submenuOpen = MenuItemStyler() + .color(switch (variant) { + .solid => FortalTokens.grayA3(), + .soft => FortalTokens.accentA3(), + }) + .onHovered(highlighted) + // Roving `data-highlighted` state, not a focus-visible ring. + .onFocused(highlighted) + .onPressed(highlighted); + + // The chevron keeps the exact Radix subtrigger icon size even though + // content trailing icons are text-matched. + return MenuItemStyler() + .trailingIcon(.color(FortalTokens.gray12()).size(metrics.indicatorSize)) + .onSelected(submenuOpen); +} + +DividerStyler _fortalMenuDividerStyler(_FortalMenuMetrics metrics) => + DividerStyler() + .height(1) + .margin( + .only( + left: metrics.leadingInset, + right: metrics.trailingInset, + top: FortalTokens.space2(), + bottom: FortalTokens.space2(), + ), + ) + .color(FortalTokens.grayA6()); + +class _FortalMenuMetrics { + const _FortalMenuMetrics({ + required this.contentPadding, + required this.contentRadius, + required this.itemHeight, + required this.itemRadius, + required this.leadingInset, + required this.trailingInset, + required this.indicatorSize, + required this.contentIconSize, + required this.triggerGap, + required this.text, + }); + + final double contentPadding; + final Radius contentRadius; + final double itemHeight; + final Radius itemRadius; + final double leadingInset; + final double trailingInset; + final double indicatorSize; + final double contentIconSize; + final double triggerGap; + final TextStyleToken text; +} + +_FortalMenuMetrics _fortalMenuMetrics(FortalMenuSize size) => switch (size) { + .size1 => _FortalMenuMetrics( + contentPadding: FortalTokens.space1(), + contentRadius: FortalTokens.radius3(), + itemHeight: FortalTokens.space5(), + itemRadius: FortalTokens.radius1(), + leadingInset: FortalTokens.space2(), + trailingInset: FortalTokens.space2(), + indicatorSize: FortalTokens.selectIndicatorSize1(), + contentIconSize: FortalTokens.space3(), + triggerGap: FortalTokens.space1(), + text: FortalTokens.text1, + ), + .size2 => _FortalMenuMetrics( + contentPadding: FortalTokens.space2(), + contentRadius: FortalTokens.radius4(), + itemHeight: FortalTokens.space6(), + itemRadius: FortalTokens.radius2(), + leadingInset: FortalTokens.space3(), + trailingInset: FortalTokens.space3(), + indicatorSize: FortalTokens.selectIndicatorSize2(), + contentIconSize: FortalTokens.space4(), + triggerGap: FortalTokens.space2(), + text: FortalTokens.text2, + ), +}; diff --git a/registry_source/lib/src/fortal/components/menu.g.dart b/registry_source/lib/src/fortal/components/menu.g.dart new file mode 100644 index 000000000..b4aefe785 --- /dev/null +++ b/registry_source/lib/src/fortal/components/menu.g.dart @@ -0,0 +1,149 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'menu.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal menu content with Radix-owned size, variant, and contrast behavior. +class FortalMenu extends StatelessWidget { + const FortalMenu({ + super.key, + this.variant = .solid, + this.size = .size2, + this.highContrast = false, + this.style = const MenuStyler.create(), + required this.trigger, + required this.items, + this.controller, + this.onSelected, + this.onOpen, + this.onClose, + this.onCanceled, + this.onOpenRequested, + this.onCloseRequested, + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.closeOnClickOutside = true, + this.triggerFocusNode, + this.positioning = const OverlayPositionConfig(), + this.semanticLabel, + this.excludeSemantics = false, + }); + + const FortalMenu.solid({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const MenuStyler.create(), + required this.trigger, + required this.items, + this.controller, + this.onSelected, + this.onOpen, + this.onClose, + this.onCanceled, + this.onOpenRequested, + this.onCloseRequested, + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.closeOnClickOutside = true, + this.triggerFocusNode, + this.positioning = const OverlayPositionConfig(), + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = FortalMenuVariant.solid; + + const FortalMenu.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const MenuStyler.create(), + required this.trigger, + required this.items, + this.controller, + this.onSelected, + this.onOpen, + this.onClose, + this.onCanceled, + this.onOpenRequested, + this.onCloseRequested, + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.closeOnClickOutside = true, + this.triggerFocusNode, + this.positioning = const OverlayPositionConfig(), + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = FortalMenuVariant.soft; + + final FortalMenuVariant variant; + + final FortalMenuSize size; + + final bool highContrast; + + final MenuStyler style; + + final RemixMenuTrigger trigger; + + final List> items; + + final MenuController? controller; + + final ValueChanged? onSelected; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final VoidCallback? onCanceled; + + final RawMenuAnchorOpenRequestedCallback? onOpenRequested; + + final RawMenuAnchorCloseRequestedCallback? onCloseRequested; + + final bool consumeOutsideTaps; + + final bool useRootOverlay; + + final bool closeOnClickOutside; + + final FocusNode? triggerFocusNode; + + final OverlayPositionConfig positioning; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixMenu( + key: this.key, + style: fortalMenuStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + trigger: this.trigger, + items: this.items, + controller: this.controller, + onSelected: this.onSelected, + onOpen: this.onOpen, + onClose: this.onClose, + onCanceled: this.onCanceled, + onOpenRequested: this.onOpenRequested, + onCloseRequested: this.onCloseRequested, + consumeOutsideTaps: this.consumeOutsideTaps, + useRootOverlay: this.useRootOverlay, + closeOnClickOutside: this.closeOnClickOutside, + triggerFocusNode: this.triggerFocusNode, + positioning: this.positioning, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/popover.dart b/registry_source/lib/src/fortal/components/popover.dart new file mode 100644 index 000000000..779751323 --- /dev/null +++ b/registry_source/lib/src/fortal/components/popover.dart @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'popover.g.dart'; + +/// Fortal popover size presets matching Radix Themes 3.3.0. +enum FortalPopoverSize { size1, size2, size3, size4 } + +/// Fortal-themed preset for [RemixPopover]. +/// +/// The generated [FortalPopover] defaults to [FortalPopoverSize.size2], a +/// 480-pixel maximum width, and no arrow. +@MixWidget(target: RemixPopover.new) +PopoverStyler fortalPopoverStyle({ + FortalPopoverSize size = FortalPopoverSize.size2, + PopoverStyler style = const PopoverStyler.create(), +}) { + final radius = switch (size) { + FortalPopoverSize.size1 || + FortalPopoverSize.size2 => FortalTokens.radius4(), + FortalPopoverSize.size3 || + FortalPopoverSize.size4 => FortalTokens.radius5(), + }; + final padding = switch (size) { + FortalPopoverSize.size1 => FortalTokens.space3(), + FortalPopoverSize.size2 => FortalTokens.space4(), + FortalPopoverSize.size3 => FortalTokens.space5(), + FortalPopoverSize.size4 => FortalTokens.space6(), + }; + + return PopoverStyler() + .maxWidth(480) + .padding(.all(padding)) + .borderRadius(.all(radius)) + .color(FortalTokens.colorPanel()) + .decoration( + BoxDecorationMix.create(boxShadow: FortalTokens.shadow5.mix()), + ) + .containerEffects( + RemixBoxEffectsMix.backdropBlur(FortalTokens.panelBlur()), + ) + .merge(style); +} diff --git a/registry_source/lib/src/fortal/components/popover.g.dart b/registry_source/lib/src/fortal/components/popover.g.dart new file mode 100644 index 000000000..3e0892c32 --- /dev/null +++ b/registry_source/lib/src/fortal/components/popover.g.dart @@ -0,0 +1,87 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'popover.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixPopover]. +/// +/// The generated [FortalPopover] defaults to [FortalPopoverSize.size2], a +/// 480-pixel maximum width, and no arrow. +class FortalPopover extends StatelessWidget { + const FortalPopover({ + super.key, + this.size = FortalPopoverSize.size2, + this.style = const PopoverStyler.create(), + required this.popoverChild, + required this.child, + this.positioning = const OverlayPositionConfig(), + this.consumeOutsideTaps = true, + this.useRootOverlay = false, + this.openOnTap = true, + this.triggerFocusNode, + this.onOpen, + this.onClose, + this.onOpenRequested, + this.onCloseRequested, + this.controller, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final FortalPopoverSize size; + + final PopoverStyler style; + + final Widget popoverChild; + + final Widget child; + + final OverlayPositionConfig positioning; + + final bool consumeOutsideTaps; + + final bool useRootOverlay; + + final bool openOnTap; + + final FocusNode? triggerFocusNode; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final RawMenuAnchorOpenRequestedCallback? onOpenRequested; + + final RawMenuAnchorCloseRequestedCallback? onCloseRequested; + + final MenuController? controller; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixPopover( + key: this.key, + style: fortalPopoverStyle(size: this.size, style: this.style), + popoverChild: this.popoverChild, + child: this.child, + positioning: this.positioning, + consumeOutsideTaps: this.consumeOutsideTaps, + useRootOverlay: this.useRootOverlay, + openOnTap: this.openOnTap, + triggerFocusNode: this.triggerFocusNode, + onOpen: this.onOpen, + onClose: this.onClose, + onOpenRequested: this.onOpenRequested, + onCloseRequested: this.onCloseRequested, + controller: this.controller, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/progress.dart b/registry_source/lib/src/fortal/components/progress.dart new file mode 100644 index 000000000..0df978afb --- /dev/null +++ b/registry_source/lib/src/fortal/components/progress.dart @@ -0,0 +1,131 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'progress.g.dart'; + +/// Fortal progress size presets. +enum FortalProgressSize { size1, size2, size3 } + +/// Fortal progress color variants. +enum FortalProgressVariant { classic, surface, soft } + +/// Fortal-themed preset for [RemixProgress]. +@MixWidget(target: RemixProgress.new) +ProgressStyler fortalProgressStyle({ + FortalProgressVariant variant = .surface, + FortalProgressSize size = .size2, + bool highContrast = false, + ProgressStyler style = const ProgressStyler.create(), +}) { + return (switch (variant) { + .classic => _fortalProgressClassicStyler(size, highContrast: highContrast), + .surface => _fortalProgressSurfaceStyler(size, highContrast: highContrast), + .soft => _fortalProgressSoftStyler(size, highContrast: highContrast), + }).merge(style); +} + +ProgressStyler _fortalProgressBaseStyler(FortalProgressSize size) { + final metrics = _fortalProgressMetrics(size); + return ProgressStyler( + container: .width(.infinity) + .height(metrics.height) + .borderRadius(.all(metrics.radius)) + .clipBehavior(.antiAlias), + track: .width(.infinity).height(metrics.height), + indicator: .height(metrics.height).borderRadius(.all(metrics.radius)), + trackEffects: RemixBoxEffectsMix( + behindContent: _fortalProgressLayer(), + overContent: _fortalProgressLayer(), + ), + indicatorEffects: RemixBoxEffectsMix( + behindContent: _fortalProgressLayer(), + overContent: _fortalProgressLayer(), + ), + ); +} + +ProgressStyler _fortalProgressClassicStyler( + FortalProgressSize size, { + required bool highContrast, +}) { + return _fortalProgressBaseStyler(size) + .trackColor(FortalTokens.grayA3()) + .trackEffects( + RemixBoxEffectsMix.overContent( + _fortalProgressLayer(shadowToken: FortalTokens.shadow1Layers), + ), + ) + .indicatorColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentTrack(), + ); +} + +ProgressStyler _fortalProgressSurfaceStyler( + FortalProgressSize size, { + required bool highContrast, +}) { + return _fortalProgressBaseStyler(size) + .trackColor(FortalTokens.grayA3()) + .trackEffects( + RemixBoxEffectsMix.overContent( + _fortalProgressLayer( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.grayA4(), + spreadRadius: 1, + ), + ], + ), + ), + ) + .indicatorColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accentTrack(), + ); +} + +ProgressStyler _fortalProgressSoftStyler( + FortalProgressSize size, { + required bool highContrast, +}) { + return _fortalProgressBaseStyler(size) + .trackColor(FortalTokens.grayA4()) + .track( + .foregroundDecoration(BoxDecorationMix(color: FortalTokens.whiteA1())), + ) + .indicatorColor( + highContrast ? FortalTokens.accent12() : FortalTokens.accent8(), + ) + .indicator( + .foregroundDecoration( + BoxDecorationMix( + color: highContrast ? null : FortalTokens.accentA5(), + ), + ), + ); +} + +({double height, Radius radius}) _fortalProgressMetrics( + FortalProgressSize size, +) => switch (size) { + .size1 => ( + height: FortalTokens.space1(), + radius: FortalTokens.progressRadius1(), + ), + .size2 => ( + height: FortalTokens.progressHeight2(), + radius: FortalTokens.progressRadius2(), + ), + .size3 => ( + height: FortalTokens.space2(), + radius: FortalTokens.progressRadius3(), + ), +}; + +RemixBoxEffectLayerMix _fortalProgressLayer({ + List? shadows, + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix(shadows: shadows, shadowToken: shadowToken); diff --git a/registry_source/lib/src/fortal/components/progress.g.dart b/registry_source/lib/src/fortal/components/progress.g.dart new file mode 100644 index 000000000..db166505f --- /dev/null +++ b/registry_source/lib/src/fortal/components/progress.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'progress.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixProgress]. +class FortalProgress extends StatelessWidget { + const FortalProgress({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const ProgressStyler.create(), + required this.value, + this.semanticsLabel, + this.semanticsValue, + }); + + const FortalProgress.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ProgressStyler.create(), + required this.value, + this.semanticsLabel, + this.semanticsValue, + }) : variant = FortalProgressVariant.classic; + + const FortalProgress.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ProgressStyler.create(), + required this.value, + this.semanticsLabel, + this.semanticsValue, + }) : variant = FortalProgressVariant.surface; + + const FortalProgress.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ProgressStyler.create(), + required this.value, + this.semanticsLabel, + this.semanticsValue, + }) : variant = FortalProgressVariant.soft; + + final FortalProgressVariant variant; + + final FortalProgressSize size; + + final bool highContrast; + + final ProgressStyler style; + + final double value; + + final String? semanticsLabel; + + final String? semanticsValue; + + @override + Widget build(BuildContext context) { + return RemixProgress( + key: this.key, + style: fortalProgressStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + value: this.value, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/radio.dart b/registry_source/lib/src/fortal/components/radio.dart new file mode 100644 index 000000000..2aa1aaec3 --- /dev/null +++ b/registry_source/lib/src/fortal/components/radio.dart @@ -0,0 +1,256 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'radio.g.dart'; + +/// Fortal radio size presets. +enum FortalRadioSize { + /// Compact radio. + size1, + + /// Default radio. + size2, + + /// Large radio. + size3, +} + +/// Fortal radio color variants. +enum FortalRadioVariant { + /// Raised treatment with Radix's classic shadow and gradient layers. + classic, + + /// Surface treatment with neutral border. + surface, + + /// Soft accent treatment. + soft, +} + +/// Fortal-themed preset for [RemixRadio]. +@MixWidget(target: RemixRadio.new) +RadioStyler fortalRadioStyle({ + FortalRadioVariant variant = .surface, + FortalRadioSize size = .size2, + bool highContrast = false, + RadioStyler style = const RadioStyler.create(), +}) { + return (switch (variant) { + .classic => _fortalRadioClassicStyler(size, highContrast: highContrast), + .surface => _fortalRadioSurfaceStyler(size, highContrast: highContrast), + .soft => _fortalRadioSoftStyler(size, highContrast: highContrast), + }).merge(style); +} + +RadioStyler _fortalRadioBaseStyler(FortalRadioSize size) { + final metrics = _fortalRadioMetrics(size); + return RadioStyler( + container: .size( + metrics.size, + metrics.size, + ).alignment(.center).borderRadius(.all(FortalTokens.radiusCircle())), + indicator: .size( + metrics.indicatorSize, + metrics.indicatorSize, + ).borderRadius(.all(FortalTokens.radiusCircle())), + containerEffects: RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix(), + overContent: RemixBoxEffectLayerMix(), + ), + ).onFocusVisible( + .containerEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ); +} + +RadioStyler _fortalRadioClassicStyler( + FortalRadioSize size, { + required bool highContrast, +}) { + final selectedColor = highContrast + ? FortalTokens.accent12() + : FortalTokens.accentIndicator(); + return _fortalRadioBaseStyler(size) + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadowToken: FortalTokens.shadow1Layers), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + fortalInsetSurface(strokes: [FortalTokens.gray7()]), + ), + ) + .indicatorColor( + highContrast ? FortalTokens.accent1() : FortalTokens.accentContrast(), + ) + .onSelected( + .color(selectedColor) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [ + FortalTokens.whiteA3(), + const Color(0x00000000), + FortalTokens.blackA3(), + ], + ), + ], + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.whiteA4(), + offset: const Offset(0, 0.5), + blurRadius: 0.5, + ), + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.blackA4(), + offset: const Offset(0, -0.5), + blurRadius: 0.5, + ), + ], + ), + ), + ) + .indicatorColor( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + ) + .onDisabled( + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadowToken: FortalTokens.shadow1Layers), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor(FortalTokens.grayA8()), + ); +} + +RadioStyler _fortalRadioSurfaceStyler( + FortalRadioSize size, { + required bool highContrast, +}) { + return _fortalRadioBaseStyler(size) + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + fortalInsetSurface(strokes: [FortalTokens.grayA7()]), + ), + ) + .indicator( + .color( + FortalTokens.accent9(), + ).borderRadius(.all(FortalTokens.radiusCircle())), + ) + .onSelected( + .color( + highContrast + ? FortalTokens.accent12() + : FortalTokens.accentIndicator(), + ) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .indicatorColor( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + ) + .onDisabled( + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + fortalInsetSurface(strokes: [FortalTokens.grayA6()]), + ), + ) + .indicatorColor(FortalTokens.grayA8()), + ); +} + +RadioStyler _fortalRadioSoftStyler( + FortalRadioSize size, { + required bool highContrast, +}) { + return _fortalRadioBaseStyler(size) + .color(FortalTokens.accentA4()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .indicator( + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent11(), + ).borderRadius(.all(FortalTokens.radiusCircle())), + ) + .onSelected( + .color(FortalTokens.accentA4()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .indicator( + .color( + highContrast + ? FortalTokens.accent12() + : FortalTokens.accent11(), + ), + ), + ) + .onDisabled( + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .indicatorColor(FortalTokens.grayA8()), + ); +} + +({double size, double indicatorSize}) _fortalRadioMetrics( + FortalRadioSize size, +) => switch (size) { + .size1 => ( + size: FortalTokens.checkboxSize1(), + indicatorSize: FortalTokens.radioIndicatorSize1(), + ), + .size2 => ( + size: FortalTokens.space4(), + indicatorSize: FortalTokens.radioIndicatorSize2(), + ), + .size3 => ( + size: FortalTokens.checkboxSize3(), + indicatorSize: FortalTokens.radioIndicatorSize3(), + ), +}; diff --git a/registry_source/lib/src/fortal/components/radio.g.dart b/registry_source/lib/src/fortal/components/radio.g.dart new file mode 100644 index 000000000..12e86835b --- /dev/null +++ b/registry_source/lib/src/fortal/components/radio.g.dart @@ -0,0 +1,119 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'radio.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixRadio]. +class FortalRadio extends StatelessWidget { + const FortalRadio({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const RadioStyler.create(), + required this.value, + required this.semanticLabel, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + }); + + /// Raised treatment with Radix's classic shadow and gradient layers. + const FortalRadio.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const RadioStyler.create(), + required this.value, + required this.semanticLabel, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + }) : variant = FortalRadioVariant.classic; + + /// Surface treatment with neutral border. + const FortalRadio.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const RadioStyler.create(), + required this.value, + required this.semanticLabel, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + }) : variant = FortalRadioVariant.surface; + + /// Soft accent treatment. + const FortalRadio.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const RadioStyler.create(), + required this.value, + required this.semanticLabel, + this.enabled = true, + this.toggleable = false, + this.mouseCursor, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + }) : variant = FortalRadioVariant.soft; + + final FortalRadioVariant variant; + + final FortalRadioSize size; + + final bool highContrast; + + final RadioStyler style; + + final T value; + + final String semanticLabel; + + final bool enabled; + + final bool toggleable; + + final MouseCursor? mouseCursor; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixRadio( + key: this.key, + style: fortalRadioStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + value: this.value, + semanticLabel: this.semanticLabel, + enabled: this.enabled, + toggleable: this.toggleable, + mouseCursor: this.mouseCursor, + focusNode: this.focusNode, + autofocus: this.autofocus, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/segmented_control.dart b/registry_source/lib/src/fortal/components/segmented_control.dart new file mode 100644 index 000000000..005f85036 --- /dev/null +++ b/registry_source/lib/src/fortal/components/segmented_control.dart @@ -0,0 +1,244 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'segmented_control.g.dart'; + +double _resolveSegmentedControlActiveLetterSpacing3(BuildContext context) { + final fontSize = FortalTokens.text3.resolve(context).fontSize!; + return -0.01 * fontSize; +} + +const _segmentedControlActiveLetterSpacing3 = ContextToken( + _resolveSegmentedControlActiveLetterSpacing3, +); + +/// Radix layers the track as `color-surface` under a `gray-a3` +/// background-image. One BoxDecoration cannot stack two background fills, so +/// the recipe pre-blends the pair; a foreground overlay would instead paint +/// over the selected indicator fill, breaking the source z-order. +Color _resolveSegmentedControlTrackBackground(BuildContext context) => + Color.alphaBlend( + FortalTokens.grayA3.resolve(context), + FortalTokens.colorSurface.resolve(context), + ); + +const _segmentedControlTrackBackground = ContextToken( + _resolveSegmentedControlTrackBackground, +); + +/// The disabled root swaps only `background-color` to `gray-3`; the `gray-a3` +/// background-image layer persists in the source, so it stays in the blend. +Color _resolveSegmentedControlDisabledTrackBackground(BuildContext context) => + Color.alphaBlend( + FortalTokens.grayA3.resolve(context), + FortalTokens.gray3.resolve(context), + ); + +const _segmentedControlDisabledTrackBackground = ContextToken( + _resolveSegmentedControlDisabledTrackBackground, +); + +/// Radix Themes SegmentedControl size presets. +enum FortalSegmentedControlSize { size1, size2, size3 } + +/// Radix Themes SegmentedControl variants. +enum FortalSegmentedControlVariant { surface, classic } + +/// Fortal recipe for [RemixSegmentedControl]. +/// +/// Content icons use size-matched 12/16/20 token defaults rather than the +/// ambient icon size. Control and item styles may override these defaults. +/// +/// Paints the selected item in place. It does not reproduce Radix's sliding +/// indicator, duplicate-label crossfade, inactive separators, or max-content +/// overflow. Changing an item's label with the selection can therefore cause a +/// small intrinsic-width shift. +@MixWidget(target: RemixSegmentedControl.new) +SegmentedControlStyler fortalSegmentedControlStyle({ + FortalSegmentedControlVariant variant = .surface, + FortalSegmentedControlSize size = .size2, + SegmentedControlStyler style = const SegmentedControlStyler.create(), +}) { + final metrics = _fortalSegmentedControlMetrics(size); + final item = _fortalSegmentedControlItemStyle(variant, metrics); + + return SegmentedControlStyler() + .mainAxisSize(.min) + .minHeight(metrics.height) + .borderRadius(.all(metrics.radius)) + .color(_segmentedControlTrackBackground()) + .clipBehavior(.antiAlias) + .item(item) + .onDisabled( + SegmentedControlStyler().color( + _segmentedControlDisabledTrackBackground(), + ), + ) + .merge(style); +} + +SegmentedControlItemStyler _fortalSegmentedControlItemStyle( + FortalSegmentedControlVariant variant, + _FortalSegmentedControlMetrics metrics, +) { + final base = SegmentedControlItemStyler() + .minHeight(metrics.height) + .padding(.horizontal(metrics.paddingX)) + .spacing(metrics.itemGap) + .label( + TextStyler() + .style(metrics.text.mix()) + .color(FortalTokens.gray12()) + .fontWeight(FortalTokens.fontWeightRegular()) + .letterSpacing(0) + .wordSpacing(0) + // Radix keeps `min-width: max-content` on the track, so a label + // never wraps and the track overflows a narrow parent instead. + // The equal-segment layout shrinks to fit, so pin one line and + // ellipsize to preserve the same single-line behavior. + .maxLines(1) + .overflow(TextOverflow.ellipsis), + ) + .icon(IconStyler().color(FortalTokens.gray12()).size(metrics.iconSize)) + .containerEffects( + RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix(), + overContent: RemixBoxEffectLayerMix(), + ), + ); + final selected = _fortalSegmentedControlSelectedItem(variant, metrics); + final disabled = SegmentedControlItemStyler() + .label(TextStyler().color(FortalTokens.grayA8())) + .icon(IconStyler().color(FortalTokens.grayA8())); + final disabledSelected = disabled + .color(const Color(0x00000000)) + .borderRadius(.all(metrics.radius)) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalSegmentedControlFill(FortalTokens.grayA3()), + overContent: RemixBoxEffectLayerMix(shadows: const []), + ), + ); + + return base + .onHovered(.color(FortalTokens.grayA2())) + .onSelected( + selected + .onHovered(.color(const Color(0x00000000))) + .onDisabled(disabledSelected), + ) + .onFocusVisible( + SegmentedControlItemStyler() + .borderRadius(.all(metrics.radius)) + .containerEffects( + fortalFocusOutline(FortalTokens.focus8(), offset: -1), + ), + ) + .onDisabled(disabled.onSelected(disabledSelected)); +} + +SegmentedControlItemStyler _fortalSegmentedControlSelectedItem( + FortalSegmentedControlVariant variant, + _FortalSegmentedControlMetrics metrics, +) { + final overContent = switch (variant) { + .surface => RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + color: FortalTokens.grayA4(), + spreadRadius: 1, + shapeInset: 1, + ), + ], + ), + .classic => RemixBoxEffectLayerMix( + shadowToken: FortalTokens.segmentedControlClassicIndicatorShadows, + ), + }; + + return SegmentedControlItemStyler() + .color(const Color(0x00000000)) + .borderRadius(.all(metrics.radius)) + .label( + TextStyler() + .fontWeight(FortalTokens.fontWeightMedium()) + .letterSpacing(metrics.activeLetterSpacing) + .wordSpacing(0), + ) + .containerEffects( + RemixBoxEffectsMix( + behindContent: _fortalSegmentedControlFill( + FortalTokens.segmentedControlIndicatorBackground(), + inset: 1, + ), + overContent: overContent, + ), + ); +} + +RemixBoxEffectLayerMix _fortalSegmentedControlFill( + Color color, { + double? inset, +}) => RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix(colors: [color, color]), + ], + gradientInsets: inset == null ? const [] : [inset], +); + +class _FortalSegmentedControlMetrics { + const _FortalSegmentedControlMetrics({ + required this.height, + required this.paddingX, + required this.itemGap, + required this.radius, + required this.text, + required this.activeLetterSpacing, + required this.iconSize, + }); + + final double height; + final double paddingX; + final double itemGap; + final Radius radius; + final TextStyleToken text; + final double activeLetterSpacing; + final double iconSize; +} + +_FortalSegmentedControlMetrics _fortalSegmentedControlMetrics( + FortalSegmentedControlSize size, +) => switch (size) { + .size1 => _FortalSegmentedControlMetrics( + height: FortalTokens.space5(), + paddingX: FortalTokens.space3(), + itemGap: FortalTokens.space1(), + radius: FortalTokens.radius2OrFull(), + text: FortalTokens.text1, + activeLetterSpacing: FortalTokens.tabActiveLetterSpacing1(), + iconSize: FortalTokens.space3(), + ), + .size2 => _FortalSegmentedControlMetrics( + height: FortalTokens.space6(), + paddingX: FortalTokens.space4(), + itemGap: FortalTokens.space2(), + radius: FortalTokens.radius2OrFull(), + text: FortalTokens.text2, + activeLetterSpacing: FortalTokens.tabActiveLetterSpacing2(), + iconSize: FortalTokens.space4(), + ), + .size3 => _FortalSegmentedControlMetrics( + height: FortalTokens.space7(), + paddingX: FortalTokens.space4(), + itemGap: FortalTokens.space3(), + radius: FortalTokens.radius3OrFull(), + text: FortalTokens.text3, + // The pinned `-0.01em` is derived from the resolved size-3 text token so + // it remains exact at every Fortal scaling without adding an eighth token. + activeLetterSpacing: _segmentedControlActiveLetterSpacing3(), + iconSize: FortalTokens.spinnerSize3(), + ), +}; diff --git a/registry_source/lib/src/fortal/components/segmented_control.g.dart b/registry_source/lib/src/fortal/components/segmented_control.g.dart new file mode 100644 index 000000000..71bbd08d2 --- /dev/null +++ b/registry_source/lib/src/fortal/components/segmented_control.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'segmented_control.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal recipe for [RemixSegmentedControl]. +/// +/// Content icons use size-matched 12/16/20 token defaults rather than the +/// ambient icon size. Control and item styles may override these defaults. +/// +/// Paints the selected item in place. It does not reproduce Radix's sliding +/// indicator, duplicate-label crossfade, inactive separators, or max-content +/// overflow. Changing an item's label with the selection can therefore cause a +/// small intrinsic-width shift. +class FortalSegmentedControl extends StatelessWidget { + const FortalSegmentedControl({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const SegmentedControlStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }); + + const FortalSegmentedControl.surface({ + super.key, + this.size = .size2, + this.style = const SegmentedControlStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = FortalSegmentedControlVariant.surface; + + const FortalSegmentedControl.classic({ + super.key, + this.size = .size2, + this.style = const SegmentedControlStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = FortalSegmentedControlVariant.classic; + + final FortalSegmentedControlVariant variant; + + final FortalSegmentedControlSize size; + + final SegmentedControlStyler style; + + final List> items; + + final T? selectedValue; + + final ValueChanged? onChanged; + + final bool enabled; + + final Axis orientation; + + final bool loop; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixSegmentedControl( + key: this.key, + style: fortalSegmentedControlStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + items: this.items, + selectedValue: this.selectedValue, + onChanged: this.onChanged, + enabled: this.enabled, + orientation: this.orientation, + loop: this.loop, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/select.dart b/registry_source/lib/src/fortal/components/select.dart new file mode 100644 index 000000000..7cc7fb449 --- /dev/null +++ b/registry_source/lib/src/fortal/components/select.dart @@ -0,0 +1,328 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'select.g.dart'; + +/// Radix Themes Select root size presets. +enum FortalSelectSize { size1, size2, size3 } + +/// Radix Themes Select variants. +enum FortalSelectVariant { surface, soft, ghost } + +/// Fortal-themed Select with Radix-owned trigger and content configuration. +/// +/// Content icons use size-matched 12/16/20 token defaults rather than the +/// ambient icon size. Override the trigger icon through [style] when needed. +@MixWidget(target: RemixSelect.new) +SelectStyler fortalSelectStyle({ + FortalSelectVariant variant = .surface, + FortalSelectSize size = .size2, + bool highContrast = false, + SelectStyler style = const SelectStyler.create(), +}) { + return SelectStyler() + .trigger(_fortalSelectTriggerStyler(variant, size)) + .content(_fortalSelectContentStyler(size)) + .item(_fortalSelectItemStyler(variant, size, highContrast: highContrast)) + .merge(style); +} + +/// Creates the established combined-variant Select item recipe. +SelectMenuItemStyler fortalSelectMenuItemStyle({ + FortalSelectVariant variant = .surface, + FortalSelectSize size = .size2, + bool highContrast = false, +}) => _fortalSelectItemStyler(variant, size, highContrast: highContrast); + +SelectTriggerStyler _fortalSelectTriggerStyler( + FortalSelectVariant variant, + FortalSelectSize size, +) { + final radius = _fortalSelectTriggerRadius(size); + final base = SelectTriggerStyler() + .direction(.horizontal) + .mainAxisAlignment(.spaceBetween) + .borderRadius(.all(radius)) + .label(_fortalSelectTriggerText(size, color: FortalTokens.gray12())) + .placeholder( + _fortalSelectTriggerText(size, color: FortalTokens.grayA10()), + ) + .icon( + .color(FortalTokens.gray12()).size(switch (size) { + .size1 => FortalTokens.space3(), + .size2 => FortalTokens.space4(), + .size3 => FortalTokens.spinnerSize3(), + }), + ) + .indicator(.color(FortalTokens.gray12()).size(size == .size3 ? 11 : 9)) + .onFocusVisible( + .containerEffects( + RemixBoxEffectsMix.overContent(_fortalSelectFocusRing()), + ), + ) + .merge(_fortalSelectTriggerSizeStyler(variant, size)); + + return switch (variant) { + .surface => _fortalSelectSurfaceTrigger(base), + .soft => _fortalSelectSoftTrigger(base), + .ghost => _fortalSelectGhostTrigger(base), + }; +} + +TextStyler _fortalSelectTriggerText(FortalSelectSize size, {Color? color}) { + final token = switch (size) { + .size1 => FortalTokens.text1, + .size2 => FortalTokens.text2, + .size3 => FortalTokens.text3, + }; + return TextStyler(style: token.mix()) + .fontWeight(FortalTokens.fontWeightRegular()) + .color(color ?? FortalTokens.gray12()); +} + +Radius _fortalSelectTriggerRadius(FortalSelectSize size) => switch (size) { + .size1 => FortalTokens.radius1OrFull(), + .size2 => FortalTokens.radius2OrFull(), + .size3 => FortalTokens.radius3OrFull(), +}; + +SelectTriggerStyler _fortalSelectTriggerSizeStyler( + FortalSelectVariant variant, + FortalSelectSize size, +) { + final style = SelectTriggerStyler().spacing(switch (size) { + .size1 => FortalTokens.space1(), + .size2 => FortalTokens.selectSpace1Half(), + .size3 => FortalTokens.space2(), + }); + return switch (variant) { + .ghost => switch (size) { + .size1 || .size2 => + style + .padding(.horizontal(FortalTokens.space2())) + .padding(.vertical(FortalTokens.space1())) + .margin(.horizontal(FortalTokens.selectGhostMarginX12())) + .margin(.vertical(FortalTokens.selectGhostMarginY12())), + .size3 => + style + .padding(.horizontal(FortalTokens.space3())) + .padding(.vertical(FortalTokens.selectSpace1Half())) + .margin(.horizontal(FortalTokens.selectGhostMarginX3())) + .margin(.vertical(FortalTokens.selectGhostMarginY3())), + }, + .surface || .soft => switch (size) { + .size1 => + style + .height(FortalTokens.space5()) + .padding(.horizontal(FortalTokens.space2())), + .size2 => + style + .height(FortalTokens.space6()) + .padding(.horizontal(FortalTokens.space3())), + .size3 => + style + .height(FortalTokens.space7()) + .padding(.horizontal(FortalTokens.space4())), + }, + }; +} + +RemixBoxEffectLayerMix _fortalSelectFocusRing() { + return RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix(color: FortalTokens.focus8(), spreadRadius: 1), + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: FortalTokens.focus8(), + spreadRadius: 1, + ), + ], + ); +} + +SelectTriggerStyler _fortalSelectSurfaceTrigger(SelectTriggerStyler base) { + return base + .indicatorOpacity(0.9) + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.grayA7()]), + ), + ) + .onHovered( + .containerEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.grayA8()]), + ), + ), + ) + .onSelected( + .containerEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.grayA8()]), + ), + ), + ) + .onDisabled( + .color(FortalTokens.grayA2()) + .label(.color(FortalTokens.grayA11())) + .icon(.color(FortalTokens.grayA9())) + .indicator(.color(FortalTokens.grayA9())) + .containerEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.grayA6()]), + ), + ), + ); +} + +SelectTriggerStyler _fortalSelectSoftTrigger(SelectTriggerStyler base) { + return base + .label(.color(FortalTokens.accent12())) + .placeholder(.color(FortalTokens.accent12())) + .placeholderOpacity(0.6) + .icon(.color(FortalTokens.accent12())) + .indicator(.color(FortalTokens.accent12())) + .color(FortalTokens.accentA3()) + .onHovered(.color(FortalTokens.accentA4())) + .onSelected(.color(FortalTokens.accentA4())) + .onDisabled( + .label(.color(FortalTokens.grayA11())) + .icon(.color(FortalTokens.grayA9())) + .indicator(.color(FortalTokens.grayA9())) + .color(FortalTokens.grayA3()), + ); +} + +SelectTriggerStyler _fortalSelectGhostTrigger(SelectTriggerStyler base) { + return base + .label(.color(FortalTokens.accent12())) + .placeholder(.color(FortalTokens.accent12())) + .placeholderOpacity(0.6) + .icon(.color(FortalTokens.accent12())) + .indicator(.color(FortalTokens.accent12())) + .color(const Color(0x00000000)) + .onHovered(.color(FortalTokens.accentA3())) + .onSelected(.color(FortalTokens.accentA3())) + .onDisabled( + .label(.color(FortalTokens.grayA11())) + .icon(.color(FortalTokens.grayA9())) + .indicator(.color(FortalTokens.grayA9())) + .color(const Color(0x00000000)), + ); +} + +SelectContentStyler _fortalSelectContentStyler(FortalSelectSize size) { + final radius = switch (size) { + .size1 => FortalTokens.radius3(), + .size2 || .size3 => FortalTokens.radius4(), + }; + return SelectContentStyler() + .padding( + .all(switch (size) { + .size1 => FortalTokens.space1(), + .size2 || .size3 => FortalTokens.space2(), + }), + ) + .borderRadius(.all(radius)) + .color(FortalTokens.colorPanel()) + .decoration( + BoxDecorationMix.create(boxShadow: FortalTokens.shadow5.mix()), + ) + .clipBehavior(Clip.antiAlias) + .containerEffects( + RemixBoxEffectsMix.backdropBlur(FortalTokens.panelBlur()), + ); +} + +SelectMenuItemStyler _fortalSelectItemStyler( + FortalSelectVariant variant, + FortalSelectSize size, { + bool highContrast = false, +}) { + final metrics = _fortalSelectContentMetrics(size); + final base = SelectMenuItemStyler() + .direction(.horizontal) + .height(metrics.itemHeight) + .padding(.horizontal(metrics.indicatorWidth)) + .borderRadius(.all(metrics.itemRadius)) + .text( + TextStyler(style: metrics.itemText.mix()).color(FortalTokens.gray12()), + ) + .indicator( + BoxStyler( + alignment: .center, + constraints: BoxConstraintsMix.width(metrics.indicatorWidth), + ), + ) + .icon( + IconStyler(color: FortalTokens.gray12(), size: metrics.indicatorSize), + ); + + final highlighted = switch (variant) { + .surface || .ghost => + SelectMenuItemStyler() + .color( + highContrast ? FortalTokens.accent12() : FortalTokens.accent9(), + ) + .text( + TextStyler().color( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + ) + .iconColor( + highContrast + ? FortalTokens.accent1() + : FortalTokens.accentContrast(), + ), + .soft => SelectMenuItemStyler().color(FortalTokens.accentA4()), + }; + + // Naked's focused option is Radix's roving `data-highlighted` item, not a + // CSS focus ring, so this intentionally follows raw focus. + return base + .onHovered(highlighted) + .onFocused(highlighted) + .onPressed(highlighted) + .onDisabled( + .color( + const Color(0x00000000), + ).text(.color(FortalTokens.grayA8())).iconColor(FortalTokens.grayA8()), + ); +} + +({ + double itemHeight, + double indicatorWidth, + double indicatorSize, + Radius itemRadius, + TextStyleToken itemText, +}) +_fortalSelectContentMetrics(FortalSelectSize size) => switch (size) { + .size1 => ( + itemHeight: FortalTokens.space5(), + indicatorWidth: FortalTokens.selectIndicatorWidth1(), + indicatorSize: FortalTokens.selectIndicatorSize1(), + itemRadius: FortalTokens.radius1(), + itemText: FortalTokens.text1, + ), + .size2 => ( + itemHeight: FortalTokens.space6(), + indicatorWidth: FortalTokens.space5(), + indicatorSize: FortalTokens.selectIndicatorSize2(), + itemRadius: FortalTokens.radius2(), + itemText: FortalTokens.text2, + ), + .size3 => ( + itemHeight: FortalTokens.space6(), + indicatorWidth: FortalTokens.space5(), + indicatorSize: FortalTokens.selectIndicatorSize2(), + itemRadius: FortalTokens.radius2(), + itemText: FortalTokens.text3, + ), +}; diff --git a/registry_source/lib/src/fortal/components/select.g.dart b/registry_source/lib/src/fortal/components/select.g.dart new file mode 100644 index 000000000..614120b92 --- /dev/null +++ b/registry_source/lib/src/fortal/components/select.g.dart @@ -0,0 +1,159 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'select.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed Select with Radix-owned trigger and content configuration. +/// +/// Content icons use size-matched 12/16/20 token defaults rather than the +/// ambient icon size. Override the trigger icon through [style] when needed. +class FortalSelect extends StatelessWidget { + const FortalSelect({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const SelectStyler.create(), + required this.trigger, + required this.items, + this.selectedValue, + this.positioning = const OverlayPositionConfig( + side: .bottom, + alignment: .center, + ), + this.onChanged, + this.onOpen, + this.onClose, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.closeOnSelect = true, + this.focusNode, + }); + + const FortalSelect.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SelectStyler.create(), + required this.trigger, + required this.items, + this.selectedValue, + this.positioning = const OverlayPositionConfig( + side: .bottom, + alignment: .center, + ), + this.onChanged, + this.onOpen, + this.onClose, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.closeOnSelect = true, + this.focusNode, + }) : variant = FortalSelectVariant.surface; + + const FortalSelect.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SelectStyler.create(), + required this.trigger, + required this.items, + this.selectedValue, + this.positioning = const OverlayPositionConfig( + side: .bottom, + alignment: .center, + ), + this.onChanged, + this.onOpen, + this.onClose, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.closeOnSelect = true, + this.focusNode, + }) : variant = FortalSelectVariant.soft; + + const FortalSelect.ghost({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SelectStyler.create(), + required this.trigger, + required this.items, + this.selectedValue, + this.positioning = const OverlayPositionConfig( + side: .bottom, + alignment: .center, + ), + this.onChanged, + this.onOpen, + this.onClose, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.semanticLabel, + this.closeOnSelect = true, + this.focusNode, + }) : variant = FortalSelectVariant.ghost; + + final FortalSelectVariant variant; + + final FortalSelectSize size; + + final bool highContrast; + + final SelectStyler style; + + final RemixSelectTrigger trigger; + + final List> items; + + final T? selectedValue; + + final OverlayPositionConfig positioning; + + final ValueChanged? onChanged; + + final VoidCallback? onOpen; + + final VoidCallback? onClose; + + final bool enabled; + + final MouseCursor mouseCursor; + + final String? semanticLabel; + + final bool closeOnSelect; + + final FocusNode? focusNode; + + @override + Widget build(BuildContext context) { + return RemixSelect( + key: this.key, + style: fortalSelectStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + trigger: this.trigger, + items: this.items, + selectedValue: this.selectedValue, + positioning: this.positioning, + onChanged: this.onChanged, + onOpen: this.onOpen, + onClose: this.onClose, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + semanticLabel: this.semanticLabel, + closeOnSelect: this.closeOnSelect, + focusNode: this.focusNode, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/sidebar.dart b/registry_source/lib/src/fortal/components/sidebar.dart new file mode 100644 index 000000000..d89becf3d --- /dev/null +++ b/registry_source/lib/src/fortal/components/sidebar.dart @@ -0,0 +1,148 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'text.dart'; +import 'toggle.dart'; +import 'tooltip.dart'; + +part 'sidebar.g.dart'; + +const _sectionLabelHorizontalPadding = 14.0; +const _sectionLabelVerticalPadding = 6.0; +const _sectionLabelLetterSpacing = 0.7; +const _destinationSpacing = 2.0; +const _minimumDestinationTargetHeight = 48.0; + +/// Fortal-themed preset for [RemixSidebar]. +/// +/// The recipe paints the solid panel surface with a trailing edge border, +/// pads the scrolling destination region, keeps section labels compact and +/// muted, separates sections with Fortal's `space3` token, and reuses the +/// ghost `size2` toggle treatment inside full-width destinations with a +/// 48-logical-pixel minimum height. The footer carries the divider that +/// separates account content from navigation. [highContrast] strengthens +/// section and selected destination content without changing layout. +/// [panelPadding] applies host-owned insets inside the painted panel surface. +/// +/// The recipe sets no panel width and no header padding. The host can supply +/// expanded/collapsed widths to the widget for coordinated animation, or size +/// the panel itself. Header metrics usually match an application top bar. +@MixWidget(target: RemixSidebar.new) +SidebarStyler fortalSidebarStyle({ + bool highContrast = false, + bool collapsed = false, + EdgeInsetsGeometry? panelPadding, + SidebarStyler style = const SidebarStyler.create(), +}) { + final horizontalPadding = Prop.mix(_SidebarHorizontalPadding(collapsed)); + return SidebarStyler( + container: + FlexBoxStyler(padding: EdgeInsetsGeometryMix.maybeValue(panelPadding)) + .color(FortalTokens.colorPanelSolid()) + .border( + .end( + .color( + FortalTokens.grayA5(), + ).width(FortalTokens.borderWidth1()), + ), + ), + content: FlexBoxStyler() + .spacing(FortalTokens.space3()) + .padding( + EdgeInsetsMix.create( + left: horizontalPadding, + right: horizontalPadding, + top: Prop.token(FortalTokens.space4), + bottom: Prop.token(FortalTokens.space4), + ), + ), + footer: BoxStyler().border( + .top(.color(FortalTokens.gray6()).width(FortalTokens.borderWidth1())), + ), + sectionLabel: fortalTextStyle(size: .size1, weight: .medium) + .color(highContrast ? FortalTokens.gray12() : FortalTokens.gray11()) + .uppercase() + .letterSpacing(_sectionLabelLetterSpacing) + .wrap( + .padding( + .symmetric( + horizontal: _sectionLabelHorizontalPadding, + vertical: _sectionLabelVerticalPadding, + ), + ), + ), + tooltip: fortalTooltipStyle(), + destinations: FlexBoxStyler().spacing(_destinationSpacing), + destination: + fortalToggleStyle( + variant: .ghost, + size: .size2, + highContrast: highContrast, + ) + .minHeight(_minimumDestinationTargetHeight) + .padding( + EdgeInsetsMix.create( + left: Prop.mix( + _DestinationInlinePadding(collapsed, left: true), + ), + right: Prop.mix( + _DestinationInlinePadding(collapsed, left: false), + ), + ), + ) + .container(.mainAxisSize(.max).mainAxisAlignment(.start)), + ).merge(style); +} + +/// Repays the narrowing panel inset on the leading side so icons hold still. +/// +/// Icons stay `space3 + space4` from the panel's leading edge: expanded rows +/// start at `space4`, and the rail's narrower inset is added back here. A 72px +/// dashboard rail then settles each icon on its center line. The trailing side +/// keeps the toggle's `space3`. Sides are physical so this merges with the +/// toggle's own horizontal padding; the text direction picks the leading one. +final class _DestinationInlinePadding extends Mix { + const _DestinationInlinePadding(this.collapsed, {required this.left}); + final bool collapsed; + final bool left; + + @override + double resolve(BuildContext context) { + final trailing = FortalTokens.space3.resolve(context); + final leading = (Directionality.of(context) == TextDirection.ltr) == left; + if (!leading) return trailing; + return trailing + + FortalTokens.space4.resolve(context) - + _SidebarHorizontalPadding(collapsed).resolve(context); + } + + @override + Mix merge(Mix? other) => other ?? this; + + @override + List get props => [collapsed, left]; +} + +/// Resolve both endpoints before interpolation so theme scaling stays live. +final class _SidebarHorizontalPadding extends Mix { + const _SidebarHorizontalPadding(this.collapsed); + final bool collapsed; + + @override + double resolve(BuildContext context) { + final expansion = + RemixSidebar.maybeAnimationOf(context)?.expansion ?? + (collapsed ? 0.0 : 1.0); + final rail = FortalTokens.space2.resolve(context); + final expanded = FortalTokens.space3.resolve(context); + return rail + (expanded - rail) * expansion; + } + + @override + Mix merge(Mix? other) => other ?? this; + + @override + List get props => [collapsed]; +} diff --git a/registry_source/lib/src/fortal/components/sidebar.g.dart b/registry_source/lib/src/fortal/components/sidebar.g.dart new file mode 100644 index 000000000..4333055d2 --- /dev/null +++ b/registry_source/lib/src/fortal/components/sidebar.g.dart @@ -0,0 +1,105 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sidebar.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixSidebar]. +/// +/// The recipe paints the solid panel surface with a trailing edge border, +/// pads the scrolling destination region, keeps section labels compact and +/// muted, separates sections with Fortal's `space3` token, and reuses the +/// ghost `size2` toggle treatment inside full-width destinations with a +/// 48-logical-pixel minimum height. The footer carries the divider that +/// separates account content from navigation. [highContrast] strengthens +/// section and selected destination content without changing layout. +/// [panelPadding] applies host-owned insets inside the painted panel surface. +/// +/// The recipe sets no panel width and no header padding. The host can supply +/// expanded/collapsed widths to the widget for coordinated animation, or size +/// the panel itself. Header metrics usually match an application top bar. +class FortalSidebar extends StatelessWidget { + const FortalSidebar({ + super.key, + this.highContrast = false, + this.collapsed = false, + this.panelPadding, + this.style = const SidebarStyler.create(), + this.header, + this.showTooltips = true, + this.tooltipPositioning, + this.expandedWidth, + this.collapsedWidth, + this.animationStyle = const AnimationStyle(), + required this.sections, + required this.selectedValue, + this.onSelected, + this.footer, + this.enabled = true, + this.semanticLabel, + this.excludeSemantics = false, + }); + + final bool highContrast; + + final bool collapsed; + + final EdgeInsetsGeometry? panelPadding; + + final SidebarStyler style; + + final Widget? header; + + final bool showTooltips; + + final OverlayPositionConfig? tooltipPositioning; + + final double? expandedWidth; + + final double? collapsedWidth; + + final AnimationStyle animationStyle; + + final List> sections; + + final T? selectedValue; + + final ValueChanged? onSelected; + + final Widget? footer; + + final bool enabled; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixSidebar( + key: this.key, + style: fortalSidebarStyle( + highContrast: this.highContrast, + collapsed: this.collapsed, + panelPadding: this.panelPadding, + style: this.style, + ), + header: this.header, + collapsed: this.collapsed, + showTooltips: this.showTooltips, + tooltipPositioning: this.tooltipPositioning, + expandedWidth: this.expandedWidth, + collapsedWidth: this.collapsedWidth, + animationStyle: this.animationStyle, + sections: this.sections, + selectedValue: this.selectedValue, + onSelected: this.onSelected, + footer: this.footer, + enabled: this.enabled, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/sidebar_layout.dart b/registry_source/lib/src/fortal/components/sidebar_layout.dart new file mode 100644 index 000000000..c8789a606 --- /dev/null +++ b/registry_source/lib/src/fortal/components/sidebar_layout.dart @@ -0,0 +1,364 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +/// Accessible name for the compact navigation sheet's dialog barrier. +const _closeNavigationLabel = 'Close navigation'; + +/// Accessible name for the compact navigation sheet itself. +const _navigationSemanticLabel = 'Navigation'; + +/// A width, in logical pixels, reserved outside the compact sheet so its +/// scrim stays reachable on narrow screens. +const _compactSheetBarrierGutter = 56.0; + +/// Fortal-themed shell layout pairing a [sidebar] with a [body]. +/// +/// A layout, not a styled component: it owns no `Spec`, ships no generated +/// adapter, and paints nothing of its own beyond the compact sheet's panel +/// surface. [sidebar] is expected to be an already-configured `FortalSidebar` +/// (or any widget) that renders its own collapsed/expanded content; this +/// widget only decides where that content sits. +/// +/// At or above [compactBreakpoint] logical pixels of available width, the +/// layout renders a row: [sidebar] at [collapsedWidth] or [sidebarWidth] +/// (matching [collapsed]), animated over 200ms with an ease-in-out curve — +/// the same timing `RemixSidebar` uses by default — next to an expanded +/// column holding the optional [header] above [body]. +/// +/// Below [compactBreakpoint], [sidebar] is hidden from the row entirely and +/// instead presented as a full-height sheet pinned to the layout's *start* +/// edge (end edge in RTL), opened and closed through +/// [FortalSidebarLayoutScope]. The sheet is a [showRemixDialog] route, which +/// supplies the barrier, Escape-to-dismiss, and focus containment; this +/// widget only positions the sheet's content and supplies its panel surface. +/// +/// [compactOpen] and [onCompactOpenChanged] make the sheet's open state +/// controlled. Leave [compactOpen] null to let the layout manage it, still +/// observing changes through [onCompactOpenChanged] if supplied. +/// +/// A controlled [compactOpen] is the single source of truth: a barrier tap, +/// Escape, or a back gesture requests closure through [onCompactOpenChanged]. +/// The host must set [compactOpen] to `false` to dismiss it. Crossing back +/// above [compactBreakpoint] hides the sheet and requests a closed state. +/// +/// ```dart +/// FortalSidebarLayout( +/// sidebar: FortalSidebar( +/// sections: sections, +/// selectedValue: page, +/// onSelected: (value) { +/// setState(() => page = value); +/// FortalSidebarLayoutScope.of(context).closeCompact(); +/// }, +/// ), +/// header: const TopBar(), +/// body: PageBody(page: page), +/// ) +/// ``` +class FortalSidebarLayout extends StatefulWidget { + const FortalSidebarLayout({ + super.key, + required this.sidebar, + required this.body, + this.header, + this.compactBreakpoint = 720, + this.sidebarWidth = 256, + this.collapsedWidth = 72, + this.collapsed = false, + this.compactOpen, + this.onCompactOpenChanged, + }) : assert(compactBreakpoint > 0), + assert(sidebarWidth > 0), + assert(collapsedWidth > 0 && collapsedWidth <= sidebarWidth); + + /// The navigation panel. Rendered inline while wide, and inside the + /// compact sheet while narrow. + final Widget sidebar; + + /// The page content, always visible. + final Widget body; + + /// Optional fixed content above [body], in both presentations. + final Widget? header; + + /// The available-width threshold, in logical pixels, below which the + /// layout switches to its compact presentation. + final double compactBreakpoint; + + /// The wide-mode panel width when [collapsed] is false. + final double sidebarWidth; + + /// The wide-mode panel width when [collapsed] is true. + final double collapsedWidth; + + /// Whether the wide-mode panel renders at [collapsedWidth] instead of + /// [sidebarWidth]. The host toggles this; [sidebar] itself decides how its + /// own content responds. + final bool collapsed; + + /// Controlled compact-sheet visibility. Null lets the layout manage it. + final bool? compactOpen; + + /// Called when the user requests a different open state, or an + /// uncontrolled sheet changes state. Updating [compactOpen] itself does + /// not emit another callback. + final ValueChanged? onCompactOpenChanged; + + @override + State createState() => _FortalSidebarLayoutState(); +} + +class _FortalSidebarLayoutState extends State { + bool _selfOpen = false; + bool _sheetShowing = false; + + /// The layout's presentation as of its most recent build, so [_openCompact] + /// can no-op while wide even when called outside that build. + bool _isCompact = false; + + /// The route [showRemixDialog] pushed for the open sheet, captured from + /// inside its own builder via `ModalRoute.of` so [_removeSheetRoute] can + /// close exactly that route on the Navigator that actually owns it, + /// rather than popping whatever a bare `Navigator.of(context)` finds. + Route? _sheetRoute; + + bool get _effectiveOpen => widget.compactOpen ?? _selfOpen; + + void _setOpen(bool value) { + if (_effectiveOpen == value) return; + if (widget.compactOpen == null) { + setState(() => _selfOpen = value); + } + widget.onCompactOpenChanged?.call(value); + } + + // No-op while wide, so open state never carries over to the next compact + // presentation. + void _openCompact() { + if (!_isCompact) return; + _setOpen(true); + } + + void _closeCompact() => _setOpen(false); + + void _reconcileSheet(bool isCompact) { + _isCompact = isCompact; + final desiredOpen = isCompact && _effectiveOpen; + if (desiredOpen == _sheetShowing) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_isCompact && _effectiveOpen) { + if (!_sheetShowing) _pushSheet(); + } else if (_sheetShowing) { + _removeSheetRoute(); + } + }); + } + + void _removeSheetRoute() { + final route = _sheetRoute; + if (route == null || !route.isActive) return; + route.navigator?.removeRoute(route); + } + + Future _pushSheet() async { + _sheetShowing = true; + final reduceMotion = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + await showRemixDialog( + context: context, + barrierDismissible: true, + barrierLabel: _closeNavigationLabel, + barrierColor: MixScope.tokenOf(FortalTokens.colorOverlay, context), + transitionDuration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 250), + builder: (dialogContext) { + _sheetRoute = ModalRoute.of(dialogContext); + final available = MediaQuery.sizeOf(dialogContext).width; + final width = math.min( + widget.sidebarWidth, + math.max(0.0, available - _compactSheetBarrierGutter), + ); + return PopScope( + canPop: widget.compactOpen == null, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _closeCompact(); + }, + child: FortalSidebarLayoutScope._( + isCompact: true, + isCompactOpen: true, + openCompact: _openCompact, + closeCompact: _closeCompact, + child: Align( + alignment: AlignmentDirectional.centerStart, + // A plain DecoratedBox paints the panel surface without + // affecting layout, unlike a Mix `Box`, whose border-box sizing + // would shrink `width` by the border's own stroke width. + child: DecoratedBox( + decoration: BoxDecoration( + color: MixScope.tokenOf( + FortalTokens.colorPanelSolid, + dialogContext, + ), + border: BorderDirectional( + end: BorderSide( + color: MixScope.tokenOf( + FortalTokens.grayA5, + dialogContext, + ), + width: MixScope.tokenOf( + FortalTokens.borderWidth1, + dialogContext, + ), + ), + ), + ), + child: SizedBox( + width: width, + height: double.infinity, + child: RemixDialog( + semanticLabel: _navigationSemanticLabel, + child: widget.sidebar, + ), + ), + ), + ), + ), + ); + }, + ); + // Reached once, however the route completed: a user dismissal or + // _removeSheetRoute above. + _sheetRoute = null; + _sheetShowing = false; + if (mounted) _setOpen(false); + } + + @override + void dispose() { + final route = _sheetRoute; + if (route != null) { + // Navigator mutations must wait until the current tree update finishes. + WidgetsBinding.instance.addPostFrameCallback((_) { + final navigator = route.navigator; + if (navigator != null && navigator.mounted && route.isActive) { + navigator.removeRoute(route); + } + }); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < widget.compactBreakpoint; + _reconcileSheet(isCompact); + + return FortalSidebarLayoutScope._( + isCompact: isCompact, + // Anded with isCompact so it can't read true while wide. + isCompactOpen: isCompact && _effectiveOpen, + openCompact: _openCompact, + closeCompact: _closeCompact, + child: isCompact ? _body() : _wideRow(), + ); + }, + ); + } + + Widget _wideRow() { + final reduceMotion = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedContainer( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 200), + curve: Curves.easeInOut, + width: widget.collapsed ? widget.collapsedWidth : widget.sidebarWidth, + child: widget.sidebar, + ), + Expanded(child: _body()), + ], + ); + } + + Widget _body() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ?widget.header, + Expanded(child: widget.body), + ], + ); + } +} + +/// Reads the layout's compact state and drives its compact sheet. +/// +/// Available to both the layout's normal subtree (for example, a [header]'s +/// menu button) and the compact sheet's own subtree (for example, a +/// destination's `onSelected` callback closing the sheet after navigating), +/// since the layout re-provides this scope inside the sheet route. +class FortalSidebarLayoutScope extends InheritedWidget { + // Fortal source floors at Dart 3.11, one release before private named + // parameters, so this assigns the private fields explicitly instead of + // naming the parameters after them. + const FortalSidebarLayoutScope._({ + required this.isCompact, + required this.isCompactOpen, + required VoidCallback openCompact, + required VoidCallback closeCompact, + required super.child, + }) : _openCompact = openCompact, // ignore: prefer_initializing_formals + _closeCompact = closeCompact; // ignore: prefer_initializing_formals + + /// Whether the layout is currently in its compact presentation. + final bool isCompact; + + /// Whether the compact sheet is currently open. + /// + /// Always false outside a compact presentation. + final bool isCompactOpen; + + final VoidCallback _openCompact; + final VoidCallback _closeCompact; + + /// Opens the compact sheet. A no-op while wide. + void openCompact() => _openCompact(); + + /// Closes the compact sheet. A no-op when already closed. + void closeCompact() => _closeCompact(); + + /// Reads the nearest [FortalSidebarLayoutScope]. + /// + /// Throws a [FlutterError] outside a [FortalSidebarLayout]. + static FortalSidebarLayoutScope of(BuildContext context) { + final scope = maybeOf(context); + if (scope == null) { + throw FlutterError( + 'FortalSidebarLayoutScope.of requires a FortalSidebarLayout ancestor.', + ); + } + return scope; + } + + /// Reads the nearest [FortalSidebarLayoutScope], or null outside a + /// [FortalSidebarLayout]. + static FortalSidebarLayoutScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(FortalSidebarLayoutScope oldWidget) => + isCompact != oldWidget.isCompact || + isCompactOpen != oldWidget.isCompactOpen; +} diff --git a/registry_source/lib/src/fortal/components/skeleton.dart b/registry_source/lib/src/fortal/components/skeleton.dart new file mode 100644 index 000000000..1a24561f7 --- /dev/null +++ b/registry_source/lib/src/fortal/components/skeleton.dart @@ -0,0 +1,27 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'skeleton.g.dart'; + +/// Fortal recipe for [RemixSkeleton]. +/// +/// The pulse starts and rests on `grayA3` before moving toward `grayA4`; +/// Radix's CSS `alternate-reverse` phase starts from `grayA4`. +@MixWidget(target: RemixSkeleton.new) +SkeletonStyler fortalSkeletonStyle({ + SkeletonStyler style = const SkeletonStyler.create(), +}) { + return SkeletonStyler() + .container( + BoxStyler() + .minHeight(FortalTokens.space3()) + .color(FortalTokens.grayA3()) + .borderRadius(.all(FortalTokens.radius1())), + ) + .pulseColor(FortalTokens.grayA4()) + .duration(FortalTokens.skeletonPulseDuration()) + .merge(style); +} diff --git a/registry_source/lib/src/fortal/components/skeleton.g.dart b/registry_source/lib/src/fortal/components/skeleton.g.dart new file mode 100644 index 000000000..c081b0105 --- /dev/null +++ b/registry_source/lib/src/fortal/components/skeleton.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'skeleton.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal recipe for [RemixSkeleton]. +/// +/// The pulse starts and rests on `grayA3` before moving toward `grayA4`; +/// Radix's CSS `alternate-reverse` phase starts from `grayA4`. +class FortalSkeleton extends StatelessWidget { + const FortalSkeleton({ + super.key, + this.style = const SkeletonStyler.create(), + this.child, + this.loading = true, + }); + + final SkeletonStyler style; + + final Widget? child; + + final bool loading; + + @override + Widget build(BuildContext context) { + return RemixSkeleton( + key: this.key, + style: fortalSkeletonStyle(style: this.style), + child: this.child, + loading: this.loading, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/slider.dart b/registry_source/lib/src/fortal/components/slider.dart new file mode 100644 index 000000000..1df8e6238 --- /dev/null +++ b/registry_source/lib/src/fortal/components/slider.dart @@ -0,0 +1,350 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'slider.g.dart'; + +/// Radix Themes slider sizes. +enum FortalSliderSize { size1, size2, size3 } + +/// Radix Themes slider variants. +enum FortalSliderVariant { classic, surface, soft } + +/// Fortal slider with Radix-owned size, variant, and component overrides. +@MixWidget(target: RemixSlider.new) +SliderStyler fortalSliderStyle({ + FortalSliderVariant variant = .surface, + FortalSliderSize size = .size2, + bool highContrast = false, + SliderStyler style = const SliderStyler.create(), +}) { + final metrics = _fortalSliderMetrics(size); + final radius = BorderRadiusMix.all(metrics.trackRadius); + final thumbRadius = BorderRadiusMix.all(FortalTokens.radius1OrThumb()); + final base = SliderStyler() + .track(.borderRadius(radius)) + .range(.borderRadius(radius)) + .thumb( + .size(metrics.thumbSize, metrics.thumbSize).borderRadius(thumbRadius), + ) + .thickness(metrics.trackSize) + .thumbFocusEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix(color: FortalTokens.accent3(), spreadRadius: 3), + RemixBoxShadowMix(color: FortalTokens.focus8(), spreadRadius: 5), + ], + ), + ), + ); + + final styled = switch (variant) { + .classic => _fortalSliderClassic( + base, + trackRadius: radius, + thumbRadius: thumbRadius, + highContrast: highContrast, + ), + .surface => _fortalSliderSurface( + base, + trackRadius: radius, + thumbRadius: thumbRadius, + highContrast: highContrast, + ), + .soft => _fortalSliderSoft( + base, + trackRadius: radius, + thumbRadius: thumbRadius, + highContrast: highContrast, + ), + }; + return styled + .onDisabled( + _fortalSliderDisabled( + variant, + trackRadius: radius, + thumbRadius: thumbRadius, + ), + ) + .variant( + ContextVariant( + 'fortalSliderDisabledDarkBlend', + (context) => FortalTheme.of(context).isDark, + ), + SliderStyler().onDisabled(.blendMode(BlendMode.screen)), + ) + .merge(style); +} + +SliderStyler _fortalSliderSurface( + SliderStyler base, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, + required bool highContrast, +}) => base + .track(.color(FortalTokens.grayA3())) + .range(.color(FortalTokens.accentTrack())) + .thumbColor(const Color(0xFFFFFFFF)) + .trackEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.grayA5()]), + ), + ) + .rangeEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.grayA5()]).merge( + RemixBoxEffectLayerMix( + gradients: _fortalSliderHighContrastGradients(highContrast), + ), + ), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([ + BoxShadowMix(color: FortalTokens.blackA4(), spreadRadius: 1), + ]), + ), + ); + +SliderStyler _fortalSliderClassic( + SliderStyler base, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, + required bool highContrast, +}) => base + .track(.color(FortalTokens.grayA3())) + .range(.color(FortalTokens.accentTrack())) + .thumbColor(const Color(0xFFFFFFFF)) + .trackEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadowToken: FortalTokens.shadow1Layers), + ), + ) + .rangeEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: _fortalSliderHighContrastGradients(highContrast), + shadows: highContrast + ? [ + _fortalSliderInset(FortalTokens.grayA3()), + _fortalSliderInset(FortalTokens.blackA2()), + _fortalSliderInset( + FortalTokens.blackA2(), + offset: const Offset(0, 1.5), + blurRadius: 2, + spreadRadius: 0, + ), + ] + : [ + _fortalSliderInset(FortalTokens.grayA3()), + _fortalSliderInset(FortalTokens.accentA4()), + _fortalSliderInset(FortalTokens.blackA1()), + _fortalSliderInset( + FortalTokens.blackA2(), + offset: const Offset(0, 1.5), + blurRadius: 2, + spreadRadius: 0, + ), + ], + ), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([ + BoxShadowMix(color: FortalTokens.blackA3(), spreadRadius: 1), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + ]), + ), + ); + +SliderStyler _fortalSliderSoft( + SliderStyler base, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, + required bool highContrast, +}) => base + .track(.color(FortalTokens.grayA4())) + .range(.color(FortalTokens.accent6())) + .thumbColor(const Color(0xFFFFFFFF)) + .trackEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.whiteA1(), FortalTokens.whiteA1()], + ), + ], + ), + ), + ) + .rangeEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.accentA5(), FortalTokens.accentA5()], + ), + ..._fortalSliderHighContrastGradients(highContrast), + ], + ), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([ + BoxShadowMix(color: FortalTokens.blackA3(), spreadRadius: 1), + BoxShadowMix(color: FortalTokens.grayA2(), spreadRadius: 1), + BoxShadowMix(color: FortalTokens.accentA2(), spreadRadius: 1), + BoxShadowMix( + color: FortalTokens.grayA4(), + offset: const Offset(0, 1), + blurRadius: 2, + ), + BoxShadowMix( + color: FortalTokens.grayA3(), + offset: const Offset(0, 1), + blurRadius: 3, + spreadRadius: -0.5, + ), + ]), + ), + ); + +SliderStyler _fortalSliderDisabled( + FortalSliderVariant variant, { + required BorderRadiusMix trackRadius, + required BorderRadiusMix thumbRadius, +}) { + final track = switch (variant) { + .surface => + SliderStyler() + .track(.color(FortalTokens.grayA3())) + .trackEffects( + RemixBoxEffectsMix.behindContent( + fortalInsetSurface(strokes: [FortalTokens.grayA4()]), + ), + ), + .classic => + SliderStyler() + .track(.color(FortalTokens.grayA3())) + .trackEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadowToken: FortalTokens.sliderClassicDisabledTrackShadows, + ), + ), + ), + .soft => + SliderStyler() + .track(.color(FortalTokens.grayA4())) + .trackEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(gradients: const []), + ), + ), + }; + return track + .range(.color(const Color(0x00000000))) + .thumbColor(FortalTokens.gray1()) + .rangeEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(gradients: const [], shadows: const []), + ), + ) + .rangeEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(gradients: const [], shadows: const []), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([ + BoxShadowMix( + color: switch (variant) { + .soft => FortalTokens.gray5(), + .classic || .surface => FortalTokens.gray6(), + }, + spreadRadius: 1, + ), + ]), + ), + ) + .thumbFocusEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix(shadows: const []), + ), + ) + .blendMode(BlendMode.multiply); +} + +RemixBoxShadowMix _fortalSliderInset( + Color color, { + Offset offset = Offset.zero, + double blurRadius = 0, + double spreadRadius = 1, +}) => RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: color, + offset: offset, + blurRadius: blurRadius, + spreadRadius: spreadRadius, +); + +List _fortalSliderHighContrastGradients( + bool highContrast, +) => highContrast + ? [ + RemixLinearGradientMix( + colors: [ + FortalTokens.sliderHighContrastOverlay(), + FortalTokens.sliderHighContrastOverlay(), + ], + ), + ] + : const []; + +class _FortalSliderMetrics { + const _FortalSliderMetrics({ + required this.trackSize, + required this.thumbSize, + required this.trackRadius, + }); + + final double trackSize; + final double thumbSize; + final Radius trackRadius; +} + +_FortalSliderMetrics _fortalSliderMetrics(FortalSliderSize size) => + switch (size) { + .size1 => _FortalSliderMetrics( + trackSize: FortalTokens.sliderTrackSize1(), + thumbSize: FortalTokens.sliderThumbSize1(), + trackRadius: FortalTokens.sliderTrackRadius1(), + ), + .size2 => _FortalSliderMetrics( + trackSize: FortalTokens.sliderTrackSize2(), + thumbSize: FortalTokens.sliderThumbSize2(), + trackRadius: FortalTokens.sliderTrackRadius2(), + ), + .size3 => _FortalSliderMetrics( + trackSize: FortalTokens.sliderTrackSize3(), + thumbSize: FortalTokens.sliderThumbSize3(), + trackRadius: FortalTokens.sliderTrackRadius3(), + ), + }; diff --git a/registry_source/lib/src/fortal/components/slider.g.dart b/registry_source/lib/src/fortal/components/slider.g.dart new file mode 100644 index 000000000..758109958 --- /dev/null +++ b/registry_source/lib/src/fortal/components/slider.g.dart @@ -0,0 +1,158 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'slider.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal slider with Radix-owned size, variant, and component overrides. +class FortalSlider extends StatelessWidget { + const FortalSlider({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const SliderStyler.create(), + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + this.semanticLabel, + this.semanticFormatterCallback, + this.excludeSemantics = false, + }); + + const FortalSlider.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SliderStyler.create(), + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + this.semanticLabel, + this.semanticFormatterCallback, + this.excludeSemantics = false, + }) : variant = FortalSliderVariant.classic; + + const FortalSlider.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SliderStyler.create(), + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + this.semanticLabel, + this.semanticFormatterCallback, + this.excludeSemantics = false, + }) : variant = FortalSliderVariant.surface; + + const FortalSlider.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SliderStyler.create(), + required this.value, + this.onChanged, + this.onChangeStart, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.snapDivisions, + this.semanticLabel, + this.semanticFormatterCallback, + this.excludeSemantics = false, + }) : variant = FortalSliderVariant.soft; + + final FortalSliderVariant variant; + + final FortalSliderSize size; + + final bool highContrast; + + final SliderStyler style; + + final double value; + + final ValueChanged? onChanged; + + final ValueChanged? onChangeStart; + + final ValueChanged? onChangeEnd; + + final double min; + + final double max; + + final bool enabled; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final int? snapDivisions; + + final String? semanticLabel; + + final NakedSliderSemanticFormatterCallback? semanticFormatterCallback; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixSlider( + key: this.key, + style: fortalSliderStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + value: this.value, + onChanged: this.onChanged, + onChangeStart: this.onChangeStart, + onChangeEnd: this.onChangeEnd, + min: this.min, + max: this.max, + enabled: this.enabled, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + snapDivisions: this.snapDivisions, + semanticLabel: this.semanticLabel, + semanticFormatterCallback: this.semanticFormatterCallback, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/spinner.dart b/registry_source/lib/src/fortal/components/spinner.dart new file mode 100644 index 000000000..96b7f4716 --- /dev/null +++ b/registry_source/lib/src/fortal/components/spinner.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'spinner.g.dart'; + +/// Fortal spinner size presets. +enum FortalSpinnerSize { size1, size2, size3 } + +/// Fortal-themed preset for [RemixSpinner] using the inherited foreground color. +@MixWidget(target: RemixSpinner.new) +SpinnerStyler fortalSpinnerStyle({ + FortalSpinnerSize size = .size2, + SpinnerStyler style = const SpinnerStyler.create(), +}) { + return SpinnerStyler( + opacity: 0.65, + leafRadius: FortalTokens.radius1(), + duration: const Duration(milliseconds: 800), + ).merge(_fortalSpinnerSizeStyler(size)).merge(style); +} + +SpinnerStyler _fortalSpinnerSizeStyler(FortalSpinnerSize size) { + return switch (size) { + .size1 => SpinnerStyler(size: FortalTokens.space3()), + .size2 => SpinnerStyler(size: FortalTokens.space4()), + .size3 => SpinnerStyler(size: FortalTokens.spinnerSize3()), + }; +} diff --git a/registry_source/lib/src/fortal/components/spinner.g.dart b/registry_source/lib/src/fortal/components/spinner.g.dart new file mode 100644 index 000000000..c28494ea1 --- /dev/null +++ b/registry_source/lib/src/fortal/components/spinner.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spinner.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixSpinner] using the inherited foreground color. +class FortalSpinner extends StatelessWidget { + const FortalSpinner({ + super.key, + this.size = .size2, + this.style = const SpinnerStyler.create(), + this.semanticsLabel, + this.semanticsValue, + }); + + final FortalSpinnerSize size; + + final SpinnerStyler style; + + final String? semanticsLabel; + + final String? semanticsValue; + + @override + Widget build(BuildContext context) { + return RemixSpinner( + key: this.key, + style: fortalSpinnerStyle(size: this.size, style: this.style), + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/switch.dart b/registry_source/lib/src/fortal/components/switch.dart new file mode 100644 index 000000000..115f21138 --- /dev/null +++ b/registry_source/lib/src/fortal/components/switch.dart @@ -0,0 +1,354 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'switch.g.dart'; + +/// Fortal switch size presets. +enum FortalSwitchSize { + /// Compact switch. + size1, + + /// Default switch. + size2, + + /// Large switch. + size3, +} + +/// Fortal switch color variants. +enum FortalSwitchVariant { + /// Raised treatment with Radix's classic shadows. + classic, + + /// Surface treatment with a visible border. + surface, + + /// Softer accent treatment. + soft, +} + +/// Fortal-themed preset for [RemixSwitch]. +@MixWidget(target: RemixSwitch.new) +SwitchStyler fortalSwitchStyle({ + FortalSwitchVariant variant = .surface, + FortalSwitchSize size = .size2, + bool highContrast = false, + SwitchStyler style = const SwitchStyler.create(), +}) { + return (switch (variant) { + .classic => _fortalSwitchClassicStyler(size, highContrast: highContrast), + .surface => _fortalSwitchSurfaceStyler(size, highContrast: highContrast), + .soft => _fortalSwitchSoftStyler(size, highContrast: highContrast), + }).merge(style); +} + +SwitchStyler _fortalSwitchBaseStyler(FortalSwitchSize size) { + final metrics = _fortalSwitchMetrics(size); + return SwitchStyler( + container: .size( + metrics.width, + metrics.height, + ).padding(.all(1)).borderRadius(.all(metrics.radius)), + thumb: .size( + metrics.thumbSize, + metrics.thumbSize, + ).borderRadius(.all(metrics.radius)), + trackEffects: RemixBoxEffectsMix( + behindContent: _fortalSwitchLayer(), + overContent: _fortalSwitchLayer(), + ), + ) + .thumbColor(const Color(0xFFFFFFFF)) + .onFocusVisible( + .trackEffects( + RemixBoxEffectsMix( + outline: BorderSideMix( + color: FortalTokens.focus8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: 2, + ), + ), + ); +} + +SwitchStyler _fortalSwitchClassicStyler( + FortalSwitchSize size, { + required bool highContrast, +}) { + return _fortalSwitchBaseStyler(size) + .trackColor(FortalTokens.grayA4()) + .trackEffects( + RemixBoxEffectsMix.behindContent( + _fortalSwitchLayer(shadowToken: FortalTokens.shadow1Layers), + ), + ) + .thumb( + _fortalSwitchThumbStyler(selected: false, highContrast: highContrast), + ) + .onSelected( + SwitchStyler() + .trackColor( + highContrast + ? FortalTokens.accent12() + : FortalTokens.accentTrack(), + ) + .trackEffects( + RemixBoxEffectsMix.behindContent( + _fortalSwitchLayer( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.grayA3(), + spreadRadius: 1, + ), + RemixBoxShadowMix( + kind: .inset, + color: highContrast + ? FortalTokens.blackA2() + : FortalTokens.accentA4(), + spreadRadius: 1, + ), + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.blackA2(), + offset: const Offset(0, 1.5), + blurRadius: 2, + ), + ], + ), + ), + ) + .thumb( + _fortalSwitchThumbStyler( + selected: true, + highContrast: highContrast, + ), + ), + ) + .onPressed( + SwitchStyler() + .trackColor(FortalTokens.grayA5()) + .trackEffects( + RemixBoxEffectsMix.behindContent(_fortalSwitchLayer()), + ), + ) + .onDisabled(_fortalSwitchDisabledStyler(classic: true)); +} + +SwitchStyler _fortalSwitchSurfaceStyler( + FortalSwitchSize size, { + required bool highContrast, +}) { + return _fortalSwitchBaseStyler(size) + .trackColor(FortalTokens.grayA3()) + .trackEffects(RemixBoxEffectsMix.behindContent(_fortalSwitchLayer())) + .trackEffects( + RemixBoxEffectsMix.overContent( + _fortalSwitchInsetRing(FortalTokens.grayA5()), + ), + ) + .thumb( + _fortalSwitchThumbStyler(selected: false, highContrast: highContrast), + ) + .onSelected( + SwitchStyler() + .trackColor( + highContrast + ? FortalTokens.accent12() + : FortalTokens.accentTrack(), + ) + .trackEffects( + RemixBoxEffectsMix.behindContent(_fortalSwitchLayer()), + ) + .thumb( + _fortalSwitchThumbStyler( + selected: true, + highContrast: highContrast, + ), + ), + ) + .onPressed( + SwitchStyler() + .trackColor(FortalTokens.grayA4()) + .trackEffects( + RemixBoxEffectsMix.behindContent(_fortalSwitchLayer()), + ), + ) + .onDisabled(_fortalSwitchDisabledStyler()); +} + +SwitchStyler _fortalSwitchSoftStyler( + FortalSwitchSize size, { + required bool highContrast, +}) { + return _fortalSwitchBaseStyler(size) + .trackColor(FortalTokens.grayA3()) + .trackEffects(RemixBoxEffectsMix.behindContent(_fortalSwitchLayer())) + .thumb(_fortalSwitchSoftThumbStyler(false)) + .onSelected( + SwitchStyler() + .trackColor( + highContrast ? FortalTokens.accentA6() : FortalTokens.accentA4(), + ) + .trackEffects( + RemixBoxEffectsMix.behindContent(_fortalSwitchLayer()), + ) + .thumb(_fortalSwitchSoftThumbStyler(true)), + ) + .onPressed( + SwitchStyler() + .trackColor(FortalTokens.grayA4()) + .trackEffects( + RemixBoxEffectsMix.behindContent(_fortalSwitchLayer()), + ), + ) + .onDisabled(_fortalSwitchDisabledStyler(soft: true)); +} + +({double width, double height, double thumbSize, Radius radius}) +_fortalSwitchMetrics(FortalSwitchSize size) { + final height = switch (size) { + .size1 => FortalTokens.space4(), + .size2 => FortalTokens.switchHeight2(), + .size3 => FortalTokens.space5(), + }; + final width = switch (size) { + .size1 => FortalTokens.switchWidth1(), + .size2 => FortalTokens.switchWidth2(), + .size3 => FortalTokens.switchWidth3(), + }; + final thumbSize = switch (size) { + .size1 => FortalTokens.switchThumbSize1(), + .size2 => FortalTokens.switchThumbSize2(), + .size3 => FortalTokens.switchThumbSize3(), + }; + final radius = switch (size) { + .size1 => FortalTokens.radius1OrThumb(), + .size2 || .size3 => FortalTokens.radius2OrThumb(), + }; + return (width: width, height: height, thumbSize: thumbSize, radius: radius); +} + +SwitchStyler _fortalSwitchDisabledStyler({ + bool classic = false, + bool soft = false, +}) { + final trackColor = switch ((classic, soft)) { + (true, _) => FortalTokens.grayA5(), + (_, true) => FortalTokens.grayA4(), + _ => FortalTokens.grayA3(), + }; + return SwitchStyler() + .trackColor(trackColor) + .trackEffects( + RemixBoxEffectsMix.behindContent( + _fortalSwitchLayer( + shadowToken: classic ? FortalTokens.shadow1Layers : null, + ), + ), + ) + .trackEffects( + RemixBoxEffectsMix.overContent( + classic || soft + ? _fortalSwitchLayer(shadows: const []) + : _fortalSwitchInsetRing(FortalTokens.grayA3()), + ), + ) + .thumb( + BoxStyler().decoration( + .boxShadow([ + BoxShadowMix(color: FortalTokens.grayA2(), spreadRadius: 1), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + ]), + ), + ) + .thumbColor(FortalTokens.gray2()); +} + +BoxStyler _fortalSwitchThumbStyler({ + required bool selected, + required bool highContrast, +}) => BoxStyler().decoration( + .boxShadow( + selected + ? [ + BoxShadowMix( + color: FortalTokens.blackA2(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + BoxShadowMix( + color: highContrast + ? FortalTokens.blackA2() + : FortalTokens.accentA4(), + spreadRadius: 1, + ), + BoxShadowMix( + color: FortalTokens.blackA2(), + offset: const Offset(-1, 0), + blurRadius: 1, + ), + ] + : [ + BoxShadowMix(color: FortalTokens.blackA2(), spreadRadius: 1), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: FortalTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + ], + ), +); + +BoxStyler _fortalSwitchSoftThumbStyler(bool selected) => BoxStyler().decoration( + .boxShadow([ + BoxShadowMix(color: FortalTokens.blackA1(), spreadRadius: 1), + BoxShadowMix( + color: selected ? FortalTokens.blackA2() : FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: selected ? FortalTokens.accentA3() : FortalTokens.blackA1(), + offset: const Offset(0, 1), + blurRadius: 3, + ), + BoxShadowMix( + color: selected ? FortalTokens.accentA3() : FortalTokens.blackA1(), + offset: const Offset(0, 2), + blurRadius: 4, + spreadRadius: -1, + ), + ]), +); + +RemixBoxEffectLayerMix _fortalSwitchInsetRing(Color color) => + _fortalSwitchLayer( + shadows: [RemixBoxShadowMix(kind: .inset, color: color, spreadRadius: 1)], + ); + +RemixBoxEffectLayerMix _fortalSwitchLayer({ + List? shadows, + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix(shadows: shadows, shadowToken: shadowToken); diff --git a/registry_source/lib/src/fortal/components/switch.g.dart b/registry_source/lib/src/fortal/components/switch.g.dart new file mode 100644 index 000000000..1c7aa4c0c --- /dev/null +++ b/registry_source/lib/src/fortal/components/switch.g.dart @@ -0,0 +1,126 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'switch.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixSwitch]. +class FortalSwitch extends StatelessWidget { + const FortalSwitch({ + super.key, + this.variant = .surface, + this.size = .size2, + this.highContrast = false, + this.style = const SwitchStyler.create(), + required this.selected, + required this.semanticLabel, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + /// Raised treatment with Radix's classic shadows. + const FortalSwitch.classic({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SwitchStyler.create(), + required this.selected, + required this.semanticLabel, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalSwitchVariant.classic; + + /// Surface treatment with a visible border. + const FortalSwitch.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SwitchStyler.create(), + required this.selected, + required this.semanticLabel, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalSwitchVariant.surface; + + /// Softer accent treatment. + const FortalSwitch.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const SwitchStyler.create(), + required this.selected, + required this.semanticLabel, + this.onChanged, + this.enabled = true, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalSwitchVariant.soft; + + final FortalSwitchVariant variant; + + final FortalSwitchSize size; + + final bool highContrast; + + final SwitchStyler style; + + final bool selected; + + final String semanticLabel; + + final ValueChanged? onChanged; + + final bool enabled; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixSwitch( + key: this.key, + style: fortalSwitchStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + selected: this.selected, + semanticLabel: this.semanticLabel, + onChanged: this.onChanged, + enabled: this.enabled, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/tabs.dart b/registry_source/lib/src/fortal/components/tabs.dart new file mode 100644 index 000000000..4a6f4497a --- /dev/null +++ b/registry_source/lib/src/fortal/components/tabs.dart @@ -0,0 +1,127 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'tabs.g.dart'; + +/// Fortal tab-list size presets matching Radix Themes 3.3.0. +enum FortalTabsSize { size1, size2 } + +/// Fortal-themed preset for [RemixTabBar]. +/// +/// The tab-list bottom border is a single hairline at every Radix size, so this +/// preset takes no `size` — unlike [fortalTabStyle], whose per-tab metrics vary. +@MixWidget(target: RemixTabBar.new) +TabBarStyler fortalTabBarStyle({ + TabBarStyler style = const TabBarStyler.create(), +}) { + return TabBarStyler() + .border( + .bottom( + .color(FortalTokens.grayA5()).width(FortalTokens.borderWidth1()), + ), + ) + .merge(style); +} + +/// Fortal-themed preset for [RemixTabView]. +@MixWidget(target: RemixTabView.new) +TabViewStyler fortalTabViewStyle({ + TabViewStyler style = const TabViewStyler.create(), +}) => TabViewStyler().merge(style); + +/// Fortal-themed preset for [RemixTab]. +@MixWidget(target: RemixTab.new) +TabStyler fortalTabStyle({ + FortalTabsSize size = FortalTabsSize.size2, + bool highContrast = false, + TabStyler style = const TabStyler.create(), +}) { + final metrics = switch (size) { + FortalTabsSize.size1 => ( + height: FortalTokens.space6(), + outerPaddingX: FortalTokens.space1(), + innerPaddingX: FortalTokens.space1(), + innerPaddingY: FortalTokens.tabInnerPaddingY1(), + radius: FortalTokens.radius1(), + text: FortalTokens.text1.mix(), + activeLetterSpacing: FortalTokens.tabActiveLetterSpacing1(), + ), + FortalTabsSize.size2 => ( + height: FortalTokens.space7(), + outerPaddingX: FortalTokens.space2(), + innerPaddingX: FortalTokens.space2(), + innerPaddingY: FortalTokens.space1(), + radius: FortalTokens.radius2(), + text: FortalTokens.text2.mix(), + activeLetterSpacing: FortalTokens.tabActiveLetterSpacing2(), + ), + }; + + return TabStyler() + .label( + .style(metrics.text).letterSpacing(0.0).color(FortalTokens.grayA11()), + ) + .icon(.color(FortalTokens.grayA11()).size(FortalTokens.space4())) + .wrap( + .box( + BoxStyler() + .height(metrics.height) + .padding(.horizontal(metrics.outerPaddingX)) + .alignment(.center) + .border( + .bottom( + .color( + const Color(0x00000000), + ).width(FortalTokens.borderWidth2()), + ), + ), + ), + ) + .container( + .direction(.horizontal) + .padding(.horizontal(metrics.innerPaddingX)) + .padding(.vertical(metrics.innerPaddingY)) + .borderRadius(.all(metrics.radius)) + .mainAxisAlignment(.center) + .spacing(FortalTokens.space2()), + ) + .onHovered( + .label(.color(FortalTokens.gray12())) + .icon(.color(FortalTokens.gray12())) + .color(FortalTokens.grayA3()) + .onFocusVisible(.color(FortalTokens.accentA3())), + ) + .onFocusVisible( + // Solid `focus-8` where the other three rings use alpha `focus-a8`. + // See fortalFocusRing: unresolved whether that is intentional. + TabStyler().fortalFocusRing( + color: FortalTokens.focus8(), + strokeAlign: null, + ), + ) + .onSelected( + .label( + .color(FortalTokens.gray12()) + .fontWeight(FortalTokens.fontWeightMedium()) + .letterSpacing(metrics.activeLetterSpacing), + ) + .icon(.color(FortalTokens.gray12())) + .wrap( + .box( + BoxStyler().border( + .bottom( + .color( + highContrast + ? FortalTokens.accent12() + : FortalTokens.accentIndicator(), + ).width(FortalTokens.borderWidth2()), + ), + ), + ), + ), + ) + .merge(style); +} diff --git a/registry_source/lib/src/fortal/components/tabs.g.dart b/registry_source/lib/src/fortal/components/tabs.g.dart new file mode 100644 index 000000000..b7fb0d44a --- /dev/null +++ b/registry_source/lib/src/fortal/components/tabs.g.dart @@ -0,0 +1,146 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tabs.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixTabBar]. +/// +/// The tab-list bottom border is a single hairline at every Radix size, so this +/// preset takes no `size` — unlike [fortalTabStyle], whose per-tab metrics vary. +class FortalTabBar extends StatelessWidget { + const FortalTabBar({ + super.key, + this.style = const TabBarStyler.create(), + required this.child, + }); + + final TabBarStyler style; + + final Widget child; + + @override + Widget build(BuildContext context) { + return RemixTabBar( + key: this.key, + style: fortalTabBarStyle(style: this.style), + child: this.child, + ); + } +} + +/// Fortal-themed preset for [RemixTabView]. +class FortalTabView extends StatelessWidget { + const FortalTabView({ + super.key, + this.style = const TabViewStyler.create(), + required this.tabId, + required this.child, + this.maintainState = true, + }); + + final TabViewStyler style; + + final String tabId; + + final Widget child; + + final bool maintainState; + + @override + Widget build(BuildContext context) { + return RemixTabView( + key: this.key, + style: fortalTabViewStyle(style: this.style), + tabId: this.tabId, + child: this.child, + maintainState: this.maintainState, + ); + } +} + +/// Fortal-themed preset for [RemixTab]. +class FortalTab extends StatelessWidget { + const FortalTab({ + super.key, + this.size = FortalTabsSize.size2, + this.highContrast = false, + this.style = const TabStyler.create(), + required this.tabId, + this.child, + this.label, + this.icon, + this.enabled = true, + this.mouseCursor = SystemMouseCursors.click, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.onFocusChange, + this.onHoverChange, + this.onPressChange, + this.builder, + this.semanticLabel, + }); + + final FortalTabsSize size; + + final bool highContrast; + + final TabStyler style; + + final String tabId; + + final Widget? child; + + final String? label; + + final IconData? icon; + + final bool enabled; + + final MouseCursor mouseCursor; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final ValueChanged? onFocusChange; + + final ValueChanged? onHoverChange; + + final ValueChanged? onPressChange; + + final ValueWidgetBuilder? builder; + + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + return RemixTab( + key: this.key, + style: fortalTabStyle( + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + tabId: this.tabId, + child: this.child, + label: this.label, + icon: this.icon, + enabled: this.enabled, + mouseCursor: this.mouseCursor, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + onFocusChange: this.onFocusChange, + onHoverChange: this.onHoverChange, + onPressChange: this.onPressChange, + builder: this.builder, + semanticLabel: this.semanticLabel, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/text.dart b/registry_source/lib/src/fortal/components/text.dart new file mode 100644 index 000000000..ba498eba4 --- /dev/null +++ b/registry_source/lib/src/fortal/components/text.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'typography.dart'; + +part 'text.g.dart'; + +/// Fortal-themed body text on the Radix nine-step scale. +/// +/// Omitted [size] and [weight] resolve to the Radix root run (`text3`, +/// regular) from the active [FortalScope]'s tokens rather than the ambient +/// `DefaultTextStyle`. This deliberately deviates from Radix's CSS `1em` +/// inheritance: a token default cannot be silently replaced by a host-installed +/// text run (a `Material` surface, or a host with no run at all), which keeps +/// Fortal text a function of the theme alone. Set [accent] to take the +/// surrounding [FortalScope]'s accent colour; leaving it false uses the +/// neutral `gray12` foreground. +@MixWidget() +TextStyler fortalTextStyle({ + FortalTextSize? size, + FortalTextWeight? weight, + TextAlign? align, + bool softWrap = true, + bool truncate = false, + bool accent = false, + bool highContrast = false, + TextStyler style = const TextStyler.create(), +}) { + var recipe = TextStyler().style( + fortalTextSizeToken(size ?? FortalTextSize.size3).mix(), + ); + recipe = recipe.fontWeight( + fortalTextWeightToken(weight ?? FortalTextWeight.regular)(), + ); + recipe = accent + ? fortalAccentForeground(recipe, highContrast: highContrast) + : recipe.color(FortalTokens.gray12()); + recipe = recipe.inherit(false); + + return fortalApplyTextFlow( + recipe, + align: align, + softWrap: softWrap, + truncate: truncate, + ).merge(style); +} diff --git a/registry_source/lib/src/fortal/components/text.g.dart b/registry_source/lib/src/fortal/components/text.g.dart new file mode 100644 index 000000000..53e87f208 --- /dev/null +++ b/registry_source/lib/src/fortal/components/text.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'text.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed body text on the Radix nine-step scale. +/// +/// Omitted [size] and [weight] resolve to the Radix root run (`text3`, +/// regular) from the active [FortalScope]'s tokens rather than the ambient +/// `DefaultTextStyle`. This deliberately deviates from Radix's CSS `1em` +/// inheritance: a token default cannot be silently replaced by a host-installed +/// text run (a `Material` surface, or a host with no run at all), which keeps +/// Fortal text a function of the theme alone. Set [accent] to take the +/// surrounding [FortalScope]'s accent colour; leaving it false uses the +/// neutral `gray12` foreground. +class FortalText extends StatelessWidget { + const FortalText( + this.text, { + super.key, + this.size, + this.weight, + this.align, + this.softWrap = true, + this.truncate = false, + this.accent = false, + this.highContrast = false, + this.style = const TextStyler.create(), + }); + + final FortalTextSize? size; + + final FortalTextWeight? weight; + + final TextAlign? align; + + final bool softWrap; + + final bool truncate; + + final bool accent; + + final bool highContrast; + + final TextStyler style; + + final String text; + + @override + Widget build(BuildContext context) { + return fortalTextStyle( + size: this.size, + weight: this.weight, + align: this.align, + softWrap: this.softWrap, + truncate: this.truncate, + accent: this.accent, + highContrast: this.highContrast, + style: this.style, + ).call(this.text, key: this.key); + } +} diff --git a/registry_source/lib/src/fortal/components/textfield.dart b/registry_source/lib/src/fortal/components/textfield.dart new file mode 100644 index 000000000..55893381d --- /dev/null +++ b/registry_source/lib/src/fortal/components/textfield.dart @@ -0,0 +1,383 @@ +// `DragStartBehavior` and `MaxLengthEnforcement` appear in the generated +// FortalTextField/FortalTextArea constructors, so they must be visible from +// this library even though nothing here references them directly. +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'textfield.g.dart'; + +/// Fortal text field size presets. +enum FortalTextFieldSize { + /// Compact text field. + size1, + + /// Default text field. + size2, + + /// Large text field. + size3, +} + +/// Fortal text field color variants. +enum FortalTextFieldVariant { + /// Raised treatment with Radix's level-one shadow. + classic, + + /// Surface treatment with neutral border and text colors. + surface, + + /// Soft accent treatment. + soft, +} + +Color _resolveNeutralTextInputPlaceholder(BuildContext context) { + final color = FortalTokens.grayA10.resolve(context); + return color.withValues(alpha: color.a * 0.5); +} + +const _neutralTextInputPlaceholder = ContextToken( + _resolveNeutralTextInputPlaceholder, +); + +/// Fortal-themed preset for [RemixTextField]. +@MixWidget(target: RemixTextField.new) +TextFieldStyler fortalTextFieldStyle({ + FortalTextFieldVariant variant = .surface, + FortalTextFieldSize size = .size2, + TextFieldStyler style = const TextFieldStyler.create(), +}) { + final metrics = _fortalTextFieldMetrics(size, bordered: variant != .soft); + final base = _fortalTextInputBaseStyle( + container: BoxStyler() + .height(metrics.height) + .padding(.horizontal(metrics.paddingX)) + .borderRadius(.all(metrics.radius)) + .clipBehavior(.antiAlias), + spacing: metrics.spacing, + crossAxisAlignment: .center, + text: metrics.text, + focusColor: switch (variant) { + .soft => FortalTokens.accent8(), + .classic || .surface => FortalTokens.focus8(), + }, + ); + + final recipe = switch (variant) { + .classic => _fortalApplyClassicTextInput(base), + .surface => _fortalApplySurfaceTextInput(base), + .soft => _fortalApplySoftTextInput(base, placeholderOpacity: 0.60), + }; + + return recipe + .variant(ContextVariant.widgetState(.error), _fortalTextInputErrorStyle()) + .merge(style); +} + +TextFieldStyler _fortalTextInputBaseStyle({ + required BoxStyler container, + required double spacing, + required CrossAxisAlignment crossAxisAlignment, + required TextStyleToken text, + required Color focusColor, +}) => + TextFieldStyler( + container: container, + spacing: spacing, + crossAxisAlignment: crossAxisAlignment, + text: .style(text.mix()), + hintText: .style(text.mix()).textHeightBehavior( + TextHeightBehaviorMix() + .applyHeightToFirstAscent(false) + .applyHeightToLastDescent(true), + ), + helperText: .style(FortalTokens.text1.mix()), + label: .style(FortalTokens.text2.mix()), + cursorWidth: 1.5, + containerEffects: RemixBoxEffectsMix( + behindContent: RemixBoxEffectLayerMix(), + overContent: RemixBoxEffectLayerMix(), + ), + ) + .wrap(.iconTheme(color: FortalTokens.gray11(), size: 16.0)) + // Radix keys text-input rings from :focus/:focus-within, so unlike + // control focus rings this intentionally follows raw focus. + .onFocused( + .containerEffects(fortalFocusOutline(focusColor, offset: -1)), + ); + +TextFieldStyler _fortalApplyClassicTextInput(TextFieldStyler base) => + _fortalApplyNeutralTextInput(base) + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix(shadowToken: FortalTokens.shadow1Layers), + ), + ) + .onDisabled( + _fortalNeutralTextInputDisabledStyle() + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.grayA2(), FortalTokens.grayA2()], + ), + ], + shadowToken: FortalTokens.shadow1Layers, + ), + ), + ), + ); + +TextFieldStyler _fortalApplySurfaceTextInput(TextFieldStyler base) => + _fortalApplyNeutralTextInput(base) + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + fortalInsetSurface(strokes: [FortalTokens.grayA7()]), + ), + ) + .onDisabled( + _fortalNeutralTextInputDisabledStyle() + .color(FortalTokens.colorSurface()) + .containerEffects( + RemixBoxEffectsMix.behindContent( + RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.grayA2(), FortalTokens.grayA2()], + ), + ], + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + fortalInsetSurface(strokes: [FortalTokens.grayA6()]), + ), + ), + ); + +TextFieldStyler _fortalApplySoftTextInput( + TextFieldStyler base, { + required double placeholderOpacity, +}) => base + .merge( + TextFieldStyler( + text: .fontWeight(FortalTokens.fontWeightRegular()), + hintText: .fontWeight(FortalTokens.fontWeightRegular()), + cursorColor: FortalTokens.accent12(), + helperText: .color( + FortalTokens.gray11(), + ).fontWeight(FortalTokens.fontWeightRegular()), + label: .color( + FortalTokens.gray12(), + ).fontWeight(FortalTokens.fontWeightMedium()), + ), + ) + .textColor(FortalTokens.accent12()) + .text(.selectionColor(FortalTokens.accentA5())) + .onEnabled( + .hintText( + .color(FortalTokens.accent12().withValues(alpha: placeholderOpacity)), + ), + ) + .wrap(.iconTheme(color: FortalTokens.accent10())) + .color(FortalTokens.accentA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ) + .onDisabled( + _fortalSoftTextInputDisabledStyle() + .color(FortalTokens.grayA3()) + .containerEffects( + RemixBoxEffectsMix.behindContent(RemixBoxEffectLayerMix()), + ), + ); + +TextFieldStyler _fortalApplyNeutralTextInput(TextFieldStyler base) => + base.merge( + TextFieldStyler( + text: .color( + FortalTokens.gray12(), + ).selectionColor(FortalTokens.focusA5()), + hintText: .color(_neutralTextInputPlaceholder()), + cursorColor: FortalTokens.gray12(), + helperText: .color(FortalTokens.gray11()), + label: .color( + FortalTokens.gray12(), + ).fontWeight(FortalTokens.fontWeightMedium()), + ), + ); + +// Keep the disabled-color branch on raw focus for the same :focus-within +// contract as the enabled text input. +TextFieldStyler _fortalTextInputDisabledBaseStyle() => + TextFieldStyler( + text: .color( + FortalTokens.grayA11(), + ).selectionColor(FortalTokens.grayA5()), + cursorColor: FortalTokens.grayA11(), + ).onFocused( + .containerEffects(fortalFocusOutline(FortalTokens.gray8(), offset: -1)), + ); + +TextFieldStyler _fortalNeutralTextInputDisabledStyle() => + _fortalTextInputDisabledBaseStyle().hintText( + .color(_neutralTextInputPlaceholder()), + ); + +TextFieldStyler _fortalSoftTextInputDisabledStyle() => + _fortalTextInputDisabledBaseStyle().hintText( + .color(FortalTokens.accent12().withValues(alpha: 0.5)), + ); + +TextFieldStyler _fortalTextInputErrorStyle() => TextFieldStyler( + helperText: .color(FortalTokens.error11()), + label: .color(FortalTokens.error11()), + cursorColor: FortalTokens.error9(), + containerEffects: RemixBoxEffectsMix( + overContent: RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + kind: .inset, + color: FortalTokens.errorA7(), + spreadRadius: 1, + ), + ], + ), + outline: BorderSideMix( + color: FortalTokens.error8(), + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: -1, + ), +); + +({ + double height, + double paddingX, + double spacing, + Radius radius, + TextStyleToken text, +}) +_fortalTextFieldMetrics(FortalTextFieldSize size, {required bool bordered}) => + switch (size) { + .size1 => ( + height: FortalTokens.space5(), + paddingX: bordered + ? FortalTokens.textFieldPadding1() + : FortalTokens.selectSpace1Half(), + spacing: FortalTokens.space2(), + radius: FortalTokens.radius2OrFull(), + text: FortalTokens.text1, + ), + .size2 => ( + height: FortalTokens.space6(), + paddingX: bordered + ? FortalTokens.textFieldPadding2() + : FortalTokens.space2(), + spacing: FortalTokens.space2(), + radius: FortalTokens.radius2OrFull(), + text: FortalTokens.text2, + ), + .size3 => ( + height: FortalTokens.space7(), + paddingX: bordered + ? FortalTokens.textFieldPadding3() + : FortalTokens.space3(), + spacing: FortalTokens.space3(), + radius: FortalTokens.radius3OrFull(), + text: FortalTokens.text3, + ), + }; + +/// Radix Themes TextArea size presets. +enum FortalTextAreaSize { size1, size2, size3 } + +/// Radix Themes TextArea variants. +enum FortalTextAreaVariant { classic, surface, soft } + +/// Fortal recipe for [RemixTextArea]. +/// +/// Scrolling follows the host platform; this recipe does not reproduce Radix's +/// themed browser scrollbar or resize handle. +@MixWidget(target: RemixTextArea.new) +TextFieldStyler fortalTextAreaStyle({ + FortalTextAreaVariant variant = .surface, + FortalTextAreaSize size = .size2, + TextFieldStyler style = const TextFieldStyler.create(), +}) { + final metrics = _fortalTextAreaMetrics(size); + final base = _fortalTextInputBaseStyle( + container: BoxStyler() + .minHeight(metrics.minHeight) + .padding( + .symmetric(horizontal: metrics.paddingX, vertical: metrics.paddingY), + ) + .borderRadius(.all(metrics.radius)) + .clipBehavior(.antiAlias), + spacing: metrics.spacing, + crossAxisAlignment: .start, + text: metrics.text, + focusColor: switch (variant) { + .soft => FortalTokens.accent8(), + .classic || .surface => FortalTokens.focus8(), + }, + ); + + final recipe = switch (variant) { + .classic => _fortalApplyClassicTextInput(base), + .surface => _fortalApplySurfaceTextInput(base), + .soft => _fortalApplySoftTextInput(base, placeholderOpacity: 0.65), + }; + + return recipe + .variant(ContextVariant.widgetState(.error), _fortalTextInputErrorStyle()) + .merge(style); +} + +({ + double minHeight, + double paddingX, + double paddingY, + double spacing, + Radius radius, + TextStyleToken text, +}) +_fortalTextAreaMetrics(FortalTextAreaSize size) => switch (size) { + .size1 => ( + minHeight: FortalTokens.space8(), + paddingX: FortalTokens.selectSpace1Half(), + paddingY: FortalTokens.space1(), + spacing: FortalTokens.space2(), + radius: FortalTokens.radius2(), + text: FortalTokens.text1, + ), + .size2 => ( + minHeight: FortalTokens.space9(), + paddingX: FortalTokens.space2(), + paddingY: FortalTokens.selectSpace1Half(), + spacing: FortalTokens.space2(), + radius: FortalTokens.radius2(), + text: FortalTokens.text2, + ), + .size3 => ( + minHeight: FortalTokens.textAreaMinHeight3(), + paddingX: FortalTokens.space3(), + paddingY: FortalTokens.space2(), + spacing: FortalTokens.space3(), + radius: FortalTokens.radius3(), + text: FortalTokens.text3, + ), +}; diff --git a/registry_source/lib/src/fortal/components/textfield.g.dart b/registry_source/lib/src/fortal/components/textfield.g.dart new file mode 100644 index 000000000..07ac28a2c --- /dev/null +++ b/registry_source/lib/src/fortal/components/textfield.g.dart @@ -0,0 +1,882 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'textfield.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixTextField]. +class FortalTextField extends StatelessWidget { + const FortalTextField({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = .none, + this.textDirection, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.obscuringCharacter = '•', + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }); + + /// Raised treatment with Radix's level-one shadow. + const FortalTextField.classic({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = .none, + this.textDirection, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.obscuringCharacter = '•', + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = FortalTextFieldVariant.classic; + + /// Surface treatment with neutral border and text colors. + const FortalTextField.surface({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = .none, + this.textDirection, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.obscuringCharacter = '•', + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = FortalTextFieldVariant.surface; + + /// Soft accent treatment. + const FortalTextField.soft({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = .none, + this.textDirection, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.obscuringCharacter = '•', + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = FortalTextFieldVariant.soft; + + final FortalTextFieldVariant variant; + + final FortalTextFieldSize size; + + final TextFieldStyler style; + + final TextEditingController? controller; + + final FocusNode? focusNode; + + final String? label; + + final String? hintText; + + final String? helperText; + + final bool error; + + final TextInputType? keyboardType; + + final TextInputAction? textInputAction; + + final TextCapitalization textCapitalization; + + final TextDirection? textDirection; + + final bool obscureText; + + final bool enabled; + + final bool readOnly; + + final bool autofocus; + + final int? maxLines; + + final int? minLines; + + final bool expands; + + final int? maxLength; + + final MaxLengthEnforcement? maxLengthEnforcement; + + final ValueChanged? onChanged; + + final VoidCallback? onEditingComplete; + + final ValueChanged? onSubmitted; + + final AppPrivateCommandCallback? onAppPrivateCommand; + + final List? inputFormatters; + + final bool? showCursor; + + final String obscuringCharacter; + + final bool autocorrect; + + final bool enableSuggestions; + + final SmartDashesType? smartDashesType; + + final SmartQuotesType? smartQuotesType; + + final DragStartBehavior dragStartBehavior; + + final bool enableInteractiveSelection; + + final TextSelectionControls? selectionControls; + + final GestureTapCallback? onTap; + + final TapRegionCallback? onTapOutside; + + final TapRegionUpCallback? onPressUpOutside; + + final bool onTapAlwaysCalled; + + final ScrollController? scrollController; + + final ScrollPhysics? scrollPhysics; + + final Iterable? autofillHints; + + final ContentInsertionConfiguration? contentInsertionConfiguration; + + final Clip clipBehavior; + + final String? restorationId; + + final bool stylusHandwritingEnabled; + + final bool enableIMEPersonalizedLearning; + + final EditableTextContextMenuBuilder? contextMenuBuilder; + + final SpellCheckConfiguration? spellCheckConfiguration; + + final TextMagnifierConfiguration? magnifierConfiguration; + + final bool canRequestFocus; + + final bool? ignorePointers; + + final UndoHistoryController? undoController; + + final Object groupId; + + final Widget? leading; + + final Widget? trailing; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixTextField( + key: this.key, + style: fortalTextFieldStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + controller: this.controller, + focusNode: this.focusNode, + label: this.label, + hintText: this.hintText, + helperText: this.helperText, + error: this.error, + keyboardType: this.keyboardType, + textInputAction: this.textInputAction, + textCapitalization: this.textCapitalization, + textDirection: this.textDirection, + obscureText: this.obscureText, + enabled: this.enabled, + readOnly: this.readOnly, + autofocus: this.autofocus, + maxLines: this.maxLines, + minLines: this.minLines, + expands: this.expands, + maxLength: this.maxLength, + maxLengthEnforcement: this.maxLengthEnforcement, + onChanged: this.onChanged, + onEditingComplete: this.onEditingComplete, + onSubmitted: this.onSubmitted, + onAppPrivateCommand: this.onAppPrivateCommand, + inputFormatters: this.inputFormatters, + showCursor: this.showCursor, + obscuringCharacter: this.obscuringCharacter, + autocorrect: this.autocorrect, + enableSuggestions: this.enableSuggestions, + smartDashesType: this.smartDashesType, + smartQuotesType: this.smartQuotesType, + dragStartBehavior: this.dragStartBehavior, + enableInteractiveSelection: this.enableInteractiveSelection, + selectionControls: this.selectionControls, + onTap: this.onTap, + onTapOutside: this.onTapOutside, + onPressUpOutside: this.onPressUpOutside, + onTapAlwaysCalled: this.onTapAlwaysCalled, + scrollController: this.scrollController, + scrollPhysics: this.scrollPhysics, + autofillHints: this.autofillHints, + contentInsertionConfiguration: this.contentInsertionConfiguration, + clipBehavior: this.clipBehavior, + restorationId: this.restorationId, + stylusHandwritingEnabled: this.stylusHandwritingEnabled, + enableIMEPersonalizedLearning: this.enableIMEPersonalizedLearning, + contextMenuBuilder: this.contextMenuBuilder, + spellCheckConfiguration: this.spellCheckConfiguration, + magnifierConfiguration: this.magnifierConfiguration, + canRequestFocus: this.canRequestFocus, + ignorePointers: this.ignorePointers, + undoController: this.undoController, + groupId: this.groupId, + leading: this.leading, + trailing: this.trailing, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + ); + } +} + +/// Fortal recipe for [RemixTextArea]. +/// +/// Scrolling follows the host platform; this recipe does not reproduce Radix's +/// themed browser scrollbar or resize handle. +class FortalTextArea extends StatelessWidget { + const FortalTextArea({ + super.key, + this.variant = .surface, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType = TextInputType.multiline, + this.textInputAction = TextInputAction.newline, + this.textCapitalization = .none, + this.textDirection, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines, + this.minLines = 2, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }); + + const FortalTextArea.classic({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType = TextInputType.multiline, + this.textInputAction = TextInputAction.newline, + this.textCapitalization = .none, + this.textDirection, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines, + this.minLines = 2, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = FortalTextAreaVariant.classic; + + const FortalTextArea.surface({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType = TextInputType.multiline, + this.textInputAction = TextInputAction.newline, + this.textCapitalization = .none, + this.textDirection, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines, + this.minLines = 2, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = FortalTextAreaVariant.surface; + + const FortalTextArea.soft({ + super.key, + this.size = .size2, + this.style = const TextFieldStyler.create(), + this.controller, + this.focusNode, + this.label, + this.hintText, + this.helperText, + this.error = false, + this.keyboardType = TextInputType.multiline, + this.textInputAction = TextInputAction.newline, + this.textCapitalization = .none, + this.textDirection, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.maxLines, + this.minLines = 2, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.showCursor, + this.autocorrect = true, + this.enableSuggestions = true, + this.smartDashesType, + this.smartQuotesType, + this.dragStartBehavior = .start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.onTapOutside, + this.onPressUpOutside, + this.onTapAlwaysCalled = false, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.contentInsertionConfiguration, + this.clipBehavior = .hardEdge, + this.restorationId, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.canRequestFocus = true, + this.ignorePointers, + this.undoController, + this.groupId = EditableText, + this.leading, + this.trailing, + this.semanticLabel, + this.semanticHint, + this.excludeSemantics = false, + }) : variant = FortalTextAreaVariant.soft; + + final FortalTextAreaVariant variant; + + final FortalTextAreaSize size; + + final TextFieldStyler style; + + final TextEditingController? controller; + + final FocusNode? focusNode; + + final String? label; + + final String? hintText; + + final String? helperText; + + final bool error; + + final TextInputType? keyboardType; + + final TextInputAction? textInputAction; + + final TextCapitalization textCapitalization; + + final TextDirection? textDirection; + + final bool enabled; + + final bool readOnly; + + final bool autofocus; + + final int? maxLines; + + final int? minLines; + + final int? maxLength; + + final MaxLengthEnforcement? maxLengthEnforcement; + + final ValueChanged? onChanged; + + final VoidCallback? onEditingComplete; + + final ValueChanged? onSubmitted; + + final AppPrivateCommandCallback? onAppPrivateCommand; + + final List? inputFormatters; + + final bool? showCursor; + + final bool autocorrect; + + final bool enableSuggestions; + + final SmartDashesType? smartDashesType; + + final SmartQuotesType? smartQuotesType; + + final DragStartBehavior dragStartBehavior; + + final bool enableInteractiveSelection; + + final TextSelectionControls? selectionControls; + + final GestureTapCallback? onTap; + + final TapRegionCallback? onTapOutside; + + final TapRegionUpCallback? onPressUpOutside; + + final bool onTapAlwaysCalled; + + final ScrollController? scrollController; + + final ScrollPhysics? scrollPhysics; + + final Iterable? autofillHints; + + final ContentInsertionConfiguration? contentInsertionConfiguration; + + final Clip clipBehavior; + + final String? restorationId; + + final bool stylusHandwritingEnabled; + + final bool enableIMEPersonalizedLearning; + + final EditableTextContextMenuBuilder? contextMenuBuilder; + + final SpellCheckConfiguration? spellCheckConfiguration; + + final TextMagnifierConfiguration? magnifierConfiguration; + + final bool canRequestFocus; + + final bool? ignorePointers; + + final UndoHistoryController? undoController; + + final Object groupId; + + final Widget? leading; + + final Widget? trailing; + + final String? semanticLabel; + + final String? semanticHint; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixTextArea( + key: this.key, + style: fortalTextAreaStyle( + variant: this.variant, + size: this.size, + style: this.style, + ), + controller: this.controller, + focusNode: this.focusNode, + label: this.label, + hintText: this.hintText, + helperText: this.helperText, + error: this.error, + keyboardType: this.keyboardType, + textInputAction: this.textInputAction, + textCapitalization: this.textCapitalization, + textDirection: this.textDirection, + enabled: this.enabled, + readOnly: this.readOnly, + autofocus: this.autofocus, + maxLines: this.maxLines, + minLines: this.minLines, + maxLength: this.maxLength, + maxLengthEnforcement: this.maxLengthEnforcement, + onChanged: this.onChanged, + onEditingComplete: this.onEditingComplete, + onSubmitted: this.onSubmitted, + onAppPrivateCommand: this.onAppPrivateCommand, + inputFormatters: this.inputFormatters, + showCursor: this.showCursor, + autocorrect: this.autocorrect, + enableSuggestions: this.enableSuggestions, + smartDashesType: this.smartDashesType, + smartQuotesType: this.smartQuotesType, + dragStartBehavior: this.dragStartBehavior, + enableInteractiveSelection: this.enableInteractiveSelection, + selectionControls: this.selectionControls, + onTap: this.onTap, + onTapOutside: this.onTapOutside, + onPressUpOutside: this.onPressUpOutside, + onTapAlwaysCalled: this.onTapAlwaysCalled, + scrollController: this.scrollController, + scrollPhysics: this.scrollPhysics, + autofillHints: this.autofillHints, + contentInsertionConfiguration: this.contentInsertionConfiguration, + clipBehavior: this.clipBehavior, + restorationId: this.restorationId, + stylusHandwritingEnabled: this.stylusHandwritingEnabled, + enableIMEPersonalizedLearning: this.enableIMEPersonalizedLearning, + contextMenuBuilder: this.contextMenuBuilder, + spellCheckConfiguration: this.spellCheckConfiguration, + magnifierConfiguration: this.magnifierConfiguration, + canRequestFocus: this.canRequestFocus, + ignorePointers: this.ignorePointers, + undoController: this.undoController, + groupId: this.groupId, + leading: this.leading, + trailing: this.trailing, + semanticLabel: this.semanticLabel, + semanticHint: this.semanticHint, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/toast.dart b/registry_source/lib/src/fortal/components/toast.dart new file mode 100644 index 000000000..e854c52ee --- /dev/null +++ b/registry_source/lib/src/fortal/components/toast.dart @@ -0,0 +1,157 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; +import 'button.dart'; +import 'icon_button.dart'; + +part 'toast.g.dart'; + +/// Fortal toast size presets. +enum FortalToastSize { size1, size2, size3 } + +/// Fortal toast surfaces, matching the Card surface and classic treatments. +enum FortalToastVariant { surface, classic } + +/// The color role of the leading icon. +/// +/// Visual only: it never changes [RemixToastData.priority]. Choose +/// [RemixToastPriority.assertive] explicitly when a message needs an +/// immediate announcement. +enum FortalToastIntent { accent, neutral, error } + +/// Fortal-themed toast surface for [RemixToast] and [RemixToastScope]. +/// +/// A Fortal extension: Radix Themes has no toast, so the recipe reuses the +/// Card panel and shadow tokens. The surface caps at 360 logical pixels and +/// shrinks with the available width. +/// +/// ```dart +/// RemixToastScope(style: fortalToastStyle(), child: const Shell()) +/// ``` +@MixWidget(target: RemixToast.new) +ToastStyler fortalToastStyle({ + FortalToastVariant variant = .classic, + FortalToastSize size = .size2, + FortalToastIntent intent = .accent, + ToastStyler style = const ToastStyler.create(), +}) { + final metrics = _fortalToastMetrics(size); + final base = + ToastStyler( + container: FlexBoxStyler().spacing(metrics.gap), + content: FlexBoxStyler().spacing(FortalTokens.space1()), + icon: IconStyler() + .size(metrics.iconSize) + .color(_fortalToastIntentColor(intent)), + title: TextStyler(style: metrics.text.mix()) + .fontWeight(FortalTokens.fontWeightMedium()) + .color(FortalTokens.gray12()), + description: TextStyler( + style: metrics.text.mix(), + ).color(FortalTokens.gray11()), + action: fortalButtonStyle(variant: .ghost, size: .size1), + closeButton: fortalIconButtonStyle(variant: .ghost, size: .size1) + .merge( + IconButtonStyler().icon( + IconStyler().color(FortalTokens.gray11()), + ), + ), + ) + .padding(.all(metrics.padding)) + .borderRadius(.all(metrics.radius)) + .maxWidth(360) + .containerEffects( + RemixBoxEffectsMix.backdropBlur(FortalTokens.panelBlur()), + ); + + return (switch (variant) { + .surface => + base + .containerEffects( + RemixBoxEffectsMix.behindContent(_fortalToastPanel()), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadows: [ + RemixBoxShadowMix( + color: FortalTokens.grayStroke5(), + spreadRadius: 1, + shapeInset: 1, + ), + ], + ), + ), + ) + .decoration( + BoxDecorationMix.create(boxShadow: FortalTokens.shadow4.mix()), + ), + .classic => + base + .containerEffects( + RemixBoxEffectsMix.behindContent( + _fortalToastPanel( + shadowToken: FortalTokens.cardClassicOuterShadows, + ), + ), + ) + .containerEffects( + RemixBoxEffectsMix.overContent( + RemixBoxEffectLayerMix( + shadowToken: FortalTokens.cardClassicInnerShadows, + ), + ), + ), + }).merge(style); +} + +({ + double padding, + double gap, + double iconSize, + Radius radius, + TextStyleToken text, +}) +_fortalToastMetrics(FortalToastSize size) => switch (size) { + .size1 => ( + padding: FortalTokens.space3(), + gap: FortalTokens.space2(), + iconSize: FortalTokens.space4(), + radius: FortalTokens.radius3(), + text: FortalTokens.text1, + ), + .size2 => ( + padding: FortalTokens.space4(), + gap: FortalTokens.space3(), + iconSize: FortalTokens.space4(), + radius: FortalTokens.radius4(), + text: FortalTokens.text2, + ), + .size3 => ( + padding: FortalTokens.space5(), + gap: FortalTokens.space3(), + iconSize: FortalTokens.space5(), + radius: FortalTokens.radius5(), + text: FortalTokens.text3, + ), +}; + +Color _fortalToastIntentColor(FortalToastIntent intent) => switch (intent) { + .accent => FortalTokens.accent11(), + .neutral => FortalTokens.gray11(), + .error => FortalTokens.error11(), +}; + +RemixBoxEffectLayerMix _fortalToastPanel({ + RemixBoxShadowListToken? shadowToken, +}) => RemixBoxEffectLayerMix( + gradients: [ + RemixLinearGradientMix( + colors: [FortalTokens.colorPanel(), FortalTokens.colorPanel()], + ), + ], + gradientInsets: const [1], + shadowToken: shadowToken, +); diff --git a/registry_source/lib/src/fortal/components/toast.g.dart b/registry_source/lib/src/fortal/components/toast.g.dart new file mode 100644 index 000000000..a2b74ec47 --- /dev/null +++ b/registry_source/lib/src/fortal/components/toast.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toast.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed toast surface for [RemixToast] and [RemixToastScope]. +/// +/// A Fortal extension: Radix Themes has no toast, so the recipe reuses the +/// Card panel and shadow tokens. The surface caps at 360 logical pixels and +/// shrinks with the available width. +/// +/// ```dart +/// RemixToastScope(style: fortalToastStyle(), child: const Shell()) +/// ``` +class FortalToast extends StatelessWidget { + const FortalToast({ + super.key, + this.variant = .classic, + this.size = .size2, + this.intent = .accent, + this.style = const ToastStyler.create(), + required this.title, + this.description, + this.icon, + this.action, + this.onDismiss, + this.dismissLabel, + this.excludeMessageSemantics = false, + }); + + const FortalToast.surface({ + super.key, + this.size = .size2, + this.intent = .accent, + this.style = const ToastStyler.create(), + required this.title, + this.description, + this.icon, + this.action, + this.onDismiss, + this.dismissLabel, + this.excludeMessageSemantics = false, + }) : variant = FortalToastVariant.surface; + + const FortalToast.classic({ + super.key, + this.size = .size2, + this.intent = .accent, + this.style = const ToastStyler.create(), + required this.title, + this.description, + this.icon, + this.action, + this.onDismiss, + this.dismissLabel, + this.excludeMessageSemantics = false, + }) : variant = FortalToastVariant.classic; + + final FortalToastVariant variant; + + final FortalToastSize size; + + final FortalToastIntent intent; + + final ToastStyler style; + + final String title; + + final String? description; + + final IconData? icon; + + final RemixToastAction? action; + + final VoidCallback? onDismiss; + + final String? dismissLabel; + + final bool excludeMessageSemantics; + + @override + Widget build(BuildContext context) { + return RemixToast( + key: this.key, + style: fortalToastStyle( + variant: this.variant, + size: this.size, + intent: this.intent, + style: this.style, + ), + title: this.title, + description: this.description, + icon: this.icon, + action: this.action, + onDismiss: this.onDismiss, + dismissLabel: this.dismissLabel, + excludeMessageSemantics: this.excludeMessageSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/toggle.dart b/registry_source/lib/src/fortal/components/toggle.dart new file mode 100644 index 000000000..ff13e97c0 --- /dev/null +++ b/registry_source/lib/src/fortal/components/toggle.dart @@ -0,0 +1,136 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'toggle.g.dart'; + +/// Fortal toggle size presets. +enum FortalToggleSize { size1, size2, size3 } + +/// Fortal toggle color and border variants. +enum FortalToggleVariant { ghost, outline } + +/// Fortal-themed preset for [RemixToggle]. +@MixWidget(target: RemixToggle.new) +ToggleStyler fortalToggleStyle({ + FortalToggleVariant variant = .ghost, + FortalToggleSize size = .size2, + bool highContrast = false, + ToggleStyler style = const ToggleStyler.create(), +}) { + return (switch (variant) { + .ghost => _fortalToggleGhostStyler(size, highContrast: highContrast), + .outline => _fortalToggleOutlineStyler(size, highContrast: highContrast), + }).merge(style); +} + +ToggleStyler _fortalToggleBaseStyler(FortalToggleSize size) { + return ToggleStyler() + .container(.mainAxisSize(.min)) + .labelColor(FortalTokens.gray12()) + .iconColor(FortalTokens.gray12()) + .labelFontWeight(FortalTokens.fontWeightMedium()) + .merge(_fortalToggleSizeStyler(size)); +} + +ToggleStyler _fortalToggleFocusStyler() => ToggleStyler().fortalFocusRing(); + +ToggleStyler _fortalToggleDisabledStyler({bool outlined = false}) { + final style = ToggleStyler() + .color(FortalTokens.grayA3()) + .labelColor(FortalTokens.gray8()) + .iconColor(FortalTokens.gray8()); + return outlined + ? style.border( + .color(FortalTokens.grayA6()) + .width(FortalTokens.borderWidth1()) + .strokeAlign(BorderSide.strokeAlignInside), + ) + : style; +} + +ToggleStyler _fortalToggleGhostStyler( + FortalToggleSize size, { + required bool highContrast, +}) { + final selectedContent = highContrast + ? FortalTokens.accent12() + : FortalTokens.accent11(); + return _fortalToggleBaseStyler(size) + .color(const Color(0x00000000)) + .onHovered(ToggleStyler().color(FortalTokens.grayA3())) + .onPressed(ToggleStyler().color(FortalTokens.grayA4())) + .onSelected( + ToggleStyler() + .color(FortalTokens.accent3()) + .labelColor(selectedContent) + .iconColor(selectedContent) + .onHovered(ToggleStyler().color(FortalTokens.accent4())) + .onPressed(ToggleStyler().color(FortalTokens.accent5())), + ) + .onFocusVisible(_fortalToggleFocusStyler()) + .onDisabled(_fortalToggleDisabledStyler()); +} + +ToggleStyler _fortalToggleOutlineStyler( + FortalToggleSize size, { + required bool highContrast, +}) { + final selectedContent = highContrast + ? FortalTokens.accent12() + : FortalTokens.accent11(); + return _fortalToggleBaseStyler(size) + .color(const Color(0x00000000)) + .border( + .color(FortalTokens.gray7()) + .width(FortalTokens.borderWidth1()) + .strokeAlign(BorderSide.strokeAlignInside), + ) + .onHovered(ToggleStyler().color(FortalTokens.grayA3())) + .onPressed(ToggleStyler().color(FortalTokens.grayA4())) + .onSelected( + ToggleStyler() + .color(FortalTokens.accentA3()) + .labelColor(selectedContent) + .iconColor(selectedContent) + .border(.color(FortalTokens.accentA5())) + .onHovered(ToggleStyler().color(FortalTokens.accentA4())) + .onPressed(ToggleStyler().color(FortalTokens.accentA5())), + ) + .onFocusVisible(_fortalToggleFocusStyler()) + .onDisabled(_fortalToggleDisabledStyler(outlined: true)); +} + +ToggleStyler _fortalToggleSizeStyler(FortalToggleSize size) { + return switch (size) { + .size1 => ToggleStyler( + container: FlexBoxStyler() + .padding(.horizontal(FortalTokens.space2())) + .padding(.vertical(FortalTokens.space1())) + .borderRadius(.all(FortalTokens.radius2())) + .spacing(FortalTokens.toggleGap1()), + label: .style(FortalTokens.text1.mix()), + icon: .size(FortalTokens.space3()), + ), + .size2 => ToggleStyler( + container: FlexBoxStyler() + .padding(.horizontal(FortalTokens.space3())) + .padding(.vertical(FortalTokens.space2())) + .borderRadius(.all(FortalTokens.radius2())) + .spacing(FortalTokens.space1()), + label: .style(FortalTokens.text2.mix()), + icon: .size(FortalTokens.space4()), + ), + .size3 => ToggleStyler( + container: FlexBoxStyler() + .padding(.horizontal(FortalTokens.space4())) + .padding(.vertical(FortalTokens.space2())) + .borderRadius(.all(FortalTokens.radius3())) + .spacing(FortalTokens.toggleGap3()), + label: .style(FortalTokens.text3.mix()), + icon: .size(FortalTokens.spinnerSize3()), + ), + }; +} diff --git a/registry_source/lib/src/fortal/components/toggle.g.dart b/registry_source/lib/src/fortal/components/toggle.g.dart new file mode 100644 index 000000000..3ad9a2303 --- /dev/null +++ b/registry_source/lib/src/fortal/components/toggle.g.dart @@ -0,0 +1,119 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toggle.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixToggle]. +class FortalToggle extends StatelessWidget { + const FortalToggle({ + super.key, + this.variant = .ghost, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.label, + this.icon, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }); + + const FortalToggle.ghost({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.label, + this.icon, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalToggleVariant.ghost; + + const FortalToggle.outline({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleStyler.create(), + required this.selected, + this.onChanged, + this.enabled = true, + this.label, + this.icon, + this.enableFeedback = true, + this.focusNode, + this.autofocus = false, + this.semanticLabel, + this.excludeSemantics = false, + this.mouseCursor = SystemMouseCursors.click, + }) : variant = FortalToggleVariant.outline; + + final FortalToggleVariant variant; + + final FortalToggleSize size; + + final bool highContrast; + + final ToggleStyler style; + + final bool selected; + + final ValueChanged? onChanged; + + final bool enabled; + + final String? label; + + final IconData? icon; + + final bool enableFeedback; + + final FocusNode? focusNode; + + final bool autofocus; + + final String? semanticLabel; + + final bool excludeSemantics; + + final MouseCursor mouseCursor; + + @override + Widget build(BuildContext context) { + return RemixToggle( + key: this.key, + style: fortalToggleStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + selected: this.selected, + onChanged: this.onChanged, + enabled: this.enabled, + label: this.label, + icon: this.icon, + enableFeedback: this.enableFeedback, + focusNode: this.focusNode, + autofocus: this.autofocus, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + mouseCursor: this.mouseCursor, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/toggle_group.dart b/registry_source/lib/src/fortal/components/toggle_group.dart new file mode 100644 index 000000000..66978ea69 --- /dev/null +++ b/registry_source/lib/src/fortal/components/toggle_group.dart @@ -0,0 +1,121 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'toggle_group.g.dart'; + +/// Fortal toggle-group size presets. +enum FortalToggleGroupSize { size1, size2, size3 } + +/// Fortal toggle-group color treatments. +enum FortalToggleGroupVariant { soft, surface } + +/// Fortal-themed segmented-control preset for [RemixToggleGroup]. +@MixWidget(target: RemixToggleGroup.new) +ToggleGroupStyler fortalToggleGroupStyle({ + FortalToggleGroupVariant variant = .soft, + FortalToggleGroupSize size = .size2, + bool highContrast = false, + ToggleGroupStyler style = const ToggleGroupStyler.create(), +}) { + final ( + selectedColor, + selectedHoverColor, + selectedPressedColor, + ) = switch (variant) { + .soft => ( + FortalTokens.accent3(), + FortalTokens.accent4(), + FortalTokens.accent5(), + ), + .surface => ( + FortalTokens.accentSurface(), + FortalTokens.accentA4(), + FortalTokens.accentA5(), + ), + }; + final selectedForeground = highContrast + ? FortalTokens.accent12() + : FortalTokens.accent11(); + + return ToggleGroupStyler( + container: FlexBoxStyler( + decoration: BoxDecorationMix( + border: BorderMix.all( + BorderSideMix( + color: FortalTokens.gray7(), + width: FortalTokens.borderWidth1(), + ), + ), + color: FortalTokens.colorSurface(), + ), + clipBehavior: .hardEdge, + mainAxisSize: .min, + spacing: 0, + ), + item: .alignment(.center) + .labelColor(FortalTokens.gray11()) + .iconColor(FortalTokens.gray11()) + .labelFontWeight(FortalTokens.fontWeightMedium()) + .onHovered(ToggleGroupItemStyler().color(FortalTokens.grayA3())) + .onPressed(ToggleGroupItemStyler().color(FortalTokens.grayA4())) + .onSelected( + ToggleGroupItemStyler() + .color(selectedColor) + .labelColor(selectedForeground) + .iconColor(selectedForeground) + .onHovered(ToggleGroupItemStyler().color(selectedHoverColor)) + .onPressed(ToggleGroupItemStyler().color(selectedPressedColor)), + ) + .onFocusVisible(ToggleGroupItemStyler().fortalFocusRing()) + .onDisabled( + ToggleGroupItemStyler() + .color(FortalTokens.grayA3()) + .labelColor(FortalTokens.gray8()) + .iconColor(FortalTokens.gray8()), + ), + ).merge(_fortalToggleGroupSizeStyler(size)).merge(style); +} + +ToggleGroupStyler _fortalToggleGroupSizeStyler(FortalToggleGroupSize size) { + return switch (size) { + .size1 => ToggleGroupStyler( + container: FlexBoxStyler().borderRadius(.all(FortalTokens.radius2())), + item: + .container( + FlexBoxStyler() + .padding(.horizontal(FortalTokens.space2())) + .padding(.vertical(FortalTokens.space1())) + .spacing(FortalTokens.toggleGap1()), + ) + .label(.style(FortalTokens.text1.mix())) + .icon(.size(FortalTokens.space3())), + ), + .size2 => ToggleGroupStyler( + container: FlexBoxStyler().borderRadius(.all(FortalTokens.radius2())), + item: + .container( + FlexBoxStyler() + .padding(.horizontal(FortalTokens.space3())) + .padding(.vertical(FortalTokens.space2())) + .spacing(FortalTokens.space1()), + ) + .label(.style(FortalTokens.text2.mix())) + .icon(.size(FortalTokens.space4())), + ), + .size3 => ToggleGroupStyler( + container: FlexBoxStyler().borderRadius(.all(FortalTokens.radius3())), + item: + .container( + FlexBoxStyler() + .padding(.horizontal(FortalTokens.space4())) + .padding(.vertical(FortalTokens.space2())) + .spacing(FortalTokens.toggleGap3()), + ) + .label(.style(FortalTokens.text3.mix())) + .icon(.size(FortalTokens.spinnerSize3())), + ), + }; +} diff --git a/registry_source/lib/src/fortal/components/toggle_group.g.dart b/registry_source/lib/src/fortal/components/toggle_group.g.dart new file mode 100644 index 000000000..7e8a23350 --- /dev/null +++ b/registry_source/lib/src/fortal/components/toggle_group.g.dart @@ -0,0 +1,101 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'toggle_group.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed segmented-control preset for [RemixToggleGroup]. +class FortalToggleGroup extends StatelessWidget { + const FortalToggleGroup({ + super.key, + this.variant = .soft, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleGroupStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }); + + const FortalToggleGroup.soft({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleGroupStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = FortalToggleGroupVariant.soft; + + const FortalToggleGroup.surface({ + super.key, + this.size = .size2, + this.highContrast = false, + this.style = const ToggleGroupStyler.create(), + required this.items, + required this.selectedValue, + this.onChanged, + this.enabled = true, + this.orientation = .horizontal, + this.loop = true, + this.semanticLabel, + this.excludeSemantics = false, + }) : variant = FortalToggleGroupVariant.surface; + + final FortalToggleGroupVariant variant; + + final FortalToggleGroupSize size; + + final bool highContrast; + + final ToggleGroupStyler style; + + final List> items; + + final T? selectedValue; + + final ValueChanged? onChanged; + + final bool enabled; + + final Axis orientation; + + final bool loop; + + final String? semanticLabel; + + final bool excludeSemantics; + + @override + Widget build(BuildContext context) { + return RemixToggleGroup( + key: this.key, + style: fortalToggleGroupStyle( + variant: this.variant, + size: this.size, + highContrast: this.highContrast, + style: this.style, + ), + items: this.items, + selectedValue: this.selectedValue, + onChanged: this.onChanged, + enabled: this.enabled, + orientation: this.orientation, + loop: this.loop, + semanticLabel: this.semanticLabel, + excludeSemantics: this.excludeSemantics, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/tooltip.dart b/registry_source/lib/src/fortal/components/tooltip.dart new file mode 100644 index 000000000..2ac0c1023 --- /dev/null +++ b/registry_source/lib/src/fortal/components/tooltip.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; +import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'tooltip.g.dart'; + +/// Fortal-themed preset for [RemixTooltip]. +@MixWidget(target: RemixTooltip.new) +TooltipStyler fortalTooltipStyle({ + TooltipStyler style = const TooltipStyler.create(), +}) { + return TooltipStyler( + label: .style(FortalTokens.text1.mix()), + waitDuration: const Duration(milliseconds: 200), + ) + .borderRadius(.all(FortalTokens.radius2())) + .padding(.vertical(FortalTokens.space1())) + .padding(.horizontal(FortalTokens.space2())) + .label(.color(FortalTokens.gray1())) + .color(FortalTokens.gray12()) + .merge(style); +} diff --git a/registry_source/lib/src/fortal/components/tooltip.g.dart b/registry_source/lib/src/fortal/components/tooltip.g.dart new file mode 100644 index 000000000..28c17a0eb --- /dev/null +++ b/registry_source/lib/src/fortal/components/tooltip.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tooltip.dart'; + +// ************************************************************************** +// MixWidgetGenerator +// ************************************************************************** + +/// Fortal-themed preset for [RemixTooltip]. +class FortalTooltip extends StatelessWidget { + const FortalTooltip({ + super.key, + this.style = const TooltipStyler.create(), + required this.tooltipChild, + required this.child, + this.open, + this.onOpenChanged, + this.tooltipSemantics, + this.positioning = const OverlayPositionConfig(), + }); + + final TooltipStyler style; + + final Widget tooltipChild; + + final Widget child; + + final bool? open; + + final ValueChanged? onOpenChanged; + + final String? tooltipSemantics; + + final OverlayPositionConfig positioning; + + @override + Widget build(BuildContext context) { + return RemixTooltip( + key: this.key, + style: fortalTooltipStyle(style: this.style), + tooltipChild: this.tooltipChild, + child: this.child, + open: this.open, + onOpenChanged: this.onOpenChanged, + tooltipSemantics: this.tooltipSemantics, + positioning: this.positioning, + ); + } +} diff --git a/registry_source/lib/src/fortal/components/typography.dart b/registry_source/lib/src/fortal/components/typography.dart new file mode 100644 index 000000000..9a1843bd1 --- /dev/null +++ b/registry_source/lib/src/fortal/components/typography.dart @@ -0,0 +1,82 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +/// The nine-step Radix Themes text scale supplied by [FortalTokens]. +enum FortalTextSize { + size1, + size2, + size3, + size4, + size5, + size6, + size7, + size8, + size9, +} + +/// Font weights supported by the Fortal typography scale. +/// +/// Closed rather than Flutter's [FontWeight], which is an open class accepting +/// any value from 1 to 1000; Radix ships exactly these four. +enum FortalTextWeight { light, regular, medium, bold } + +TextStyleToken fortalTextSizeToken(FortalTextSize size) => switch (size) { + .size1 => FortalTokens.text1, + .size2 => FortalTokens.text2, + .size3 => FortalTokens.text3, + .size4 => FortalTokens.text4, + .size5 => FortalTokens.text5, + .size6 => FortalTokens.text6, + .size7 => FortalTokens.text7, + .size8 => FortalTokens.text8, + .size9 => FortalTokens.text9, +}; + +FontWeightToken fortalTextWeightToken(FortalTextWeight weight) => + switch (weight) { + .light => FortalTokens.fontWeightLight, + .regular => FortalTokens.fontWeightRegular, + .medium => FortalTokens.fontWeightMedium, + .bold => FortalTokens.fontWeightBold, + }; + +/// [truncate] deliberately wins over [softWrap], forcing one ellipsized line. +TextStyler fortalApplyTextFlow( + TextStyler style, { + TextAlign? align, + required bool softWrap, + required bool truncate, +}) { + if (align != null) style = style.textAlign(align); + if (truncate) { + return style.maxLines(1).softWrap(false).overflow(TextOverflow.ellipsis); + } + + return style.softWrap(softWrap); +} + +TextStyler fortalAccentForeground( + TextStyler style, { + required bool highContrast, +}) => style.color( + highContrast ? FortalTokens.accent12() : FortalTokens.accentA11(), +); + +/// Code, Kbd, and Link derive em-relative geometry from the resolved font size, +/// so unlike the other recipes they cannot stay context-free. +TextStyle fortalResolveTextToken(BuildContext context, FortalTextSize size) => + MixScope.tokenOf(fortalTextSizeToken(size), context); + +Color fortalResolveColor(BuildContext context, ColorToken token) => + MixScope.tokenOf(token, context); + +/// Derived from the resolved `radius1` rather than duplicating the Fortal +/// radius enum table, so theme radius and scaling changes flow through. +double fortalRadiusFactor(BuildContext context) { + final scaling = FortalTheme.of(context).scaling.factor; + final radius = MixScope.tokenOf(FortalTokens.radius1, context); + + return radius.x / (3 * scaling); +} diff --git a/packages/remix_fortal/lib/src/icons.dart b/registry_source/lib/src/fortal/icons.dart similarity index 100% rename from packages/remix_fortal/lib/src/icons.dart rename to registry_source/lib/src/fortal/icons.dart diff --git a/registry_source/lib/src/fortal/recipes/activity_recipe.dart b/registry_source/lib/src/fortal/recipes/activity_recipe.dart new file mode 100644 index 000000000..c54739df4 --- /dev/null +++ b/registry_source/lib/src/fortal/recipes/activity_recipe.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/activity.dart'; + +import '../components/disclosure.dart'; +import '../theme/theme.dart'; + +@immutable +final class FortalAgentActivityRecipe { + const FortalAgentActivityRecipe({ + required this.style, + required this.disclosureStyle, + }); + final AgentActivityStyler style; + final DisclosureStyler disclosureStyle; +} + +FortalAgentActivityRecipe fortalAgentActivityRecipe({ + AgentActivityStyler style = const AgentActivityStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => FortalAgentActivityRecipe( + style: AgentActivityStyler( + viewport: BoxStyler().maxHeight(200), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color(FortalTokens.gray12()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color(FortalTokens.gray12()).fontSize(14), + itemDetail: TextStyler().color(FortalTokens.gray11()).fontSize(12), + count: TextStyler() + .color(FortalTokens.gray11()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color(FortalTokens.gray12()).size(16), + pendingStatus: IconStyler().color(FortalTokens.gray9()).size(12), + activeStatus: IconStyler().color(FortalTokens.accent9()).size(12), + completedStatus: IconStyler().color(FortalTokens.accent9()).size(12), + ).merge(style), + disclosureStyle: fortalDisclosureStyle( + style: DisclosureStyler() + .content(BoxStyler().padding(.all(0))) + .merge(disclosureStyle), + ), +); diff --git a/registry_source/lib/src/fortal/recipes/answer_recipe.dart b/registry_source/lib/src/fortal/recipes/answer_recipe.dart new file mode 100644 index 000000000..858672be6 --- /dev/null +++ b/registry_source/lib/src/fortal/recipes/answer_recipe.dart @@ -0,0 +1,52 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/answer.dart'; + +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/icon_button.dart'; +import '../theme/theme.dart'; + +@immutable +final class FortalAgentAnswerRecipe { + const FortalAgentAnswerRecipe({ + required this.style, + required this.surfaceStyle, + required this.sourcesStyle, + required this.copyStyle, + required this.retryStyle, + }); + final AgentAnswerStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler sourcesStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +FortalAgentAnswerRecipe fortalAgentAnswerRecipe({ + AgentAnswerStyler style = const AgentAnswerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler sourcesStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => FortalAgentAnswerRecipe( + style: AgentAnswerStyler( + body: BoxStyler(), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + feedback: BoxStyler().padding(.only(top: 6)), + sourcesLabel: TextStyler().color(FortalTokens.gray12()).fontSize(13), + indicator: IconStyler().color(FortalTokens.gray12()).size(16), + ).merge(style), + surfaceStyle: fortalCardStyle(size: .size2, style: surfaceStyle), + sourcesStyle: fortalDisclosureStyle(style: sourcesStyle), + copyStyle: fortalIconButtonStyle( + variant: .ghost, + size: .size1, + style: copyStyle, + ), + retryStyle: fortalIconButtonStyle( + variant: .ghost, + size: .size1, + style: retryStyle, + ), +); diff --git a/registry_source/lib/src/fortal/recipes/composer_recipe.dart b/registry_source/lib/src/fortal/recipes/composer_recipe.dart new file mode 100644 index 000000000..358b3f9ad --- /dev/null +++ b/registry_source/lib/src/fortal/recipes/composer_recipe.dart @@ -0,0 +1,64 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/composer.dart'; + +import '../components/card.dart'; +import '../components/icon_button.dart'; +import '../components/textfield.dart'; +import '../theme/theme.dart'; + +@immutable +final class FortalAgentComposerRecipe { + const FortalAgentComposerRecipe({ + required this.style, + required this.surfaceStyle, + required this.fieldStyle, + required this.submitStyle, + required this.stopStyle, + }); + final AgentComposerStyler style; + final CardStyler surfaceStyle; + final TextFieldStyler fieldStyle; + final IconButtonStyler submitStyle; + final IconButtonStyler stopStyle; +} + +FortalAgentComposerRecipe fortalAgentComposerRecipe({ + AgentComposerStyler style = const AgentComposerStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + TextFieldStyler fieldStyle = const TextFieldStyler.create(), + IconButtonStyler submitStyle = const IconButtonStyler.create(), + IconButtonStyler stopStyle = const IconButtonStyler.create(), +}) => FortalAgentComposerRecipe( + style: AgentComposerStyler( + toolbar: FlexBoxStyler() + .direction(.horizontal) + .mainAxisSize(.max) + .crossAxisAlignment(.center) + .spacing(8) + .padding(.only(top: 8)), + ).merge(style), + surfaceStyle: fortalCardStyle( + size: .size2, + style: CardStyler().padding(.all(12)).merge(surfaceStyle), + ), + fieldStyle: fortalTextAreaStyle( + style: TextFieldStyler() + .color(const Color(0x00000000)) + .border(.style(.none)) + .minHeight(56) + .padding(.all(4)) + .merge(fieldStyle), + ), + submitStyle: fortalIconButtonStyle( + size: .size2, + style: IconButtonStyler().size(40, 40).merge(submitStyle), + ), + stopStyle: fortalIconButtonStyle( + size: .size2, + style: IconButtonStyler() + .color(FortalTokens.error9()) + .size(40, 40) + .merge(stopStyle), + ), +); diff --git a/registry_source/lib/src/fortal/recipes/execution_recipe.dart b/registry_source/lib/src/fortal/recipes/execution_recipe.dart new file mode 100644 index 000000000..4b2104ec6 --- /dev/null +++ b/registry_source/lib/src/fortal/recipes/execution_recipe.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/execution.dart'; + +import '../components/card.dart'; +import '../components/disclosure.dart'; +import '../components/icon_button.dart'; +import '../theme/theme.dart'; + +@immutable +final class FortalAgentExecutionRecipe { + const FortalAgentExecutionRecipe({ + required this.style, + required this.surfaceStyle, + required this.disclosureStyle, + required this.copyStyle, + required this.retryStyle, + }); + final AgentExecutionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler disclosureStyle; + final IconButtonStyler copyStyle; + final IconButtonStyler retryStyle; +} + +FortalAgentExecutionRecipe fortalAgentExecutionRecipe({ + AgentExecutionStyler style = const AgentExecutionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), + IconButtonStyler copyStyle = const IconButtonStyler.create(), + IconButtonStyler retryStyle = const IconButtonStyler.create(), +}) => FortalAgentExecutionRecipe( + style: AgentExecutionStyler( + header: FlexBoxStyler().spacing(8), + output: BoxStyler() + .color(FortalTokens.gray3()) + .borderRadius(.circular(6)) + .padding(.all(12)), + actions: FlexBoxStyler().spacing(6).padding(.only(top: 8)), + tool: TextStyler().color(FortalTokens.gray11()).fontSize(12), + title: TextStyler() + .color(FortalTokens.gray12()) + .fontWeight(FontWeight.w600), + meta: TextStyler().color(FortalTokens.gray11()).fontSize(12), + status: TextStyler().color(FortalTokens.gray11()).fontSize(12), + toolIcon: IconStyler().color(FortalTokens.gray12()).size(16), + statusIcon: IconStyler().color(FortalTokens.accent9()).size(12), + indicator: IconStyler().color(FortalTokens.gray12()).size(16), + ).merge(style), + surfaceStyle: fortalCardStyle(size: .size2, style: surfaceStyle), + disclosureStyle: fortalDisclosureStyle(style: disclosureStyle), + copyStyle: fortalIconButtonStyle( + variant: .ghost, + size: .size1, + style: copyStyle, + ), + retryStyle: fortalIconButtonStyle( + variant: .ghost, + size: .size1, + style: retryStyle, + ), +); diff --git a/registry_source/lib/src/fortal/recipes/message_recipe.dart b/registry_source/lib/src/fortal/recipes/message_recipe.dart new file mode 100644 index 000000000..83e1445b5 --- /dev/null +++ b/registry_source/lib/src/fortal/recipes/message_recipe.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/message.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; + +@immutable +final class FortalAgentMessageRecipe { + const FortalAgentMessageRecipe({ + required this.style, + required this.surfaceStyle, + required this.collapsibleStyle, + required this.toggleStyle, + }); + final AgentMessageStyler style; + final CardStyler surfaceStyle; + final AgentMessageCollapsibleStyler collapsibleStyle; + final ButtonStyler toggleStyle; +} + +FortalAgentMessageRecipe fortalAgentMessageRecipe({ + AgentMessageStyler style = const AgentMessageStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + AgentMessageCollapsibleStyler collapsibleStyle = + const AgentMessageCollapsibleStyler.create(), + ButtonStyler toggleStyle = const ButtonStyler.create(), +}) => FortalAgentMessageRecipe( + style: AgentMessageStyler( + row: FlexBoxStyler().mainAxisSize(.max).spacing(8), + avatar: BoxStyler().size(28, 28), + header: BoxStyler().padding(.only(bottom: 6)), + body: BoxStyler(), + footer: BoxStyler().padding(.only(top: 4)), + maxWidth: 640, + ).merge(style), + surfaceStyle: fortalCardStyle(style: surfaceStyle), + collapsibleStyle: AgentMessageCollapsibleStyler( + collapsedHeight: 72, + container: BoxStyler(), + clipped: BoxStyler(), + ).merge(collapsibleStyle), + toggleStyle: fortalButtonStyle( + variant: .ghost, + size: .size1, + style: toggleStyle, + ), +); diff --git a/registry_source/lib/src/fortal/recipes/permission_recipe.dart b/registry_source/lib/src/fortal/recipes/permission_recipe.dart new file mode 100644 index 000000000..106b4afcd --- /dev/null +++ b/registry_source/lib/src/fortal/recipes/permission_recipe.dart @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/permission.dart'; + +import '../components/button.dart'; +import '../components/card.dart'; +import '../components/data_list.dart'; +import '../components/disclosure.dart'; +import '../theme/theme.dart'; + +@immutable +final class FortalAgentPermissionRecipe { + const FortalAgentPermissionRecipe({ + required this.style, + required this.surfaceStyle, + required this.detailsStyle, + required this.parametersStyle, + required this.allowOnceStyle, + required this.alwaysAllowStyle, + required this.denyStyle, + }); + final AgentPermissionStyler style; + final CardStyler surfaceStyle; + final DisclosureStyler detailsStyle; + final DataListStyler parametersStyle; + final ButtonStyler allowOnceStyle; + final ButtonStyler alwaysAllowStyle; + final ButtonStyler denyStyle; +} + +FortalAgentPermissionRecipe fortalAgentPermissionRecipe({ + AgentPermissionStyler style = const AgentPermissionStyler.create(), + CardStyler surfaceStyle = const CardStyler.create(), + DisclosureStyler detailsStyle = const DisclosureStyler.create(), + DataListStyler parametersStyle = const DataListStyler.create(), + ButtonStyler allowOnceStyle = const ButtonStyler.create(), + ButtonStyler alwaysAllowStyle = const ButtonStyler.create(), + ButtonStyler denyStyle = const ButtonStyler.create(), +}) => FortalAgentPermissionRecipe( + style: AgentPermissionStyler( + header: FlexBoxStyler().spacing(8), + actions: FlexBoxStyler().spacing(8).padding(.only(top: 8)), + title: TextStyler() + .color(FortalTokens.gray12()) + .fontWeight(FontWeight.w600), + tool: TextStyler().color(FortalTokens.gray11()).fontSize(12), + description: TextStyler() + .color(FortalTokens.gray11()) + .wrap(.padding(.symmetric(vertical: 8))), + status: TextStyler().color(FortalTokens.gray11()).fontSize(12), + detailsLabel: TextStyler().color(FortalTokens.gray12()).fontSize(13), + toolIcon: IconStyler().color(FortalTokens.gray12()).size(16), + statusIcon: IconStyler().color(FortalTokens.accent9()).size(12), + indicator: IconStyler().color(FortalTokens.gray12()).size(16), + ).merge(style), + surfaceStyle: fortalCardStyle(size: .size2, style: surfaceStyle), + detailsStyle: fortalDisclosureStyle(style: detailsStyle), + parametersStyle: fortalDataListStyle(style: parametersStyle), + allowOnceStyle: fortalButtonStyle(style: allowOnceStyle), + alwaysAllowStyle: fortalButtonStyle( + variant: .outline, + style: alwaysAllowStyle, + ), + denyStyle: fortalButtonStyle(variant: .ghost, style: denyStyle), +); diff --git a/registry_source/lib/src/fortal/recipes/plan_recipe.dart b/registry_source/lib/src/fortal/recipes/plan_recipe.dart new file mode 100644 index 000000000..cbf5a1f6f --- /dev/null +++ b/registry_source/lib/src/fortal/recipes/plan_recipe.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/plan.dart'; + +import '../components/disclosure.dart'; +import '../theme/theme.dart'; + +@immutable +final class FortalAgentPlanRecipe { + const FortalAgentPlanRecipe({ + required this.style, + required this.disclosureStyle, + }); + final AgentPlanStyler style; + final DisclosureStyler disclosureStyle; +} + +FortalAgentPlanRecipe fortalAgentPlanRecipe({ + AgentPlanStyler style = const AgentPlanStyler.create(), + DisclosureStyler disclosureStyle = const DisclosureStyler.create(), +}) => FortalAgentPlanRecipe( + style: AgentPlanStyler( + viewport: BoxStyler().maxHeight(220), + item: FlexBoxStyler().spacing(6).padding(.symmetric(vertical: 6)), + summaryTitle: TextStyler() + .color(FortalTokens.gray12()) + .fontSize(14) + .fontWeight(FontWeight.w600), + itemTitle: TextStyler().color(FortalTokens.gray12()).fontSize(14), + itemDetail: TextStyler().color(FortalTokens.gray11()).fontSize(12), + count: TextStyler() + .color(FortalTokens.gray11()) + .fontSize(12) + .wrap(.padding(.only(right: 8))), + indicator: IconStyler().color(FortalTokens.gray12()).size(16), + pendingStatus: IconStyler().color(FortalTokens.gray9()).size(18), + activeStatus: IconStyler().color(FortalTokens.accent9()).size(18), + completedStatus: IconStyler().color(FortalTokens.accent9()).size(18), + cancelledStatus: IconStyler().color(FortalTokens.gray9()).size(18), + ).merge(style), + disclosureStyle: fortalDisclosureStyle(style: disclosureStyle), +); diff --git a/registry_source/lib/src/fortal/recipes/transcript_recipe.dart b/registry_source/lib/src/fortal/recipes/transcript_recipe.dart new file mode 100644 index 000000000..e580854e7 --- /dev/null +++ b/registry_source/lib/src/fortal/recipes/transcript_recipe.dart @@ -0,0 +1,19 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; +import '../../agent/components/transcript.dart'; + +@immutable +final class FortalAgentTranscriptRecipe { + const FortalAgentTranscriptRecipe({required this.style}); + final AgentTranscriptStyler style; +} + +FortalAgentTranscriptRecipe fortalAgentTranscriptRecipe({ + AgentTranscriptStyler style = const AgentTranscriptStyler.create(), +}) => FortalAgentTranscriptRecipe( + style: AgentTranscriptStyler( + viewport: BoxStyler().padding(.only(right: 12)), + item: BoxStyler(), + spacing: 16, + ).merge(style), +); diff --git a/registry_source/lib/src/fortal/theme/computed.dart b/registry_source/lib/src/fortal/theme/computed.dart new file mode 100644 index 000000000..8fde85da6 --- /dev/null +++ b/registry_source/lib/src/fortal/theme/computed.dart @@ -0,0 +1,558 @@ +/// Fortal computed tokens and functional color utilities. +/// +/// Implements computed role tokens (accent-contrast, accent-track, etc.) and +/// background/overlay colors that mirror Radix Themes behavior while keeping the +/// original Radix Colors data. +/// +/// Components should use these functional roles rather than raw color steps. +library; + +import 'dart:math' as math; +import 'dart:ui' show Color, Offset; + +import 'package:flutter/painting.dart' show BoxShadow, ColorSwatch; +import 'package:remix/remix.dart' + show MixToken, RemixBoxShadow, RemixBoxShadowKind; + +import 'radix_colors.dart'; +import 'theme_data.dart'; +import 'tokens.dart'; + +// ============================================================================ +// FUNCTIONAL / COMPUTED IMPLEMENTATIONS +// ============================================================================ + +/// Computes solid focus ring color (accent step 8). +Color computeFocus8(RadixColorScale accent) => accent.step(8); + +/// Computes translucent text selection color (accent alpha step 5). +Color computeFocusA5(RadixColorScale accent) => accent.alphaStep(5); + +/// Computes translucent focus ring color (accent alpha step 8). +Color computeFocusA8(RadixColorScale accent) => accent.alphaStep(8); + +// ============================================================================ +// BACKGROUND / PANEL / OVERLAY +// ============================================================================ + +/// Computes primary page background color. +/// +/// Radix Themes uses white in light mode and gray step 1 in dark mode. +Color computeColorBackground(RadixColorScale gray, {required bool isDark}) => + isDark ? gray.step(1) : const Color(0xFFFFFFFF); + +/// Computes solid background for panels and input surfaces. +/// +/// Radix Themes uses white in light mode and gray step 2 in dark mode. +Color computeColorPanelSolid(RadixColorScale gray, {required bool isDark}) => + isDark ? gray.step(2) : const Color(0xFFFFFFFF); + +/// Computes translucent background for floating panels. +/// +/// Radix Themes uses 70% white in light mode and gray alpha step 2 in dark +/// mode. +Color computeColorPanelTranslucent( + RadixColorScale gray, { + required bool isDark, +}) => isDark ? gray.alphaStep(2) : const Color(0xB3FFFFFF); + +/// Computes the neutral control surface color. +/// +/// Radix Themes uses 85% white in light mode and 25% black in dark mode. +Color computeColorSurface({required bool isDark}) => + isDark ? const Color(0x40000000) : const Color(0xD9FFFFFF); + +/// Computes modal backdrop overlay color. +/// +/// Uses black alpha step 6 (light mode) or step 8 (dark mode). +Color computeColorOverlay({required bool isDark}) => + isDark ? blackAlpha[8]! : blackAlpha[6]!; + +/// Computes the mode-aware stroke used by elevation shadows. +Color computeShadowStroke(RadixColorScale gray, {required bool isDark}) => + mixOklabPremultiplied( + gray.alphaStep(isDark ? 6 : 3), + gray.step(isDark ? 6 : 3), + 0.25, + ); + +/// Mixes two sRGB colors in OKLab after premultiplying their channels by +/// alpha, matching CSS `color-mix(in oklab, ...)` for translucent colors. +Color mixOklabPremultiplied(Color first, Color second, double amount) { + if (!amount.isFinite || amount < 0 || amount > 1) { + throw ArgumentError.value( + amount, + 'amount', + 'Expected a value from 0 to 1.', + ); + } + final firstWeight = 1 - amount; + final alpha = first.a * firstWeight + second.a * amount; + if (alpha == 0) return const Color(0x00000000); + + final firstLab = _srgbToOklab(first); + final secondLab = _srgbToOklab(second); + final mixedLab = ( + lightness: + (firstLab.lightness * first.a * firstWeight + + secondLab.lightness * second.a * amount) / + alpha, + a: + (firstLab.a * first.a * firstWeight + secondLab.a * second.a * amount) / + alpha, + b: + (firstLab.b * first.a * firstWeight + secondLab.b * second.a * amount) / + alpha, + ); + final rgb = _oklabToSrgb(mixedLab); + + return Color.from( + alpha: alpha, + red: rgb.red.clamp(0, 1), + green: rgb.green.clamp(0, 1), + blue: rgb.blue.clamp(0, 1), + ); +} + +({double lightness, double a, double b}) _srgbToOklab(Color color) { + final red = _linearizeSrgb(color.r); + final green = _linearizeSrgb(color.g); + final blue = _linearizeSrgb(color.b); + final l = math + .pow( + 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue, + 1 / 3, + ) + .toDouble(); + final m = math + .pow( + 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue, + 1 / 3, + ) + .toDouble(); + final s = math + .pow( + 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue, + 1 / 3, + ) + .toDouble(); + + return ( + lightness: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ); +} + +({double red, double green, double blue}) _oklabToSrgb( + ({double lightness, double a, double b}) color, +) { + final l = math + .pow(color.lightness + 0.3963377774 * color.a + 0.2158037573 * color.b, 3) + .toDouble(); + final m = math + .pow(color.lightness - 0.1055613458 * color.a - 0.0638541728 * color.b, 3) + .toDouble(); + final s = math + .pow(color.lightness - 0.0894841775 * color.a - 1.2914855480 * color.b, 3) + .toDouble(); + + return ( + red: _encodeSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + green: _encodeSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + blue: _encodeSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), + ); +} + +double _linearizeSrgb(double channel) => channel <= 0.04045 + ? channel / 12.92 + : math.pow((channel + 0.055) / 1.055, 2.4).toDouble(); + +double _encodeSrgb(double channel) => channel <= 0.0031308 + ? 12.92 * channel + : 1.055 * math.pow(channel, 1 / 2.4).toDouble() - 0.055; + +/// Builds Radix Themes elevation shadows for the active brightness. +Map, Object> buildFortalShadows({ + required bool isDark, + required FortalThemeColors colors, +}) { + if (isDark) { + final shadows = >{ + 'shadow1': [ + _shadow( + colors.gray.scale.alphaStep(3), + kind: .inset, + offset: const Offset(0, -1), + blur: 1, + ), + _shadow(colors.gray.scale.alphaStep(3), kind: .inset, spread: 1), + _shadow( + colors.blackAlpha[5]!, + kind: .inset, + offset: const Offset(0, 3), + blur: 4, + ), + _shadow(colors.gray.scale.alphaStep(4), kind: .inset, spread: 1), + ], + 'shadow2': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, blur: 0.5), + _shadow(colors.blackAlpha[6]!, offset: const Offset(0, 1), blur: 1), + _shadow( + colors.blackAlpha[6]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + _shadow(colors.blackAlpha[5]!, offset: const Offset(0, 1), blur: 3), + ], + 'shadow3': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow( + colors.blackAlpha[3]!, + offset: const Offset(0, 2), + blur: 3, + spread: -2, + ), + _shadow( + colors.blackAlpha[6]!, + offset: const Offset(0, 3), + blur: 8, + spread: -2, + ), + _shadow( + colors.blackAlpha[7]!, + offset: const Offset(0, 4), + blur: 12, + spread: -4, + ), + ], + 'shadow4': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, offset: const Offset(0, 8), blur: 40), + _shadow( + colors.blackAlpha[5]!, + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow5': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[5]!, offset: const Offset(0, 12), blur: 60), + _shadow( + colors.blackAlpha[7]!, + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow6': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[4]!, offset: const Offset(0, 12), blur: 60), + _shadow(colors.blackAlpha[6]!, offset: const Offset(0, 16), blur: 64), + _shadow( + colors.blackAlpha[11]!, + offset: const Offset(0, 16), + blur: 36, + spread: -20, + ), + ], + }; + return _fortalShadowTokens(shadows); + } + + final shadows = >{ + 'shadow1': [ + _shadow(colors.gray.scale.alphaStep(5), kind: .inset, spread: 1), + _shadow( + colors.gray.scale.alphaStep(2), + kind: .inset, + offset: const Offset(0, 1.5), + blur: 2, + ), + _shadow( + colors.blackAlpha[2]!, + kind: .inset, + offset: const Offset(0, 1.5), + blur: 2, + ), + ], + 'shadow2': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[1]!, blur: 0.5), + _shadow( + colors.gray.scale.alphaStep(2), + offset: const Offset(0, 1), + blur: 1, + ), + _shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + _shadow(colors.blackAlpha[1]!, offset: const Offset(0, 1), blur: 3), + ], + 'shadow3': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 2), + blur: 3, + spread: -2, + ), + _shadow( + colors.blackAlpha[2]!, + offset: const Offset(0, 3), + blur: 12, + spread: -4, + ), + _shadow( + colors.blackAlpha[2]!, + offset: const Offset(0, 4), + blur: 16, + spread: -8, + ), + ], + 'shadow4': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[1]!, offset: const Offset(0, 8), blur: 40), + _shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow5': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, offset: const Offset(0, 12), blur: 60), + _shadow( + colors.gray.scale.alphaStep(5), + offset: const Offset(0, 12), + blur: 32, + spread: -16, + ), + ], + 'shadow6': [ + _shadow(colors.shadowStroke, spread: 1), + _shadow(colors.blackAlpha[3]!, offset: const Offset(0, 12), blur: 60), + _shadow( + colors.gray.scale.alphaStep(2), + offset: const Offset(0, 16), + blur: 64, + ), + _shadow( + colors.gray.scale.alphaStep(7), + offset: const Offset(0, 16), + blur: 36, + spread: -20, + ), + ], + }; + return _fortalShadowTokens(shadows); +} + +Map, Object> _fortalShadowTokens( + Map> shadows, +) { + final shadow1 = shadows['shadow1']!; + final shadow2 = shadows['shadow2']!; + return { + FortalTokens.shadow1: _ordinaryShadows(shadow1), + FortalTokens.shadow1Layers: shadow1, + FortalTokens.shadow2: _ordinaryShadows(shadow2), + FortalTokens.segmentedControlClassicIndicatorShadows: [ + for (final shadow in shadow2) + RemixBoxShadow( + kind: shadow.kind, + color: shadow.color, + offset: shadow.offset, + blurRadius: shadow.blurRadius, + spreadRadius: shadow.spreadRadius, + shapeInset: 1, + ), + ], + FortalTokens.shadow3: _ordinaryShadows(shadows['shadow3']!), + FortalTokens.shadow4: _ordinaryShadows(shadows['shadow4']!), + FortalTokens.shadow5: _ordinaryShadows(shadows['shadow5']!), + FortalTokens.shadow6: _ordinaryShadows(shadows['shadow6']!), + }; +} + +List _ordinaryShadows(List shadows) => [ + for (final shadow in shadows) + BoxShadow( + color: shadow.color, + offset: shadow.offset, + blurRadius: shadow.blurRadius, + spreadRadius: shadow.spreadRadius, + ), +]; + +RemixBoxShadow _shadow( + Color color, { + RemixBoxShadowKind kind = RemixBoxShadowKind.outer, + Offset offset = Offset.zero, + double blur = 0, + double spread = 0, +}) => RemixBoxShadow( + kind: kind, + color: color, + offset: offset, + blurRadius: blur, + spreadRadius: spread, +); + +// ============================================================================ +// RESOLVER (merged from resolver.dart) +// ============================================================================ + +/// Container for all computed Fortal theme colors and scales. +/// +/// Holds resolved color system for a specific theme configuration. +/// Created by [resolveFortalTokens] for internal use by the token system. +class FortalThemeColors { + final RadixColor accent; + final RadixColor gray; + final ColorSwatch blackAlpha; + final ColorSwatch whiteAlpha; + + // Functional colors + final Color colorBackground; + final Color colorSurface; + final Color colorPanelSolid; + final Color colorPanelTranslucent; + final Color colorOverlay; + final Color shadowStroke; + + // Focus + final Color focus8; + final Color focusA5; + final Color focusA8; + + const FortalThemeColors({ + required this.accent, + required this.gray, + required this.blackAlpha, + required this.whiteAlpha, + required this.colorBackground, + required this.colorSurface, + required this.colorPanelSolid, + required this.colorPanelTranslucent, + required this.colorOverlay, + required this.shadowStroke, + required this.focus8, + required this.focusA5, + required this.focusA8, + }); +} + +// Map by enum .name to generated RadixColorTheme instances (light/dark contained). +const Map _accentThemesByName = { + 'gray': gray, + 'mauve': mauve, + 'slate': slate, + 'sage': sage, + 'olive': olive, + 'sand': sand, + 'amber': amber, + 'blue': blue, + 'bronze': bronze, + 'brown': brown, + 'crimson': crimson, + 'cyan': cyan, + 'gold': gold, + 'grass': grass, + 'green': green, + 'indigo': indigo, + 'iris': iris, + 'jade': jade, + 'lime': lime, + 'mint': mint, + 'orange': orange, + 'pink': pink, + 'plum': plum, + 'purple': purple, + 'red': red, + 'ruby': ruby, + 'sky': sky, + 'teal': teal, + 'tomato': tomato, + 'violet': violet, + 'yellow': yellow, +}; + +const Map _grayThemesByName = { + 'gray': gray, + 'mauve': mauve, + 'slate': slate, + 'sage': sage, + 'olive': olive, + 'sand': sand, +}; + +/// Resolves all computed tokens for a theme configuration. +FortalThemeColors resolveFortalTokens(FortalThemeConfig theme) { + // Pick light/dark RadixColor for accent and neutral using enum .name keys + final accentColor = theme.accent ?? FortalAccentColor.indigo; + final grayColor = theme.gray ?? FortalGrayColor.slate; + final String accentName = accentColor.name; + final String grayName = grayColor.name; + final RadixColorTheme grayTheme = _grayThemesByName[grayName]!; + final RadixColorTheme accentTheme = accentColor == .gray + ? grayTheme + : _accentThemesByName[accentName]!; + final RadixColor accentRC = theme.isDark + ? accentTheme.dark + : accentTheme.light; + final RadixColor grayRC = theme.isDark ? grayTheme.dark : grayTheme.light; + + // Extract scales + final RadixColorScale accent = accentRC.scale; + final RadixColorScale gray = grayRC.scale; + + // Neutral alpha swatches + const ColorSwatch blackA = blackAlpha; + const ColorSwatch whiteA = whiteAlpha; + + // Backgrounds/panels/overlay + final Color colorBackground = computeColorBackground( + gray, + isDark: theme.isDark, + ); + final Color colorPanelSolid = computeColorPanelSolid( + gray, + isDark: theme.isDark, + ); + final Color colorPanelTranslucent = computeColorPanelTranslucent( + gray, + isDark: theme.isDark, + ); + final Color colorSurface = computeColorSurface(isDark: theme.isDark); + final Color colorOverlay = computeColorOverlay(isDark: theme.isDark); + final Color shadowStroke = computeShadowStroke(gray, isDark: theme.isDark); + + // Focus + final Color focus8 = computeFocus8(accent); + final Color focusA5 = computeFocusA5(accent); + final Color focusA8 = computeFocusA8(accent); + + return FortalThemeColors( + accent: accentRC, + gray: grayRC, + blackAlpha: blackA, + whiteAlpha: whiteA, + colorBackground: colorBackground, + colorSurface: colorSurface, + colorPanelSolid: colorPanelSolid, + colorPanelTranslucent: colorPanelTranslucent, + colorOverlay: colorOverlay, + shadowStroke: shadowStroke, + focus8: focus8, + focusA5: focusA5, + focusA8: focusA8, + ); +} diff --git a/registry_source/lib/src/fortal/theme/control_styles.dart b/registry_source/lib/src/fortal/theme/control_styles.dart new file mode 100644 index 000000000..fbf55d261 --- /dev/null +++ b/registry_source/lib/src/fortal/theme/control_styles.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import 'tokens.dart'; + +/// A CSS outline that does not affect layout. +RemixBoxEffectsMix fortalFocusOutline(Color color, {required double offset}) => + RemixBoxEffectsMix( + outline: BorderSideMix( + color: color, + width: 2, + strokeAlign: BorderSide.strokeAlignInside, + ), + outlineOffset: offset, + ); + +/// The focus ring for stylers that can only express a border. +/// +/// [fortalFocusOutline] is the preferred form, but it returns a +/// [RemixBoxEffectsMix] and several stylers — `ToggleStyler`, +/// `ToggleGroupItemStyler`, `TabStyler` — expose no `containerEffects` slot to +/// put one in. Those recipes each re-derived the same border, so the width +/// token lives here instead of in four places. +extension FortalFocusRing> on RemixBoxStylerAnchors { + /// Applies the ring to this styler. + /// + /// [color] defaults to `focus-a8`. Tabs is the one caller that differs: it + /// passes the solid `focus-8`. Whether that is intentional is unresolved — + /// the pinned Chromium probes capture computed styles only, not + /// `:focus-visible`, so it cannot be settled from the reference fixtures. + /// Preserved as-is rather than unified on a guess. + /// + /// Tabs also leaves [strokeAlign] unset, but that is not a second + /// difference: `BorderSide` itself defaults to `strokeAlignInside`, so unset + /// and explicit resolve to the same -1.0. The parameter is nullable only so + /// tabs can keep expressing it as absent. + T fortalFocusRing({ + Color? color, + double? strokeAlign = BorderSide.strokeAlignInside, + }) => border( + .all( + BorderSideMix( + color: color ?? _focusRingColor(), + width: _focusRingWidth(), + strokeAlign: strokeAlign, + ), + ), + ); +} + +/// The same ring for a Mix [FlexBoxStyler], which sits outside Remix's +/// `RemixBoxStylerAnchors` interface — accordion rings its trigger, not itself. +/// Separate extension rather than a differently-named function so both read as +/// `.fortalFocusRing()`; the receiver type picks the right one. +extension FortalFocusRingFlexBox on FlexBoxStyler { + FlexBoxStyler fortalFocusRing() => border( + .color( + _focusRingColor(), + ).width(_focusRingWidth()).strokeAlign(BorderSide.strokeAlignInside), + ); +} + +Color _focusRingColor() => FortalTokens.focusA8(); +double _focusRingWidth() => FortalTokens.focusRingWidth(); + +/// A one-pixel inset stroke, optionally layered over a fill. +RemixBoxEffectLayerMix fortalInsetSurface({required List strokes}) => + RemixBoxEffectLayerMix( + shadows: [ + for (final stroke in strokes) + RemixBoxShadowMix( + kind: RemixBoxShadowKind.inset, + color: stroke, + spreadRadius: 1, + ), + ], + ); diff --git a/registry_source/lib/src/fortal/theme/radix_colors.dart b/registry_source/lib/src/fortal/theme/radix_colors.dart new file mode 100644 index 000000000..ae0d17020 --- /dev/null +++ b/registry_source/lib/src/fortal/theme/radix_colors.dart @@ -0,0 +1,2488 @@ +// GENERATED CODE - DO NOT EDIT +// Generated from: sRGB fallback tokens extracted from the pinned @radix-ui/themes npm artifact +// Radix Themes version: 3.3.0 +// Radix Colors version: bundled with Radix Themes 3.3.0 +// Source integrity: sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw== + +library; + +import 'package:flutter/painting.dart'; + +class RadixColor { + final RadixColorScale scale; + final Color surface; + final Color indicator; + final Color track; + final Color contrast; + + const RadixColor( + this.scale, + this.surface, + this.indicator, + this.track, + this.contrast, + ); +} + +class RadixColorTheme { + final RadixColor light; + final RadixColor dark; + + const RadixColorTheme(this.light, this.dark); +} + +class RadixColorScale { + final ColorSwatch solid; + final ColorSwatch alpha; + + const RadixColorScale(this.solid, this.alpha); + + /// The most subtle background color (step 1). + Color get appBackground => step(1); + + /// Subtle background with slightly more presence (step 2). + Color get subtleBackground => step(2); + + /// Default background for interactive components (step 3). + Color get componentBackground => step(3); + + /// Background color for components on hover (step 4). + Color get componentBackgroundHover => step(4); + + /// Background color for active/pressed components (step 5). + Color get componentBackgroundActive => step(5); + + /// Subtle border color for gentle separation (step 6). + Color get subtleBorder => step(6); + + /// Standard border color for components (step 7). + Color get componentBorder => step(7); + + /// Border color for hover and focus states (step 8). + Color get componentBorderHover => step(8); + + /// Primary solid background color (step 9). + Color get solidBackground => step(9); + + /// Solid background color on hover (step 10). + Color get solidBackgroundHover => step(10); + + /// Low contrast text color (step 11). + Color get lowContrastText => step(11); + + /// High contrast text color (step 12). + Color get highContrastText => step(12); + + /// Gets a solid color from the 12-step scale. + /// + /// Steps must be between 1 and 12. Falls back to step 9 if unavailable. + Color step(int n) { + assert(n >= 1 && n <= 12, 'Step must be between 1 and 12'); + + return solid[n] ?? solid[9]!; + } + + /// Gets a translucent color from the 12-step alpha scale. + /// + /// Alpha variants maintain saturation when composited. + /// Falls back to alpha step 9 if unavailable. + Color alphaStep(int n) { + assert(n >= 1 && n <= 12, 'Step must be between 1 and 12'); + + return alpha[n] ?? alpha[9]!; + } +} + +// gray color scale +const _grayLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8d8d8d, { + 1: Color(0xfffcfcfc), + 2: Color(0xfff9f9f9), + 3: Color(0xfff0f0f0), + 4: Color(0xffe8e8e8), + 5: Color(0xffe0e0e0), + 6: Color(0xffd9d9d9), + 7: Color(0xffcecece), + 8: Color(0xffbbbbbb), + 9: Color(0xff8d8d8d), + 10: Color(0xff838383), + 11: Color(0xff646464), + 12: Color(0xff202020), + }), + ColorSwatch(0x72000000, { + 1: Color(0x03000000), + 2: Color(0x06000000), + 3: Color(0x0f000000), + 4: Color(0x17000000), + 5: Color(0x1f000000), + 6: Color(0x26000000), + 7: Color(0x31000000), + 8: Color(0x44000000), + 9: Color(0x72000000), + 10: Color(0x7c000000), + 11: Color(0x9b000000), + 12: Color(0xdf000000), + }), + ), + Color(0xccffffff), + Color(0xff8d8d8d), + Color(0xff8d8d8d), + Color(0xffffffff), +); + +const _grayDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff6e6e6e, { + 1: Color(0xff111111), + 2: Color(0xff191919), + 3: Color(0xff222222), + 4: Color(0xff2a2a2a), + 5: Color(0xff313131), + 6: Color(0xff3a3a3a), + 7: Color(0xff484848), + 8: Color(0xff606060), + 9: Color(0xff6e6e6e), + 10: Color(0xff7b7b7b), + 11: Color(0xffb4b4b4), + 12: Color(0xffeeeeee), + }), + ColorSwatch(0x64ffffff, { + 1: Color(0x00000000), + 2: Color(0x09ffffff), + 3: Color(0x12ffffff), + 4: Color(0x1bffffff), + 5: Color(0x22ffffff), + 6: Color(0x2cffffff), + 7: Color(0x3bffffff), + 8: Color(0x55ffffff), + 9: Color(0x64ffffff), + 10: Color(0x72ffffff), + 11: Color(0xafffffff), + 12: Color(0xedffffff), + }), + ), + Color(0x80212121), + Color(0xff6e6e6e), + Color(0xff6e6e6e), + Color(0xffffffff), +); + +// mauve color scale +const _mauveLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8e8c99, { + 1: Color(0xfffdfcfd), + 2: Color(0xfffaf9fb), + 3: Color(0xfff2eff3), + 4: Color(0xffeae7ec), + 5: Color(0xffe3dfe6), + 6: Color(0xffdbd8e0), + 7: Color(0xffd0cdd7), + 8: Color(0xffbcbac7), + 9: Color(0xff8e8c99), + 10: Color(0xff84828e), + 11: Color(0xff65636d), + 12: Color(0xff211f26), + }), + ColorSwatch(0x7305001d, { + 1: Color(0x03550055), + 2: Color(0x062b0055), + 3: Color(0x10300040), + 4: Color(0x18200036), + 5: Color(0x20200038), + 6: Color(0x27140035), + 7: Color(0x32100033), + 8: Color(0x45080031), + 9: Color(0x7305001d), + 10: Color(0x7d050019), + 11: Color(0x9c040011), + 12: Color(0xe0020008), + }), + ), + Color(0xccffffff), + Color(0xff8e8c99), + Color(0xff8e8c99), + Color(0xffffffff), +); + +const _mauveDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff6f6d78, { + 1: Color(0xff121113), + 2: Color(0xff1a191b), + 3: Color(0xff232225), + 4: Color(0xff2b292d), + 5: Color(0xff323035), + 6: Color(0xff3c393f), + 7: Color(0xff49474e), + 8: Color(0xff625f69), + 9: Color(0xff6f6d78), + 10: Color(0xff7c7a85), + 11: Color(0xffb5b2bc), + 12: Color(0xffeeeef0), + }), + ColorSwatch(0x6eeae6fd, { + 1: Color(0x00000000), + 2: Color(0x09f5f4f6), + 3: Color(0x14ebeaf8), + 4: Color(0x1deee5f8), + 5: Color(0x25efe6fe), + 6: Color(0x30f1e6fd), + 7: Color(0x40eee9ff), + 8: Color(0x5deee7ff), + 9: Color(0x6eeae6fd), + 10: Color(0x7cece9fd), + 11: Color(0xb7f5f1ff), + 12: Color(0xeffdfdff), + }), + ), + Color(0x80222123), + Color(0xff6f6d78), + Color(0xff6f6d78), + Color(0xffffffff), +); + +// slate color scale +const _slateLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8b8d98, { + 1: Color(0xfffcfcfd), + 2: Color(0xfff9f9fb), + 3: Color(0xfff0f0f3), + 4: Color(0xffe8e8ec), + 5: Color(0xffe0e1e6), + 6: Color(0xffd9d9e0), + 7: Color(0xffcdced6), + 8: Color(0xffb9bbc6), + 9: Color(0xff8b8d98), + 10: Color(0xff80838d), + 11: Color(0xff60646c), + 12: Color(0xff1c2024), + }), + ColorSwatch(0x7400051d, { + 1: Color(0x03000055), + 2: Color(0x06000055), + 3: Color(0x0f000033), + 4: Color(0x1700002d), + 5: Color(0x1f000932), + 6: Color(0x2600002f), + 7: Color(0x3200062e), + 8: Color(0x46000830), + 9: Color(0x7400051d), + 10: Color(0x7f00071b), + 11: Color(0x9f000714), + 12: Color(0xe3000509), + }), + ), + Color(0xccffffff), + Color(0xff8b8d98), + Color(0xff8b8d98), + Color(0xffffffff), +); + +const _slateDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff696e77, { + 1: Color(0xff111113), + 2: Color(0xff18191b), + 3: Color(0xff212225), + 4: Color(0xff272a2d), + 5: Color(0xff2e3135), + 6: Color(0xff363a3f), + 7: Color(0xff43484e), + 8: Color(0xff5a6169), + 9: Color(0xff696e77), + 10: Color(0xff777b84), + 11: Color(0xffb0b4ba), + 12: Color(0xffedeef0), + }), + ColorSwatch(0x6ddfebfd, { + 1: Color(0x00000000), + 2: Color(0x09d8f4f6), + 3: Color(0x14ddeaf8), + 4: Color(0x1dd3edf8), + 5: Color(0x25d9edfe), + 6: Color(0x30d6ebfd), + 7: Color(0x40d9edff), + 8: Color(0x5dd9edff), + 9: Color(0x6ddfebfd), + 10: Color(0x7be5edfd), + 11: Color(0xb5f1f7fe), + 12: Color(0xeffcfdff), + }), + ), + Color(0x801f2123), + Color(0xff696e77), + Color(0xff696e77), + Color(0xffffffff), +); + +// sage color scale +const _sageLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff868e8b, { + 1: Color(0xfffbfdfc), + 2: Color(0xfff7f9f8), + 3: Color(0xffeef1f0), + 4: Color(0xffe6e9e8), + 5: Color(0xffdfe2e0), + 6: Color(0xffd7dad9), + 7: Color(0xffcbcfcd), + 8: Color(0xffb8bcba), + 9: Color(0xff868e8b), + 10: Color(0xff7c8481), + 11: Color(0xff5f6563), + 12: Color(0xff1a211e), + }), + ColorSwatch(0x7900110b, { + 1: Color(0x04008040), + 2: Color(0x08004020), + 3: Color(0x11002d1e), + 4: Color(0x19001f15), + 5: Color(0x20001808), + 6: Color(0x2800140d), + 7: Color(0x3400140a), + 8: Color(0x47000f08), + 9: Color(0x7900110b), + 10: Color(0x8300100a), + 11: Color(0xa0000a07), + 12: Color(0xe5000805), + }), + ), + Color(0xccffffff), + Color(0xff868e8b), + Color(0xff868e8b), + Color(0xffffffff), +); + +const _sageDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff63706b, { + 1: Color(0xff101211), + 2: Color(0xff171918), + 3: Color(0xff202221), + 4: Color(0xff272a29), + 5: Color(0xff2e3130), + 6: Color(0xff373b39), + 7: Color(0xff444947), + 8: Color(0xff5b625f), + 9: Color(0xff63706b), + 10: Color(0xff717d79), + 11: Color(0xffadb5b2), + 12: Color(0xffeceeed), + }), + ColorSwatch(0x66dffdf2, { + 1: Color(0x00000000), + 2: Color(0x08f0f2f1), + 3: Color(0x12f3f5f4), + 4: Color(0x1af2fefd), + 5: Color(0x22f1fbfa), + 6: Color(0x2dedfbf4), + 7: Color(0x3cedfcf7), + 8: Color(0x57ebfdf6), + 9: Color(0x66dffdf2), + 10: Color(0x74e5fdf6), + 11: Color(0xb0f4fefb), + 12: Color(0xedfdfffe), + }), + ), + Color(0x801e201f), + Color(0xff63706b), + Color(0xff63706b), + Color(0xffffffff), +); + +// olive color scale +const _oliveLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff898e87, { + 1: Color(0xfffcfdfc), + 2: Color(0xfff8faf8), + 3: Color(0xffeff1ef), + 4: Color(0xffe7e9e7), + 5: Color(0xffdfe2df), + 6: Color(0xffd7dad7), + 7: Color(0xffcccfcc), + 8: Color(0xffb9bcb8), + 9: Color(0xff898e87), + 10: Color(0xff7f847d), + 11: Color(0xff60655f), + 12: Color(0xff1d211c), + }), + ColorSwatch(0x78050f00, { + 1: Color(0x03005500), + 2: Color(0x07004900), + 3: Color(0x10002000), + 4: Color(0x18001600), + 5: Color(0x20001800), + 6: Color(0x28001400), + 7: Color(0x33000f00), + 8: Color(0x47040f00), + 9: Color(0x78050f00), + 10: Color(0x82040e00), + 11: Color(0xa0020a00), + 12: Color(0xe3010600), + }), + ), + Color(0xccffffff), + Color(0xff898e87), + Color(0xff898e87), + Color(0xffffffff), +); + +const _oliveDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff687066, { + 1: Color(0xff111210), + 2: Color(0xff181917), + 3: Color(0xff212220), + 4: Color(0xff282a27), + 5: Color(0xff2f312e), + 6: Color(0xff383a36), + 7: Color(0xff454843), + 8: Color(0xff5c625b), + 9: Color(0xff687066), + 10: Color(0xff767d74), + 11: Color(0xffafb5ad), + 12: Color(0xffeceeec), + }), + ColorSwatch(0x66ebfde7, { + 1: Color(0x00000000), + 2: Color(0x08f1f2f0), + 3: Color(0x12f4f5f3), + 4: Color(0x1af3fef2), + 5: Color(0x22f2fbf1), + 6: Color(0x2cf4faed), + 7: Color(0x3bf2fced), + 8: Color(0x57edfdeb), + 9: Color(0x66ebfde7), + 10: Color(0x74f0fdec), + 11: Color(0xb0f6fef4), + 12: Color(0xedfdfffd), + }), + ), + Color(0x801f201e), + Color(0xff687066), + Color(0xff687066), + Color(0xffffffff), +); + +// sand color scale +const _sandLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8d8d86, { + 1: Color(0xfffdfdfc), + 2: Color(0xfff9f9f8), + 3: Color(0xfff1f0ef), + 4: Color(0xffe9e8e6), + 5: Color(0xffe2e1de), + 6: Color(0xffdad9d6), + 7: Color(0xffcfceca), + 8: Color(0xffbcbbb5), + 9: Color(0xff8d8d86), + 10: Color(0xff82827c), + 11: Color(0xff63635e), + 12: Color(0xff21201c), + }), + ColorSwatch(0x790f0f00, { + 1: Color(0x03555500), + 2: Color(0x07252500), + 3: Color(0x10201000), + 4: Color(0x191f1500), + 5: Color(0x211f1800), + 6: Color(0x29191300), + 7: Color(0x35191400), + 8: Color(0x4a191501), + 9: Color(0x790f0f00), + 10: Color(0x830c0c00), + 11: Color(0xa1080800), + 12: Color(0xe3060500), + }), + ), + Color(0xccffffff), + Color(0xff8d8d86), + Color(0xff8d8d86), + Color(0xffffffff), +); + +const _sandDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff6f6d66, { + 1: Color(0xff111110), + 2: Color(0xff191918), + 3: Color(0xff222221), + 4: Color(0xff2a2a28), + 5: Color(0xff31312e), + 6: Color(0xff3b3a37), + 7: Color(0xff494844), + 8: Color(0xff62605b), + 9: Color(0xff6f6d66), + 10: Color(0xff7c7b74), + 11: Color(0xffb5b3ad), + 12: Color(0xffeeeeec), + }), + ColorSwatch(0x65fffae9, { + 1: Color(0x00000000), + 2: Color(0x09f4f4f3), + 3: Color(0x13f6f6f5), + 4: Color(0x1bfefef3), + 5: Color(0x23fbfbeb), + 6: Color(0x2dfffaed), + 7: Color(0x3cfffbed), + 8: Color(0x57fff9eb), + 9: Color(0x65fffae9), + 10: Color(0x73fffdee), + 11: Color(0xb0fffcf4), + 12: Color(0xedfffffd), + }), + ), + Color(0x80212120), + Color(0xff6f6d66), + Color(0xff6f6d66), + Color(0xffffffff), +); + +// tomato color scale +const _tomatoLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffe54d2e, { + 1: Color(0xfffffcfc), + 2: Color(0xfffff8f7), + 3: Color(0xfffeebe7), + 4: Color(0xffffdcd3), + 5: Color(0xffffcdc2), + 6: Color(0xfffdbdaf), + 7: Color(0xfff5a898), + 8: Color(0xffec8e7b), + 9: Color(0xffe54d2e), + 10: Color(0xffdd4425), + 11: Color(0xffd13415), + 12: Color(0xff5c271f), + }), + ColorSwatch(0xd1df2600, { + 1: Color(0x03ff0000), + 2: Color(0x08ff2000), + 3: Color(0x18f52b00), + 4: Color(0x2cff3500), + 5: Color(0x3dff2e00), + 6: Color(0x50f92d00), + 7: Color(0x67e72800), + 8: Color(0x84db2500), + 9: Color(0xd1df2600), + 10: Color(0xdad72400), + 11: Color(0xeacd2200), + 12: Color(0xe0460900), + }), + ), + Color(0xccfff6f5), + Color(0xffe54d2e), + Color(0xffe54d2e), + Color(0xffffffff), +); + +const _tomatoDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffe54d2e, { + 1: Color(0xff181111), + 2: Color(0xff1f1513), + 3: Color(0xff391714), + 4: Color(0xff4e1511), + 5: Color(0xff5e1c16), + 6: Color(0xff6e2920), + 7: Color(0xff853a2d), + 8: Color(0xffac4d39), + 9: Color(0xffe54d2e), + 10: Color(0xffec6142), + 11: Color(0xffff977d), + 12: Color(0xfffbd3cb), + }), + ColorSwatch(0xe4fe5431, { + 1: Color(0x08f11212), + 2: Color(0x0fff5533), + 3: Color(0x2bff3523), + 4: Color(0x42fd2011), + 5: Color(0x53fe3321), + 6: Color(0x64ff4f38), + 7: Color(0x7dfd644a), + 8: Color(0xa7fe6d4e), + 9: Color(0xe4fe5431), + 10: Color(0xebff6847), + 11: Color(0xffff977d), + 12: Color(0xfbffd6ce), + }), + ), + Color(0x802d1915), + Color(0xffe54d2e), + Color(0xffe54d2e), + Color(0xffffffff), +); + +// red color scale +const _redLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffe5484d, { + 1: Color(0xfffffcfc), + 2: Color(0xfffff7f7), + 3: Color(0xfffeebec), + 4: Color(0xffffdbdc), + 5: Color(0xffffcdce), + 6: Color(0xfffdbdbe), + 7: Color(0xfff4a9aa), + 8: Color(0xffeb8e90), + 9: Color(0xffe5484d), + 10: Color(0xffdc3e42), + 11: Color(0xffce2c31), + 12: Color(0xff641723), + }), + ColorSwatch(0xb7db0007, { + 1: Color(0x03ff0000), + 2: Color(0x08ff0000), + 3: Color(0x14f3000d), + 4: Color(0x24ff0008), + 5: Color(0x32ff0006), + 6: Color(0x42f80004), + 7: Color(0x56df0003), + 8: Color(0x71d20005), + 9: Color(0xb7db0007), + 10: Color(0xc1d10005), + 11: Color(0xd3c40006), + 12: Color(0xe855000d), + }), + ), + Color(0xccfff5f5), + Color(0xffe5484d), + Color(0xffe5484d), + Color(0xffffffff), +); + +const _redDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffe5484d, { + 1: Color(0xff191111), + 2: Color(0xff201314), + 3: Color(0xff3b1219), + 4: Color(0xff500f1c), + 5: Color(0xff611623), + 6: Color(0xff72232d), + 7: Color(0xff8c333a), + 8: Color(0xffb54548), + 9: Color(0xffe5484d), + 10: Color(0xffec5d5e), + 11: Color(0xffff9592), + 12: Color(0xffffd1d9), + }), + ColorSwatch(0xe4fe4e54, { + 1: Color(0x09f41212), + 2: Color(0x11f22f3e), + 3: Color(0x2dff173f), + 4: Color(0x44fe0a3b), + 5: Color(0x56ff2047), + 6: Color(0x68ff3e56), + 7: Color(0x84ff5361), + 8: Color(0xb0ff5d61), + 9: Color(0xe4fe4e54), + 10: Color(0xebff6465), + 11: Color(0xffff9592), + 12: Color(0xffffd1d9), + }), + ), + Color(0x802f1517), + Color(0xffe5484d), + Color(0xffe5484d), + Color(0xffffffff), +); + +// ruby color scale +const _rubyLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffe54666, { + 1: Color(0xfffffcfd), + 2: Color(0xfffff7f8), + 3: Color(0xfffeeaed), + 4: Color(0xffffdce1), + 5: Color(0xffffced6), + 6: Color(0xfff8bfc8), + 7: Color(0xffefacb8), + 8: Color(0xffe592a3), + 9: Color(0xffe54666), + 10: Color(0xffdc3b5d), + 11: Color(0xffca244d), + 12: Color(0xff64172b), + }), + ColorSwatch(0xb9db002c, { + 1: Color(0x03ff0055), + 2: Color(0x08ff0020), + 3: Color(0x15f30025), + 4: Color(0x23ff0025), + 5: Color(0x31ff002a), + 6: Color(0x40e40024), + 7: Color(0x53ce0025), + 8: Color(0x6dc30028), + 9: Color(0xb9db002c), + 10: Color(0xc4d2002c), + 11: Color(0xdbc10030), + 12: Color(0xe8550016), + }), + ), + Color(0xccfff5f6), + Color(0xffe54666), + Color(0xffe54666), + Color(0xffffffff), +); + +const _rubyDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffe54666, { + 1: Color(0xff191113), + 2: Color(0xff1e1517), + 3: Color(0xff3a141e), + 4: Color(0xff4e1325), + 5: Color(0xff5e1a2e), + 6: Color(0xff6f2539), + 7: Color(0xff883447), + 8: Color(0xffb3445a), + 9: Color(0xffe54666), + 10: Color(0xffec5a72), + 11: Color(0xffff949d), + 12: Color(0xfffed2e1), + }), + ColorSwatch(0xe4fe4c70, { + 1: Color(0x09f4124a), + 2: Color(0x0efe5a7f), + 3: Color(0x2cff235d), + 4: Color(0x42fd195e), + 5: Color(0x53fe2d6b), + 6: Color(0x65ff4476), + 7: Color(0x80ff577d), + 8: Color(0xaeff5c7c), + 9: Color(0xe4fe4c70), + 10: Color(0xebff617b), + 11: Color(0xffff949d), + 12: Color(0xfeffd3e2), + }), + ), + Color(0x802b191d), + Color(0xffe54666), + Color(0xffe54666), + Color(0xffffffff), +); + +// crimson color scale +const _crimsonLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffe93d82, { + 1: Color(0xfffffcfd), + 2: Color(0xfffef7f9), + 3: Color(0xffffe9f0), + 4: Color(0xfffedce7), + 5: Color(0xfffacedd), + 6: Color(0xfff3bed1), + 7: Color(0xffeaacc3), + 8: Color(0xffe093b2), + 9: Color(0xffe93d82), + 10: Color(0xffdf3478), + 11: Color(0xffcb1d63), + 12: Color(0xff621639), + }), + ColorSwatch(0xc2e2005b, { + 1: Color(0x03ff0055), + 2: Color(0x08e00040), + 3: Color(0x16ff0052), + 4: Color(0x23f80051), + 5: Color(0x31e5004f), + 6: Color(0x41d0004b), + 7: Color(0x53bf0047), + 8: Color(0x6cb6004a), + 9: Color(0xc2e2005b), + 10: Color(0xcbd70056), + 11: Color(0xe2c4004f), + 12: Color(0xe9530026), + }), + ), + Color(0xccfef5f8), + Color(0xffe93d82), + Color(0xffe93d82), + Color(0xffffffff), +); + +const _crimsonDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffe93d82, { + 1: Color(0xff191114), + 2: Color(0xff201318), + 3: Color(0xff381525), + 4: Color(0xff4d122f), + 5: Color(0xff5c1839), + 6: Color(0xff6d2545), + 7: Color(0xff873356), + 8: Color(0xffb0436e), + 9: Color(0xffe93d82), + 10: Color(0xffee518a), + 11: Color(0xffff92ad), + 12: Color(0xfffdd3e8), + }), + ColorSwatch(0xe8fe418d, { + 1: Color(0x09f41267), + 2: Color(0x11f22f7a), + 3: Color(0x2afe2a8b), + 4: Color(0x41fd1587), + 5: Color(0x51fd278f), + 6: Color(0x63fe4597), + 7: Color(0x7ffd559b), + 8: Color(0xabfe5b9b), + 9: Color(0xe8fe418d), + 10: Color(0xedff5693), + 11: Color(0xffff92ad), + 12: Color(0xfdffd5ea), + }), + ), + Color(0x802f151f), + Color(0xffe93d82), + Color(0xffe93d82), + Color(0xffffffff), +); + +// pink color scale +const _pinkLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffd6409f, { + 1: Color(0xfffffcfe), + 2: Color(0xfffef7fb), + 3: Color(0xfffee9f5), + 4: Color(0xfffbdcef), + 5: Color(0xfff6cee7), + 6: Color(0xffefbfdd), + 7: Color(0xffe7acd0), + 8: Color(0xffdd93c2), + 9: Color(0xffd6409f), + 10: Color(0xffcf3897), + 11: Color(0xffc2298a), + 12: Color(0xff651249), + }), + ColorSwatch(0xbfc8007f, { + 1: Color(0x03ff00aa), + 2: Color(0x08e00080), + 3: Color(0x16f4008c), + 4: Color(0x23e2008b), + 5: Color(0x31d10083), + 6: Color(0x40c00078), + 7: Color(0x53b6006f), + 8: Color(0x6caf006f), + 9: Color(0xbfc8007f), + 10: Color(0xc7c2007a), + 11: Color(0xd6b60074), + 12: Color(0xed59003b), + }), + ), + Color(0xccfef5fa), + Color(0xffd6409f), + Color(0xffd6409f), + Color(0xffffffff), +); + +const _pinkDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffd6409f, { + 1: Color(0xff191117), + 2: Color(0xff21121d), + 3: Color(0xff37172f), + 4: Color(0xff4b143d), + 5: Color(0xff591c47), + 6: Color(0xff692955), + 7: Color(0xff833869), + 8: Color(0xffa84885), + 9: Color(0xffd6409f), + 10: Color(0xffde51a8), + 11: Color(0xffff8dcc), + 12: Color(0xfffdd1ea), + }), + ColorSwatch(0xd4fe49bc, { + 1: Color(0x09f412bc), + 2: Color(0x12f420bb), + 3: Color(0x29fe37cc), + 4: Color(0x3ffc1ec4), + 5: Color(0x4efd35c2), + 6: Color(0x5ffd51c7), + 7: Color(0x7bfd62c8), + 8: Color(0xa2ff68c8), + 9: Color(0xd4fe49bc), + 10: Color(0xdcff5cc0), + 11: Color(0xffff8dcc), + 12: Color(0xfdffd3ec), + }), + ), + Color(0x80311329), + Color(0xffd6409f), + Color(0xffd6409f), + Color(0xffffffff), +); + +// plum color scale +const _plumLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffab4aba, { + 1: Color(0xfffefcff), + 2: Color(0xfffdf7fd), + 3: Color(0xfffbebfb), + 4: Color(0xfff7def8), + 5: Color(0xfff2d1f3), + 6: Color(0xffe9c2ec), + 7: Color(0xffdeade3), + 8: Color(0xffcf91d8), + 9: Color(0xffab4aba), + 10: Color(0xffa144af), + 11: Color(0xff953ea3), + 12: Color(0xff53195d), + }), + ColorSwatch(0xb589009e, { + 1: Color(0x03aa00ff), + 2: Color(0x08c000c0), + 3: Color(0x14cc00cc), + 4: Color(0x21c200c9), + 5: Color(0x2eb700bd), + 6: Color(0x3da400b0), + 7: Color(0x529900a8), + 8: Color(0x6e9000a5), + 9: Color(0xb589009e), + 10: Color(0xbb7f0092), + 11: Color(0xc1730086), + 12: Color(0xe640004b), + }), + ), + Color(0xccfdf5fd), + Color(0xffab4aba), + Color(0xffab4aba), + Color(0xffffffff), +); + +const _plumDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffab4aba, { + 1: Color(0xff181118), + 2: Color(0xff201320), + 3: Color(0xff351a35), + 4: Color(0xff451d47), + 5: Color(0xff512454), + 6: Color(0xff5e3061), + 7: Color(0xff734079), + 8: Color(0xff92549c), + 9: Color(0xffab4aba), + 10: Color(0xffb658c4), + 11: Color(0xffe796f3), + 12: Color(0xfff4d4f4), + }), + ColorSwatch(0xb6e961fe, { + 1: Color(0x08f112f1), + 2: Color(0x11f22ff2), + 3: Color(0x27fd4cfd), + 4: Color(0x3af646ff), + 5: Color(0x48f455ff), + 6: Color(0x56f66dff), + 7: Color(0x70f07cfd), + 8: Color(0x95ee84ff), + 9: Color(0xb6e961fe), + 10: Color(0xc0ed70ff), + 11: Color(0xf3f19cfe), + 12: Color(0xf4feddfe), + }), + ), + Color(0x802f152f), + Color(0xffab4aba), + Color(0xffab4aba), + Color(0xffffffff), +); + +// purple color scale +const _purpleLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff8e4ec6, { + 1: Color(0xfffefcfe), + 2: Color(0xfffbf7fe), + 3: Color(0xfff7edfe), + 4: Color(0xfff2e2fc), + 5: Color(0xffead5f9), + 6: Color(0xffe0c4f4), + 7: Color(0xffd1afec), + 8: Color(0xffbe93e4), + 9: Color(0xff8e4ec6), + 10: Color(0xff8347b9), + 11: Color(0xff8145b5), + 12: Color(0xff402060), + }), + ColorSwatch(0xb15c00ad, { + 1: Color(0x03aa00aa), + 2: Color(0x088000e0), + 3: Color(0x128e00f1), + 4: Color(0x1d8d00e5), + 5: Color(0x2a8000db), + 6: Color(0x3b7a01d0), + 7: Color(0x506d00c3), + 8: Color(0x6c6600c0), + 9: Color(0xb15c00ad), + 10: Color(0xb853009e), + 11: Color(0xba52009a), + 12: Color(0xdf250049), + }), + ), + Color(0xccfaf5fe), + Color(0xff8e4ec6), + Color(0xff8e4ec6), + Color(0xffffffff), +); + +const _purpleDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff8e4ec6, { + 1: Color(0xff18111b), + 2: Color(0xff1e1523), + 3: Color(0xff301c3b), + 4: Color(0xff3d224e), + 5: Color(0xff48295c), + 6: Color(0xff54346b), + 7: Color(0xff664282), + 8: Color(0xff8457aa), + 9: Color(0xff8e4ec6), + 10: Color(0xff9a5cd0), + 11: Color(0xffd19dff), + 12: Color(0xffecd9fa), + }), + ColorSwatch(0xc2b661ff, { + 1: Color(0x0bb412f9), + 2: Color(0x14b744f7), + 3: Color(0x2dc150ff), + 4: Color(0x42bb53fd), + 5: Color(0x51be5cfd), + 6: Color(0x61c16dfd), + 7: Color(0x7ac378fd), + 8: Color(0xa4c47eff), + 9: Color(0xc2b661ff), + 10: Color(0xcdbc6fff), + 11: Color(0xffd19dff), + 12: Color(0xfaf1ddff), + }), + ), + Color(0x802b1735), + Color(0xff8e4ec6), + Color(0xff8e4ec6), + Color(0xffffffff), +); + +// violet color scale +const _violetLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff6e56cf, { + 1: Color(0xfffdfcfe), + 2: Color(0xfffaf8ff), + 3: Color(0xfff4f0fe), + 4: Color(0xffebe4ff), + 5: Color(0xffe1d9ff), + 6: Color(0xffd4cafe), + 7: Color(0xffc2b5f5), + 8: Color(0xffaa99ec), + 9: Color(0xff6e56cf), + 10: Color(0xff654dc4), + 11: Color(0xff6550b9), + 12: Color(0xff2f265f), + }), + ColorSwatch(0xa92400b7, { + 1: Color(0x035500aa), + 2: Color(0x074900ff), + 3: Color(0x0f4400ee), + 4: Color(0x1b4300ff), + 5: Color(0x263600ff), + 6: Color(0x353100fb), + 7: Color(0x4a2d01dd), + 8: Color(0x662b00d0), + 9: Color(0xa92400b7), + 10: Color(0xb22300ab), + 11: Color(0xaf1f0099), + 12: Color(0xd90b0043), + }), + ), + Color(0xccf9f6ff), + Color(0xff6e56cf), + Color(0xff6e56cf), + Color(0xffffffff), +); + +const _violetDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff6e56cf, { + 1: Color(0xff14121f), + 2: Color(0xff1b1525), + 3: Color(0xff291f43), + 4: Color(0xff33255b), + 5: Color(0xff3c2e69), + 6: Color(0xff473876), + 7: Color(0xff56468b), + 8: Color(0xff6958ad), + 9: Color(0xff6e56cf), + 10: Color(0xff7d66d9), + 11: Color(0xffbaa7ff), + 12: Color(0xffe2ddfe), + }), + ColorSwatch(0xcc8668ff, { + 1: Color(0x0f4422ff), + 2: Color(0x16853ff9), + 3: Color(0x368354fe), + 4: Color(0x507d51fd), + 5: Color(0x5f845ffd), + 6: Color(0x6d8f6cfd), + 7: Color(0x839879ff), + 8: Color(0xa8977dfe), + 9: Color(0xcc8668ff), + 10: Color(0xd79176fe), + 11: Color(0xffbaa7ff), + 12: Color(0xfee3deff), + }), + ), + Color(0x80251939), + Color(0xff6e56cf), + Color(0xff6e56cf), + Color(0xffffffff), +); + +// iris color scale +const _irisLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff5b5bd6, { + 1: Color(0xfffdfdff), + 2: Color(0xfff8f8ff), + 3: Color(0xfff0f1fe), + 4: Color(0xffe6e7ff), + 5: Color(0xffdadcff), + 6: Color(0xffcbcdff), + 7: Color(0xffb8baf8), + 8: Color(0xff9b9ef0), + 9: Color(0xff5b5bd6), + 10: Color(0xff5151cd), + 11: Color(0xff5753c6), + 12: Color(0xff272962), + }), + ColorSwatch(0xa40000c0, { + 1: Color(0x020000ff), + 2: Color(0x070000ff), + 3: Color(0x0f0011ee), + 4: Color(0x19000bff), + 5: Color(0x25000eff), + 6: Color(0x34000aff), + 7: Color(0x470008e6), + 8: Color(0x640008d9), + 9: Color(0xa40000c0), + 10: Color(0xae0000b6), + 11: Color(0xac0600ab), + 12: Color(0xd8000246), + }), + ), + Color(0xccf6f6ff), + Color(0xff5b5bd6), + Color(0xff5b5bd6), + Color(0xffffffff), +); + +const _irisDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff5b5bd6, { + 1: Color(0xff13131e), + 2: Color(0xff171625), + 3: Color(0xff202248), + 4: Color(0xff262a65), + 5: Color(0xff303374), + 6: Color(0xff3d3e82), + 7: Color(0xff4a4a95), + 8: Color(0xff5958b1), + 9: Color(0xff5b5bd6), + 10: Color(0xff6e6ade), + 11: Color(0xffb1a9ff), + 12: Color(0xffe0dffe), + }), + ColorSwatch(0xd46a6afe, { + 1: Color(0x0e3636fe), + 2: Color(0x16564bf9), + 3: Color(0x3b525bff), + 4: Color(0x5a4d58ff), + 5: Color(0x6b5b62fd), + 6: Color(0x7a6d6ffd), + 7: Color(0x8e7777fe), + 8: Color(0xac7b7afe), + 9: Color(0xd46a6afe), + 10: Color(0xdc7d79ff), + 11: Color(0xffb1a9ff), + 12: Color(0xfee1e0ff), + }), + ), + Color(0x801d1b39), + Color(0xff5b5bd6), + Color(0xff5b5bd6), + Color(0xffffffff), +); + +// indigo color scale +const _indigoLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff3e63dd, { + 1: Color(0xfffdfdfe), + 2: Color(0xfff7f9ff), + 3: Color(0xffedf2fe), + 4: Color(0xffe1e9ff), + 5: Color(0xffd2deff), + 6: Color(0xffc1d0ff), + 7: Color(0xffabbdf9), + 8: Color(0xff8da4ef), + 9: Color(0xff3e63dd), + 10: Color(0xff3358d4), + 11: Color(0xff3a5bc7), + 12: Color(0xff1f2d5c), + }), + ColorSwatch(0xc10031d2, { + 1: Color(0x02000080), + 2: Color(0x080040ff), + 3: Color(0x120047f1), + 4: Color(0x1e0044ff), + 5: Color(0x2d0044ff), + 6: Color(0x3e003eff), + 7: Color(0x540037ed), + 8: Color(0x720034dc), + 9: Color(0xc10031d2), + 10: Color(0xcc002ec9), + 11: Color(0xc5002bb7), + 12: Color(0xe0001046), + }), + ), + Color(0xccf5f8ff), + Color(0xff3e63dd), + Color(0xff3e63dd), + Color(0xffffffff), +); + +const _indigoDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff3e63dd, { + 1: Color(0xff11131f), + 2: Color(0xff141726), + 3: Color(0xff182449), + 4: Color(0xff1d2e62), + 5: Color(0xff253974), + 6: Color(0xff304384), + 7: Color(0xff3a4f97), + 8: Color(0xff435db1), + 9: Color(0xff3e63dd), + 10: Color(0xff5472e4), + 11: Color(0xff9eb1ff), + 12: Color(0xffd6e1ff), + }), + ColorSwatch(0xdb4671ff, { + 1: Color(0x0f1133ff), + 2: Color(0x173354fa), + 3: Color(0x3c2f62ff), + 4: Color(0x573566ff), + 5: Color(0x6b4171fd), + 6: Color(0x7c5178fd), + 7: Color(0x905a7fff), + 8: Color(0xac5b81fe), + 9: Color(0xdb4671ff), + 10: Color(0xe35c7efe), + 11: Color(0xff9eb1ff), + 12: Color(0xffd6e1ff), + }), + ), + Color(0x80171d3b), + Color(0xff3e63dd), + Color(0xff3e63dd), + Color(0xffffffff), +); + +// blue color scale +const _blueLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff0090ff, { + 1: Color(0xfffbfdff), + 2: Color(0xfff4faff), + 3: Color(0xffe6f4fe), + 4: Color(0xffd5efff), + 5: Color(0xffc2e5ff), + 6: Color(0xffacd8fc), + 7: Color(0xff8ec8f6), + 8: Color(0xff5eb1ef), + 9: Color(0xff0090ff), + 10: Color(0xff0588f0), + 11: Color(0xff0d74ce), + 12: Color(0xff113264), + }), + ColorSwatch(0xff0090ff, { + 1: Color(0x040080ff), + 2: Color(0x0b008cff), + 3: Color(0x19008ff5), + 4: Color(0x2a009eff), + 5: Color(0x3d0093ff), + 6: Color(0x530088f6), + 7: Color(0x710083eb), + 8: Color(0xa10084e6), + 9: Color(0xff0090ff), + 10: Color(0xfa0086f0), + 11: Color(0xf2006dcb), + 12: Color(0xee002359), + }), + ), + Color(0xccf1f9ff), + Color(0xff0090ff), + Color(0xff0090ff), + Color(0xffffffff), +); + +const _blueDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff0090ff, { + 1: Color(0xff0d1520), + 2: Color(0xff111927), + 3: Color(0xff0d2847), + 4: Color(0xff003362), + 5: Color(0xff004074), + 6: Color(0xff104d87), + 7: Color(0xff205d9e), + 8: Color(0xff2870bd), + 9: Color(0xff0090ff), + 10: Color(0xff3b9eff), + 11: Color(0xff70b8ff), + 12: Color(0xffc2e6ff), + }), + ColorSwatch(0xff0090ff, { + 1: Color(0x11004df2), + 2: Color(0x181166fb), + 3: Color(0x3a0077ff), + 4: Color(0x570075ff), + 5: Color(0x6b0081fd), + 6: Color(0x7f0f89fd), + 7: Color(0x982a91fe), + 8: Color(0xb93094fe), + 9: Color(0xff0090ff), + 10: Color(0xff3b9eff), + 11: Color(0xff70b8ff), + 12: Color(0xffc2e6ff), + }), + ), + Color(0x8011213d), + Color(0xff0090ff), + Color(0xff0090ff), + Color(0xffffffff), +); + +// cyan color scale +const _cyanLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff00a2c7, { + 1: Color(0xfffafdfe), + 2: Color(0xfff2fafb), + 3: Color(0xffdef7f9), + 4: Color(0xffcaf1f6), + 5: Color(0xffb5e9f0), + 6: Color(0xff9ddde7), + 7: Color(0xff7dcedc), + 8: Color(0xff3db9cf), + 9: Color(0xff00a2c7), + 10: Color(0xff0797b9), + 11: Color(0xff107d98), + 12: Color(0xff0d3c48), + }), + ColorSwatch(0xff00a2c7, { + 1: Color(0x050099cc), + 2: Color(0x0d009db1), + 3: Color(0x2100c2d1), + 4: Color(0x3500bcd4), + 5: Color(0x4a01b4cc), + 6: Color(0x6200a7c1), + 7: Color(0x82009fbb), + 8: Color(0xc200a3c0), + 9: Color(0xff00a2c7), + 10: Color(0xf80094b7), + 11: Color(0xef007491), + 12: Color(0xf200323e), + }), + ), + Color(0xcceff9fa), + Color(0xff00a2c7), + Color(0xff00a2c7), + Color(0xffffffff), +); + +const _cyanDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff00a2c7, { + 1: Color(0xff0b161a), + 2: Color(0xff101b20), + 3: Color(0xff082c36), + 4: Color(0xff003848), + 5: Color(0xff004558), + 6: Color(0xff045468), + 7: Color(0xff12677e), + 8: Color(0xff11809c), + 9: Color(0xff00a2c7), + 10: Color(0xff23afd0), + 11: Color(0xff4ccce6), + 12: Color(0xffb6ecf7), + }), + ColorSwatch(0xc300cfff, { + 1: Color(0x0a0091f7), + 2: Color(0x1102a7f2), + 3: Color(0x2800befd), + 4: Color(0x3b00baff), + 5: Color(0x4d00befd), + 6: Color(0x5e00c7fd), + 7: Color(0x7514cdff), + 8: Color(0x9511cfff), + 9: Color(0xc300cfff), + 10: Color(0xcd28d6ff), + 11: Color(0xe552e1fe), + 12: Color(0xf7bbf3fe), + }), + ), + Color(0x8011252d), + Color(0xff00a2c7), + Color(0xff00a2c7), + Color(0xffffffff), +); + +// teal color scale +const _tealLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff12a594, { + 1: Color(0xfffafefd), + 2: Color(0xfff3fbf9), + 3: Color(0xffe0f8f3), + 4: Color(0xffccf3ea), + 5: Color(0xffb8eae0), + 6: Color(0xffa1ded2), + 7: Color(0xff83cdc1), + 8: Color(0xff53b9ab), + 9: Color(0xff12a594), + 10: Color(0xff0d9b8a), + 11: Color(0xff008573), + 12: Color(0xff0d3d38), + }), + ColorSwatch(0xed009e8c, { + 1: Color(0x0500cc99), + 2: Color(0x0c00aa80), + 3: Color(0x1f00c69d), + 4: Color(0x3300c396), + 5: Color(0x4700b490), + 6: Color(0x5e00a685), + 7: Color(0x7c009980), + 8: Color(0xac009783), + 9: Color(0xed009e8c), + 10: Color(0xf2009684), + 11: Color(0xff008573), + 12: Color(0xf200332d), + }), + ), + Color(0xccf0faf8), + Color(0xff12a594), + Color(0xff12a594), + Color(0xffffffff), +); + +const _tealDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff12a594, { + 1: Color(0xff0d1514), + 2: Color(0xff111c1b), + 3: Color(0xff0d2d2a), + 4: Color(0xff023b37), + 5: Color(0xff084843), + 6: Color(0xff145750), + 7: Color(0xff1c6961), + 8: Color(0xff207e73), + 9: Color(0xff12a594), + 10: Color(0xff0eb39e), + 11: Color(0xff0bd8b6), + 12: Color(0xffadf0dd), + }), + ColorSwatch(0x9f13ffe4, { + 1: Color(0x0500deab), + 2: Color(0x0c12fbe6), + 3: Color(0x1e00ffe6), + 4: Color(0x2d00ffe9), + 5: Color(0x3b00ffea), + 6: Color(0x4b1cffe8), + 7: Color(0x5f2efde8), + 8: Color(0x7532ffe7), + 9: Color(0x9f13ffe4), + 10: Color(0xae0dffe0), + 11: Color(0xd60afed5), + 12: Color(0xefb8ffeb), + }), + ), + Color(0x80132725), + Color(0xff12a594), + Color(0xff12a594), + Color(0xffffffff), +); + +// jade color scale +const _jadeLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff29a383, { + 1: Color(0xfffbfefd), + 2: Color(0xfff4fbf7), + 3: Color(0xffe6f7ed), + 4: Color(0xffd6f1e3), + 5: Color(0xffc3e9d7), + 6: Color(0xffacdec8), + 7: Color(0xff8bceb6), + 8: Color(0xff56ba9f), + 9: Color(0xff29a383), + 10: Color(0xff26997b), + 11: Color(0xff208368), + 12: Color(0xff1d3b31), + }), + ColorSwatch(0xd600916b, { + 1: Color(0x0400c080), + 2: Color(0x0b00a346), + 3: Color(0x1900ae48), + 4: Color(0x2900a851), + 5: Color(0x3c00a255), + 6: Color(0x53009a57), + 7: Color(0x7400945f), + 8: Color(0xa900976e), + 9: Color(0xd600916b), + 10: Color(0xd9008764), + 11: Color(0xdf007152), + 12: Color(0xe2002217), + }), + ), + Color(0xccf1faf5), + Color(0xff29a383), + Color(0xff29a383), + Color(0xffffffff), +); + +const _jadeDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff29a383, { + 1: Color(0xff0d1512), + 2: Color(0xff121c18), + 3: Color(0xff0f2e22), + 4: Color(0xff0b3b2c), + 5: Color(0xff114837), + 6: Color(0xff1b5745), + 7: Color(0xff246854), + 8: Color(0xff2a7e68), + 9: Color(0xff29a383), + 10: Color(0xff27b08b), + 11: Color(0xff1fd8a4), + 12: Color(0xffadf0d4), + }), + ColorSwatch(0x9d38feca, { + 1: Color(0x0500de45), + 2: Color(0x0c27fba6), + 3: Color(0x2002f999), + 4: Color(0x2d00ffaa), + 5: Color(0x3b11ffb6), + 6: Color(0x4b34ffc2), + 7: Color(0x5e45fdc7), + 8: Color(0x7548ffcf), + 9: Color(0x9d38feca), + 10: Color(0xab31fec7), + 11: Color(0xd621fec0), + 12: Color(0xefb8ffe1), + }), + ), + Color(0x8013271f), + Color(0xff29a383), + Color(0xff29a383), + Color(0xffffffff), +); + +// green color scale +const _greenLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff30a46c, { + 1: Color(0xfffbfefc), + 2: Color(0xfff4fbf6), + 3: Color(0xffe6f6eb), + 4: Color(0xffd6f1df), + 5: Color(0xffc4e8d1), + 6: Color(0xffadddc0), + 7: Color(0xff8eceaa), + 8: Color(0xff5bb98b), + 9: Color(0xff30a46c), + 10: Color(0xff2b9a66), + 11: Color(0xff218358), + 12: Color(0xff193b2d), + }), + ColorSwatch(0xcf008f4a, { + 1: Color(0x0400c040), + 2: Color(0x0b00a32f), + 3: Color(0x1900a433), + 4: Color(0x2900a838), + 5: Color(0x3b019c39), + 6: Color(0x5200963c), + 7: Color(0x71009140), + 8: Color(0xa400924b), + 9: Color(0xcf008f4a), + 10: Color(0xd4008647), + 11: Color(0xde00713f), + 12: Color(0xe6002616), + }), + ), + Color(0xccf1faf4), + Color(0xff30a46c), + Color(0xff30a46c), + Color(0xffffffff), +); + +const _greenDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff30a46c, { + 1: Color(0xff0e1512), + 2: Color(0xff121b17), + 3: Color(0xff132d21), + 4: Color(0xff113b29), + 5: Color(0xff174933), + 6: Color(0xff20573e), + 7: Color(0xff28684a), + 8: Color(0xff2f7c57), + 9: Color(0xff30a46c), + 10: Color(0xff33b074), + 11: Color(0xff3dd68c), + 12: Color(0xffb1f1cb), + }), + ColorSwatch(0x9e44ffa4, { + 1: Color(0x0500de45), + 2: Color(0x0b29f99d), + 3: Color(0x1e22ff99), + 4: Color(0x2d11ff99), + 5: Color(0x3c2bffa2), + 6: Color(0x4b44ffaa), + 7: Color(0x5e50fdac), + 8: Color(0x7354ffad), + 9: Color(0x9e44ffa4), + 10: Color(0xab43fea4), + 11: Color(0xd446fea5), + 12: Color(0xf0bbffd7), + }), + ), + Color(0x8015251d), + Color(0xff30a46c), + Color(0xff30a46c), + Color(0xffffffff), +); + +// grass color scale +const _grassLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff46a758, { + 1: Color(0xfffbfefb), + 2: Color(0xfff5fbf5), + 3: Color(0xffe9f6e9), + 4: Color(0xffdaf1db), + 5: Color(0xffc9e8ca), + 6: Color(0xffb2ddb5), + 7: Color(0xff94ce9a), + 8: Color(0xff65ba74), + 9: Color(0xff46a758), + 10: Color(0xff3e9b4f), + 11: Color(0xff2a7e3b), + 12: Color(0xff203c25), + }), + ColorSwatch(0xb9008619, { + 1: Color(0x0400c000), + 2: Color(0x0a009900), + 3: Color(0x16009700), + 4: Color(0x25009f07), + 5: Color(0x36009305), + 6: Color(0x4d008f0a), + 7: Color(0x6b018b0f), + 8: Color(0x9a008d19), + 9: Color(0xb9008619), + 10: Color(0xc1007b17), + 11: Color(0xd5006514), + 12: Color(0xdf002006), + }), + ), + Color(0xccf3faf3), + Color(0xff46a758), + Color(0xff46a758), + Color(0xffffffff), +); + +const _grassDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff46a758, { + 1: Color(0xff0e1511), + 2: Color(0xff141a15), + 3: Color(0xff1b2a1e), + 4: Color(0xff1d3a24), + 5: Color(0xff25482d), + 6: Color(0xff2d5736), + 7: Color(0xff366740), + 8: Color(0xff3e7949), + 9: Color(0xff46a758), + 10: Color(0xff53b365), + 11: Color(0xff71d083), + 12: Color(0xffc2f0c2), + }), + ColorSwatch(0xa165ff82, { + 1: Color(0x0500de12), + 2: Color(0x0a5ef778), + 3: Color(0x1b70fe8c), + 4: Color(0x2c57ff80), + 5: Color(0x3b68ff8b), + 6: Color(0x4b71ff8f), + 7: Color(0x5d77fd92), + 8: Color(0x7077fd90), + 9: Color(0xa165ff82), + 10: Color(0xae72ff8d), + 11: Color(0xcd89ff9f), + 12: Color(0xefceffce), + }), + ), + Color(0x8019231b), + Color(0xff46a758), + Color(0xff46a758), + Color(0xffffffff), +); + +// bronze color scale +const _bronzeLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffa18072, { + 1: Color(0xfffdfcfc), + 2: Color(0xfffdf7f5), + 3: Color(0xfff6edea), + 4: Color(0xffefe4df), + 5: Color(0xffe7d9d3), + 6: Color(0xffdfcdc5), + 7: Color(0xffd3bcb3), + 8: Color(0xffc2a499), + 9: Color(0xffa18072), + 10: Color(0xff957468), + 11: Color(0xff7d5e54), + 12: Color(0xff43302b), + }), + ColorSwatch(0x8d551a00, { + 1: Color(0x03550000), + 2: Color(0x0acc3300), + 3: Color(0x15922500), + 4: Color(0x20802800), + 5: Color(0x2c742300), + 6: Color(0x3a732400), + 7: Color(0x4c6c1f00), + 8: Color(0x66671c00), + 9: Color(0x8d551a00), + 10: Color(0x974c1500), + 11: Color(0xab3d0f00), + 12: Color(0xd41d0600), + }), + ), + Color(0xccfdf5f3), + Color(0xffa18072), + Color(0xffa18072), + Color(0xffffffff), +); + +const _bronzeDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffa18072, { + 1: Color(0xff141110), + 2: Color(0xff1c1917), + 3: Color(0xff262220), + 4: Color(0xff302a27), + 5: Color(0xff3b3330), + 6: Color(0xff493e3a), + 7: Color(0xff5a4c47), + 8: Color(0xff6f5f58), + 9: Color(0xffa18072), + 10: Color(0xffae8c7e), + 11: Color(0xffd4b3a5), + 12: Color(0xffede0d9), + }), + ColorSwatch(0x9bfec7b0, { + 1: Color(0x04d11100), + 2: Color(0x0cfbbc91), + 3: Color(0x17faceb8), + 4: Color(0x22facdb6), + 5: Color(0x2dffd2c1), + 6: Color(0x3cffd1c0), + 7: Color(0x4ffdd0c0), + 8: Color(0x65ffd6c5), + 9: Color(0x9bfec7b0), + 10: Color(0xa9fecab5), + 11: Color(0xd1ffd7c6), + 12: Color(0xecfff1e9), + }), + ), + Color(0x8027211d), + Color(0xffa18072), + Color(0xffa18072), + Color(0xffffffff), +); + +// gold color scale +const _goldLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff978365, { + 1: Color(0xfffdfdfc), + 2: Color(0xfffaf9f2), + 3: Color(0xfff2f0e7), + 4: Color(0xffeae6db), + 5: Color(0xffe1dccf), + 6: Color(0xffd8d0bf), + 7: Color(0xffcbc0aa), + 8: Color(0xffb9a88d), + 9: Color(0xff978365), + 10: Color(0xff8c7a5e), + 11: Color(0xff71624b), + 12: Color(0xff3b352b), + }), + ColorSwatch(0x9a533200, { + 1: Color(0x03555500), + 2: Color(0x0d9d8a00), + 3: Color(0x18756000), + 4: Color(0x246b4e00), + 5: Color(0x30604600), + 6: Color(0x40644400), + 7: Color(0x55634200), + 8: Color(0x72633d00), + 9: Color(0x9a533200), + 10: Color(0xa1492d00), + 11: Color(0xb4362100), + 12: Color(0xd4130c00), + }), + ), + Color(0xccf9f8ef), + Color(0xff978365), + Color(0xff978365), + Color(0xffffffff), +); + +const _goldDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff978365, { + 1: Color(0xff121211), + 2: Color(0xff1b1a17), + 3: Color(0xff24231f), + 4: Color(0xff2d2b26), + 5: Color(0xff38352e), + 6: Color(0xff444039), + 7: Color(0xff544f46), + 8: Color(0xff696256), + 9: Color(0xff978365), + 10: Color(0xffa39073), + 11: Color(0xffcbb99f), + 12: Color(0xffe8e2d9), + }), + ColorSwatch(0x90ffdba6, { + 1: Color(0x02919111), + 2: Color(0x0bf9e29d), + 3: Color(0x15f8ecbb), + 4: Color(0x1effeec4), + 5: Color(0x2afeecc2), + 6: Color(0x37feebcb), + 7: Color(0x48ffedcd), + 8: Color(0x5ffdeaca), + 9: Color(0x90ffdba6), + 10: Color(0x9dfedfb0), + 11: Color(0xc8fee7c6), + 12: Color(0xe7fef7ed), + }), + ), + Color(0x8025231d), + Color(0xff978365), + Color(0xff978365), + Color(0xffffffff), +); + +// brown color scale +const _brownLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffad7f58, { + 1: Color(0xfffefdfc), + 2: Color(0xfffcf9f6), + 3: Color(0xfff6eee7), + 4: Color(0xfff0e4d9), + 5: Color(0xffebdaca), + 6: Color(0xffe4cdb7), + 7: Color(0xffdcbc9f), + 8: Color(0xffcea37e), + 9: Color(0xffad7f58), + 10: Color(0xffa07553), + 11: Color(0xff815e46), + 12: Color(0xff3e332e), + }), + ColorSwatch(0xa7823c00, { + 1: Color(0x03aa5500), + 2: Color(0x09aa5500), + 3: Color(0x18a04b00), + 4: Color(0x269b4a00), + 5: Color(0x359f4d00), + 6: Color(0x48a04e00), + 7: Color(0x60a34e00), + 8: Color(0x819f4a00), + 9: Color(0xa7823c00), + 10: Color(0xac723300), + 11: Color(0xb9522100), + 12: Color(0xd1140600), + }), + ), + Color(0xccfbf8f4), + Color(0xffad7f58), + Color(0xffad7f58), + Color(0xffffffff), +); + +const _brownDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffad7f58, { + 1: Color(0xff12110f), + 2: Color(0xff1c1816), + 3: Color(0xff28211d), + 4: Color(0xff322922), + 5: Color(0xff3e3128), + 6: Color(0xff4d3c2f), + 7: Color(0xff614a39), + 8: Color(0xff7c5f46), + 9: Color(0xffad7f58), + 10: Color(0xffb88c67), + 11: Color(0xffdbb594), + 12: Color(0xfff2e1ca), + }), + ColorSwatch(0xa8feb87d, { + 1: Color(0x02911100), + 2: Color(0x0cfba67c), + 3: Color(0x19fcb58c), + 4: Color(0x24fbbb8a), + 5: Color(0x31fcb889), + 6: Color(0x41fdba87), + 7: Color(0x56ffbb88), + 8: Color(0x73ffbe87), + 9: Color(0xa8feb87d), + 10: Color(0xb3ffc18c), + 11: Color(0xd9fed1aa), + 12: Color(0xf2feecd4), + }), + ), + Color(0x80271f1b), + Color(0xffad7f58), + Color(0xffad7f58), + Color(0xffffffff), +); + +// orange color scale +const _orangeLight = RadixColor( + RadixColorScale( + ColorSwatch(0xfff76b15, { + 1: Color(0xfffefcfb), + 2: Color(0xfffff7ed), + 3: Color(0xffffefd6), + 4: Color(0xffffdfb5), + 5: Color(0xffffd19a), + 6: Color(0xffffc182), + 7: Color(0xfff5ae73), + 8: Color(0xffec9455), + 9: Color(0xfff76b15), + 10: Color(0xffef5f00), + 11: Color(0xffcc4e00), + 12: Color(0xff582d1d), + }), + ColorSwatch(0xeaf65e00, { + 1: Color(0x04c04000), + 2: Color(0x12ff8e00), + 3: Color(0x29ff9c00), + 4: Color(0x4aff9101), + 5: Color(0x65ff8b00), + 6: Color(0x7dff8100), + 7: Color(0x8ced6c00), + 8: Color(0xaae35f00), + 9: Color(0xeaf65e00), + 10: Color(0xffef5f00), + 11: Color(0xffcc4e00), + 12: Color(0xe2431200), + }), + ), + Color(0xccfff5e9), + Color(0xfff76b15), + Color(0xfff76b15), + Color(0xffffffff), +); + +const _orangeDark = RadixColor( + RadixColorScale( + ColorSwatch(0xfff76b15, { + 1: Color(0xff17120e), + 2: Color(0xff1e160f), + 3: Color(0xff331e0b), + 4: Color(0xff462100), + 5: Color(0xff562800), + 6: Color(0xff66350c), + 7: Color(0xff7e451d), + 8: Color(0xffa35829), + 9: Color(0xfff76b15), + 10: Color(0xffff801f), + 11: Color(0xffffa057), + 12: Color(0xffffe0c2), + }), + ColorSwatch(0xf7fe6d15, { + 1: Color(0x07ec3600), + 2: Color(0x0efe6d00), + 3: Color(0x25fb6a00), + 4: Color(0x39ff5900), + 5: Color(0x4aff6100), + 6: Color(0x5cfd7504), + 7: Color(0x75ff832c), + 8: Color(0x9dfe8438), + 9: Color(0xf7fe6d15), + 10: Color(0xffff801f), + 11: Color(0xffffa057), + 12: Color(0xffffe0c2), + }), + ), + Color(0x80271d13), + Color(0xfff76b15), + Color(0xfff76b15), + Color(0xffffffff), +); + +// amber color scale +const _amberLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffffc53d, { + 1: Color(0xfffefdfb), + 2: Color(0xfffefbe9), + 3: Color(0xfffff7c2), + 4: Color(0xffffee9c), + 5: Color(0xfffbe577), + 6: Color(0xfff3d673), + 7: Color(0xffe9c162), + 8: Color(0xffe2a336), + 9: Color(0xffffc53d), + 10: Color(0xffffba18), + 11: Color(0xffab6400), + 12: Color(0xff4f3422), + }), + ColorSwatch(0xc2ffb300, { + 1: Color(0x04c08000), + 2: Color(0x16f4d100), + 3: Color(0x3dffde00), + 4: Color(0x63ffd400), + 5: Color(0x88f8cf00), + 6: Color(0x8ceab500), + 7: Color(0x9ddc9b00), + 8: Color(0xc9da8a00), + 9: Color(0xc2ffb300), + 10: Color(0xe7ffb300), + 11: Color(0xffab6400), + 12: Color(0xdd341500), + }), + ), + Color(0xccfefae4), + Color(0xffffc53d), + Color(0xffffc53d), + Color(0xff21201c), +); + +const _amberDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffffc53d, { + 1: Color(0xff16120c), + 2: Color(0xff1d180f), + 3: Color(0xff302008), + 4: Color(0xff3f2700), + 5: Color(0xff4d3000), + 6: Color(0xff5c3d05), + 7: Color(0xff714f19), + 8: Color(0xff8f6424), + 9: Color(0xffffc53d), + 10: Color(0xffffd60a), + 11: Color(0xffffca16), + 12: Color(0xffffe7b3), + }), + ColorSwatch(0xffffc53d, { + 1: Color(0x06e63c00), + 2: Color(0x0dfd9b00), + 3: Color(0x22fa8200), + 4: Color(0x32fc8200), + 5: Color(0x41fd8b00), + 6: Color(0x51fd9b00), + 7: Color(0x67ffab25), + 8: Color(0x87ffae35), + 9: Color(0xffffc53d), + 10: Color(0xffffd60a), + 11: Color(0xffffca16), + 12: Color(0xffffe7b3), + }), + ), + Color(0x80271f13), + Color(0xffffc53d), + Color(0xffe2ac37), + Color(0xff21201c), +); + +// yellow color scale +const _yellowLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffffe629, { + 1: Color(0xfffdfdf9), + 2: Color(0xfffefce9), + 3: Color(0xfffffab8), + 4: Color(0xfffff394), + 5: Color(0xffffe770), + 6: Color(0xfff3d768), + 7: Color(0xffe4c767), + 8: Color(0xffd5ae39), + 9: Color(0xffffe629), + 10: Color(0xffffdc00), + 11: Color(0xff9e6c00), + 12: Color(0xff473b1f), + }), + ColorSwatch(0xd6ffe100, { + 1: Color(0x06aaaa00), + 2: Color(0x16f4dd00), + 3: Color(0x47ffee00), + 4: Color(0x6bffe301), + 5: Color(0x8fffd500), + 6: Color(0x97ebbc00), + 7: Color(0x98d2a100), + 8: Color(0xc6c99700), + 9: Color(0xd6ffe100), + 10: Color(0xffffdc00), + 11: Color(0xff9e6c00), + 12: Color(0xe02e2000), + }), + ), + Color(0xccfefbe4), + Color(0xffffe629), + Color(0xffffe629), + Color(0xff21201c), +); + +const _yellowDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffffe629, { + 1: Color(0xff14120b), + 2: Color(0xff1b180f), + 3: Color(0xff2d2305), + 4: Color(0xff362b00), + 5: Color(0xff433500), + 6: Color(0xff524202), + 7: Color(0xff665417), + 8: Color(0xff836a21), + 9: Color(0xffffe629), + 10: Color(0xffffff57), + 11: Color(0xfff5e147), + 12: Color(0xfff6eeb4), + }), + ColorSwatch(0xffffe629, { + 1: Color(0x04d15100), + 2: Color(0x0bf9b400), + 3: Color(0x1effaa00), + 4: Color(0x28fdb700), + 5: Color(0x36febb00), + 6: Color(0x46fec400), + 7: Color(0x5cfdcb22), + 8: Color(0x7bfdca32), + 9: Color(0xffffe629), + 10: Color(0xffffff57), + 11: Color(0xf5fee949), + 12: Color(0xf6fef6ba), + }), + ), + Color(0x80231f13), + Color(0xffffe629), + Color(0xffd2b929), + Color(0xff21201c), +); + +// lime color scale +const _limeLight = RadixColor( + RadixColorScale( + ColorSwatch(0xffbdee63, { + 1: Color(0xfffcfdfa), + 2: Color(0xfff8faf3), + 3: Color(0xffeef6d6), + 4: Color(0xffe2f0bd), + 5: Color(0xffd3e7a6), + 6: Color(0xffc2da91), + 7: Color(0xffabc978), + 8: Color(0xff8db654), + 9: Color(0xffbdee63), + 10: Color(0xffb0e64c), + 11: Color(0xff5c7c2f), + 12: Color(0xff37401c), + }), + ColorSwatch(0x9c93e400, { + 1: Color(0x05669900), + 2: Color(0x0c6b9500), + 3: Color(0x2996c800), + 4: Color(0x428fc600), + 5: Color(0x5981bb00), + 6: Color(0x6e72aa00), + 7: Color(0x87619900), + 8: Color(0xab559200), + 9: Color(0x9c93e400), + 10: Color(0xb38fdc00), + 11: Color(0xd0375f00), + 12: Color(0xe31e2900), + }), + ), + Color(0xccf6f9f0), + Color(0xffbdee63), + Color(0xffbdee63), + Color(0xff1d211c), +); + +const _limeDark = RadixColor( + RadixColorScale( + ColorSwatch(0xffbdee63, { + 1: Color(0xff11130c), + 2: Color(0xff151a10), + 3: Color(0xff1f2917), + 4: Color(0xff29371d), + 5: Color(0xff334423), + 6: Color(0xff3d522a), + 7: Color(0xff496231), + 8: Color(0xff577538), + 9: Color(0xffbdee63), + 10: Color(0xffd4ff70), + 11: Color(0xffbde56c), + 12: Color(0xffe3f7ba), + }), + ColorSwatch(0xedcaff69, { + 1: Color(0x0311bb00), + 2: Color(0x0a78f700), + 3: Color(0x1a9bfd4c), + 4: Color(0x29a7fe5c), + 5: Color(0x37affe65), + 6: Color(0x46b2fe6d), + 7: Color(0x57b6ff6f), + 8: Color(0x6cb6fd6d), + 9: Color(0xedcaff69), + 10: Color(0xffd4ff70), + 11: Color(0xe4d1fe77), + 12: Color(0xf7e9febf), + }), + ), + Color(0x801b2115), + Color(0xffbdee63), + Color(0xff98c254), + Color(0xff1d211c), +); + +// mint color scale +const _mintLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff86ead4, { + 1: Color(0xfff9fefd), + 2: Color(0xfff2fbf9), + 3: Color(0xffddf9f2), + 4: Color(0xffc8f4e9), + 5: Color(0xffb3ecde), + 6: Color(0xff9ce0d0), + 7: Color(0xff7ecfbd), + 8: Color(0xff4cbba5), + 9: Color(0xff86ead4), + 10: Color(0xff7de0cb), + 11: Color(0xff027864), + 12: Color(0xff16433c), + }), + ColorSwatch(0x7900d3a5, { + 1: Color(0x0600d5aa), + 2: Color(0x0d00b18a), + 3: Color(0x2200d29e), + 4: Color(0x3700cc99), + 5: Color(0x4c00c091), + 6: Color(0x6300b086), + 7: Color(0x8100a17d), + 8: Color(0xb3009e7f), + 9: Color(0x7900d3a5), + 10: Color(0x8200c399), + 11: Color(0xfd007763), + 12: Color(0xe900312a), + }), + ), + Color(0xcceffaf8), + Color(0xff86ead4), + Color(0xff86ead4), + Color(0xff1a211e), +); + +const _mintDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff86ead4, { + 1: Color(0xff0e1515), + 2: Color(0xff0f1b1b), + 3: Color(0xff092c2b), + 4: Color(0xff003a38), + 5: Color(0xff004744), + 6: Color(0xff105650), + 7: Color(0xff1e685f), + 8: Color(0xff277f70), + 9: Color(0xff86ead4), + 10: Color(0xffa8f5e5), + 11: Color(0xff58d5ba), + 12: Color(0xffc4f5e1), + }), + ColorSwatch(0xe992ffe7, { + 1: Color(0x0500dede), + 2: Color(0x0b00f9f9), + 3: Color(0x1d00fff6), + 4: Color(0x2c00fff4), + 5: Color(0x3a00fff2), + 6: Color(0x4a0effeb), + 7: Color(0x5e34fde5), + 8: Color(0x7641ffdf), + 9: Color(0xe992ffe7), + 10: Color(0xf5aefeed), + 11: Color(0xd267ffde), + 12: Color(0xf5cbfee9), + }), + ), + Color(0x80152727), + Color(0xff86ead4), + Color(0xff65c3b0), + Color(0xff1a211e), +); + +// sky color scale +const _skyLight = RadixColor( + RadixColorScale( + ColorSwatch(0xff7ce2fe, { + 1: Color(0xfff9feff), + 2: Color(0xfff1fafd), + 3: Color(0xffe1f6fd), + 4: Color(0xffd1f0fa), + 5: Color(0xffbee7f5), + 6: Color(0xffa9daed), + 7: Color(0xff8dcae3), + 8: Color(0xff60b3d7), + 9: Color(0xff7ce2fe), + 10: Color(0xff74daf8), + 11: Color(0xff00749e), + 12: Color(0xff1d3e56), + }), + ColorSwatch(0x8300c7fe, { + 1: Color(0x0600d5ff), + 2: Color(0x0e00a4db), + 3: Color(0x1e00b3ee), + 4: Color(0x2e00ace4), + 5: Color(0x4100a1d8), + 6: Color(0x560092ca), + 7: Color(0x720089c1), + 8: Color(0x9f0085bf), + 9: Color(0x8300c7fe), + 10: Color(0x8b00bcf3), + 11: Color(0xff00749e), + 12: Color(0xe2002540), + }), + ), + Color(0xcceef9fd), + Color(0xff7ce2fe), + Color(0xff7ce2fe), + Color(0xff1c2024), +); + +const _skyDark = RadixColor( + RadixColorScale( + ColorSwatch(0xff7ce2fe, { + 1: Color(0xff0d141f), + 2: Color(0xff111a27), + 3: Color(0xff112840), + 4: Color(0xff113555), + 5: Color(0xff154467), + 6: Color(0xff1b537b), + 7: Color(0xff1f6692), + 8: Color(0xff197cae), + 9: Color(0xff7ce2fe), + 10: Color(0xffa8eeff), + 11: Color(0xff75c7f0), + 12: Color(0xffc2f3ff), + }), + ColorSwatch(0xfe7ce3ff, { + 1: Color(0x0f0044ff), + 2: Color(0x181171fb), + 3: Color(0x331184fc), + 4: Color(0x49128fff), + 5: Color(0x5d1c9dfd), + 6: Color(0x7228a5ff), + 7: Color(0x8b2badfe), + 8: Color(0xa91db2fe), + 9: Color(0xfe7ce3ff), + 10: Color(0xffa8eeff), + 11: Color(0xef7cd3ff), + 12: Color(0xffc2f3ff), + }), + ), + Color(0x8013233b), + Color(0xff7ce2fe), + Color(0xff5bbde2), + Color(0xff1c2024), +); + +// blackA neutral +const _blackAlphaAlpha = ColorSwatch(0xb3000000, { + 1: Color(0x0d000000), + 2: Color(0x1a000000), + 3: Color(0x26000000), + 4: Color(0x33000000), + 5: Color(0x4d000000), + 6: Color(0x66000000), + 7: Color(0x80000000), + 8: Color(0x99000000), + 9: Color(0xb3000000), + 10: Color(0xcc000000), + 11: Color(0xe6000000), + 12: Color(0xf2000000), +}); + +// whiteA neutral +const _whiteAlphaAlpha = ColorSwatch(0xb3ffffff, { + 1: Color(0x0dffffff), + 2: Color(0x1affffff), + 3: Color(0x26ffffff), + 4: Color(0x33ffffff), + 5: Color(0x4dffffff), + 6: Color(0x66ffffff), + 7: Color(0x80ffffff), + 8: Color(0x99ffffff), + 9: Color(0xb3ffffff), + 10: Color(0xccffffff), + 11: Color(0xe6ffffff), + 12: Color(0xf2ffffff), +}); + +// Color theme instances +const gray = RadixColorTheme(_grayLight, _grayDark); +const mauve = RadixColorTheme(_mauveLight, _mauveDark); +const slate = RadixColorTheme(_slateLight, _slateDark); +const sage = RadixColorTheme(_sageLight, _sageDark); +const olive = RadixColorTheme(_oliveLight, _oliveDark); +const sand = RadixColorTheme(_sandLight, _sandDark); +const tomato = RadixColorTheme(_tomatoLight, _tomatoDark); +const red = RadixColorTheme(_redLight, _redDark); +const ruby = RadixColorTheme(_rubyLight, _rubyDark); +const crimson = RadixColorTheme(_crimsonLight, _crimsonDark); +const pink = RadixColorTheme(_pinkLight, _pinkDark); +const plum = RadixColorTheme(_plumLight, _plumDark); +const purple = RadixColorTheme(_purpleLight, _purpleDark); +const violet = RadixColorTheme(_violetLight, _violetDark); +const iris = RadixColorTheme(_irisLight, _irisDark); +const indigo = RadixColorTheme(_indigoLight, _indigoDark); +const blue = RadixColorTheme(_blueLight, _blueDark); +const cyan = RadixColorTheme(_cyanLight, _cyanDark); +const teal = RadixColorTheme(_tealLight, _tealDark); +const jade = RadixColorTheme(_jadeLight, _jadeDark); +const green = RadixColorTheme(_greenLight, _greenDark); +const grass = RadixColorTheme(_grassLight, _grassDark); +const bronze = RadixColorTheme(_bronzeLight, _bronzeDark); +const gold = RadixColorTheme(_goldLight, _goldDark); +const brown = RadixColorTheme(_brownLight, _brownDark); +const orange = RadixColorTheme(_orangeLight, _orangeDark); +const amber = RadixColorTheme(_amberLight, _amberDark); +const yellow = RadixColorTheme(_yellowLight, _yellowDark); +const lime = RadixColorTheme(_limeLight, _limeDark); +const mint = RadixColorTheme(_mintLight, _mintDark); +const sky = RadixColorTheme(_skyLight, _skyDark); + +// Neutral instances +const blackAlpha = _blackAlphaAlpha; +const whiteAlpha = _whiteAlphaAlpha; diff --git a/registry_source/lib/src/fortal/theme/surface_frame.dart b/registry_source/lib/src/fortal/theme/surface_frame.dart new file mode 100644 index 000000000..887ef205e --- /dev/null +++ b/registry_source/lib/src/fortal/theme/surface_frame.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +/// Builds a rounded panel whose visible frame paints above its children. +/// +/// Composite surfaces often contain edge-to-edge child backgrounds. A regular +/// [BoxDecoration] border paints behind those children, so their antialiased +/// rounded edges can partially cover the frame. This keeps the fill and clip on +/// the background decoration, reserves the same inset explicitly, and paints +/// the frame through the container's foreground decoration. +BoxStyler fortalSurfaceFrame({ + required Color fillColor, + required Color borderColor, + required double borderWidth, + required Radius radius, +}) { + final borderRadius = BorderRadiusMix.all(radius); + return BoxStyler() + .color(fillColor) + .padding(.all(borderWidth)) + .borderRadius(borderRadius) + .clipBehavior(.antiAlias) + .foregroundDecoration( + BoxDecorationMix( + border: BoxBorderMix.all( + BorderSideMix(color: borderColor, width: borderWidth), + ), + borderRadius: borderRadius, + ), + ); +} diff --git a/registry_source/lib/src/fortal/theme/theme.dart b/registry_source/lib/src/fortal/theme/theme.dart new file mode 100644 index 000000000..353b67858 --- /dev/null +++ b/registry_source/lib/src/fortal/theme/theme.dart @@ -0,0 +1,14 @@ +// Internal entrypoint for the theme layer, imported by every component under +// `src/components/`. The public entrypoint exports this barrel. + +export 'computed.dart'; +export 'control_styles.dart'; +export 'surface_frame.dart'; +export 'theme_data.dart' hide buildFortalScopeTokens; +export 'theme_scope.dart'; +export 'tokens.dart'; + +// `radix_colors.dart` deliberately stays out of this barrel because it exposes +// bare color names such as `gray` and `red`. The public package barrel exports +// it explicitly for compatibility; installed applications import it only from +// `theme_data.dart`. diff --git a/registry_source/lib/src/fortal/theme/theme_data.dart b/registry_source/lib/src/fortal/theme/theme_data.dart new file mode 100644 index 000000000..dd11ea972 --- /dev/null +++ b/registry_source/lib/src/fortal/theme/theme_data.dart @@ -0,0 +1,1079 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import 'computed.dart'; +import 'radix_colors.dart' as radix; +import 'theme_scope.dart' show FortalScope; +import 'tokens.dart'; + +/// Available accent colors matching Radix Themes names. +enum FortalAccentColor { + gray, + mauve, + slate, + sage, + olive, + sand, + gold, + bronze, + brown, + yellow, + amber, + orange, + tomato, + red, + ruby, + crimson, + pink, + plum, + purple, + violet, + iris, + indigo, + blue, + cyan, + teal, + jade, + green, + grass, + lime, + mint, + sky, +} + +/// Available neutral gray families matching Radix Themes names. +enum FortalGrayColor { gray, mauve, slate, sage, olive, sand } + +/// Theme-level radius multipliers matching the Radix Themes presets. +enum FortalRadius { none, small, medium, large, full } + +/// Background treatment used by floating panels. +enum FortalPanelBackground { solid, translucent } + +/// Discrete theme scaling values supported by Radix Themes. +enum FortalScaling { + percent90(0.9), + percent95(0.95), + percent100(1.0), + percent105(1.05), + percent110(1.1); + + const FortalScaling(this.factor); + + /// Numeric multiplier represented by this preset. + final double factor; +} + +/// Partial theme values applied by a [FortalScope]. +@immutable +class FortalThemeConfig { + const FortalThemeConfig({ + this.accent, + this.gray, + this.brightness, + this.panelBackground, + this.radius, + this.scaling, + this.hasBackground, + }); + + final FortalAccentColor? accent; + final FortalGrayColor? gray; + final Brightness? brightness; + final FortalPanelBackground? panelBackground; + final FortalRadius? radius; + final FortalScaling? scaling; + final bool? hasBackground; + + bool get isDark => brightness == .dark; + + FortalThemeConfig copyWith({ + FortalAccentColor? accent, + FortalGrayColor? gray, + Brightness? brightness, + FortalPanelBackground? panelBackground, + FortalRadius? radius, + FortalScaling? scaling, + bool? hasBackground, + }) => FortalThemeConfig( + accent: accent ?? this.accent, + gray: gray ?? this.gray, + brightness: brightness ?? this.brightness, + panelBackground: panelBackground ?? this.panelBackground, + radius: radius ?? this.radius, + scaling: scaling ?? this.scaling, + hasBackground: hasBackground ?? this.hasBackground, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FortalThemeConfig && + accent == other.accent && + gray == other.gray && + brightness == other.brightness && + panelBackground == other.panelBackground && + radius == other.radius && + scaling == other.scaling && + hasBackground == other.hasBackground; + + @override + int get hashCode => Object.hash( + accent, + gray, + brightness, + panelBackground, + radius, + scaling, + hasBackground, + ); + + Widget createScope({List? orderOfModifiers, required Widget child}) => + FortalScope( + accent: accent, + gray: gray, + brightness: brightness, + panelBackground: panelBackground, + radius: radius, + scaling: scaling, + hasBackground: hasBackground, + orderOfModifiers: orderOfModifiers, + child: child, + ); +} + +/// Fully resolved theme values inherited by a Fortal subtree. +@immutable +class FortalThemeData extends FortalThemeConfig { + const FortalThemeData({ + required FortalAccentColor super.accent, + required FortalGrayColor super.gray, + required Brightness super.brightness, + required FortalPanelBackground super.panelBackground, + required FortalRadius super.radius, + required FortalScaling super.scaling, + required bool super.hasBackground, + }); + + @override + FortalAccentColor get accent => super.accent!; + @override + FortalGrayColor get gray => super.gray!; + @override + Brightness get brightness => super.brightness!; + @override + FortalPanelBackground get panelBackground => super.panelBackground!; + @override + FortalRadius get radius => super.radius!; + @override + FortalScaling get scaling => super.scaling!; + @override + bool get hasBackground => super.hasBackground!; + + @override + bool get isDark => brightness == .dark; + + @override + FortalThemeData copyWith({ + FortalAccentColor? accent, + FortalGrayColor? gray, + Brightness? brightness, + FortalPanelBackground? panelBackground, + FortalRadius? radius, + FortalScaling? scaling, + bool? hasBackground, + }) => FortalThemeData( + accent: accent ?? this.accent, + gray: gray ?? this.gray, + brightness: brightness ?? this.brightness, + panelBackground: panelBackground ?? this.panelBackground, + radius: radius ?? this.radius, + scaling: scaling ?? this.scaling, + hasBackground: hasBackground ?? this.hasBackground, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FortalThemeData && + accent == other.accent && + gray == other.gray && + brightness == other.brightness && + panelBackground == other.panelBackground && + radius == other.radius && + scaling == other.scaling && + hasBackground == other.hasBackground; + + @override + int get hashCode => Object.hash( + accent, + gray, + brightness, + panelBackground, + radius, + scaling, + hasBackground, + ); +} + +/// Builds the token map for a Fortal scope. Used by [FortalScope]. +Map, Object> buildFortalScopeTokens(FortalThemeData theme) { + final tokens = resolveFortalTokens(theme); + final scaling = theme.scaling.factor; + final shadows = buildFortalShadows(isDark: theme.isDark, colors: tokens); + + final colorTokens = { + // Role and functional tokens + FortalTokens.colorBackground: tokens.colorBackground, + FortalTokens.colorSurface: tokens.colorSurface, + FortalTokens.segmentedControlIndicatorBackground: theme.isDark + ? tokens.gray.scale.alphaStep(3) + : tokens.colorBackground, + FortalTokens.colorPanelSolid: tokens.colorPanelSolid, + FortalTokens.colorPanelTranslucent: tokens.colorPanelTranslucent, + FortalTokens.colorPanel: theme.panelBackground == .solid + ? tokens.colorPanelSolid + : tokens.colorPanelTranslucent, + FortalTokens.colorOverlay: tokens.colorOverlay, + FortalTokens.sliderHighContrastOverlay: theme.isDark + ? const Color(0x00000000) + : tokens.blackAlpha[8]!, + FortalTokens.error3: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(3), + FortalTokens.error7: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(7), + FortalTokens.error8: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(8), + FortalTokens.error9: (theme.isDark ? radix.red.dark : radix.red.light).scale + .step(9), + FortalTokens.error11: (theme.isDark ? radix.red.dark : radix.red.light) + .scale + .step(11), + FortalTokens.error12: (theme.isDark ? radix.red.dark : radix.red.light) + .scale + .step(12), + FortalTokens.errorA7: (theme.isDark ? radix.red.dark : radix.red.light) + .scale + .alphaStep(7), + ..._accentColorTokens(tokens), + // Gray steps + FortalTokens.gray1: tokens.gray.scale.step(1), + FortalTokens.gray2: tokens.gray.scale.step(2), + FortalTokens.gray3: tokens.gray.scale.step(3), + FortalTokens.gray4: tokens.gray.scale.step(4), + FortalTokens.gray5: tokens.gray.scale.step(5), + FortalTokens.gray6: tokens.gray.scale.step(6), + FortalTokens.gray7: tokens.gray.scale.step(7), + FortalTokens.gray8: tokens.gray.scale.step(8), + FortalTokens.gray9: tokens.gray.scale.step(9), + FortalTokens.gray10: tokens.gray.scale.step(10), + FortalTokens.gray11: tokens.gray.scale.step(11), + FortalTokens.gray12: tokens.gray.scale.step(12), + // Gray role tokens (from resolved colors) + FortalTokens.graySurface: tokens.gray.surface, + FortalTokens.grayIndicator: tokens.gray.indicator, + FortalTokens.grayTrack: tokens.gray.track, + FortalTokens.grayContrast: tokens.gray.contrast, + // Gray alpha a1..a12 + FortalTokens.grayA1: tokens.gray.scale.alphaStep(1), + FortalTokens.grayA2: tokens.gray.scale.alphaStep(2), + FortalTokens.grayA3: tokens.gray.scale.alphaStep(3), + FortalTokens.grayA4: tokens.gray.scale.alphaStep(4), + FortalTokens.grayA5: tokens.gray.scale.alphaStep(5), + FortalTokens.grayA6: tokens.gray.scale.alphaStep(6), + FortalTokens.grayA7: tokens.gray.scale.alphaStep(7), + FortalTokens.grayA8: tokens.gray.scale.alphaStep(8), + FortalTokens.grayA9: tokens.gray.scale.alphaStep(9), + FortalTokens.grayA10: tokens.gray.scale.alphaStep(10), + FortalTokens.grayA11: tokens.gray.scale.alphaStep(11), + FortalTokens.grayA12: tokens.gray.scale.alphaStep(12), + // Neutral helpers derived from primitives + FortalTokens.blackA1: tokens.blackAlpha[1]!, + FortalTokens.blackA2: tokens.blackAlpha[2]!, + FortalTokens.blackA3: tokens.blackAlpha[3]!, + FortalTokens.blackA4: tokens.blackAlpha[4]!, + FortalTokens.blackA5: tokens.blackAlpha[5]!, + FortalTokens.blackA6: tokens.blackAlpha[6]!, + FortalTokens.blackA7: tokens.blackAlpha[7]!, + FortalTokens.blackA8: tokens.blackAlpha[8]!, + FortalTokens.blackA9: tokens.blackAlpha[9]!, + FortalTokens.blackA10: tokens.blackAlpha[10]!, + FortalTokens.blackA11: tokens.blackAlpha[11]!, + FortalTokens.blackA12: tokens.blackAlpha[12]!, + FortalTokens.whiteA1: tokens.whiteAlpha[1]!, + FortalTokens.whiteA2: tokens.whiteAlpha[2]!, + FortalTokens.whiteA3: tokens.whiteAlpha[3]!, + FortalTokens.whiteA4: tokens.whiteAlpha[4]!, + FortalTokens.whiteA5: tokens.whiteAlpha[5]!, + FortalTokens.whiteA6: tokens.whiteAlpha[6]!, + FortalTokens.whiteA7: tokens.whiteAlpha[7]!, + FortalTokens.whiteA8: tokens.whiteAlpha[8]!, + FortalTokens.whiteA9: tokens.whiteAlpha[9]!, + FortalTokens.whiteA10: tokens.whiteAlpha[10]!, + FortalTokens.whiteA11: tokens.whiteAlpha[11]!, + FortalTokens.whiteA12: tokens.whiteAlpha[12]!, + FortalTokens.shadowStroke: tokens.shadowStroke, + FortalTokens.grayStroke3: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(3), + tokens.gray.scale.step(3), + 0.25, + ), + FortalTokens.grayStroke4: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(4), + tokens.gray.scale.step(4), + 0.25, + ), + FortalTokens.grayStroke5: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(5), + tokens.gray.scale.step(5), + 0.25, + ), + FortalTokens.grayStroke6: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(6), + tokens.gray.scale.step(6), + 0.25, + ), + FortalTokens.grayStroke7: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(7), + tokens.gray.scale.step(7), + 0.25, + ), + FortalTokens.dataTableBorder: mixOklabPremultiplied( + tokens.gray.scale.alphaStep(5), + tokens.gray.scale.step(6), + 0.5, + ), + }; + final allTokens = , Object>{ + ...colorTokens, + FortalTokens.panelBlur: + theme.panelBackground == FortalPanelBackground.translucent ? 64.0 : 0.0, + FortalTokens.space1: 4.0 * scaling, + FortalTokens.space2: 8.0 * scaling, + FortalTokens.space3: 12.0 * scaling, + FortalTokens.space4: 16.0 * scaling, + FortalTokens.space5: 24.0 * scaling, + FortalTokens.space6: 32.0 * scaling, + FortalTokens.space7: 40.0 * scaling, + FortalTokens.space8: 48.0 * scaling, + FortalTokens.space9: 64.0 * scaling, + FortalTokens.spinnerSize3: 20.0 * scaling, + FortalTokens.dataTableRowHeight1: 36.0 * scaling, + FortalTokens.dataTableRowHeight2: 44.0 * scaling, + FortalTokens.toggleGap1: 2.0 * scaling, + FortalTokens.toggleGap3: 6.0 * scaling, + FortalTokens.avatarSize6: 80.0 * scaling, + FortalTokens.avatarSize7: 96.0 * scaling, + FortalTokens.avatarSize8: 128.0 * scaling, + FortalTokens.avatarSize9: 160.0 * scaling, + FortalTokens.avatarIconSize1: 12.0 * scaling, + FortalTokens.avatarIconSize2: 16.0 * scaling, + FortalTokens.avatarIconSize3: 20.0 * scaling, + FortalTokens.avatarIconSize4: 24.0 * scaling, + FortalTokens.avatarIconSize5: 32.0 * scaling, + FortalTokens.avatarIconSize6: 40.0 * scaling, + FortalTokens.avatarIconSize7: 48.0 * scaling, + FortalTokens.avatarIconSize8: 64.0 * scaling, + FortalTokens.avatarIconSize9: 80.0 * scaling, + FortalTokens.badgePaddingX1: 6.0 * scaling, + FortalTokens.badgePaddingY1: 2.0 * scaling, + FortalTokens.badgePaddingX3: 10.0 * scaling, + FortalTokens.checkboxSize1: 14.0 * scaling, + FortalTokens.checkboxSize3: 20.0 * scaling, + FortalTokens.checkboxIndicatorSize1: 9.0 * scaling, + FortalTokens.checkboxIndicatorSize2: 10.0 * scaling, + FortalTokens.checkboxIndicatorSize3: 12.0 * scaling, + FortalTokens.checkboxGroupItemGap1: 6.0 * scaling, + FortalTokens.checkboxGroupItemGap2: 7.0 * scaling, + FortalTokens.checkboxGroupItemGap3: 8.0 * scaling, + FortalTokens.radioIndicatorSize1: 5.6 * scaling, + FortalTokens.radioIndicatorSize2: 6.4 * scaling, + FortalTokens.radioIndicatorSize3: 8.0 * scaling, + FortalTokens.checkboxRadius1: _scaledRadiusToken( + theme.radius, + scaling, + 3.0 * 0.875, + ), + FortalTokens.checkboxRadius3: _scaledRadiusToken( + theme.radius, + scaling, + 3.0 * 1.25, + ), + FortalTokens.switchHeight2: 20.0 * scaling, + FortalTokens.switchWidth1: 28.0 * scaling, + FortalTokens.switchWidth2: 35.0 * scaling, + FortalTokens.switchWidth3: 42.0 * scaling, + FortalTokens.switchThumbSize1: 16.0 * scaling - 2.0, + FortalTokens.switchThumbSize2: 20.0 * scaling - 2.0, + FortalTokens.switchThumbSize3: 24.0 * scaling - 2.0, + FortalTokens.progressHeight2: 6.0 * scaling, + FortalTokens.sliderTrackSize1: 6.0 * scaling, + FortalTokens.sliderTrackSize2: 8.0 * scaling, + FortalTokens.sliderTrackSize3: 10.0 * scaling, + FortalTokens.sliderThumbSize1: 13.0 * scaling, + FortalTokens.sliderThumbSize2: 16.0 * scaling, + FortalTokens.sliderThumbSize3: 19.0 * scaling, + FortalTokens.textFieldPadding1: 6.0 * scaling, + FortalTokens.textFieldPadding2: 8.0 * scaling, + FortalTokens.textFieldPadding3: 12.0 * scaling, + FortalTokens.textAreaMinHeight3: 80.0, + FortalTokens.dataListRowGap3: 20.0 * scaling, + FortalTokens.dataListLabelMinWidth: 120.0, + FortalTokens.tabInnerPaddingY1: 2.0 * scaling, + FortalTokens.tabActiveLetterSpacing1: -0.12 * scaling, + FortalTokens.tabActiveLetterSpacing2: -0.14 * scaling, + FortalTokens.selectSpace1Half: 6.0 * scaling, + FortalTokens.selectIndicatorWidth1: 20.0 * scaling, + FortalTokens.selectIndicatorSize1: 8.0 * scaling, + FortalTokens.selectIndicatorSize2: 10.0 * scaling, + FortalTokens.selectGhostMarginX12: -8.0 * scaling, + FortalTokens.selectGhostMarginY12: -4.0 * scaling, + FortalTokens.selectGhostMarginX3: -12.0 * scaling, + FortalTokens.selectGhostMarginY3: -6.0 * scaling, + ..._radiusTokensFor(theme.radius, scaling), + + // Exact layered Radix shadow tokens, resolved for the active color scales. + ...shadows, + FortalTokens.sliderClassicDisabledTrackShadows: _scaleShadowOpacity( + shadows[FortalTokens.shadow1Layers]! as List, + 0.5, + ), + FortalTokens.cardClassicOuterShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .outer, + state: .idle, + ), + FortalTokens.cardClassicInnerShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .inner, + state: .idle, + ), + FortalTokens.cardClassicHoverOuterShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .outer, + state: .hovered, + ), + FortalTokens.cardClassicHoverInnerShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .inner, + state: .hovered, + ), + FortalTokens.cardClassicActiveOuterShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .outer, + state: .active, + ), + FortalTokens.cardClassicActiveInnerShadows: _cardClassicShadows( + tokens, + isDark: theme.isDark, + layer: .inner, + state: .active, + ), + FortalTokens.selectTriggerClassicShadows: _selectClassicShadows( + tokens, + isDark: theme.isDark, + ), + FortalTokens.selectTriggerClassicHoverShadows: [ + _insetShadow(tokens.gray.scale.alphaStep(3), spread: 1), + ..._selectClassicShadows(tokens, isDark: theme.isDark), + ], + FortalTokens.baseButtonClassicDisabledShadows: + _baseButtonClassicDisabledShadows(tokens, isDark: theme.isDark), + FortalTokens.baseButtonClassicShadows: _baseButtonClassicShadows( + tokens, + isDark: theme.isDark, + highContrast: false, + ), + FortalTokens.baseButtonClassicHighContrastShadows: + _baseButtonClassicShadows( + tokens, + isDark: theme.isDark, + highContrast: true, + ), + FortalTokens.baseButtonClassicActiveShadows: + _baseButtonClassicActiveShadows(tokens, highContrast: false), + FortalTokens.baseButtonClassicActiveHighContrastShadows: + _baseButtonClassicActiveShadows(tokens, highContrast: true), + FortalTokens.baseButtonClassicAfterInset: theme.isDark ? 1.0 : 2.0, + FortalTokens.baseButtonGhostPaddingY3: 6.0 * scaling, + FortalTokens.baseButtonGhostMarginX12: -8.0 * scaling, + FortalTokens.baseButtonGhostMarginY12: -4.0 * scaling, + FortalTokens.baseButtonGhostMarginX3: -12.0 * scaling, + FortalTokens.baseButtonGhostMarginY3: -6.0 * scaling, + FortalTokens.baseButtonGhostMarginX4: -16.0 * scaling, + FortalTokens.baseButtonGhostMarginY4: -8.0 * scaling, + FortalTokens.iconButtonGhostPadding2: 6.0 * scaling, + FortalTokens.iconButtonGhostMargin1: -4.0 * scaling, + FortalTokens.iconButtonGhostMargin2: -6.0 * scaling, + FortalTokens.iconButtonGhostMargin3: -8.0 * scaling, + FortalTokens.iconButtonGhostMargin4: -12.0 * scaling, + FortalTokens.cardGhostMargin1: -12.0 * scaling, + FortalTokens.cardGhostMargin2: -16.0 * scaling, + FortalTokens.cardGhostMargin3: -24.0 * scaling, + FortalTokens.cardGhostMargin4: -32.0 * scaling, + FortalTokens.cardGhostMargin5: -48.0 * scaling, + FortalTokens.borderWidth1: 1.0, + FortalTokens.borderWidth2: 2.0, + FortalTokens.focusRingWidth: 2.0, + FortalTokens.focusRingOffset: 2.0, + FortalTokens.text1: TextStyle( + fontSize: 12.0 * scaling, + letterSpacing: 0.0025 * 12.0 * scaling, + height: 16.0 / 12.0, + ), + FortalTokens.text2: TextStyle( + fontSize: 14.0 * scaling, + letterSpacing: 0.0, + height: 20.0 / 14.0, + ), + FortalTokens.text3: TextStyle( + fontSize: 16.0 * scaling, + letterSpacing: 0.0, + height: 24.0 / 16.0, + ), + FortalTokens.accordionText2: TextStyle( + fontSize: 15.0 * scaling, + letterSpacing: 0.0, + height: 20.0 / 15.0, + ), + FortalTokens.text4: TextStyle( + fontSize: 18.0 * scaling, + letterSpacing: -0.0025 * 18.0 * scaling, + height: 26.0 / 18.0, + ), + FortalTokens.text5: TextStyle( + fontSize: 20.0 * scaling, + letterSpacing: -0.005 * 20.0 * scaling, + height: 28.0 / 20.0, + ), + FortalTokens.text6: TextStyle( + fontSize: 24.0 * scaling, + letterSpacing: -0.00625 * 24.0 * scaling, + height: 30.0 / 24.0, + ), + FortalTokens.text7: TextStyle( + fontSize: 28.0 * scaling, + letterSpacing: -0.0075 * 28.0 * scaling, + height: 36.0 / 28.0, + ), + FortalTokens.text8: TextStyle( + fontSize: 35.0 * scaling, + letterSpacing: -0.01 * 35.0 * scaling, + height: 40.0 / 35.0, + ), + FortalTokens.text9: TextStyle( + fontSize: 60.0 * scaling, + letterSpacing: -0.025 * 60.0 * scaling, + height: 1.0, + ), + FortalTokens.avatarFallback1One: _avatarFallbackText( + fontSize: 14, + letterSpacing: 0.0025 * 12, + scaling: scaling, + ), + FortalTokens.avatarFallback1Two: _avatarFallbackText( + fontSize: 12, + letterSpacing: 0.0025 * 12, + scaling: scaling, + ), + FortalTokens.avatarFallback2One: _avatarFallbackText( + fontSize: 16, + letterSpacing: 0, + scaling: scaling, + ), + FortalTokens.avatarFallback2Two: _avatarFallbackText( + fontSize: 14, + letterSpacing: 0, + scaling: scaling, + ), + FortalTokens.avatarFallback3One: _avatarFallbackText( + fontSize: 18, + letterSpacing: 0, + scaling: scaling, + ), + FortalTokens.avatarFallback3Two: _avatarFallbackText( + fontSize: 16, + letterSpacing: 0, + scaling: scaling, + ), + FortalTokens.avatarFallback4One: _avatarFallbackText( + fontSize: 20, + letterSpacing: -0.0025 * 18, + scaling: scaling, + ), + FortalTokens.avatarFallback4Two: _avatarFallbackText( + fontSize: 18, + letterSpacing: -0.0025 * 18, + scaling: scaling, + ), + FortalTokens.avatarFallback5: _avatarFallbackText( + fontSize: 24, + letterSpacing: -0.00625 * 24, + scaling: scaling, + ), + FortalTokens.avatarFallback6: _avatarFallbackText( + fontSize: 28, + letterSpacing: -0.0075 * 28, + scaling: scaling, + ), + FortalTokens.avatarFallback7: _avatarFallbackText( + fontSize: 28, + letterSpacing: -0.0075 * 28, + scaling: scaling, + ), + FortalTokens.avatarFallback8: _avatarFallbackText( + fontSize: 35, + letterSpacing: -0.01 * 35, + scaling: scaling, + ), + FortalTokens.avatarFallback9: _avatarFallbackText( + fontSize: 60, + letterSpacing: -0.025 * 60, + scaling: scaling, + ), + + // Font weights (token values) + FortalTokens.fontWeightLight: FontWeight.w300, + FortalTokens.fontWeightRegular: FontWeight.w400, + FortalTokens.fontWeightMedium: FontWeight.w500, + // Match Radix Themes font weights (bold = 700) + FortalTokens.fontWeightBold: FontWeight.w700, + + // Durations (token values) + FortalTokens.transitionFast: const Duration(milliseconds: 100), + FortalTokens.transitionSlow: const Duration(milliseconds: 300), + FortalTokens.skeletonPulseDuration: const Duration(milliseconds: 1000), + }; + + return allTokens; +} + +TextStyle _avatarFallbackText({ + required double fontSize, + required double letterSpacing, + required double scaling, +}) => TextStyle( + fontSize: fontSize * scaling, + letterSpacing: letterSpacing * scaling, + height: 1, +); + +enum _CardShadowLayer { outer, inner } + +enum _CardShadowState { idle, hovered, active } + +List _cardClassicShadows( + FortalThemeColors colors, { + required bool isDark, + required _CardShadowLayer layer, + required _CardShadowState state, +}) { + final inner = layer == _CardShadowLayer.inner; + final shapeInset = inner ? 1.0 : 0.0; + final border = switch ((isDark, state)) { + (true, _) => mixOklabPremultiplied( + colors.gray.scale.alphaStep(6), + colors.gray.scale.step(6), + 0.25, + ), + (false, _CardShadowState.hovered) => mixOklabPremultiplied( + colors.gray.scale.alphaStep(4), + colors.gray.scale.step(4), + 0.25, + ), + (false, _) => mixOklabPremultiplied( + colors.gray.scale.alphaStep(3), + colors.gray.scale.step(3), + 0.25, + ), + }; + + RemixBoxShadow shadow( + Color color, { + Offset offset = Offset.zero, + double blur = 0, + required double spread, + }) => RemixBoxShadow( + color: color, + offset: offset, + blurRadius: blur, + spreadRadius: spread, + shapeInset: shapeInset, + ); + + if (state == _CardShadowState.hovered) { + if (isDark) { + return [ + shadow(border, spread: inner ? 1 : 0), + shadow(colors.gray.scale.alphaStep(4), blur: 1, spread: inner ? 1 : 0), + shadow( + colors.gray.scale.alphaStep(4), + blur: 1, + spread: inner ? -1 : -2, + ), + shadow( + colors.gray.scale.alphaStep(3), + blur: 3, + spread: inner ? -2 : -3, + ), + shadow( + colors.gray.scale.alphaStep(3), + blur: 12, + spread: inner ? -2 : -3, + ), + shadow( + colors.gray.scale.alphaStep(7), + blur: 16, + spread: inner ? -8 : -9, + ), + ]; + } + return [ + shadow(border, spread: inner ? 1 : 0), + shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 1), + blur: 1, + spread: inner ? 1 : 0, + ), + shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 2), + blur: 1, + spread: inner ? -1 : -2, + ), + shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 2), + blur: 3, + spread: inner ? -2 : -3, + ), + shadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 3), + blur: 12, + spread: inner ? -4 : -5, + ), + shadow( + colors.blackAlpha[1]!, + offset: const Offset(0, 4), + blur: 16, + spread: inner ? -8 : -9, + ), + ]; + } + + final active = state == _CardShadowState.active; + final subtle = isDark ? colors.blackAlpha[3]! : colors.blackAlpha[1]!; + final middle = isDark + ? colors.blackAlpha[6]! + : colors.gray.scale.alphaStep(active ? 4 : 2); + final bottom = isDark ? colors.blackAlpha[5]! : colors.blackAlpha[1]!; + return [ + shadow(border, spread: inner ? 1 : 0), + shadow(const Color(0x00000000), spread: inner ? 1 : 0), + shadow(subtle, spread: inner ? 0.5 : 0), + shadow(middle, offset: const Offset(0, 1), blur: 1, spread: inner ? 0 : -1), + shadow( + isDark ? colors.blackAlpha[6]! : colors.blackAlpha[1]!, + offset: const Offset(0, 2), + blur: 1, + spread: inner ? -1 : -2, + ), + shadow(bottom, offset: const Offset(0, 1), blur: 3, spread: inner ? 0 : -1), + ]; +} + +RemixBoxShadow _insetShadow( + Color color, { + Offset offset = Offset.zero, + double blur = 0, + double spread = 0, + double shapeInset = 0, +}) => RemixBoxShadow( + kind: RemixBoxShadowKind.inset, + color: color, + offset: offset, + blurRadius: blur, + spreadRadius: spread, + shapeInset: shapeInset, +); + +List _selectClassicShadows( + FortalThemeColors colors, { + required bool isDark, +}) { + if (isDark) { + return [ + _insetShadow(colors.whiteAlpha[4]!, spread: 1), + _insetShadow(colors.whiteAlpha[4]!, offset: const Offset(0, 1), blur: 1), + _insetShadow(colors.blackAlpha[9]!, offset: const Offset(0, -1), blur: 1), + ]; + } + return [ + _insetShadow(colors.gray.scale.alphaStep(5), spread: 1), + _insetShadow(colors.whiteAlpha[11]!, offset: const Offset(0, 2), blur: 1), + _insetShadow( + colors.gray.scale.alphaStep(4), + offset: const Offset(0, -2), + blur: 1, + ), + ]; +} + +List _baseButtonClassicDisabledShadows( + FortalThemeColors colors, { + required bool isDark, +}) { + if (isDark) { + return [ + _insetShadow(colors.gray.scale.alphaStep(5), spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(2), + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.gray.scale.alphaStep(5), + offset: const Offset(0, 1), + blur: 1, + ), + _insetShadow(colors.blackAlpha[3]!, offset: const Offset(0, -1), blur: 1), + _insetShadow(colors.gray.scale.alphaStep(2), spread: 1), + ]; + } + return [ + _insetShadow(colors.gray.scale.alphaStep(4), spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, -2), + blur: 1, + ), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + ]; +} + +List _baseButtonClassicShadows( + FortalThemeColors colors, { + required bool isDark, + required bool highContrast, +}) { + final accent = highContrast + ? colors.accent.scale.step(12) + : colors.accent.scale.step(9); + if (isDark) { + return [ + _insetShadow( + colors.whiteAlpha[4]!, + offset: const Offset(0, 2), + blur: 3, + spread: -1, + shapeInset: 1, + ), + _insetShadow(colors.whiteAlpha[2]!, spread: 1), + _insetShadow( + colors.whiteAlpha[3]!, + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow(colors.whiteAlpha[6]!, offset: const Offset(0, 1), blur: 1), + _insetShadow(colors.blackAlpha[6]!, offset: const Offset(0, -1), blur: 1), + _insetShadow(accent, spread: 1), + ]; + } + return [ + _insetShadow( + colors.whiteAlpha[4]!, + offset: const Offset(0, 2), + blur: 3, + spread: -1, + shapeInset: 2, + ), + _insetShadow(colors.gray.scale.alphaStep(4), spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, -2), + blur: 1, + ), + _insetShadow(accent, spread: 1), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.whiteAlpha[9]!, + offset: const Offset(0, 2), + blur: 1, + spread: -1, + ), + ]; +} + +List _baseButtonClassicActiveShadows( + FortalThemeColors colors, { + required bool highContrast, +}) { + final accent = highContrast + ? colors.accent.scale.step(12) + : colors.accent.scale.step(9); + return [ + _insetShadow( + colors.gray.scale.alphaStep(4), + offset: const Offset(0, 4), + blur: 2, + spread: -2, + ), + _insetShadow( + colors.gray.scale.alphaStep(7), + offset: const Offset(0, 1), + blur: 1, + ), + _insetShadow(colors.gray.scale.alphaStep(5), spread: 1), + _insetShadow(accent, spread: 1), + _insetShadow( + colors.gray.scale.alphaStep(3), + offset: const Offset(0, 3), + blur: 2, + ), + _insetShadow(colors.whiteAlpha[7]!, spread: 1), + _insetShadow(colors.whiteAlpha[5]!, offset: const Offset(0, -2), blur: 1), + ]; +} + +Map _radiusTokensFor(FortalRadius radius, double scaling) { + final factor = _radiusFactor(radius); + final thumb = switch (radius) { + .none || .small => const Radius.circular(0.5), + .medium || .large || .full => const Radius.circular(9999.0), + }; + Radius scaled(double base) => Radius.circular(base * factor * scaling); + Radius larger(Radius first, Radius second) => Radius.elliptical( + first.x > second.x ? first.x : second.x, + first.y > second.y ? first.y : second.y, + ); + final radius1 = scaled(3.0); + final radius2 = scaled(4.0); + final radius3 = scaled(6.0); + final radius4 = scaled(8.0); + final radius5 = scaled(12.0); + final radius6 = scaled(16.0); + final full = radius == .full ? const Radius.circular(9999.0) : Radius.zero; + Radius progressRadius(double height) { + final thumbBase = switch (radius) { + .none || .small => 0.5, + .medium || .large || .full => 9999.0, + }; + return Radius.circular(math.max(factor * height / 3, factor * thumbBase)); + } + + return { + FortalTokens.radius1: radius1, + FortalTokens.radius2: radius2, + FortalTokens.radius3: radius3, + FortalTokens.radius4: radius4, + FortalTokens.radius5: radius5, + FortalTokens.radius6: radius6, + FortalTokens.radiusFull: full, + FortalTokens.radiusThumb: thumb, + FortalTokens.radiusCircle: const Radius.circular(9999.0), + FortalTokens.radius1OrFull: larger(radius1, full), + FortalTokens.radius2OrFull: larger(radius2, full), + FortalTokens.radius3OrFull: larger(radius3, full), + FortalTokens.radius4OrFull: larger(radius4, full), + FortalTokens.radius5OrFull: larger(radius5, full), + FortalTokens.radius6OrFull: larger(radius6, full), + FortalTokens.radius1OrThumb: larger(radius1, thumb), + FortalTokens.radius2OrThumb: larger(radius2, thumb), + FortalTokens.progressRadius1: progressRadius(4.0 * scaling), + FortalTokens.progressRadius2: progressRadius(6.0 * scaling), + FortalTokens.progressRadius3: progressRadius(8.0 * scaling), + FortalTokens.sliderTrackRadius1: progressRadius(6.0 * scaling), + FortalTokens.sliderTrackRadius2: progressRadius(8.0 * scaling), + FortalTokens.sliderTrackRadius3: progressRadius(10.0 * scaling), + }; +} + +List _scaleShadowOpacity( + List shadows, + double factor, +) => [ + for (final shadow in shadows) + RemixBoxShadow( + kind: shadow.kind, + color: shadow.color.withValues(alpha: shadow.color.a * factor), + offset: shadow.offset, + blurRadius: shadow.blurRadius, + spreadRadius: shadow.spreadRadius, + shapeInset: shadow.shapeInset, + ), +]; + +double _radiusFactor(FortalRadius radius) => switch (radius) { + .none => 0.0, + .small => 0.75, + .medium => 1.0, + .large || .full => 1.5, +}; + +Radius _scaledRadiusToken(FortalRadius radius, double scaling, double base) => + Radius.circular(base * scaling * _radiusFactor(radius)); + +Map _accentColorTokens(FortalThemeColors tokens) { + final scale = tokens.accent.scale; + + return { + FortalTokens.accentSurface: tokens.accent.surface, + FortalTokens.accentIndicator: tokens.accent.indicator, + FortalTokens.accentTrack: tokens.accent.track, + FortalTokens.accentContrast: tokens.accent.contrast, + FortalTokens.focus8: tokens.focus8, + FortalTokens.focusA5: tokens.focusA5, + FortalTokens.focusA8: tokens.focusA8, + FortalTokens.accent1: scale.step(1), + FortalTokens.accent2: scale.step(2), + FortalTokens.accent3: scale.step(3), + FortalTokens.accent4: scale.step(4), + FortalTokens.accent5: scale.step(5), + FortalTokens.accent6: scale.step(6), + FortalTokens.accent7: scale.step(7), + FortalTokens.accent8: scale.step(8), + FortalTokens.accent9: scale.step(9), + FortalTokens.accent10: scale.step(10), + FortalTokens.accent11: scale.step(11), + FortalTokens.accent12: scale.step(12), + FortalTokens.accentA1: scale.alphaStep(1), + FortalTokens.accentA2: scale.alphaStep(2), + FortalTokens.accentA3: scale.alphaStep(3), + FortalTokens.accentA4: scale.alphaStep(4), + FortalTokens.accentA5: scale.alphaStep(5), + FortalTokens.accentA6: scale.alphaStep(6), + FortalTokens.accentA7: scale.alphaStep(7), + FortalTokens.accentA8: scale.alphaStep(8), + FortalTokens.accentA9: scale.alphaStep(9), + FortalTokens.accentA10: scale.alphaStep(10), + FortalTokens.accentA11: scale.alphaStep(11), + FortalTokens.accentA12: scale.alphaStep(12), + }; +} diff --git a/registry_source/lib/src/fortal/theme/theme_scope.dart b/registry_source/lib/src/fortal/theme/theme_scope.dart new file mode 100644 index 000000000..34652d771 --- /dev/null +++ b/registry_source/lib/src/fortal/theme/theme_scope.dart @@ -0,0 +1,190 @@ +import 'package:flutter/widgets.dart'; +import 'package:remix/remix.dart'; + +import 'theme_data.dart'; +import 'tokens.dart'; + +/// Establishes a courtesy default text run for bare [Text] descendants. +/// +/// `.radix-themes` is not only a token carrier upstream: `color.css` sets +/// `color: var(--gray-12)` in the same rule as the `data-has-background` fill, +/// and `typography.css` pins the root to `--default-font-size` +/// (`--font-size-3`), `--default-line-height`, `--default-letter-spacing`, and +/// `--default-font-weight`. Those resolve to exactly [FortalTokens.text3] plus +/// [FortalTokens.gray12] at regular weight. +/// +/// Fortal text recipes resolve and pin their own runs. This fallback keeps +/// deliberately bare [Text] descendants aligned with Radix's root typography +/// and neutral foreground. A nearer descendant `DefaultTextStyle` still wins +/// through Flutter's normal inheritance. +/// +/// Only the outermost [FortalScope] installs this. A nested scope re-scopes +/// tokens for its subtree and nothing more: upstream, `.radix-themes` inside +/// another `.radix-themes` still inherits `color` and the font properties from +/// its parent chain, and a nested scope that reinstalled the root run here +/// would silently replace whatever `DefaultTextStyle` the subtree sits in. +/// +/// The font family is deliberately left unset. Radix's `--default-font-family` +/// is the platform system stack, and a null family is Flutter's equivalent; +/// naming a concrete family here would pin every consumer to one typeface. +Widget _fortalRootTextStyle({ + required Map, Object> tokens, + required Widget child, +}) { + final root = tokens[FortalTokens.text3]! as TextStyle; + + return DefaultTextStyle( + style: root.copyWith( + color: tokens[FortalTokens.gray12]! as Color, + fontWeight: tokens[FortalTokens.fontWeightRegular]! as FontWeight, + ), + child: child, + ); +} + +/// Widget that provides Fortal design tokens to its subtree via [MixScope]. +/// +/// Place [FortalScope] below the application host so its text defaults apply. +/// For a routed app, wrap the navigator in the host's builder. This also keeps +/// [FortalTokens] available to routes and dialogs. +/// +/// ```dart +/// MaterialApp( +/// builder: (_, child) => FortalScope(child: child!), +/// home: const HomePage(), +/// ) +/// ``` +class FortalScope extends StatelessWidget { + const FortalScope({ + super.key, + this.accent, + this.gray, + this.brightness, + this.panelBackground, + this.radius, + this.scaling, + this.hasBackground, + this.orderOfModifiers, + required this.child, + }); + + final FortalAccentColor? accent; + final FortalGrayColor? gray; + final Brightness? brightness; + final FortalPanelBackground? panelBackground; + final FortalRadius? radius; + final FortalScaling? scaling; + final bool? hasBackground; + final List? orderOfModifiers; + final Widget child; + + @override + Widget build(BuildContext context) { + final config = FortalThemeConfig( + accent: accent, + gray: gray, + brightness: brightness, + panelBackground: panelBackground, + radius: radius, + scaling: scaling, + hasBackground: hasBackground, + ); + final parent = FortalTheme.maybeOf(context); + final data = _resolveFortalTheme(config, parent: parent); + final tokens = buildFortalScopeTokens(data); + Widget result = MixScope( + tokens: tokens, + orderOfModifiers: orderOfModifiers, + // Theme-root identity, not `hasBackground`, decides who owns the text + // run: a scope nested for its accent or scaling must leave the current + // run alone, while a root scope with `hasBackground: false` still + // establishes it. + child: parent == null + ? _fortalRootTextStyle(tokens: tokens, child: child) + : child, + ); + if (data.hasBackground) { + result = ColoredBox( + color: tokens[FortalTokens.colorBackground]! as Color, + child: result, + ); + } + + return FortalTheme( + data: data, + orderOfModifiers: orderOfModifiers, + child: result, + ); + } +} + +FortalThemeData _resolveFortalTheme( + FortalThemeConfig config, { + FortalThemeData? parent, +}) { + final accent = config.accent ?? parent?.accent ?? FortalAccentColor.indigo; + + return FortalThemeData( + accent: accent, + gray: config.gray ?? parent?.gray ?? FortalGrayColor.slate, + brightness: config.brightness ?? parent?.brightness ?? Brightness.light, + panelBackground: + config.panelBackground ?? + parent?.panelBackground ?? + FortalPanelBackground.translucent, + radius: config.radius ?? parent?.radius ?? FortalRadius.medium, + scaling: config.scaling ?? parent?.scaling ?? FortalScaling.percent100, + hasBackground: config.hasBackground ?? parent == null, + ); +} + +/// Makes the active [FortalThemeData] available to descendants. +class FortalTheme extends InheritedTheme { + const FortalTheme({ + super.key, + required this.data, + this.orderOfModifiers, + required super.child, + }); + + final FortalThemeData data; + final List? orderOfModifiers; + + /// Returns the closest resolved Fortal theme. + static FortalThemeData of(BuildContext context) { + final data = maybeOf(context); + if (data != null) return data; + throw FlutterError.fromParts([ + ErrorSummary('No FortalTheme found.'), + ErrorDescription( + '${context.widget.runtimeType} tried to read the Fortal theme, but no FortalScope was found above it.', + ), + context.describeElement('The context used was'), + ]); + } + + /// Returns the closest resolved Fortal theme, if one is available. + static FortalThemeData? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.data; + + /// Rebuilds only the theme and its Mix tokens. + /// + /// The captured subtree's text run is *not* synthesized here. + /// `DefaultTextStyle` is itself an [InheritedTheme], so + /// `InheritedTheme.capture` already carries the actual nearest ambient run + /// across to the new route; installing the Radix root run alongside it would + /// overwrite that capture with a value the source context never had. + @override + Widget wrap(BuildContext context, Widget child) => FortalTheme( + data: data, + orderOfModifiers: orderOfModifiers, + child: MixScope( + tokens: buildFortalScopeTokens(data), + orderOfModifiers: orderOfModifiers, + child: child, + ), + ); + + @override + bool updateShouldNotify(FortalTheme oldWidget) => data != oldWidget.data; +} diff --git a/registry_source/lib/src/fortal/theme/tokens.dart b/registry_source/lib/src/fortal/theme/tokens.dart new file mode 100644 index 000000000..f01b10c21 --- /dev/null +++ b/registry_source/lib/src/fortal/theme/tokens.dart @@ -0,0 +1,943 @@ +import 'package:remix/remix.dart'; + +import 'theme_scope.dart' show FortalScope; + +/// Design tokens for the Fortal UI system (powered by Radix Colors). +/// +/// Provides color scales (12-step accent/gray), spacing (9-step), radius (6-step), +/// shadows (6-level), typography (9-size), and functional colors. +/// +/// Example: +/// ```dart +/// Style( +/// $box.color.ref(FortalTokens.accent9), +/// $text.style.ref(FortalTokens.text3), +/// $box.padding.ref(FortalTokens.space4), +/// ) +/// ``` +/// +/// Must be used within [FortalScope] to resolve actual values. +class FortalTokens { + // ============================================================================ + // BACKGROUND AND SURFACE COLORS + // ============================================================================ + + /// Page background color selected for the active brightness. + static const colorBackground = ColorToken('fortal.color.background'); + + /// Neutral surface color for input fields and controls. + static const colorSurface = ColorToken('fortal.color.surface'); + + /// Selected SegmentedControl surface for the active brightness. + static const segmentedControlIndicatorBackground = ColorToken( + 'fortal.segmented-control.indicator-background', + ); + + /// Solid panel background selected for the active brightness. + static const colorPanelSolid = ColorToken('fortal.color.panel.solid'); + + /// Translucent panel background with alpha transparency. + static const colorPanelTranslucent = ColorToken( + 'fortal.color.panel.translucent', + ); + + /// Panel background selected by [FortalPanelBackground]. + static const colorPanel = ColorToken('fortal.color.panel'); + + /// Backdrop blur applied to translucent floating panels. + static const panelBlur = DoubleToken('fortal.panel.blur'); + + /// Dark overlay for modals and dialogs. + static const colorOverlay = ColorToken('fortal.color.overlay'); + + // ============================================================================ + // FUNCTIONAL ACCENT COLORS + // ============================================================================ + + /// Subtle accent surface for soft button variants and chips. + static const accentSurface = ColorToken('fortal.accent.surface'); + + /// Active indicator color for progress bars and sliders. + static const accentIndicator = ColorToken('fortal.accent.indicator'); + + /// Track/rail background color for sliders and progress bars. + static const accentTrack = ColorToken('fortal.accent.track'); + + /// Mode-aware overlay used by high-contrast slider ranges. + static const sliderHighContrastOverlay = ColorToken( + 'fortal.slider.high-contrast-overlay', + ); + + /// High contrast foreground for solid accent backgrounds. + static const accentContrast = ColorToken('fortal.accent.contrast'); + + // ============================================================================ + // FOCUS AND INTERACTION STATES + // ============================================================================ + + /// Solid focus ring color (accent step 8). + static const focus8 = ColorToken('fortal.focus.8'); + + /// Translucent text-selection color (accent alpha step 5). + static const focusA5 = ColorToken('fortal.focus.a5'); + + /// Translucent focus ring color with alpha transparency. + static const focusA8 = ColorToken('fortal.focus.a8'); + + /// Mode-aware red roles used by documented validation extensions. + static const error3 = ColorToken('fortal.error.3'); + static const error7 = ColorToken('fortal.error.7'); + static const error8 = ColorToken('fortal.error.8'); + static const error9 = ColorToken('fortal.error.9'); + static const error11 = ColorToken('fortal.error.11'); + static const error12 = ColorToken('fortal.error.12'); + static const errorA7 = ColorToken('fortal.error.a7'); + + // ============================================================================ + // ACCENT COLOR SCALE (12 STEPS) + // ============================================================================ + // + // Fortal uses a 12-step color scale (inherited from Radix Themes) that provides semantic meaning: + // + // Steps 1-2: App backgrounds (subtle → more visible) + // Steps 3-5: Component backgrounds (rest → hover → active) + // Steps 6-8: Borders (subtle → component → hover) + // Steps 9-10: Solid backgrounds (default → hover) + // Steps 11-12: Text (low contrast → high contrast) + // + + /// Accent step 1 - App background, most subtle. + static const accent1 = ColorToken('fortal.accent.1'); + + /// Accent step 2 - Subtle background. + static const accent2 = ColorToken('fortal.accent.2'); + + /// Accent step 3 - Component background at rest. + static const accent3 = ColorToken('fortal.accent.3'); + + /// Accent step 4 - Component background on hover. + static const accent4 = ColorToken('fortal.accent.4'); + + /// Accent step 5 - Component background when active/pressed. + static const accent5 = ColorToken('fortal.accent.5'); + + /// Accent step 6 - Subtle borders and separators. + static const accent6 = ColorToken('fortal.accent.6'); + + /// Accent step 7 - Component borders at rest. + static const accent7 = ColorToken('fortal.accent.7'); + + /// Accent step 8 - Component borders on hover and focus. + static const accent8 = ColorToken('fortal.accent.8'); + + /// Accent step 9 - Primary solid background. + static const accent9 = ColorToken('fortal.accent.9'); + + /// Accent step 10 - Solid background on hover. + static const accent10 = ColorToken('fortal.accent.10'); + + /// Accent step 11 - Low contrast text. + static const accent11 = ColorToken('fortal.accent.11'); + + /// Accent step 12 - High contrast text. + static const accent12 = ColorToken('fortal.accent.12'); + + // ============================================================================ + // GRAY COLOR SCALE (12 STEPS) + // ============================================================================ + // + // The gray scale follows the same 12-step semantic structure as accent colors, + // but provides neutral colors for text, borders, and backgrounds. + // The specific gray variant (slate, mauve, sage, etc.) is chosen in the theme. + // + + /// Gray step 1 - Page background. + static const gray1 = ColorToken('fortal.gray.1'); + + /// Gray step 2 - Panel and card backgrounds. + static const gray2 = ColorToken('fortal.gray.2'); + + /// Gray step 3 - Input backgrounds and pressed states. + static const gray3 = ColorToken('fortal.gray.3'); + + /// Gray step 4 - Input backgrounds on hover. + static const gray4 = ColorToken('fortal.gray.4'); + + /// Gray step 5 - Active states and disabled backgrounds. + static const gray5 = ColorToken('fortal.gray.5'); + + /// Gray step 6 - Subtle borders and dividers. + static const gray6 = ColorToken('fortal.gray.6'); + + /// Gray step 7 - Standard borders and outlines. + /// + /// Primary border color for form inputs, cards, + /// and component boundaries. + static const gray7 = ColorToken('fortal.gray.7'); + + /// Gray step 8 - Borders on hover and focus. + /// + /// Interactive border states and stronger separators + /// that need more visual weight. + static const gray8 = ColorToken('fortal.gray.8'); + + /// Gray step 9 - Solid neutral backgrounds. + /// + /// For neutral buttons, badges, and other elements + /// that need a solid background without accent color. + static const gray9 = ColorToken('fortal.gray.9'); + + /// Gray step 10 - Solid neutral backgrounds on hover. + /// + /// Hover state for neutral solid backgrounds, + /// providing interactive feedback. + static const gray10 = ColorToken('fortal.gray.10'); + + /// Gray step 11 - Low contrast text and secondary content. + /// + /// For secondary text, placeholders, and content that should + /// be readable but not prominent. + static const gray11 = ColorToken('fortal.gray.11'); + + /// Gray step 12 - High contrast text and primary content. + /// + /// Primary text color for body content, headings, and any text + /// that needs maximum readability and prominence. + static const gray12 = ColorToken('fortal.gray.12'); + + // ============================================================================ + // GRAY ROLE TOKENS (parity with generated JSON roles) + // ============================================================================ + /// Neutral surface baseline for the selected gray scale (matches JSON surface) + static const graySurface = ColorToken('fortal.gray.surface'); + + /// Neutral indicator color (typically gray step 9) + static const grayIndicator = ColorToken('fortal.gray.indicator'); + + /// Neutral track color (typically gray step 9) + static const grayTrack = ColorToken('fortal.gray.track'); + + /// Contrast color for content over neutral solid backgrounds (white) + static const grayContrast = ColorToken('fortal.gray.contrast'); + + // ============================================================================ + // ALPHA VARIANTS (FULL 12-STEP FOR ACCENT AND GRAY) + // ============================================================================ + + // Accent alpha steps a1..a12 + static const accentA1 = ColorToken('fortal.accent.a1'); + static const accentA2 = ColorToken('fortal.accent.a2'); + static const accentA3 = ColorToken('fortal.accent.a3'); + static const accentA4 = ColorToken('fortal.accent.a4'); + static const accentA5 = ColorToken('fortal.accent.a5'); + static const accentA6 = ColorToken('fortal.accent.a6'); + static const accentA7 = ColorToken('fortal.accent.a7'); + static const accentA8 = ColorToken('fortal.accent.a8'); + static const accentA9 = ColorToken('fortal.accent.a9'); + static const accentA10 = ColorToken('fortal.accent.a10'); + static const accentA11 = ColorToken('fortal.accent.a11'); + static const accentA12 = ColorToken('fortal.accent.a12'); + + // Gray alpha steps a1..a12 + static const grayA1 = ColorToken('fortal.gray.a1'); + static const grayA2 = ColorToken('fortal.gray.a2'); + static const grayA3 = ColorToken('fortal.gray.a3'); + static const grayA4 = ColorToken('fortal.gray.a4'); + static const grayA5 = ColorToken('fortal.gray.a5'); + static const grayA6 = ColorToken('fortal.gray.a6'); + static const grayA7 = ColorToken('fortal.gray.a7'); + static const grayA8 = ColorToken('fortal.gray.a8'); + static const grayA9 = ColorToken('fortal.gray.a9'); + static const grayA10 = ColorToken('fortal.gray.a10'); + static const grayA11 = ColorToken('fortal.gray.a11'); + static const grayA12 = ColorToken('fortal.gray.a12'); + + // ============================================================================ + // NEUTRALS FOR SHADOWS (HELPER TOKENS) + // ============================================================================ + /// Gray alpha steps are declared above (grayA1..grayA12). + + /// Black alpha steps used in layered shadows. + static const blackA1 = ColorToken('fortal.black.a1'); + static const blackA2 = ColorToken('fortal.black.a2'); + static const blackA3 = ColorToken('fortal.black.a3'); + static const blackA4 = ColorToken('fortal.black.a4'); + static const blackA5 = ColorToken('fortal.black.a5'); + static const blackA6 = ColorToken('fortal.black.a6'); + static const blackA7 = ColorToken('fortal.black.a7'); + static const blackA8 = ColorToken('fortal.black.a8'); + static const blackA9 = ColorToken('fortal.black.a9'); + static const blackA10 = ColorToken('fortal.black.a10'); + static const blackA11 = ColorToken('fortal.black.a11'); + static const blackA12 = ColorToken('fortal.black.a12'); + + /// White alpha steps used by layered classic-control recipes. + static const whiteA1 = ColorToken('fortal.white.a1'); + static const whiteA2 = ColorToken('fortal.white.a2'); + static const whiteA3 = ColorToken('fortal.white.a3'); + static const whiteA4 = ColorToken('fortal.white.a4'); + static const whiteA5 = ColorToken('fortal.white.a5'); + static const whiteA6 = ColorToken('fortal.white.a6'); + static const whiteA7 = ColorToken('fortal.white.a7'); + static const whiteA8 = ColorToken('fortal.white.a8'); + static const whiteA9 = ColorToken('fortal.white.a9'); + static const whiteA10 = ColorToken('fortal.white.a10'); + static const whiteA11 = ColorToken('fortal.white.a11'); + static const whiteA12 = ColorToken('fortal.white.a12'); + + /// Mode-aware mixed shadow stroke. + static const shadowStroke = ColorToken('fortal.shadow.stroke'); + + /// Premultiplied OKLab mixes used by Radix neutral one-pixel strokes. + static const grayStroke3 = ColorToken('fortal.gray.stroke.3'); + static const grayStroke4 = ColorToken('fortal.gray.stroke.4'); + static const grayStroke5 = ColorToken('fortal.gray.stroke.5'); + static const grayStroke6 = ColorToken('fortal.gray.stroke.6'); + static const grayStroke7 = ColorToken('fortal.gray.stroke.7'); + + // ============================================================================ + // SPACING SCALE (9 STEPS) + // ============================================================================ + // + // A consistent spacing scale based on 4px increments. + // + + /// Space step 1 - 4px. + /// + /// Smallest spacing for tight layouts, borders, + /// and fine-grained adjustments. + static const space1 = SpaceToken('fortal.space.1'); + + /// Space step 2 - 8px. + /// + /// Small spacing for component padding and margins. + /// Good for button padding and form element spacing. + static const space2 = SpaceToken('fortal.space.2'); + + /// Space step 3 - 12px. + /// + /// Medium-small spacing for comfortable padding + /// and moderate element separation. + static const space3 = SpaceToken('fortal.space.3'); + + /// Space step 4 - 16px. + /// + /// Standard spacing for most layouts. Good default + /// for card padding and section margins. + static const space4 = SpaceToken('fortal.space.4'); + + /// Space step 5 - 24px. + /// + /// Medium spacing for generous padding and + /// comfortable separation between sections. + static const space5 = SpaceToken('fortal.space.5'); + + /// Space step 6 - 32px. + /// + /// Large spacing for significant visual separation + /// and generous component padding. + static const space6 = SpaceToken('fortal.space.6'); + + /// Space step 7 - 40px. + /// + /// Extra large spacing for major layout sections + /// and prominent visual separation. + static const space7 = SpaceToken('fortal.space.7'); + + /// Space step 8 - 48px. + /// + /// Very large spacing for significant page sections + /// and major layout boundaries. + static const space8 = SpaceToken('fortal.space.8'); + + /// Space step 9 - 64px. + /// + /// Maximum spacing for major page sections + /// and substantial layout separation. + static const space9 = SpaceToken('fortal.space.9'); + + /// Spinner size 3 - 20px at 100% scaling. + /// + /// Radix defines this as 1.25 times space 4, so it needs its own resolved + /// token rather than arithmetic on an unresolved token reference. + static const spinnerSize3 = DoubleToken('fortal.spinner.size.3'); + + /// Compact gap shared by size-1 toggle extensions (2px at 100% scaling). + static const toggleGap1 = DoubleToken('fortal.toggle.gap.1'); + + /// Comfortable gap shared by size-3 toggle extensions (6px at 100%). + static const toggleGap3 = DoubleToken('fortal.toggle.gap.3'); + + /// Avatar sizes expressed as scaled pixels rather than spacing steps. + static const avatarSize6 = DoubleToken('fortal.avatar.size.6'); + static const avatarSize7 = DoubleToken('fortal.avatar.size.7'); + static const avatarSize8 = DoubleToken('fortal.avatar.size.8'); + static const avatarSize9 = DoubleToken('fortal.avatar.size.9'); + + /// Avatar icon sizes are half of each resolved avatar dimension. + /// + /// These values need dedicated tokens because arithmetic on an unresolved + /// token reference would destroy its identity before Mix can resolve it. + static const avatarIconSize1 = DoubleToken('fortal.avatar.icon-size.1'); + static const avatarIconSize2 = DoubleToken('fortal.avatar.icon-size.2'); + static const avatarIconSize3 = DoubleToken('fortal.avatar.icon-size.3'); + static const avatarIconSize4 = DoubleToken('fortal.avatar.icon-size.4'); + static const avatarIconSize5 = DoubleToken('fortal.avatar.icon-size.5'); + static const avatarIconSize6 = DoubleToken('fortal.avatar.icon-size.6'); + static const avatarIconSize7 = DoubleToken('fortal.avatar.icon-size.7'); + static const avatarIconSize8 = DoubleToken('fortal.avatar.icon-size.8'); + static const avatarIconSize9 = DoubleToken('fortal.avatar.icon-size.9'); + + /// Badge measurements that are fractional spacing expressions upstream. + static const badgePaddingX1 = DoubleToken('fortal.badge.padding-x.1'); + static const badgePaddingY1 = DoubleToken('fortal.badge.padding-y.1'); + static const badgePaddingX3 = DoubleToken('fortal.badge.padding-x.3'); + + /// Checkbox dimensions expressed as scaled pixels by Radix Themes. + static const checkboxSize1 = DoubleToken('fortal.checkbox.size.1'); + static const checkboxSize3 = DoubleToken('fortal.checkbox.size.3'); + static const checkboxIndicatorSize1 = DoubleToken( + 'fortal.checkbox.indicator-size.1', + ); + static const checkboxIndicatorSize2 = DoubleToken( + 'fortal.checkbox.indicator-size.2', + ); + static const checkboxIndicatorSize3 = DoubleToken( + 'fortal.checkbox.indicator-size.3', + ); + + /// Checkbox-group label gaps derived from Radix's size-linked `0.5em`. + static const checkboxGroupItemGap1 = DoubleToken( + 'fortal.checkbox-group.item-gap.1', + ); + static const checkboxGroupItemGap2 = DoubleToken( + 'fortal.checkbox-group.item-gap.2', + ); + static const checkboxGroupItemGap3 = DoubleToken( + 'fortal.checkbox-group.item-gap.3', + ); + + /// Radio indicators are 40% of their control size in Radix Themes. + /// + /// These values need dedicated tokens because arithmetic on an unresolved + /// token reference would destroy its identity before Mix can resolve it. + static const radioIndicatorSize1 = DoubleToken( + 'fortal.radio.indicator-size.1', + ); + static const radioIndicatorSize2 = DoubleToken( + 'fortal.radio.indicator-size.2', + ); + static const radioIndicatorSize3 = DoubleToken( + 'fortal.radio.indicator-size.3', + ); + + /// Checkbox radii derived from fractional radius-step expressions. + static const checkboxRadius1 = RadiusToken('fortal.checkbox.radius.1'); + static const checkboxRadius3 = RadiusToken('fortal.checkbox.radius.3'); + + /// Switch geometry that cannot be derived from unresolved token references. + static const switchHeight2 = DoubleToken('fortal.switch.height.2'); + static const switchWidth1 = DoubleToken('fortal.switch.width.1'); + static const switchWidth2 = DoubleToken('fortal.switch.width.2'); + static const switchWidth3 = DoubleToken('fortal.switch.width.3'); + static const switchThumbSize1 = DoubleToken('fortal.switch.thumb-size.1'); + static const switchThumbSize2 = DoubleToken('fortal.switch.thumb-size.2'); + static const switchThumbSize3 = DoubleToken('fortal.switch.thumb-size.3'); + + /// Progress geometry derived from scaled fractional upstream expressions. + static const progressHeight2 = DoubleToken('fortal.progress.height.2'); + static const progressRadius1 = RadiusToken('fortal.progress.radius.1'); + static const progressRadius2 = RadiusToken('fortal.progress.radius.2'); + static const progressRadius3 = RadiusToken('fortal.progress.radius.3'); + + /// Slider geometry expressed as scaled Radix component dimensions. + static const sliderTrackSize1 = DoubleToken('fortal.slider.track-size.1'); + static const sliderTrackSize2 = DoubleToken('fortal.slider.track-size.2'); + static const sliderTrackSize3 = DoubleToken('fortal.slider.track-size.3'); + static const sliderThumbSize1 = DoubleToken('fortal.slider.thumb-size.1'); + static const sliderThumbSize2 = DoubleToken('fortal.slider.thumb-size.2'); + static const sliderThumbSize3 = DoubleToken('fortal.slider.thumb-size.3'); + static const sliderTrackRadius1 = RadiusToken('fortal.slider.track-radius.1'); + static const sliderTrackRadius2 = RadiusToken('fortal.slider.track-radius.2'); + static const sliderTrackRadius3 = RadiusToken('fortal.slider.track-radius.3'); + + /// TextField content insets after its fixed one-pixel border. + static const textFieldPadding1 = DoubleToken('fortal.text-field.padding.1'); + static const textFieldPadding2 = DoubleToken('fortal.text-field.padding.2'); + static const textFieldPadding3 = DoubleToken('fortal.text-field.padding.3'); + + /// TextArea metrics that cannot be expressed by existing spacing tokens. + static const textAreaMinHeight3 = DoubleToken( + 'fortal.text-area.min-height.3', + ); + + /// DataList metrics that cannot be expressed by existing spacing tokens. + static const dataListRowGap3 = DoubleToken('fortal.data-list.row-gap.3'); + static const dataListLabelMinWidth = DoubleToken( + 'fortal.data-list.label-min-width', + ); + + /// Exact uppercase fallback typography for each Avatar size. + static const avatarFallback1One = TextStyleToken( + 'fortal.avatar.fallback.1.one', + ); + static const avatarFallback1Two = TextStyleToken( + 'fortal.avatar.fallback.1.two', + ); + static const avatarFallback2One = TextStyleToken( + 'fortal.avatar.fallback.2.one', + ); + static const avatarFallback2Two = TextStyleToken( + 'fortal.avatar.fallback.2.two', + ); + static const avatarFallback3One = TextStyleToken( + 'fortal.avatar.fallback.3.one', + ); + static const avatarFallback3Two = TextStyleToken( + 'fortal.avatar.fallback.3.two', + ); + static const avatarFallback4One = TextStyleToken( + 'fortal.avatar.fallback.4.one', + ); + static const avatarFallback4Two = TextStyleToken( + 'fortal.avatar.fallback.4.two', + ); + static const avatarFallback5 = TextStyleToken('fortal.avatar.fallback.5'); + static const avatarFallback6 = TextStyleToken('fortal.avatar.fallback.6'); + static const avatarFallback7 = TextStyleToken('fortal.avatar.fallback.7'); + static const avatarFallback8 = TextStyleToken('fortal.avatar.fallback.8'); + static const avatarFallback9 = TextStyleToken('fortal.avatar.fallback.9'); + + /// Tabs size 1 inner vertical padding - 2px at 100% scaling. + static const tabInnerPaddingY1 = DoubleToken('fortal.tabs.inner-padding-y.1'); + + /// Tabs size 1 active tracking - -0.12px at 100% scaling. + static const tabActiveLetterSpacing1 = DoubleToken( + 'fortal.tabs.active-letter-spacing.1', + ); + + /// Tabs size 2 active tracking - -0.14px at 100% scaling. + static const tabActiveLetterSpacing2 = DoubleToken( + 'fortal.tabs.active-letter-spacing.2', + ); + + /// Table size-1 minimum cell height (36px at 100% scaling). + /// + /// Radix writes `calc(36px * var(--scaling))` literally, so no existing + /// spacing step expresses it. + static const dataTableRowHeight1 = DoubleToken('fortal.data-table.height.1'); + + /// Table size-2 minimum cell height (44px at 100% scaling). + static const dataTableRowHeight2 = DoubleToken('fortal.data-table.height.2'); + + /// Table surface border - `color-mix(in oklab, gray-a5, gray-6)`. + /// + /// The existing `grayStroke*` tokens blend an alpha step with the *same* + /// numbered solid step at 25%; Table blends step 5 with step 6 at 50%. + static const dataTableBorder = ColorToken('fortal.data-table.border'); + + /// Select's 1.5 × space-1 measurement (6px at 100% scaling). + static const selectSpace1Half = DoubleToken('fortal.select.space.1-half'); + + /// Select size-1 indicator column width (20px at 100% scaling). + static const selectIndicatorWidth1 = DoubleToken( + 'fortal.select.indicator-width.1', + ); + + /// Select size-1 check size (8px at 100% scaling). + static const selectIndicatorSize1 = DoubleToken( + 'fortal.select.indicator-size.1', + ); + + /// Select size-2/3 check size (10px at 100% scaling). + static const selectIndicatorSize2 = DoubleToken( + 'fortal.select.indicator-size.2', + ); + + /// Negative margins that cancel Select ghost-trigger padding. + static const selectGhostMarginX12 = DoubleToken( + 'fortal.select.ghost-margin-x.1-2', + ); + static const selectGhostMarginY12 = DoubleToken( + 'fortal.select.ghost-margin-y.1-2', + ); + static const selectGhostMarginX3 = DoubleToken( + 'fortal.select.ghost-margin-x.3', + ); + static const selectGhostMarginY3 = DoubleToken( + 'fortal.select.ghost-margin-y.3', + ); + + // ============================================================================ + // BORDER RADIUS SCALE (6 STEPS + FULL) + // ============================================================================ + + /// Radius step 1 - 3px. + /// + /// Subtle rounding for small elements like buttons + /// and form inputs. Provides gentle softening of corners. + static const radius1 = RadiusToken('fortal.radius.1'); + + /// Radius step 2 - 4px. + /// + /// Small radius for compact components and minor rounding. + /// Good for small badges and tight layouts. + static const radius2 = RadiusToken('fortal.radius.2'); + + /// Radius step 3 - 6px. + /// + /// Medium radius for standard components like buttons + /// and cards. Balances modern look with usability. + static const radius3 = RadiusToken('fortal.radius.3'); + + /// Radius step 4 - 8px. + /// + /// Large radius for prominent components and generous rounding. + /// Good for larger buttons and feature cards. + static const radius4 = RadiusToken('fortal.radius.4'); + + /// Radius step 5 - 12px. + /// + /// Extra large radius for major components and modern aesthetics. + /// Suitable for large cards and prominent interface elements. + static const radius5 = RadiusToken('fortal.radius.5'); + + /// Radius step 6 - 16px. + /// + /// Very large radius for distinctive styling and major components. + /// Creates a soft, friendly appearance for large interface elements. + static const radius6 = RadiusToken('fortal.radius.6'); + + /// Theme-level full radius, enabled only by [FortalRadius.full]. + static const radiusFull = RadiusToken('fortal.radius.full'); + + /// Radius used by control thumbs. + static const radiusThumb = RadiusToken('fortal.radius.thumb'); + + /// Fixed circle radius for shapes that stay circular across theme presets. + static const radiusCircle = RadiusToken('fortal.radius.circle'); + + /// Radius step 1 promoted to a pill when the theme radius is full. + static const radius1OrFull = RadiusToken('fortal.radius.1-or-full'); + + /// Radius step 2 promoted to a pill when the theme radius is full. + static const radius2OrFull = RadiusToken('fortal.radius.2-or-full'); + + /// Radius step 3 promoted to a pill when the theme radius is full. + static const radius3OrFull = RadiusToken('fortal.radius.3-or-full'); + + /// Radius step 4 promoted to a pill when the theme radius is full. + static const radius4OrFull = RadiusToken('fortal.radius.4-or-full'); + + /// Radius step 5 promoted to a pill when the theme radius is full. + static const radius5OrFull = RadiusToken('fortal.radius.5-or-full'); + + /// Radius step 6 promoted to a pill when the theme radius is full. + static const radius6OrFull = RadiusToken('fortal.radius.6-or-full'); + + /// Radius step 1 promoted to the control-thumb radius when larger. + static const radius1OrThumb = RadiusToken('fortal.radius.1-or-thumb'); + + /// Radius step 2 promoted to the control-thumb radius when larger. + static const radius2OrThumb = RadiusToken('fortal.radius.2-or-thumb'); + + // ============================================================================ + // ELEVATION SHADOWS (6 LEVELS) + // ============================================================================ + + /// Shadow level 1 - Subtle elevation. + /// + /// Minimal shadow for slight elevation effects. + /// Good for cards and buttons in their resting state. + static const shadow1 = BoxShadowToken('fortal.shadow.1'); + + /// Exact layered shadow level 1, including inset layers. + /// + /// This additive token powers Fortal's Radix-compatible rendering while + /// [shadow1] retains the original Remix public token type. + static const shadow1Layers = RemixBoxShadowListToken( + 'fortal.shadow.1.layers', + ); + + /// Half-opacity shadow-1 layers used by a disabled classic slider track. + static const sliderClassicDisabledTrackShadows = RemixBoxShadowListToken( + 'fortal.slider.classic.disabled-track-shadows', + ); + + /// Shadow level 2 - Low elevation. + /// + /// Light shadow for gentle elevation and hover states. + /// Suitable for interactive elements and small modals. + static const shadow2 = BoxShadowToken('fortal.shadow.2'); + + /// Shadow-2 painted on SegmentedControl's fixed one-pixel inset shape. + static const segmentedControlClassicIndicatorShadows = + RemixBoxShadowListToken( + 'fortal.segmented-control.classic.indicator-shadows', + ); + + /// Shadow level 3 - Medium elevation. + /// + /// Moderate shadow for clear visual separation. + /// Good for dropdowns, tooltips, and floating elements. + static const shadow3 = BoxShadowToken('fortal.shadow.3'); + + /// Shadow level 4 - High elevation. + /// + /// Prominent shadow for important floating content. + /// Suitable for modal dialogs and important overlays. + static const shadow4 = BoxShadowToken('fortal.shadow.4'); + + /// Shadow level 5 - Very high elevation. + /// + /// Strong shadow for primary modals and major overlays. + /// Creates clear hierarchy and focus on important content. + static const shadow5 = BoxShadowToken('fortal.shadow.5'); + + /// Shadow level 6 - Maximum elevation. + /// + /// Maximum shadow depth for critical dialogs and notifications. + /// Ensures content appears above all other interface elements. + static const shadow6 = BoxShadowToken('fortal.shadow.6'); + + /// Card classic outer and inset-pseudo-element shadow lists. + static const cardClassicOuterShadows = RemixBoxShadowListToken( + 'fortal.card.classic.outer-shadows', + ); + static const cardClassicInnerShadows = RemixBoxShadowListToken( + 'fortal.card.classic.inner-shadows', + ); + static const cardClassicHoverOuterShadows = RemixBoxShadowListToken( + 'fortal.card.classic.hover.outer-shadows', + ); + static const cardClassicHoverInnerShadows = RemixBoxShadowListToken( + 'fortal.card.classic.hover.inner-shadows', + ); + static const cardClassicActiveOuterShadows = RemixBoxShadowListToken( + 'fortal.card.classic.active.outer-shadows', + ); + static const cardClassicActiveInnerShadows = RemixBoxShadowListToken( + 'fortal.card.classic.active.inner-shadows', + ); + + /// Mode-aware inset layers for a classic Select trigger. + static const selectTriggerClassicShadows = RemixBoxShadowListToken( + 'fortal.select.trigger.classic.shadows', + ); + + /// Mode-aware open/hover layers for a classic Select trigger. + static const selectTriggerClassicHoverShadows = RemixBoxShadowListToken( + 'fortal.select.trigger.classic.hover-shadows', + ); + + /// Mode-aware disabled layers shared by classic button-shaped controls. + static const baseButtonClassicDisabledShadows = RemixBoxShadowListToken( + 'fortal.base-button.classic.disabled.shadows', + ); + + /// Mode-aware classic Button/IconButton layers. + static const baseButtonClassicShadows = RemixBoxShadowListToken( + 'fortal.base-button.classic.shadows', + ); + static const baseButtonClassicHighContrastShadows = RemixBoxShadowListToken( + 'fortal.base-button.classic.high-contrast.shadows', + ); + static const baseButtonClassicActiveShadows = RemixBoxShadowListToken( + 'fortal.base-button.classic.active.shadows', + ); + static const baseButtonClassicActiveHighContrastShadows = + RemixBoxShadowListToken( + 'fortal.base-button.classic.active.high-contrast.shadows', + ); + static const baseButtonClassicAfterInset = DoubleToken( + 'fortal.base-button.classic.after-inset', + ); + static const baseButtonGhostPaddingY3 = DoubleToken( + 'fortal.base-button.ghost.padding-y.3', + ); + static const baseButtonGhostMarginX12 = DoubleToken( + 'fortal.base-button.ghost.margin-x.1-2', + ); + static const baseButtonGhostMarginY12 = DoubleToken( + 'fortal.base-button.ghost.margin-y.1-2', + ); + static const baseButtonGhostMarginX3 = DoubleToken( + 'fortal.base-button.ghost.margin-x.3', + ); + static const baseButtonGhostMarginY3 = DoubleToken( + 'fortal.base-button.ghost.margin-y.3', + ); + static const baseButtonGhostMarginX4 = DoubleToken( + 'fortal.base-button.ghost.margin-x.4', + ); + static const baseButtonGhostMarginY4 = DoubleToken( + 'fortal.base-button.ghost.margin-y.4', + ); + static const iconButtonGhostPadding2 = DoubleToken( + 'fortal.icon-button.ghost.padding.2', + ); + static const iconButtonGhostMargin1 = DoubleToken( + 'fortal.icon-button.ghost.margin.1', + ); + static const iconButtonGhostMargin2 = DoubleToken( + 'fortal.icon-button.ghost.margin.2', + ); + static const iconButtonGhostMargin3 = DoubleToken( + 'fortal.icon-button.ghost.margin.3', + ); + static const iconButtonGhostMargin4 = DoubleToken( + 'fortal.icon-button.ghost.margin.4', + ); + static const cardGhostMargin1 = DoubleToken('fortal.card.ghost.margin.1'); + static const cardGhostMargin2 = DoubleToken('fortal.card.ghost.margin.2'); + static const cardGhostMargin3 = DoubleToken('fortal.card.ghost.margin.3'); + static const cardGhostMargin4 = DoubleToken('fortal.card.ghost.margin.4'); + static const cardGhostMargin5 = DoubleToken('fortal.card.ghost.margin.5'); + + // ============================================================================ + // BORDER AND STROKE WIDTHS + // ============================================================================ + + /// Standard border width (1px). + /// + /// Default border thickness for most components like inputs, + /// cards, and dividers. Provides clear boundaries without visual weight. + static const borderWidth1 = SpaceToken('fortal.border.width.1'); + + /// Thick border width (2px). + /// + /// Heavier border for emphasis, selected states, and components + /// that need stronger visual definition. + static const borderWidth2 = SpaceToken('fortal.border.width.2'); + + /// Focus ring border width (2px). + /// + /// Standard width for focus outlines to ensure accessibility + /// compliance and clear keyboard navigation feedback. + static const focusRingWidth = SpaceToken('fortal.focus.ring.width'); + + /// Focus ring offset distance from element edge. + /// + /// Space between the component border and focus ring, + /// ensuring the focus indicator doesn't interfere with the element. + static const focusRingOffset = SpaceToken('fortal.focus.ring.offset'); + + // ============================================================================ + // TYPOGRAPHY SCALE (9 LEVELS) + // ============================================================================ + // + // Text sizes with carefully tuned line heights and letter spacing + // for optimal readability across all scales. + // + + /// Text size 1 - 12px (Small labels and metadata). + /// + /// Smallest readable text for labels, captions, and secondary metadata. + /// Includes tight letter spacing for improved legibility at small sizes. + static const text1 = TextStyleToken('fortal.text.1'); + + /// Text size 2 - 14px (Standard UI text). + /// + /// Default size for most interface text including buttons, + /// form labels, and secondary content. + static const text2 = TextStyleToken('fortal.text.2'); + + /// Text size 3 - 16px (Body text and primary content). + /// + /// Ideal for body text and primary content. Provides excellent + /// readability for extended reading on all device types. + static const text3 = TextStyleToken('fortal.text.3'); + + /// Accordion size-2 text (15px with a 20px line box at 100% scaling). + /// + /// Accordion is a Fortal extension, so this intermediate size is kept + /// separate from the upstream Radix typography scale. + static const accordionText2 = TextStyleToken('fortal.accordion.text.2'); + + /// Text size 4 - 18px (Prominent body text). + /// + /// For important content that needs more visual weight than + /// standard body text but isn't quite a heading. + static const text4 = TextStyleToken('fortal.text.4'); + + /// Text size 5 - 20px (Small headings). + /// + /// For minor headings, subheadings, and content that needs + /// to stand out from body text. + static const text5 = TextStyleToken('fortal.text.5'); + + /// Text size 6 - 24px (Medium headings). + /// + /// Standard heading size for section titles and important content. + /// Good balance between prominence and page economy. + static const text6 = TextStyleToken('fortal.text.6'); + + /// Text size 7 - 28px (Large headings). + /// + /// For major page headings and important announcements. + /// Creates strong visual hierarchy and draws attention. + static const text7 = TextStyleToken('fortal.text.7'); + + /// Text size 8 - 35px (Extra large headings). + /// + /// For hero text, page titles, and major content sections. + /// Strong negative letter spacing improves appearance at large sizes. + static const text8 = TextStyleToken('fortal.text.8'); + + /// Text size 9 - 60px (Display text). + /// + /// Maximum text size for hero sections and display typography. + /// Includes significant negative letter spacing and tight line height. + static const text9 = TextStyleToken('fortal.text.9'); + + // ============================================================================ + // FONT WEIGHT TOKENS + // ============================================================================ + + /// Light font weight (300). + /// + /// Optional lighter weight occasionally used in display typography or + /// subdued text. Provided for parity with Radix token set. + static const fontWeightLight = FontWeightToken('fortal.font.weight.light'); + + /// Regular font weight (400). + /// + /// Standard weight for body text and most interface elements. + /// Provides good readability without visual strain. + static const fontWeightRegular = FontWeightToken( + 'fortal.font.weight.regular', + ); + + /// Medium font weight (500). + /// + /// Slightly heavier than regular for UI elements that need + /// more visual weight, like active states and button text. + static const fontWeightMedium = FontWeightToken('fortal.font.weight.medium'); + + /// Bold font weight (700). + /// + /// For headings and content that needs strong emphasis. + /// Provides clear hierarchy without being too heavy. + static const fontWeightBold = FontWeightToken('fortal.font.weight.bold'); + + // ============================================================================ + // ANIMATION DURATIONS + // ============================================================================ + + /// Fast animation duration (100ms). + /// + /// For quick micro-interactions like hover states and button presses. + /// Provides immediate feedback without feeling sluggish. + static const transitionFast = DurationToken('fortal.transition.fast'); + + /// Slow animation duration (300ms). + /// + /// For more substantial transitions like modal appearances, + /// page transitions, and complex state changes. + static const transitionSlow = DurationToken('fortal.transition.slow'); + + /// One leg of the Radix Skeleton pulse. + static const skeletonPulseDuration = DurationToken( + 'fortal.skeleton.pulse-duration', + ); +} diff --git a/registry_source/pubspec.yaml b/registry_source/pubspec.yaml new file mode 100644 index 000000000..c2deef637 --- /dev/null +++ b/registry_source/pubspec.yaml @@ -0,0 +1,39 @@ +name: registry_source +description: > + The authored source of the bundled Remix registry: the default and Fortal + presets and the Agent behavior they style. Never published; it exists to be + analyzed, tested, and derived into remix_cli's templates. +publish_to: none +resolution: workspace + +# No `version:` on purpose. Nothing depends on this package and nothing ever +# will: applications consume the installed source the templates derive from. +# +# `lib/src/default` is authored under the word `Vanilla` and `lib/src/fortal` +# under `Fortal`; the builder swaps each for the consumer's prefix and asserts +# the round trip on every file. `lib/src/agent` is authored under `Agent`, and +# the recipes in each preset import it by relative path. + +# The workspace floor rather than the consumer floor: the default preset's +# `sidebar_layout` uses private named parameters, a Dart 3.12 feature, and +# installs that requirement into every consumer already. +environment: + sdk: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" + +dependencies: + flutter: + sdk: flutter + # The workspace resolves this to the sibling source package. The derived + # registry owns the hosted Remix floor installed into consumer applications. + remix: ^1.0.0-beta.10 + mix: ^2.2.0-beta.5 + mix_annotations: ^2.2.0-beta.1 + mix_chart: ^0.0.1-beta.1 + remix_ui_icons: ^0.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.10.1 + mix_generator: ^2.2.0-beta.3 diff --git a/packages/remix_fortal/radix_colors.generated.json b/registry_source/radix_colors.generated.json similarity index 100% rename from packages/remix_fortal/radix_colors.generated.json rename to registry_source/radix_colors.generated.json diff --git a/packages/remix_fortal/reference/radix_themes_3_3_0/README.md b/registry_source/reference/radix_themes_3_3_0/README.md similarity index 100% rename from packages/remix_fortal/reference/radix_themes_3_3_0/README.md rename to registry_source/reference/radix_themes_3_3_0/README.md diff --git a/packages/remix_fortal/reference/radix_themes_3_3_0/chromium/README.md b/registry_source/reference/radix_themes_3_3_0/chromium/README.md similarity index 100% rename from packages/remix_fortal/reference/radix_themes_3_3_0/chromium/README.md rename to registry_source/reference/radix_themes_3_3_0/chromium/README.md diff --git a/packages/remix_fortal/reference/radix_themes_3_3_0/chromium/computed-styles.json b/registry_source/reference/radix_themes_3_3_0/chromium/computed-styles.json similarity index 100% rename from packages/remix_fortal/reference/radix_themes_3_3_0/chromium/computed-styles.json rename to registry_source/reference/radix_themes_3_3_0/chromium/computed-styles.json diff --git a/packages/remix_fortal/reference/radix_themes_3_3_0/chromium/families-light.png b/registry_source/reference/radix_themes_3_3_0/chromium/families-light.png similarity index 100% rename from packages/remix_fortal/reference/radix_themes_3_3_0/chromium/families-light.png rename to registry_source/reference/radix_themes_3_3_0/chromium/families-light.png diff --git a/packages/remix_fortal/reference/radix_themes_3_3_0/coverage_evidence.json b/registry_source/reference/radix_themes_3_3_0/coverage_evidence.json similarity index 100% rename from packages/remix_fortal/reference/radix_themes_3_3_0/coverage_evidence.json rename to registry_source/reference/radix_themes_3_3_0/coverage_evidence.json diff --git a/packages/remix_fortal/reference/radix_themes_3_3_0/manifest.json b/registry_source/reference/radix_themes_3_3_0/manifest.json similarity index 100% rename from packages/remix_fortal/reference/radix_themes_3_3_0/manifest.json rename to registry_source/reference/radix_themes_3_3_0/manifest.json diff --git a/packages/remix_fortal/reference/radix_themes_3_3_0/manifest.schema.json b/registry_source/reference/radix_themes_3_3_0/manifest.schema.json similarity index 100% rename from packages/remix_fortal/reference/radix_themes_3_3_0/manifest.schema.json rename to registry_source/reference/radix_themes_3_3_0/manifest.schema.json diff --git a/packages/remix_fortal/scripts/README.md b/registry_source/scripts/README.md similarity index 95% rename from packages/remix_fortal/scripts/README.md rename to registry_source/scripts/README.md index e1e63c32e..6eb2100dd 100644 --- a/packages/remix_fortal/scripts/README.md +++ b/registry_source/scripts/README.md @@ -1,7 +1,7 @@ # Fortal Scripts Development utilities for regenerating Fortal's Radix color data. Both scripts -are run from this package root (`packages/remix_fortal/`) and write paths +are run from this package root (`registry_source/fortal/`) and write paths relative to it. These are **not** part of `melos run ci`. The Radix color table is pinned to diff --git a/packages/remix_fortal/scripts/extract_radix_tokens.dart b/registry_source/scripts/extract_radix_tokens.dart similarity index 100% rename from packages/remix_fortal/scripts/extract_radix_tokens.dart rename to registry_source/scripts/extract_radix_tokens.dart diff --git a/packages/remix_fortal/scripts/generate_radix_colors.dart b/registry_source/scripts/generate_radix_colors.dart similarity index 100% rename from packages/remix_fortal/scripts/generate_radix_colors.dart rename to registry_source/scripts/generate_radix_colors.dart diff --git a/packages/remix_agent/specs/components/activity.yaml b/registry_source/specs/components/activity.yaml similarity index 100% rename from packages/remix_agent/specs/components/activity.yaml rename to registry_source/specs/components/activity.yaml diff --git a/packages/remix_agent/specs/components/answer.yaml b/registry_source/specs/components/answer.yaml similarity index 100% rename from packages/remix_agent/specs/components/answer.yaml rename to registry_source/specs/components/answer.yaml diff --git a/packages/remix_agent/specs/components/composer.yaml b/registry_source/specs/components/composer.yaml similarity index 100% rename from packages/remix_agent/specs/components/composer.yaml rename to registry_source/specs/components/composer.yaml diff --git a/packages/remix_agent/specs/components/execution.yaml b/registry_source/specs/components/execution.yaml similarity index 100% rename from packages/remix_agent/specs/components/execution.yaml rename to registry_source/specs/components/execution.yaml diff --git a/packages/remix_agent/specs/components/message.yaml b/registry_source/specs/components/message.yaml similarity index 100% rename from packages/remix_agent/specs/components/message.yaml rename to registry_source/specs/components/message.yaml diff --git a/packages/remix_agent/specs/components/permission.yaml b/registry_source/specs/components/permission.yaml similarity index 100% rename from packages/remix_agent/specs/components/permission.yaml rename to registry_source/specs/components/permission.yaml diff --git a/packages/remix_agent/specs/components/plan.yaml b/registry_source/specs/components/plan.yaml similarity index 100% rename from packages/remix_agent/specs/components/plan.yaml rename to registry_source/specs/components/plan.yaml diff --git a/packages/remix_agent/specs/components/transcript.yaml b/registry_source/specs/components/transcript.yaml similarity index 97% rename from packages/remix_agent/specs/components/transcript.yaml rename to registry_source/specs/components/transcript.yaml index 2c6f9c5c2..87061c2c2 100644 --- a/packages/remix_agent/specs/components/transcript.yaml +++ b/registry_source/specs/components/transcript.yaml @@ -24,7 +24,7 @@ states: - focus-visible tokens: {} recipe_measurements: - - followThreshold default 56 logical pixels + - followThreshold default 48 logical pixels - scrollbar gutter 12 - focus ring 2 inset, host ink behavior: diff --git a/registry_source/test/agent/behavior/items_test.dart b/registry_source/test/agent/behavior/items_test.dart new file mode 100644 index 000000000..c1932fee0 --- /dev/null +++ b/registry_source/test/agent/behavior/items_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:registry_source/agent.dart'; + +void main() { + test('plan items compare every value field and hash equally', () { + final first = AgentPlanItem(id: 'one', title: 'Step', detail: 'detail'); + final second = AgentPlanItem(id: 'one', title: 'Step', detail: 'detail'); + expect(identical(first, second), isFalse); + expect(first, second); + expect(first.hashCode, second.hashCode); + for (final other in const [ + AgentPlanItem(id: 'two', title: 'Step', detail: 'detail'), + AgentPlanItem(id: 'one', title: 'Other', detail: 'detail'), + AgentPlanItem( + id: 'one', + title: 'Step', + status: .completed, + detail: 'detail', + ), + AgentPlanItem(id: 'one', title: 'Step'), + ]) { + expect(first, isNot(other)); + } + expect(first.toString(), contains('detail: detail')); + expect( + AgentPlanItem(id: 'one', title: 'Step'), + AgentPlanItem(id: 'one', title: 'Step'), + ); + }); + + test('activity values include nullable fields and child identity', () { + final child = SizedBox(height: 10); + final first = AgentActivityItem( + id: 'one', + title: 'Step', + detail: 'detail', + child: child, + ); + final second = AgentActivityItem( + id: 'one', + title: 'Step', + detail: 'detail', + child: child, + ); + expect(first, second); + expect(first.hashCode, second.hashCode); + for (final other in [ + AgentActivityItem( + id: 'two', + title: 'Step', + detail: 'detail', + child: child, + ), + AgentActivityItem( + id: 'one', + title: 'Other', + detail: 'detail', + child: child, + ), + AgentActivityItem( + id: 'one', + title: 'Step', + status: .complete, + detail: 'detail', + child: child, + ), + AgentActivityItem(id: 'one', title: 'Step', child: child), + const AgentActivityItem(id: 'one', title: 'Step', detail: 'detail'), + AgentActivityItem( + id: 'one', + title: 'Step', + detail: 'detail', + child: SizedBox(height: 10), + ), + ]) { + expect(first, isNot(other)); + } + expect(first.toString(), contains('detail: detail')); + final empty = AgentActivityItem(id: 'one', title: 'Step'); + final same = AgentActivityItem(id: 'one', title: 'Step'); + expect(empty, same); + expect(empty.hashCode, same.hashCode); + }); +} diff --git a/packages/remix_agent/test/behavior/statuses_test.dart b/registry_source/test/agent/behavior/statuses_test.dart similarity index 96% rename from packages/remix_agent/test/behavior/statuses_test.dart rename to registry_source/test/agent/behavior/statuses_test.dart index 83324d3fb..1162c7d56 100644 --- a/packages/remix_agent/test/behavior/statuses_test.dart +++ b/registry_source/test/agent/behavior/statuses_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; void main() { test('permission machine is not a boolean', () { diff --git a/packages/remix_agent/test/components/activity_test.dart b/registry_source/test/agent/components/activity_test.dart similarity index 97% rename from packages/remix_agent/test/components/activity_test.dart rename to registry_source/test/agent/components/activity_test.dart index b8986471b..9b3d306ba 100644 --- a/packages/remix_agent/test/components/activity_test.dart +++ b/registry_source/test/agent/components/activity_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/answer_test.dart b/registry_source/test/agent/components/answer_test.dart similarity index 97% rename from packages/remix_agent/test/components/answer_test.dart rename to registry_source/test/agent/components/answer_test.dart index 737a37953..c121e6595 100644 --- a/packages/remix_agent/test/components/answer_test.dart +++ b/registry_source/test/agent/components/answer_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/behavior_test.dart b/registry_source/test/agent/components/behavior_test.dart similarity index 99% rename from packages/remix_agent/test/components/behavior_test.dart rename to registry_source/test/agent/components/behavior_test.dart index 2a3f9539f..d66e1e2c7 100644 --- a/packages/remix_agent/test/components/behavior_test.dart +++ b/registry_source/test/agent/components/behavior_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/child_style_and_glyph_test.dart b/registry_source/test/agent/components/child_style_and_glyph_test.dart similarity index 91% rename from packages/remix_agent/test/components/child_style_and_glyph_test.dart rename to registry_source/test/agent/components/child_style_and_glyph_test.dart index f7e2172c9..e3815af4b 100644 --- a/packages/remix_agent/test/components/child_style_and_glyph_test.dart +++ b/registry_source/test/agent/components/child_style_and_glyph_test.dart @@ -2,15 +2,14 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; -Finder _findLucideIcon(int codePoint) => find.byWidgetPredicate( - (widget) => - widget is Icon && - widget.icon?.codePoint == codePoint && - widget.icon?.fontFamily == 'Lucide', +import 'package:remix_ui_icons/remix_ui_icons.dart'; + +Finder _findIcon(IconData expected) => find.byWidgetPredicate( + (widget) => widget is Icon && widget.icon == expected, ); Iterable _decorationColors(WidgetTester tester, Finder root) sync* { @@ -42,7 +41,7 @@ void main() { expect( find.descendant( of: find.byKey(const ValueKey('agent-composer-send')), - matching: _findLucideIcon(57418), + matching: _findIcon(RemixIcons.arrowUp), ), findsOneWidget, ); @@ -61,11 +60,11 @@ void main() { final answer = find.byType(AgentAnswer); expect( - find.descendant(of: answer, matching: _findLucideIcon(57502)), + find.descendant(of: answer, matching: _findIcon(RemixIcons.copy)), findsOneWidget, ); expect( - find.descendant(of: answer, matching: _findLucideIcon(57672)), + find.descendant(of: answer, matching: _findIcon(RemixIcons.reload)), findsOneWidget, ); }); @@ -87,15 +86,15 @@ void main() { ); final execution = find.byType(AgentExecution); - for (final codePoint in [ - 57866, // SquareTerminal - 57894, // CircleCheck - 57456, // ChevronUp - 57502, // Copy - 57672, // RotateCcw + for (final icon in [ + RemixIcons.code, + RemixIcons.checkCircled, + RemixIcons.chevronUp, + RemixIcons.copy, + RemixIcons.reload, ]) { expect( - find.descendant(of: execution, matching: _findLucideIcon(codePoint)), + find.descendant(of: execution, matching: _findIcon(icon)), findsOneWidget, ); } diff --git a/packages/remix_agent/test/components/composer_test.dart b/registry_source/test/agent/components/composer_test.dart similarity index 66% rename from packages/remix_agent/test/components/composer_test.dart rename to registry_source/test/agent/components/composer_test.dart index d860c5709..da1c149ab 100644 --- a/packages/remix_agent/test/components/composer_test.dart +++ b/registry_source/test/agent/components/composer_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; @@ -115,4 +115,53 @@ void main() { expect(stops, 1); expect(submitted, isEmpty); }); + + // Both swaps used to dispose the superseded owned object inside + // didUpdateWidget, while the child RemixTextArea still held it. Detaching + // then touched a disposed object, which asserts in debug. + testWidgets('adopting a host controller keeps the composer usable', ( + tester, + ) async { + final adopted = TextEditingController(text: 'from host'); + addTearDown(adopted.dispose); + + await pumpAgent( + tester, + const AgentComposer(initialValue: 'owned'), + overlay: true, + ); + await pumpAgent(tester, AgentComposer(controller: adopted), overlay: true); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('from host'), findsOneWidget); + }); + + testWidgets('adopting a host focus node keeps the composer usable', ( + tester, + ) async { + final adopted = FocusNode(); + addTearDown(adopted.dispose); + + await pumpAgent( + tester, + const AgentComposer(initialValue: 'text'), + overlay: true, + ); + // The owned node is created lazily by the getter, so it only exists to be + // disposed once the field has been built. + await tester.pump(); + + await pumpAgent( + tester, + AgentComposer(initialValue: 'text', focusNode: adopted), + overlay: true, + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + adopted.requestFocus(); + await tester.pump(); + expect(adopted.hasFocus, isTrue); + }); } diff --git a/registry_source/test/agent/components/disclosure_contract_test.dart b/registry_source/test/agent/components/disclosure_contract_test.dart new file mode 100644 index 000000000..b3db255f1 --- /dev/null +++ b/registry_source/test/agent/components/disclosure_contract_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:registry_source/agent.dart'; + +import '../helpers/pump.dart'; + +void main() { + testWidgets( + 'message releases the last controlled value and ignores new defaults', + (tester) async { + final requests = []; + Future pump(bool? expanded, bool defaultExpanded) async { + await pumpAgent( + tester, + AgentMessageCollapsible( + expanded: expanded, + defaultExpanded: defaultExpanded, + onExpandedChanged: requests.add, + style: AgentMessageCollapsibleStyler(collapsedHeight: 20), + child: const SizedBox(height: 100, child: Text('Long message')), + ), + ); + await tester.pumpAndSettle(); + } + + await pump(true, false); + await tester.tap(find.text('Show less')); + await tester.pump(); + await tester.tap(find.text('Show less')); + await tester.pump(); + expect(requests, [false, false]); + expect(find.text('Show less'), findsOneWidget); + + await pump(null, false); + expect(find.text('Show less'), findsOneWidget); + expect(requests, [false, false]); + await tester.tap(find.text('Show less')); + await tester.pumpAndSettle(); + expect(find.text('Show more'), findsOneWidget); + await pump(null, true); + expect(find.text('Show more'), findsOneWidget); + expect(requests, [false, false, false]); + }, + ); + + testWidgets( + 'plan releases control before applying a simultaneous completion', + (tester) async { + final requests = []; + Future pump(bool? expanded, AgentPlanItemStatus status) => + pumpAgent( + tester, + AgentPlan( + expanded: expanded, + onExpandedChanged: requests.add, + items: [AgentPlanItem(id: 'one', title: 'Step', status: status)], + ), + ); + await pump(true, .inProgress); + await pump(null, .completed); + await tester.pumpAndSettle(); + expect(requests, [false]); + expect(find.text('Step'), findsNothing); + await tester.tap(find.text('Plan')); + await tester.pumpAndSettle(); + expect(find.text('Step'), findsOneWidget); + expect(requests, [false, true]); + }, + ); + + testWidgets( + 'activity ignores working toggles and uses the current callback', + (tester) async { + final oldRequests = []; + final currentRequests = []; + Future pump(AgentRunStatus status, ValueChanged callback) => + pumpAgent( + tester, + AgentActivity( + status: status, + expanded: false, + onExpandedChanged: callback, + items: const [AgentActivityItem(id: 'one', title: 'Work')], + ), + ); + await pump(.working, oldRequests.add); + await tester.tap(find.text('Activity')); + await tester.pump(); + expect(oldRequests, isEmpty); + expect(find.text('Work'), findsOneWidget); + await pump(.complete, currentRequests.add); + await tester.pumpAndSettle(); + expect(oldRequests, isEmpty); + expect(currentRequests, [false]); + await tester.tap(find.text('Activity')); + await tester.pump(); + expect(currentRequests, [false, true]); + expect(find.text('Work'), findsNothing); + }, + ); + + testWidgets( + 'custom indicator inherits host context and receives disclosure state', + (tester) async { + const color = Color(0xFF123456); + final states = []; + await pumpAgent( + tester, + IconTheme( + data: const IconThemeData(color: color), + child: AgentPlan( + items: const [], + indicatorBuilder: (context, expanded) { + expect(IconTheme.of(context).color, color); + expect(Directionality.of(context), TextDirection.ltr); + states.add(expanded); + return const Text('Host indicator'); + }, + ), + ), + ); + expect(find.text('Host indicator'), findsOneWidget); + expect(states.last, isTrue); + await tester.tap(find.text('Plan')); + await tester.pumpAndSettle(); + expect(states.last, isFalse); + }, + ); +} diff --git a/registry_source/test/agent/components/execution_test.dart b/registry_source/test/agent/components/execution_test.dart new file mode 100644 index 000000000..72b4012a7 --- /dev/null +++ b/registry_source/test/agent/components/execution_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:registry_source/agent.dart'; + +import '../helpers/pump.dart'; + +void main() { + testWidgets('tool identifier is rendered with the execution title', ( + tester, + ) async { + await pumpAgent( + tester, + const AgentExecution( + tool: 'terminal.run', + title: 'Focused tests', + child: Text('output'), + ), + ); + + expect(find.text('terminal.run'), findsOneWidget); + expect(find.text('Focused tests'), findsOneWidget); + }); + + testWidgets('copy and retry actions appear only after settlement', ( + tester, + ) async { + Widget execution(AgentExecutionStatus status) => AgentExecution( + tool: 'tool', + title: 'Run', + status: status, + collapseOnComplete: false, + onCopy: () {}, + onRetry: () {}, + child: const Text('output'), + ); + + await pumpAgent(tester, execution(AgentExecutionStatus.running)); + expect(find.bySemanticsLabel('Copy output'), findsNothing); + expect(find.bySemanticsLabel('Retry execution'), findsNothing); + + await pumpAgent(tester, execution(AgentExecutionStatus.error)); + expect(find.bySemanticsLabel('Copy output'), findsOneWidget); + expect(find.bySemanticsLabel('Retry execution'), findsOneWidget); + }); + + testWidgets('execution status and indicator builders are replaceable', ( + tester, + ) async { + await pumpAgent( + tester, + AgentExecution( + tool: 'tool', + title: 'Run', + statusBuilder: (context, status) => + const SizedBox(key: ValueKey('custom-execution-status')), + indicatorBuilder: (context, expanded) => + const SizedBox(key: ValueKey('custom-execution-indicator')), + child: const Text('output'), + ), + ); + + expect( + find.byKey(const ValueKey('custom-execution-status')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('custom-execution-indicator')), + findsOneWidget, + ); + }); + + testWidgets( + 'a focused execution card leaves transcript scroll keys working', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + + await pumpAgent( + tester, + SizedBox( + width: 400, + height: 200, + child: AgentTranscript( + followOutput: false, + controller: controller, + children: const [ + AgentExecution( + tool: 'tool', + title: 'Run', + child: Focus( + autofocus: true, + child: SizedBox(width: 80, height: 80), + ), + ), + SizedBox(height: 400), + ], + ), + ), + ); + await tester.pump(); + + expect(controller.position.maxScrollExtent, greaterThan(0)); + expect(controller.offset, 0); + + // The card used to wrap its output in a second AgentTranscript, whose + // action consumed this intent against a zero scroll extent instead of + // letting the host transcript scroll. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(controller.offset, greaterThan(0)); + }, + ); + + testWidgets('execution announces its status once', (tester) async { + // Disposed inline, not in a tearDown: the framework verifies outstanding + // handles at the end of the test body, before tearDowns run. + final handle = tester.ensureSemantics(); + + await pumpAgent( + tester, + const AgentExecution( + tool: 'tool', + title: 'Run', + status: AgentExecutionStatus.running, + child: Text('output'), + ), + ); + + final values = []; + void collect(SemanticsNode node) { + if (node.value.isNotEmpty) values.add(node.value); + node.visitChildren((child) { + collect(child); + + return true; + }); + } + + collect(tester.getSemantics(find.byType(AgentExecution))); + + // The positive assertion keeps the negative one honest: if the walk stopped + // seeing values, this would fail rather than pass vacuously. + expect(values.where((value) => value.contains('Running')), hasLength(1)); + // The nested transcript also carried `busy`, so a running card reported its + // state twice -- once as this value, once as a nested 'Busy'. + expect(values.where((value) => value.contains('Busy')), isEmpty); + + handle.dispose(); + }); +} diff --git a/packages/remix_agent/test/components/header_parity_test.dart b/registry_source/test/agent/components/header_parity_test.dart similarity index 98% rename from packages/remix_agent/test/components/header_parity_test.dart rename to registry_source/test/agent/components/header_parity_test.dart index 5cc265b6e..d451b0007 100644 --- a/packages/remix_agent/test/components/header_parity_test.dart +++ b/registry_source/test/agent/components/header_parity_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/lifecycle_test.dart b/registry_source/test/agent/components/lifecycle_test.dart similarity index 99% rename from packages/remix_agent/test/components/lifecycle_test.dart rename to registry_source/test/agent/components/lifecycle_test.dart index 8ec90acc6..23e45c808 100644 --- a/packages/remix_agent/test/components/lifecycle_test.dart +++ b/registry_source/test/agent/components/lifecycle_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/live_edge_test.dart b/registry_source/test/agent/components/live_edge_test.dart similarity index 90% rename from packages/remix_agent/test/components/live_edge_test.dart rename to registry_source/test/agent/components/live_edge_test.dart index 9c7deefcf..e18c5c89d 100644 --- a/packages/remix_agent/test/components/live_edge_test.dart +++ b/registry_source/test/agent/components/live_edge_test.dart @@ -1,7 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; +import 'package:registry_source/src/agent/support/live_edge.dart'; import '../helpers/pump.dart'; @@ -17,6 +18,29 @@ int _coloredItems(WidgetTester tester, Finder root, Color color) => tester .length; void main() { + test('followThreshold defaults to 48 everywhere it is declared', () { + // `specs/components/transcript.yaml` records "followThreshold default 48 + // logical pixels". That worksheet read 56 while the code shipped 48, and + // `public_api_test.dart` compares worksheet filenames only, so nothing + // caught it. Pin the constant here; the worksheet stays prose. + expect(const AgentTranscript(children: []).followThreshold, 48); + expect( + AgentTranscript.builder( + itemCount: 0, + itemBuilder: (_, _) => const SizedBox.shrink(), + ).followThreshold, + 48, + ); + expect(const AgentPlan(items: []).followThreshold, 48); + expect(const AgentActivity(items: []).followThreshold, 48); + // Plan and Activity always pass their own value down, so this default is + // reachable only by a host that uses the scroll view directly. + expect( + const AgentLiveEdgeScrollView(child: SizedBox.shrink()).followThreshold, + 48, + ); + }); + testWidgets('transcript follows growth, releases, and reattaches', ( tester, ) async { diff --git a/packages/remix_agent/test/components/loading_test.dart b/registry_source/test/agent/components/loading_test.dart similarity index 98% rename from packages/remix_agent/test/components/loading_test.dart rename to registry_source/test/agent/components/loading_test.dart index c6acce815..3dfb80396 100644 --- a/packages/remix_agent/test/components/loading_test.dart +++ b/registry_source/test/agent/components/loading_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/message_test.dart b/registry_source/test/agent/components/message_test.dart similarity index 99% rename from packages/remix_agent/test/components/message_test.dart rename to registry_source/test/agent/components/message_test.dart index 88c33d771..97e65c2e9 100644 --- a/packages/remix_agent/test/components/message_test.dart +++ b/registry_source/test/agent/components/message_test.dart @@ -4,7 +4,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/permission_test.dart b/registry_source/test/agent/components/permission_test.dart similarity index 98% rename from packages/remix_agent/test/components/permission_test.dart rename to registry_source/test/agent/components/permission_test.dart index aafa4a982..7b4a16099 100644 --- a/packages/remix_agent/test/components/permission_test.dart +++ b/registry_source/test/agent/components/permission_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/plan_test.dart b/registry_source/test/agent/components/plan_test.dart similarity index 97% rename from packages/remix_agent/test/components/plan_test.dart rename to registry_source/test/agent/components/plan_test.dart index ec9537b64..bb8da132f 100644 --- a/packages/remix_agent/test/components/plan_test.dart +++ b/registry_source/test/agent/components/plan_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/semantics_test.dart b/registry_source/test/agent/components/semantics_test.dart similarity index 99% rename from packages/remix_agent/test/components/semantics_test.dart rename to registry_source/test/agent/components/semantics_test.dart index 7bec1c9e5..4f8e1a759 100644 --- a/packages/remix_agent/test/components/semantics_test.dart +++ b/registry_source/test/agent/components/semantics_test.dart @@ -4,7 +4,7 @@ import 'package:flutter/semantics.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/components/transcript_and_style_test.dart b/registry_source/test/agent/components/transcript_and_style_test.dart similarity index 80% rename from packages/remix_agent/test/components/transcript_and_style_test.dart rename to registry_source/test/agent/components/transcript_and_style_test.dart index 6fab5daa0..fb92f8284 100644 --- a/packages/remix_agent/test/components/transcript_and_style_test.dart +++ b/registry_source/test/agent/components/transcript_and_style_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; @@ -121,6 +121,38 @@ void main() { expect(node.getSemanticsData().flagsCollection.isLiveRegion, isFalse); handle.dispose(); }); + + testWidgets('a focused transcript resolves host focus styling', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await pumpAgent( + tester, + SizedBox( + height: 120, + width: 300, + child: AgentTranscript( + followOutput: false, + style: AgentTranscriptStyler( + viewport: BoxStyler().onFocused(BoxStyler().padding(.left(40))), + ), + children: [Focus(focusNode: focusNode, child: const Text('leaf'))], + ), + ), + ); + final unfocused = tester.getTopLeft(find.text('leaf')).dx; + + focusNode.requestFocus(); + await tester.pump(); + + // `focused` needs a controller, and Agent's slots resolve above any Naked + // control. Until the transcript published its own focus there was no source + // for this state, so a host's focus styling on the viewport could never + // activate. + expect(tester.getTopLeft(find.text('leaf')).dx, unfocused + 40); + }); } class _TranscriptHarness extends StatefulWidget { diff --git a/registry_source/test/agent/components/transcript_resume_test.dart b/registry_source/test/agent/components/transcript_resume_test.dart new file mode 100644 index 000000000..3d1eca46f --- /dev/null +++ b/registry_source/test/agent/components/transcript_resume_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:registry_source/agent.dart'; + +import '../helpers/pump.dart'; + +void main() { + testWidgets('explicitly re-enabling follow resumes later output growth', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + late StateSetter update; + var following = true; + var count = 30; + await pumpAgent( + tester, + StatefulBuilder( + builder: (context, setState) { + update = setState; + return SizedBox( + width: 300, + height: 200, + child: AgentTranscript( + controller: controller, + followOutput: following, + onFollowChanged: (value) => update(() => following = value), + children: List.generate( + count, + (index) => SizedBox(height: 40, child: Text('Turn $index')), + ), + ), + ); + }, + ), + ); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, 300)); + await tester.pumpAndSettle(); + expect(following, isFalse); + + // Match the application's visible Return to latest action. + update(() => following = true); + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + update(() => count += 10); + await tester.pumpAndSettle(); + expect(controller.position.extentAfter, lessThanOrEqualTo(1)); + }); +} diff --git a/packages/remix_agent/test/components/transcript_test.dart b/registry_source/test/agent/components/transcript_test.dart similarity index 98% rename from packages/remix_agent/test/components/transcript_test.dart rename to registry_source/test/agent/components/transcript_test.dart index bd8c4d2a1..297937093 100644 --- a/packages/remix_agent/test/components/transcript_test.dart +++ b/registry_source/test/agent/components/transcript_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; import '../helpers/pump.dart'; diff --git a/packages/remix_agent/test/helpers/pump.dart b/registry_source/test/agent/helpers/pump.dart similarity index 100% rename from packages/remix_agent/test/helpers/pump.dart rename to registry_source/test/agent/helpers/pump.dart diff --git a/packages/remix_agent/test/host_capabilities_test.dart b/registry_source/test/agent/host_capabilities_test.dart similarity index 97% rename from packages/remix_agent/test/host_capabilities_test.dart rename to registry_source/test/agent/host_capabilities_test.dart index ce42a710b..aab40b1aa 100644 --- a/packages/remix_agent/test/host_capabilities_test.dart +++ b/registry_source/test/agent/host_capabilities_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_agent/remix_agent.dart'; +import 'package:registry_source/agent.dart'; void main() { testWidgets('unfocused catalog widgets mount without Overlay or Navigator', ( diff --git a/registry_source/test/agent/public_api_test.dart b/registry_source/test/agent/public_api_test.dart new file mode 100644 index 000000000..99a5b18a4 --- /dev/null +++ b/registry_source/test/agent/public_api_test.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:remix/remix.dart'; +import 'package:registry_source/agent.dart'; + +void main() { + test('barrel exports the v1 catalog', () { + const composer = AgentComposer(); + const message = AgentMessage( + role: AgentRole.user, + child: SizedBox.shrink(), + ); + const answer = AgentAnswer(child: SizedBox.shrink()); + const permission = AgentPermission(tool: 't'); + const execution = AgentExecution( + tool: 't', + title: 'T', + child: SizedBox.shrink(), + ); + const plan = AgentPlan(items: []); + const activity = AgentActivity(items: []); + + expect(composer, isA()); + expect(message.role, AgentRole.user); + expect(answer.status, AgentAnswerStatus.streaming); + expect(permission.status, AgentPermissionStatus.pending); + expect(execution.status, AgentExecutionStatus.running); + expect(plan.items, isEmpty); + expect(activity.status, AgentRunStatus.working); + expect(const AgentComposerSpec(), isA()); + expect(const AgentTranscript(children: []), isA()); + expect( + const AgentPermission( + tool: 't', + parameters: [RemixDataListItem(label: 'a', value: 'b')], + ), + isA(), + ); + }); + + test('library sources do not import Material', () { + final lib = Directory('lib').existsSync() + ? Directory('lib') + : Directory('registry_source/lib'); + expect(lib.existsSync(), isTrue); + final hits = []; + for (final entity in lib.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) { + continue; + } + final source = entity.readAsStringSync(); + if (source.contains('package:flutter/material.dart') || + source.contains('package:flutter/src/material/')) { + hits.add(entity.path); + } + } + expect(hits, isEmpty); + }); + + test('every component ships a worksheet', () { + final root = Directory('lib').existsSync() ? '' : 'registry_source/'; + final components = Directory('${root}lib/src/agent/components') + .listSync() + .whereType() + .map((file) => file.uri.pathSegments.last) + .where((name) => name.endsWith('.dart') && !name.endsWith('.g.dart')) + .map((name) => name.substring(0, name.length - '.dart'.length)) + .toSet(); + final worksheets = Directory('${root}specs/components') + .listSync() + .whereType() + .map((file) => file.uri.pathSegments.last) + .where((name) => name.endsWith('.yaml')) + .map((name) => name.substring(0, name.length - '.yaml'.length)) + .toSet(); + + // skills/building-remix-design-system documents the worksheet as a + // component's first artifact, written before any code. Comparing both + // directions keeps that true: a component added without one fails, and so + // does a worksheet outliving the component it described. + expect(worksheets, components); + }); + + test('barrel exports exactly the pinned public surface', () { + final barrel = File('lib/agent.dart').existsSync() + ? File('lib/agent.dart') + : File('registry_source/lib/agent.dart'); + final exported = RegExp(r"^export '([^']+)';", multiLine: true) + .allMatches(barrel.readAsStringSync()) + .map((match) => match.group(1)!) + .toSet(); + + // Compared as a set, not asserted absent one path at a time: an equality + // fails on a *new* export too, which is the direction that leaks. Everything + // under `src/support/` is an implementation seam -- `functional_glyph.dart`, + // `live_edge.dart` -- and stays out by omission. + expect(exported, { + 'src/agent/components/activity.dart', + 'src/agent/components/answer.dart', + 'src/agent/components/composer.dart', + 'src/agent/components/execution.dart', + 'src/agent/components/message.dart', + 'src/agent/components/permission.dart', + 'src/agent/components/plan.dart', + 'src/agent/components/transcript.dart', + 'src/agent/models/activity_item.dart', + 'src/agent/models/plan_item.dart', + 'src/agent/models/statuses.dart', + }); + }); +} diff --git a/packages/remix_fortal/test/components/accordion/accordion_style_test.dart b/registry_source/test/components/accordion/accordion_style_test.dart similarity index 96% rename from packages/remix_fortal/test/components/accordion/accordion_style_test.dart rename to registry_source/test/components/accordion/accordion_style_test.dart index 0d083798c..d0573d312 100644 --- a/packages/remix_fortal/test/components/accordion/accordion_style_test.dart +++ b/registry_source/test/components/accordion/accordion_style_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { group('Fortal styles', () { diff --git a/packages/remix_fortal/test/components/accordion/accordion_widget_test.dart b/registry_source/test/components/accordion/accordion_widget_test.dart similarity index 98% rename from packages/remix_fortal/test/components/accordion/accordion_widget_test.dart rename to registry_source/test/components/accordion/accordion_widget_test.dart index 8c6927254..8aeeece18 100644 --- a/packages/remix_fortal/test/components/accordion/accordion_widget_test.dart +++ b/registry_source/test/components/accordion/accordion_widget_test.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; // Panel anatomy derives from the mapped Table family (see -// packages/remix_fortal/lib/src/components/data_table.dart): the container owns +// registry_source/fortal/lib/src/components/data_table.dart): the container owns // radius, foreground frame, fill, and clipping so the trigger and content crop into // one rounded shape instead of each rounding their own corners. These tests // pin that contract directly, plus the content font size that keeps a diff --git a/packages/remix_fortal/test/components/avatar/avatar_fortal_parity_test.dart b/registry_source/test/components/avatar/avatar_fortal_parity_test.dart similarity index 99% rename from packages/remix_fortal/test/components/avatar/avatar_fortal_parity_test.dart rename to registry_source/test/components/avatar/avatar_fortal_parity_test.dart index cd94d4171..f18cbee6e 100644 --- a/packages/remix_fortal/test/components/avatar/avatar_fortal_parity_test.dart +++ b/registry_source/test/components/avatar/avatar_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { test('defaults to Radix size3 and soft', () { diff --git a/packages/remix_fortal/test/components/badge/badge_fortal_parity_test.dart b/registry_source/test/components/badge/badge_fortal_parity_test.dart similarity index 99% rename from packages/remix_fortal/test/components/badge/badge_fortal_parity_test.dart rename to registry_source/test/components/badge/badge_fortal_parity_test.dart index 94128e74c..9f09b56e5 100644 --- a/packages/remix_fortal/test/components/badge/badge_fortal_parity_test.dart +++ b/registry_source/test/components/badge/badge_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { test('defaults to Radix size1 and soft', () { diff --git a/packages/remix_fortal/test/components/button/button_icon_sizing_test.dart b/registry_source/test/components/button/button_icon_sizing_test.dart similarity index 99% rename from packages/remix_fortal/test/components/button/button_icon_sizing_test.dart rename to registry_source/test/components/button/button_icon_sizing_test.dart index 8f5555934..fe4044788 100644 --- a/packages/remix_fortal/test/components/button/button_icon_sizing_test.dart +++ b/registry_source/test/components/button/button_icon_sizing_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/button/button_style_test.dart b/registry_source/test/components/button/button_style_test.dart similarity index 98% rename from packages/remix_fortal/test/components/button/button_style_test.dart rename to registry_source/test/components/button/button_style_test.dart index 8b1b64792..755a0ea15 100644 --- a/packages/remix_fortal/test/components/button/button_style_test.dart +++ b/registry_source/test/components/button/button_style_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/button/button_widget_test.dart b/registry_source/test/components/button/button_widget_test.dart similarity index 99% rename from packages/remix_fortal/test/components/button/button_widget_test.dart rename to registry_source/test/components/button/button_widget_test.dart index c5ead7e22..8962b0758 100644 --- a/packages/remix_fortal/test/components/button/button_widget_test.dart +++ b/registry_source/test/components/button/button_widget_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:naked_ui/naked_ui.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/callout/callout_fortal_parity_test.dart b/registry_source/test/components/callout/callout_fortal_parity_test.dart similarity index 98% rename from packages/remix_fortal/test/components/callout/callout_fortal_parity_test.dart rename to registry_source/test/components/callout/callout_fortal_parity_test.dart index e9eaa787a..a6f3883fc 100644 --- a/packages/remix_fortal/test/components/callout/callout_fortal_parity_test.dart +++ b/registry_source/test/components/callout/callout_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { test('defaults to Radix size2 and soft', () { diff --git a/packages/remix_fortal/test/components/card/card_fortal_parity_test.dart b/registry_source/test/components/card/card_fortal_parity_test.dart similarity index 99% rename from packages/remix_fortal/test/components/card/card_fortal_parity_test.dart rename to registry_source/test/components/card/card_fortal_parity_test.dart index 4e0f5451e..7efb00c8e 100644 --- a/packages/remix_fortal/test/components/card/card_fortal_parity_test.dart +++ b/registry_source/test/components/card/card_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/chart/chart_test.dart b/registry_source/test/components/chart/chart_test.dart similarity index 96% rename from packages/remix_fortal/test/components/chart/chart_test.dart rename to registry_source/test/components/chart/chart_test.dart index d266f3b72..cd78b9359 100644 --- a/packages/remix_fortal/test/components/chart/chart_test.dart +++ b/registry_source/test/components/chart/chart_test.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mix_chart/mix_chart.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { group('Fortal chart recipes', () { @@ -195,11 +195,13 @@ void main() { test('mix_chart belongs to Fortal rather than core Remix', () { final fortalPubspec = File('pubspec.yaml').readAsStringSync(); - final remixPubspec = File('../remix/pubspec.yaml').readAsStringSync(); + final remixPubspec = File( + '../packages/remix/pubspec.yaml', + ).readAsStringSync(); expect( fortalPubspec, - contains(RegExp(r'^name: remix_fortal$', multiLine: true)), + contains(RegExp(r'^name: registry_source$', multiLine: true)), ); expect( fortalPubspec, diff --git a/packages/remix_fortal/test/components/checkbox/checkbox_group_widget_test.dart b/registry_source/test/components/checkbox/checkbox_group_widget_test.dart similarity index 99% rename from packages/remix_fortal/test/components/checkbox/checkbox_group_widget_test.dart rename to registry_source/test/components/checkbox/checkbox_group_widget_test.dart index d64efb7e7..77425c561 100644 --- a/packages/remix_fortal/test/components/checkbox/checkbox_group_widget_test.dart +++ b/registry_source/test/components/checkbox/checkbox_group_widget_test.dart @@ -6,7 +6,7 @@ import 'package:remix/remix.dart'; // Suppressed per-use below, so a future accidental internal-member use still // gets flagged. import 'package:remix/src/rendering/remix_box_effects.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/checkbox/checkbox_labeled_test.dart b/registry_source/test/components/checkbox/checkbox_labeled_test.dart similarity index 98% rename from packages/remix_fortal/test/components/checkbox/checkbox_labeled_test.dart rename to registry_source/test/components/checkbox/checkbox_labeled_test.dart index 3532a6db6..f67ad4ca0 100644 --- a/packages/remix_fortal/test/components/checkbox/checkbox_labeled_test.dart +++ b/registry_source/test/components/checkbox/checkbox_labeled_test.dart @@ -8,7 +8,7 @@ import 'package:remix/src/rendering/remix_box_effects.dart'; // Deliberate: RemixPathIcon/RemixPathGlyph stay unexported, but this sibling // package verifies Fortal's pinned Radix checkbox defaults at the widget edge. import 'package:remix/src/utilities/remix_path_icon.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/checkbox/checkbox_style_test.dart b/registry_source/test/components/checkbox/checkbox_style_test.dart similarity index 85% rename from packages/remix_fortal/test/components/checkbox/checkbox_style_test.dart rename to registry_source/test/components/checkbox/checkbox_style_test.dart index d1136f04c..7d73e989b 100644 --- a/packages/remix_fortal/test/components/checkbox/checkbox_style_test.dart +++ b/registry_source/test/components/checkbox/checkbox_style_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { test('same arguments produce equal styles', () { diff --git a/packages/remix_fortal/test/components/checkbox/fortal_checkbox_disabled_test.dart b/registry_source/test/components/checkbox/fortal_checkbox_disabled_test.dart similarity index 96% rename from packages/remix_fortal/test/components/checkbox/fortal_checkbox_disabled_test.dart rename to registry_source/test/components/checkbox/fortal_checkbox_disabled_test.dart index 4c8c29e78..b01333175 100644 --- a/packages/remix_fortal/test/components/checkbox/fortal_checkbox_disabled_test.dart +++ b/registry_source/test/components/checkbox/fortal_checkbox_disabled_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { testWidgets('disabled variants resolve the Radix gray-a3 surface', ( diff --git a/packages/remix_fortal/test/components/data_list/data_list_fortal_parity_test.dart b/registry_source/test/components/data_list/data_list_fortal_parity_test.dart similarity index 98% rename from packages/remix_fortal/test/components/data_list/data_list_fortal_parity_test.dart rename to registry_source/test/components/data_list/data_list_fortal_parity_test.dart index a88e0767e..adbda07e7 100644 --- a/packages/remix_fortal/test/components/data_list/data_list_fortal_parity_test.dart +++ b/registry_source/test/components/data_list/data_list_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { for (final value in ['1', '2', 'true', 'colors/fortal.accent.9']) { diff --git a/packages/remix_fortal/test/components/data_table/data_table_fortal_parity_test.dart b/registry_source/test/components/data_table/data_table_fortal_parity_test.dart similarity index 99% rename from packages/remix_fortal/test/components/data_table/data_table_fortal_parity_test.dart rename to registry_source/test/components/data_table/data_table_fortal_parity_test.dart index a2d144901..a0fcd4da7 100644 --- a/packages/remix_fortal/test/components/data_table/data_table_fortal_parity_test.dart +++ b/registry_source/test/components/data_table/data_table_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; /// Pinned `@radix-ui/themes@3.3.0` `table.css` values at 100% scaling and the /// default `medium` radius: diff --git a/packages/remix_fortal/test/components/dialog/dialog_widget_test.dart b/registry_source/test/components/dialog/dialog_widget_test.dart similarity index 99% rename from packages/remix_fortal/test/components/dialog/dialog_widget_test.dart rename to registry_source/test/components/dialog/dialog_widget_test.dart index 37d9aaffa..6bcc93ee6 100644 --- a/packages/remix_fortal/test/components/dialog/dialog_widget_test.dart +++ b/registry_source/test/components/dialog/dialog_widget_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/services.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/disclosure/disclosure_style_test.dart b/registry_source/test/components/disclosure/disclosure_style_test.dart similarity index 99% rename from packages/remix_fortal/test/components/disclosure/disclosure_style_test.dart rename to registry_source/test/components/disclosure/disclosure_style_test.dart index 5c4b008d4..f3618f8ee 100644 --- a/packages/remix_fortal/test/components/disclosure/disclosure_style_test.dart +++ b/registry_source/test/components/disclosure/disclosure_style_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { group('Fortal disclosure styles', () { diff --git a/packages/remix_fortal/test/components/disclosure/disclosure_widget_test.dart b/registry_source/test/components/disclosure/disclosure_widget_test.dart similarity index 97% rename from packages/remix_fortal/test/components/disclosure/disclosure_widget_test.dart rename to registry_source/test/components/disclosure/disclosure_widget_test.dart index b13b519cf..e9236a320 100644 --- a/packages/remix_fortal/test/components/disclosure/disclosure_widget_test.dart +++ b/registry_source/test/components/disclosure/disclosure_widget_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/fortal_widget_test.dart b/registry_source/test/components/fortal_widget_test.dart similarity index 99% rename from packages/remix_fortal/test/components/fortal_widget_test.dart rename to registry_source/test/components/fortal_widget_test.dart index 1638e5094..b1937b0e4 100644 --- a/packages/remix_fortal/test/components/fortal_widget_test.dart +++ b/registry_source/test/components/fortal_widget_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../helpers/contrast.dart'; import '../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/menu/menu_style_test.dart b/registry_source/test/components/menu/menu_style_test.dart similarity index 96% rename from packages/remix_fortal/test/components/menu/menu_style_test.dart rename to registry_source/test/components/menu/menu_style_test.dart index a708811dd..87e0c882f 100644 --- a/packages/remix_fortal/test/components/menu/menu_style_test.dart +++ b/registry_source/test/components/menu/menu_style_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { test('assigned Fortal recipe composes semantic styles before call', () { diff --git a/packages/remix_fortal/test/components/menu/menu_styler_compatibility_test.dart b/registry_source/test/components/menu/menu_styler_compatibility_test.dart similarity index 84% rename from packages/remix_fortal/test/components/menu/menu_styler_compatibility_test.dart rename to registry_source/test/components/menu/menu_styler_compatibility_test.dart index 2ad47b183..a311f508c 100644 --- a/packages/remix_fortal/test/components/menu/menu_styler_compatibility_test.dart +++ b/registry_source/test/components/menu/menu_styler_compatibility_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { test('fortal recipes return canonical stylers', () { diff --git a/packages/remix_fortal/test/components/menu/menu_widget_test.dart b/registry_source/test/components/menu/menu_widget_test.dart similarity index 99% rename from packages/remix_fortal/test/components/menu/menu_widget_test.dart rename to registry_source/test/components/menu/menu_widget_test.dart index 231fa9d8d..74e55ad85 100644 --- a/packages/remix_fortal/test/components/menu/menu_widget_test.dart +++ b/registry_source/test/components/menu/menu_widget_test.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/src/utilities/remix_path_icon.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/popover/popover_widget_test.dart b/registry_source/test/components/popover/popover_widget_test.dart similarity index 94% rename from packages/remix_fortal/test/components/popover/popover_widget_test.dart rename to registry_source/test/components/popover/popover_widget_test.dart index 5e86745c4..52a9c3685 100644 --- a/packages/remix_fortal/test/components/popover/popover_widget_test.dart +++ b/registry_source/test/components/popover/popover_widget_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/progress/progress_widget_test.dart b/registry_source/test/components/progress/progress_widget_test.dart similarity index 98% rename from packages/remix_fortal/test/components/progress/progress_widget_test.dart rename to registry_source/test/components/progress/progress_widget_test.dart index 51170ed61..1f27af3e4 100644 --- a/packages/remix_fortal/test/components/progress/progress_widget_test.dart +++ b/registry_source/test/components/progress/progress_widget_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/semantics.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/segmented_control/segmented_control_fortal_parity_test.dart b/registry_source/test/components/segmented_control/segmented_control_fortal_parity_test.dart similarity index 99% rename from packages/remix_fortal/test/components/segmented_control/segmented_control_fortal_parity_test.dart rename to registry_source/test/components/segmented_control/segmented_control_fortal_parity_test.dart index db6d6fa8f..82577d484 100644 --- a/packages/remix_fortal/test/components/segmented_control/segmented_control_fortal_parity_test.dart +++ b/registry_source/test/components/segmented_control/segmented_control_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/select/select_fortal_parity_test.dart b/registry_source/test/components/select/select_fortal_parity_test.dart similarity index 99% rename from packages/remix_fortal/test/components/select/select_fortal_parity_test.dart rename to registry_source/test/components/select/select_fortal_parity_test.dart index 5a9d74d87..0c0541027 100644 --- a/packages/remix_fortal/test/components/select/select_fortal_parity_test.dart +++ b/registry_source/test/components/select/select_fortal_parity_test.dart @@ -7,7 +7,7 @@ import 'package:remix/remix.dart'; // for no consumer, so the private import is the cheaper coupling. If that file // moves, retarget this import rather than weakening the assertion. import 'package:remix/src/utilities/remix_path_icon.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/sidebar/sidebar_collapse_test.dart b/registry_source/test/components/sidebar/sidebar_collapse_test.dart similarity index 99% rename from packages/remix_fortal/test/components/sidebar/sidebar_collapse_test.dart rename to registry_source/test/components/sidebar/sidebar_collapse_test.dart index 6cb54a6b4..8fc8b949e 100644 --- a/packages/remix_fortal/test/components/sidebar/sidebar_collapse_test.dart +++ b/registry_source/test/components/sidebar/sidebar_collapse_test.dart @@ -2,7 +2,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { testWidgets('generated sidebar forwards AnimationStyle.noAnimation', ( diff --git a/packages/remix_fortal/test/components/sidebar/sidebar_style_test.dart b/registry_source/test/components/sidebar/sidebar_style_test.dart similarity index 99% rename from packages/remix_fortal/test/components/sidebar/sidebar_style_test.dart rename to registry_source/test/components/sidebar/sidebar_style_test.dart index 48d75a42f..2b66fecad 100644 --- a/packages/remix_fortal/test/components/sidebar/sidebar_style_test.dart +++ b/registry_source/test/components/sidebar/sidebar_style_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/sidebar_layout/sidebar_layout_test.dart b/registry_source/test/components/sidebar_layout/sidebar_layout_test.dart similarity index 99% rename from packages/remix_fortal/test/components/sidebar_layout/sidebar_layout_test.dart rename to registry_source/test/components/sidebar_layout/sidebar_layout_test.dart index 8f2984864..a4c61d89d 100644 --- a/packages/remix_fortal/test/components/sidebar_layout/sidebar_layout_test.dart +++ b/registry_source/test/components/sidebar_layout/sidebar_layout_test.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; const _sidebarKey = ValueKey('sidebar'); const _bodyKey = ValueKey('body'); @@ -575,7 +575,7 @@ void main() { test('ships no Material import', () { final source = File( - 'lib/src/components/sidebar_layout.dart', + 'lib/src/fortal/components/sidebar_layout.dart', ).readAsStringSync(); expect(source, isNot(contains('package:flutter/material.dart'))); expect(source, isNot(contains("import 'package:flutter/material"))); diff --git a/packages/remix_fortal/test/components/skeleton/skeleton_fortal_parity_test.dart b/registry_source/test/components/skeleton/skeleton_fortal_parity_test.dart similarity index 97% rename from packages/remix_fortal/test/components/skeleton/skeleton_fortal_parity_test.dart rename to registry_source/test/components/skeleton/skeleton_fortal_parity_test.dart index 7ba574426..09c94407c 100644 --- a/packages/remix_fortal/test/components/skeleton/skeleton_fortal_parity_test.dart +++ b/registry_source/test/components/skeleton/skeleton_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { testWidgets('recipe matches the pinned Skeleton surface and timing', ( diff --git a/packages/remix_fortal/test/components/spinner/spinner_widget_test.dart b/registry_source/test/components/spinner/spinner_widget_test.dart similarity index 97% rename from packages/remix_fortal/test/components/spinner/spinner_widget_test.dart rename to registry_source/test/components/spinner/spinner_widget_test.dart index 567cab109..05aadf073 100644 --- a/packages/remix_fortal/test/components/spinner/spinner_widget_test.dart +++ b/registry_source/test/components/spinner/spinner_widget_test.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/tabs/tabs_fortal_parity_test.dart b/registry_source/test/components/tabs/tabs_fortal_parity_test.dart similarity index 97% rename from packages/remix_fortal/test/components/tabs/tabs_fortal_parity_test.dart rename to registry_source/test/components/tabs/tabs_fortal_parity_test.dart index d52a2b4b3..ac1026eee 100644 --- a/packages/remix_fortal/test/components/tabs/tabs_fortal_parity_test.dart +++ b/registry_source/test/components/tabs/tabs_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { testWidgets('focus-visible hover keeps the Radix accent hover treatment', ( diff --git a/packages/remix_fortal/test/components/textfield/fortal_textfield_disabled_test.dart b/registry_source/test/components/textfield/fortal_textfield_disabled_test.dart similarity index 98% rename from packages/remix_fortal/test/components/textfield/fortal_textfield_disabled_test.dart rename to registry_source/test/components/textfield/fortal_textfield_disabled_test.dart index 81a947e0a..651b33af4 100644 --- a/packages/remix_fortal/test/components/textfield/fortal_textfield_disabled_test.dart +++ b/registry_source/test/components/textfield/fortal_textfield_disabled_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/textfield/fortal_textfield_regression_test.dart b/registry_source/test/components/textfield/fortal_textfield_regression_test.dart similarity index 99% rename from packages/remix_fortal/test/components/textfield/fortal_textfield_regression_test.dart rename to registry_source/test/components/textfield/fortal_textfield_regression_test.dart index 6c835de33..7471739b2 100644 --- a/packages/remix_fortal/test/components/textfield/fortal_textfield_regression_test.dart +++ b/registry_source/test/components/textfield/fortal_textfield_regression_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; /// Resolved-value coverage for the shared Fortal text-input recipe. void main() { diff --git a/packages/remix_fortal/test/components/textfield/text_area_fortal_parity_test.dart b/registry_source/test/components/textfield/text_area_fortal_parity_test.dart similarity index 99% rename from packages/remix_fortal/test/components/textfield/text_area_fortal_parity_test.dart rename to registry_source/test/components/textfield/text_area_fortal_parity_test.dart index 5e9fe37a3..2bac17410 100644 --- a/packages/remix_fortal/test/components/textfield/text_area_fortal_parity_test.dart +++ b/registry_source/test/components/textfield/text_area_fortal_parity_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { for (final (size, minHeight, fontSize, radius) in const [ diff --git a/packages/remix_fortal/test/components/textfield/textfield_widget_test.dart b/registry_source/test/components/textfield/textfield_widget_test.dart similarity index 99% rename from packages/remix_fortal/test/components/textfield/textfield_widget_test.dart rename to registry_source/test/components/textfield/textfield_widget_test.dart index 901fea8cf..c8447d6f6 100644 --- a/packages/remix_fortal/test/components/textfield/textfield_widget_test.dart +++ b/registry_source/test/components/textfield/textfield_widget_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/gestures.dart'; import 'package:naked_ui/naked_ui.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/toast/toast_style_test.dart b/registry_source/test/components/toast/toast_style_test.dart similarity index 98% rename from packages/remix_fortal/test/components/toast/toast_style_test.dart rename to registry_source/test/components/toast/toast_style_test.dart index c7ef8305c..2f574be9b 100644 --- a/packages/remix_fortal/test/components/toast/toast_style_test.dart +++ b/registry_source/test/components/toast/toast_style_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/toggle/toggle_widget_test.dart b/registry_source/test/components/toggle/toggle_widget_test.dart similarity index 99% rename from packages/remix_fortal/test/components/toggle/toggle_widget_test.dart rename to registry_source/test/components/toggle/toggle_widget_test.dart index 1caa4c146..b5824f89e 100644 --- a/packages/remix_fortal/test/components/toggle/toggle_widget_test.dart +++ b/registry_source/test/components/toggle/toggle_widget_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/toggle_group/toggle_group_style_test.dart b/registry_source/test/components/toggle_group/toggle_group_style_test.dart similarity index 99% rename from packages/remix_fortal/test/components/toggle_group/toggle_group_style_test.dart rename to registry_source/test/components/toggle_group/toggle_group_style_test.dart index c237326e2..83bfddafd 100644 --- a/packages/remix_fortal/test/components/toggle_group/toggle_group_style_test.dart +++ b/registry_source/test/components/toggle_group/toggle_group_style_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { testWidgets('Fortal recipe resolves in a Fortal scope', (tester) async { diff --git a/packages/remix_fortal/test/components/toggle_group/toggle_group_widget_test.dart b/registry_source/test/components/toggle_group/toggle_group_widget_test.dart similarity index 96% rename from packages/remix_fortal/test/components/toggle_group/toggle_group_widget_test.dart rename to registry_source/test/components/toggle_group/toggle_group_widget_test.dart index 79991f231..1d894613d 100644 --- a/packages/remix_fortal/test/components/toggle_group/toggle_group_widget_test.dart +++ b/registry_source/test/components/toggle_group/toggle_group_widget_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/components/typography/typography_test.dart b/registry_source/test/components/typography/typography_test.dart similarity index 99% rename from packages/remix_fortal/test/components/typography/typography_test.dart rename to registry_source/test/components/typography/typography_test.dart index b32b4a4d1..a4eb8b71a 100644 --- a/packages/remix_fortal/test/components/typography/typography_test.dart +++ b/registry_source/test/components/typography/typography_test.dart @@ -6,7 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:naked_ui/naked_ui.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/fortal/control_icon_sizing_test.dart b/registry_source/test/fortal/control_icon_sizing_test.dart similarity index 99% rename from packages/remix_fortal/test/fortal/control_icon_sizing_test.dart rename to registry_source/test/fortal/control_icon_sizing_test.dart index ca3463daf..ff5b729e7 100644 --- a/packages/remix_fortal/test/fortal/control_icon_sizing_test.dart +++ b/registry_source/test/fortal/control_icon_sizing_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/fortal/focus_ring_test.dart b/registry_source/test/fortal/focus_ring_test.dart similarity index 98% rename from packages/remix_fortal/test/fortal/focus_ring_test.dart rename to registry_source/test/fortal/focus_ring_test.dart index 4393fd44c..267e5032f 100644 --- a/packages/remix_fortal/test/fortal/focus_ring_test.dart +++ b/registry_source/test/fortal/focus_ring_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; /// Pins [fortalFocusRing] to the literal borders the recipes used before they /// shared it. Toggle, toggle group, tabs, and accordion each had their own diff --git a/packages/remix_fortal/test/fortal/fortal_base_button_shared_states_test.dart b/registry_source/test/fortal/fortal_base_button_shared_states_test.dart similarity index 99% rename from packages/remix_fortal/test/fortal/fortal_base_button_shared_states_test.dart rename to registry_source/test/fortal/fortal_base_button_shared_states_test.dart index 5c9b1a345..19e0e2246 100644 --- a/packages/remix_fortal/test/fortal/fortal_base_button_shared_states_test.dart +++ b/registry_source/test/fortal/fortal_base_button_shared_states_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { group('Fortal BaseButton shared states', () { diff --git a/packages/remix_fortal/test/fortal/fortal_control_matrix_test.dart b/registry_source/test/fortal/fortal_control_matrix_test.dart similarity index 99% rename from packages/remix_fortal/test/fortal/fortal_control_matrix_test.dart rename to registry_source/test/fortal/fortal_control_matrix_test.dart index b5b387313..7e62ed941 100644 --- a/packages/remix_fortal/test/fortal/fortal_control_matrix_test.dart +++ b/registry_source/test/fortal/fortal_control_matrix_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; import '../helpers/test_helpers.dart'; diff --git a/packages/remix_fortal/test/fortal/fortal_high_contrast_test.dart b/registry_source/test/fortal/fortal_high_contrast_test.dart similarity index 99% rename from packages/remix_fortal/test/fortal/fortal_high_contrast_test.dart rename to registry_source/test/fortal/fortal_high_contrast_test.dart index d8a45d686..99b079de1 100644 --- a/packages/remix_fortal/test/fortal/fortal_high_contrast_test.dart +++ b/registry_source/test/fortal/fortal_high_contrast_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { group('Fortal high-contrast recipes', () { diff --git a/packages/remix_fortal/test/fortal/fortal_style_override_test.dart b/registry_source/test/fortal/fortal_style_override_test.dart similarity index 98% rename from packages/remix_fortal/test/fortal/fortal_style_override_test.dart rename to registry_source/test/fortal/fortal_style_override_test.dart index 2f24c0d57..eead1f051 100644 --- a/packages/remix_fortal/test/fortal/fortal_style_override_test.dart +++ b/registry_source/test/fortal/fortal_style_override_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { const idleOverride = Color(0xFF123456); diff --git a/packages/remix_fortal/test/fortal/fortal_theme_resolution_test.dart b/registry_source/test/fortal/fortal_theme_resolution_test.dart similarity index 99% rename from packages/remix_fortal/test/fortal/fortal_theme_resolution_test.dart rename to registry_source/test/fortal/fortal_theme_resolution_test.dart index 4a5649e3e..d63c10764 100644 --- a/packages/remix_fortal/test/fortal/fortal_theme_resolution_test.dart +++ b/registry_source/test/fortal/fortal_theme_resolution_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { test('theme configuration stores only canonical nullable overrides', () { diff --git a/packages/remix_fortal/test/fortal/fortal_theme_test.dart b/registry_source/test/fortal/fortal_theme_test.dart similarity index 99% rename from packages/remix_fortal/test/fortal/fortal_theme_test.dart rename to registry_source/test/fortal/fortal_theme_test.dart index 8ae05662f..fe3be5e4c 100644 --- a/packages/remix_fortal/test/fortal/fortal_theme_test.dart +++ b/registry_source/test/fortal/fortal_theme_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { group('Fortal scaled tokens', () { diff --git a/packages/remix_fortal/test/fortal/fortal_tokens_test.dart b/registry_source/test/fortal/fortal_tokens_test.dart similarity index 99% rename from packages/remix_fortal/test/fortal/fortal_tokens_test.dart rename to registry_source/test/fortal/fortal_tokens_test.dart index fed70684f..fc17e6153 100644 --- a/packages/remix_fortal/test/fortal/fortal_tokens_test.dart +++ b/registry_source/test/fortal/fortal_tokens_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { group('Fortal semantic tokens', () { diff --git a/packages/remix_fortal/test/fortal/radix_3_3_color_fixture_test.dart b/registry_source/test/fortal/radix_3_3_color_fixture_test.dart similarity index 98% rename from packages/remix_fortal/test/fortal/radix_3_3_color_fixture_test.dart rename to registry_source/test/fortal/radix_3_3_color_fixture_test.dart index 589add357..825eb44ad 100644 --- a/packages/remix_fortal/test/fortal/radix_3_3_color_fixture_test.dart +++ b/registry_source/test/fortal/radix_3_3_color_fixture_test.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_fortal/remix_fortal.dart' as remix; +import 'package:registry_source/fortal.dart' as remix; const _themes = { 'gray': remix.gray, diff --git a/packages/remix_fortal/test/helpers/contrast.dart b/registry_source/test/helpers/contrast.dart similarity index 100% rename from packages/remix_fortal/test/helpers/contrast.dart rename to registry_source/test/helpers/contrast.dart diff --git a/packages/remix_fortal/test/helpers/test_helpers.dart b/registry_source/test/helpers/test_helpers.dart similarity index 97% rename from packages/remix_fortal/test/helpers/test_helpers.dart rename to registry_source/test/helpers/test_helpers.dart index 796044658..a04fe6046 100644 --- a/packages/remix_fortal/test/helpers/test_helpers.dart +++ b/registry_source/test/helpers/test_helpers.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; /// Fortal's counterpart to `packages/remix/test/helpers/test_helpers.dart`. /// diff --git a/packages/remix_fortal/test/host_capabilities_test.dart b/registry_source/test/host_capabilities_test.dart similarity index 97% rename from packages/remix_fortal/test/host_capabilities_test.dart rename to registry_source/test/host_capabilities_test.dart index acd5b22fc..b32e0c617 100644 --- a/packages/remix_fortal/test/host_capabilities_test.dart +++ b/registry_source/test/host_capabilities_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; /// The Remix-side host contract (no ambient `Overlay`/`Navigator` required) is /// covered by `packages/remix/test/host_capabilities_test.dart`. This suite only diff --git a/packages/remix_fortal/test/radix/fortal_icons_test.dart b/registry_source/test/radix/fortal_icons_test.dart similarity index 92% rename from packages/remix_fortal/test/radix/fortal_icons_test.dart rename to registry_source/test/radix/fortal_icons_test.dart index eaf647a0a..4a0bffeb1 100644 --- a/packages/remix_fortal/test/radix/fortal_icons_test.dart +++ b/registry_source/test/radix/fortal_icons_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:remix_fortal/remix_fortal.dart'; +import 'package:registry_source/fortal.dart'; void main() { testWidgets('FortalIcons remains a rendering-compatible RemixIcons alias', ( diff --git a/packages/remix_fortal/tool/fortal_parity/check.dart b/registry_source/tool/fortal_parity/check.dart similarity index 98% rename from packages/remix_fortal/tool/fortal_parity/check.dart rename to registry_source/tool/fortal_parity/check.dart index d574a5c71..3640598c7 100644 --- a/packages/remix_fortal/tool/fortal_parity/check.dart +++ b/registry_source/tool/fortal_parity/check.dart @@ -67,15 +67,15 @@ void main() { // Anchored so `name: remix` (the sibling package) cannot satisfy the guard. if (!pubspec.existsSync() || !RegExp( - r'^name:\s*remix_fortal\s*$', + r'^name:\s*registry_source\s*$', multiLine: true, ).hasMatch(pubspec.readAsStringSync())) { - stderr.writeln('Run this checker from packages/remix_fortal.'); + stderr.writeln('Run this checker from registry_source.'); exitCode = 64; return; } - final workspaceRoot = packageRoot.parent.parent; + final workspaceRoot = packageRoot.parent; final failures = []; final manifestFile = File( '${packageRoot.path}/reference/radix_themes_3_3_0/manifest.json', @@ -475,13 +475,15 @@ String? _readFortalStylesSource( String id, List failures, ) { - // Components are flat under lib/src/components/, and TextField and TextArea + // Components are flat under lib/src/fortal/components/, and TextField and TextArea // share one file because TextArea reuses TextField's private helpers. final recipeName = switch (id) { 'text_field' || 'text_area' => 'textfield', _ => id, }; - final file = File('${packageRoot.path}/lib/src/components/$recipeName.dart'); + final file = File( + '${packageRoot.path}/lib/src/fortal/components/$recipeName.dart', + ); if (!file.existsSync()) { failures.add('$id is missing Fortal recipe source ${file.path}.'); return null; @@ -491,7 +493,9 @@ String? _readFortalStylesSource( // The shared scale, weights, and flow helpers live beside the five // typography recipes rather than inside any one of them. - final shared = File('${packageRoot.path}/lib/src/components/typography.dart'); + final shared = File( + '${packageRoot.path}/lib/src/fortal/components/typography.dart', + ); if (!shared.existsSync()) { failures.add('$id is missing shared typography source ${shared.path}.'); return source; @@ -822,12 +826,12 @@ void _checkCoverage({ } } -/// Resolves a cited test path across both `remix_fortal` and `remix`. +/// Resolves a cited test path across both `registry_source` and `remix`. /// /// The coverage ledger cites tests by package-relative path. Extracting Fortal /// split those tests across two packages, and a single path such as /// `test/components/menu/menu_widget_test.dart` now commonly exists in *both*: -/// `remix` keeps the base behavior cases, `remix_fortal` keeps the Fortal ones. +/// `remix` keeps the base behavior cases, `registry_source` keeps the Fortal ones. /// Returning every match — and searching their union for a cited case — keeps /// the ledger's paths stable instead of rewriting every citation. List _resolveCitedTests( @@ -1174,7 +1178,7 @@ Set _dartEnumValues(String body) { } void _checkVariantConstructors(Directory packageRoot, List failures) { - final recipeRoot = Directory('${packageRoot.path}/lib/src/components'); + final recipeRoot = Directory('${packageRoot.path}/lib/src/fortal/components'); for (final entity in recipeRoot.listSync()) { if (entity is! File || !entity.path.endsWith('.dart') || diff --git a/packages/remix_fortal/tool/fortal_parity/chromium/fixture.html b/registry_source/tool/fortal_parity/chromium/fixture.html similarity index 100% rename from packages/remix_fortal/tool/fortal_parity/chromium/fixture.html rename to registry_source/tool/fortal_parity/chromium/fixture.html diff --git a/packages/remix_fortal/tool/fortal_parity/chromium/generate.mjs b/registry_source/tool/fortal_parity/chromium/generate.mjs similarity index 100% rename from packages/remix_fortal/tool/fortal_parity/chromium/generate.mjs rename to registry_source/tool/fortal_parity/chromium/generate.mjs diff --git a/packages/remix_fortal/tool/fortal_parity/chromium/package-lock.json b/registry_source/tool/fortal_parity/chromium/package-lock.json similarity index 100% rename from packages/remix_fortal/tool/fortal_parity/chromium/package-lock.json rename to registry_source/tool/fortal_parity/chromium/package-lock.json diff --git a/packages/remix_fortal/tool/fortal_parity/chromium/package.json b/registry_source/tool/fortal_parity/chromium/package.json similarity index 100% rename from packages/remix_fortal/tool/fortal_parity/chromium/package.json rename to registry_source/tool/fortal_parity/chromium/package.json diff --git a/skills/building-remix-design-system/SKILL.md b/skills/building-remix-design-system/SKILL.md index 5c2d6bd50..9cc5b51a5 100644 --- a/skills/building-remix-design-system/SKILL.md +++ b/skills/building-remix-design-system/SKILL.md @@ -33,8 +33,8 @@ that: - exposes an idiomatic public API in the *target system's* vocabulary, not Remix's or Fortal's. -`remix_fortal` is the in-repo precedent for the `packages/` shape this -skill describes: a standalone, separately versioned package that depends on +Fortal (`registry_source/lib/src/fortal`, derived into the `remix_cli` preset) +is the in-repo precedent for the shape this skill describes: a standalone, separately versioned package that depends on `remix`, owns its own token scope, and generates its widget catalog with `@MixWidget`. Follow that structure. Do not clone Fortal's *content* and swap colors — its values are hand-authored against a pinned Radix parity contract, diff --git a/skills/using-remix/SKILL.md b/skills/using-remix/SKILL.md index 2971addce..277cb5d58 100644 --- a/skills/using-remix/SKILL.md +++ b/skills/using-remix/SKILL.md @@ -28,12 +28,12 @@ is configured. | Accessible component behavior with a custom visual system | `remix`; use `Remix*` widgets and `*Styler`s | | Ready-made Radix-inspired visuals | `remix_cli` with `preset: fortal`; use the configured scope and prefixed widgets | | Fortal tokens with a customized composition | installed Fortal source plus `remix`; apply the prefixed recipe to a `Remix*` widget | -| Agent-run surfaces (composer, transcript, permission, plan) | `remix_agent`; use `Agent*` widgets. It depends on `remix` only, has no registry item, and takes its appearance from the application's installed recipes. | +| Agent-run surfaces (composer, transcript, permission, plan) | `remix_cli` items `composer`, `transcript`, `permission`, `plan`, … plus their `_recipe`; use the prefixed installed widgets. They depend on `remix` only and take their appearance from the installed recipes. | | A visual system unrelated to Fortal | base Remix styling; do not initialize the Fortal preset | Remix ships no theme. Fortal is optional application-owned source installed by -the CLI. The repository's `remix_fortal` workspace package is its analyzed -authoring and parity surface, not a dependency for new consumer applications. +the CLI. The repository's `registry_source` package is the analyzed authoring +and parity source of every preset, never a dependency of a consumer application. Preserve an existing legacy `remix_fortal` dependency unless the user asks to migrate it; do not add that dependency to a new consumer. diff --git a/test/tool/build_fortal_preset_test.dart b/test/tool/build_fortal_preset_test.dart deleted file mode 100644 index fe218b79e..000000000 --- a/test/tool/build_fortal_preset_test.dart +++ /dev/null @@ -1,252 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:test/test.dart'; -import 'package:yaml/yaml.dart'; - -import '../../tool/build_fortal_preset.dart'; - -void main() { - late Directory sandbox; - - setUp(() { - sandbox = Directory.systemTemp.createTempSync('fortal_preset_test_'); - }); - - tearDown(() { - if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); - }); - - test('every authored template round-trips and removes Fortal names', () { - final output = FortalPresetBuilder.forRepository( - Directory.current, - ).derive(); - - expect(output.sourceByTemplate, isNotEmpty); - for (final entry in output.sourceByTemplate.entries) { - final template = output.files[entry.key]!; - final rendered = template - .replaceAll('{{typePrefix}}', 'Fortal') - .replaceAll('{{valuePrefix}}', 'fortal'); - - expect(rendered, entry.value, reason: entry.key); - expect(template, isNot(contains('Fortal')), reason: entry.key); - expect(template, isNot(contains('fortal')), reason: entry.key); - } - }); - - test('registry dependencies and package floors are inferred', () { - final output = FortalPresetBuilder.forRepository( - Directory.current, - ).derive(); - final document = loadYaml(output.files['registry.yaml']!) as YamlMap; - final items = document['items'] as YamlMap; - - expect(_strings((items['button'] as YamlMap)['registryDependencies']), [ - 'theme', - 'base_button', - ]); - expect(_strings((items['data_table'] as YamlMap)['registryDependencies']), [ - 'theme', - 'checkbox', - 'icon_button', - 'select', - ]); - expect(_strings((items['sidebar'] as YamlMap)['registryDependencies']), [ - 'theme', - 'text', - 'toggle', - 'tooltip', - ]); - // sidebar_layout's `sidebar` field is typed `Widget`, not - // `FortalSidebar`, so its source never imports components/sidebar.dart - // and import inference alone would miss this dependency. It comes from - // _uninferredRegistryDependencies instead, mirroring the same manual - // dependency the default preset's hand-authored registry.yaml declares. - expect( - _strings((items['sidebar_layout'] as YamlMap)['registryDependencies']), - ['theme', 'sidebar'], - ); - expect( - (items['base_button'] as YamlMap).containsKey('dependencies'), - isFalse, - ); - expect( - (items['typography'] as YamlMap).containsKey('devDependencies'), - isFalse, - ); - expect( - ((items['chart'] as YamlMap)['dependencies'] as YamlMap).keys, - containsAll(['mix_annotations', 'mix_chart']), - ); - expect(_strings((items['button'] as YamlMap)['generated']), [ - '@ui/components/button.g.dart', - ]); - }); - - test('refuses Fortal path segments before reading registry metadata', () { - final builder = _emptyBuilder(sandbox); - _write( - builder.sourceRoot, - 'components/fortal_button.dart', - 'void recipe() {}\n', - ); - - expect( - builder.derive, - throwsA( - isA().having( - (error) => error.message, - 'message', - allOf(contains('components/fortal_button.dart'), contains('segment')), - ), - ), - ); - }); - - test('refuses reserved template tokens before substitution', () { - final builder = _emptyBuilder(sandbox); - _write( - builder.sourceRoot, - 'components/button.dart', - '// {{reserved}}\nvoid recipe() {}\n', - ); - - expect( - builder.derive, - throwsA( - isA().having( - (error) => error.message, - 'message', - allOf(contains('components/button.dart'), contains('"{{"')), - ), - ), - ); - }); - - test('refuses every package import outside the installed boundary', () { - for (final package in ['remix_fortal', 'mix', 'naked_ui']) { - final root = Directory(p.join(sandbox.path, package)); - final builder = _emptyBuilder(root); - _write( - builder.sourceRoot, - 'components/button.dart', - "import 'package:$package/example.dart';\n", - ); - - expect( - builder.derive, - throwsA( - isA().having( - (error) => error.message, - 'message', - allOf( - contains('components/button.dart'), - contains('package:$package/'), - ), - ), - ), - reason: package, - ); - } - }); - - test('check mode reports planted changed and stale output', () { - final builder = _fixtureBuilder(sandbox); - final output = builder.derive(); - builder.write(output); - expect(builder.drift(output), isEmpty); - - File( - p.join( - builder.outputRoot.path, - 'templates', - 'button', - 'button.dart.tmpl', - ), - ).writeAsStringSync('// planted drift\n'); - _write(builder.outputRoot, 'templates/stale.dart.tmpl', '// stale\n'); - - expect(builder.drift(output), [ - 'changed templates/button/button.dart.tmpl', - 'stale templates/stale.dart.tmpl', - ]); - }); -} - -FortalPresetBuilder _emptyBuilder(Directory root) => FortalPresetBuilder( - sourceRoot: Directory(p.join(root.path, 'source')), - defaultRegistryRoot: Directory(p.join(root.path, 'default')), - outputRoot: Directory(p.join(root.path, 'output')), -); - -FortalPresetBuilder _fixtureBuilder(Directory root) { - final builder = _emptyBuilder(root); - _write(builder.sourceRoot, 'theme/theme.dart', "export 'tokens.dart';\n"); - _write( - builder.sourceRoot, - 'theme/tokens.dart', - "import 'package:remix/remix.dart';\nabstract class FortalTokens {}\n", - ); - _write( - builder.sourceRoot, - 'components/button.dart', - """import 'package:mix_annotations/mix_annotations.dart'; -import 'package:remix/remix.dart'; - -import '../theme/theme.dart'; - -part 'button.g.dart'; - -void fortalButtonStyle() {} -""", - ); - _writeDefaultRegistry(builder.defaultRegistryRoot); - return builder; -} - -void _writeDefaultRegistry(Directory root) { - _write(root, 'registry.yaml', '''schema: 1 -items: - theme: - dependencies: - remix: ^1.0.0 - files: - - source: templates/theme/theme.dart.tmpl - target: "@ui/theme/theme.dart" - button: - dependencies: - mix_annotations: ^2.0.0 - devDependencies: - build_runner: ^2.0.0 - mix_generator: ^2.0.0 - files: - - source: templates/button/button.dart.tmpl - target: "@ui/components/button.dart" - chart: - dependencies: - mix_chart: ^1.0.0 - files: - - source: templates/chart/chart.dart.tmpl - target: "@ui/components/chart.dart" - icons: - dependencies: - remix_ui_icons: ^1.0.0 - files: - - source: templates/icons/icons.dart.tmpl - target: "@ui/icons.dart" -'''); - _write( - root, - 'templates/icons/icons.dart.tmpl', - 'abstract final class {{typePrefix}}Icons {}\n', - ); -} - -void _write(Directory root, String relativePath, String source) { - final file = File(p.joinAll([root.path, ...p.posix.split(relativePath)])); - file.parent.createSync(recursive: true); - file.writeAsStringSync(source); -} - -List _strings(Object? value) => (value as YamlList).cast(); diff --git a/test/tool/build_registry_agent_test.dart b/test/tool/build_registry_agent_test.dart new file mode 100644 index 000000000..86a5833bf --- /dev/null +++ b/test/tool/build_registry_agent_test.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +import '../../packages/remix_cli/lib/src/registry.dart'; +import '../../tool/build_registry.dart'; + +/// The Agent extension: `registry_source/lib/src/agent` deriving into a preset's +/// `templates/agent/` subtree. It never writes; the preset's own spec merges +/// and owns the result, which is what `build_registry_test.dart` covers. +void main() { + late Directory sandbox; + late PresetBuilder builder; + + setUp(() { + sandbox = Directory.systemTemp.createTempSync('agent_registry_test_'); + final source = Directory('${sandbox.path}/source'); + for (final file in Directory( + 'registry_source/lib/src/agent', + ).listSync(recursive: true).whereType()) { + if (!file.path.endsWith('.dart') || file.path.endsWith('.g.dart')) + continue; + _write( + source, + p.relative(file.path, from: 'registry_source/lib/src/agent'), + file.readAsStringSync(), + ); + } + final registry = Directory('${sandbox.path}/default'); + _write( + registry, + 'registry.yaml', + File( + 'packages/remix_cli/lib/src/registry/default/registry.yaml', + ).readAsStringSync(), + ); + builder = PresetBuilder( + spec: defaultAgentExtension, + sourceRoot: source, + defaultRegistryRoot: registry, + outputRoot: registry, + ); + }); + + tearDown(() => sandbox.deleteSync(recursive: true)); + + test('components reuse default floors and resolve independently', () { + final output = builder.derive(); + final items = _items(output.files['registry.yaml']!); + expect(items.keys, [ + 'models', + 'support', + 'activity', + 'answer', + 'composer', + 'execution', + 'message', + 'permission', + 'plan', + 'transcript', + ]); + expect(items['support']['registryDependencies'], ['theme']); + expect(items['support']['dependencies']['remix_ui_icons'], isNotNull); + expect(items['support']['exports'], isNull); + expect(items['models']['exports'], contains('models/statuses.dart')); + expect( + output.files.keys.where((path) => path != 'registry.yaml'), + everyElement(startsWith('templates/agent/')), + ); + expect(output.files.keys, isNot(anyElement(endsWith('.g.dart.tmpl')))); + + // Resolvable against the committed default catalog, with the theme item + // as the single owner of the Remix floor and no dependency on the + // authoring package. + final catalog = RegistryCatalog.parse( + _merged(builder.outputRoot, output), + preset: 'default', + rootUri: builder.outputRoot.uri, + ); + for (final name in items.keys.where( + (name) => name != 'models' && name != 'support', + )) { + final closure = catalog.resolve(name as String); + expect( + closure.map((item) => item.name), + containsAll(['theme', 'support', name]), + ); + expect( + closure.where((item) => item.dependencies.containsKey('remix')), + hasLength(1), + ); + expect( + closure.expand((item) => item.dependencies.keys), + isNot(contains('remix_agent')), + ); + } + }); + + test('prefix round trips and domain prose is not silently renamed', () { + final output = builder.derive(); + for (final entry in output.sourceByTemplate.entries) { + final template = output.files[entry.key]!; + expect( + template + .replaceAll('{{typePrefix}}', 'Agent') + .replaceAll('{{valuePrefix}}', 'agent'), + entry.value, + ); + final rendered = template + .replaceAll('{{typePrefix}}', 'Acme') + .replaceAll('{{valuePrefix}}', 'acme'); + expect(rendered, isNot(contains('package:remix_agent/'))); + expect(rendered, isNot(contains('../style/'))); + // Prefixable identifiers and family names remain legal. A lowercase + // standalone domain noun in authored line comments does not. + for (final line in entry.value.split('\n')) { + if (!line.trimLeft().startsWith('//')) continue; + final prose = line.replaceAll(RegExp(r'\[[^\]]+\]|`[^`]+`'), ''); + expect( + RegExp(r'\bagent\b').hasMatch(prose), + isFalse, + reason: '${entry.key}: $line', + ); + } + } + }); + + test('an extension neither writes nor checks a tree on its own', () { + final output = builder.derive(); + expect(() => builder.write(output), throwsStateError); + expect(() => builder.drift(output), throwsStateError); + }); + + test('relative imports between shared directories become dependencies', () { + final file = File( + '${builder.sourceRoot.path}/support/functional_glyph.dart', + ); + file.writeAsStringSync( + "import '../models/statuses.dart';\n${file.readAsStringSync()}", + ); + final items = _items(builder.derive().files['registry.yaml']!); + expect(items['support']['registryDependencies'], ['theme', 'models']); + }); + + test( + 'missing relative sources and prefix-sensitive URIs fail derivation', + () { + final file = File('${builder.sourceRoot.path}/models/statuses.dart'); + final original = file.readAsStringSync(); + file.writeAsStringSync("import 'missing.dart';\n$original"); + expect(builder.derive, throwsFormatException); + file.writeAsStringSync( + "export 'package:unrelated/agent.dart';\n$original", + ); + expect(builder.derive, throwsFormatException); + }, + ); + + test('shared dependency cycles are rejected by catalog validation', () { + final models = File('${builder.sourceRoot.path}/models/statuses.dart'); + final support = File( + '${builder.sourceRoot.path}/support/functional_glyph.dart', + ); + models.writeAsStringSync( + "import '../support/functional_glyph.dart';\n${models.readAsStringSync()}", + ); + support.writeAsStringSync( + "import '../models/statuses.dart';\n${support.readAsStringSync()}", + ); + final output = builder.derive(); + expect( + () => RegistryCatalog.parse( + _merged(builder.outputRoot, output), + preset: 'default', + rootUri: builder.outputRoot.uri, + ), + throwsFormatException, + ); + }); +} + +Map _items(String registry) => (loadYaml(registry) as Map)['items'] as Map; + +/// The committed default registry with the extension's items merged in, as +/// the preset writer would see it. +String _merged(Directory registryRoot, PresetOutput output) { + final file = File('${registryRoot.path}/registry.yaml'); + return jsonEncode({ + 'schema': 1, + 'items': { + ..._items(file.readAsStringSync()), + ..._items(output.files['registry.yaml']!), + }, + }); +} + +void _write(Directory root, String relative, String contents) { + final file = File(p.join(root.path, relative)); + file.parent.createSync(recursive: true); + file.writeAsStringSync(contents); +} diff --git a/test/tool/build_registry_test.dart b/test/tool/build_registry_test.dart new file mode 100644 index 000000000..b859cf4ff --- /dev/null +++ b/test/tool/build_registry_test.dart @@ -0,0 +1,688 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +import '../../tool/build_registry.dart'; + +void main() { + test( + 'Fortal merges Agent behavior and recipes without changing base output', + () { + final root = Directory.current.absolute; + final baseBuilder = PresetBuilder.forRepository(root); + final agentBuilder = PresetBuilder.forRepository( + root, + spec: fortalAgentExtension, + ); + final base = baseBuilder.derive(); + final merged = mergePresetOutputs(base, agentBuilder.derive()); + + for (final entry in base.files.entries) { + if (entry.key == 'registry.yaml') continue; + expect(merged.files[entry.key], entry.value, reason: entry.key); + } + final registry = merged.files['registry.yaml']!; + expect(registry, contains(' composer_recipe:')); + expect(registry, contains(' transcript_recipe:')); + expect( + merged.files, + contains('templates/agent/composer/composer.dart.tmpl'), + ); + expect( + merged.files, + contains('templates/recipes/composer_recipe.dart.tmpl'), + ); + expect( + merged.files.keys.where((path) => path.startsWith('templates/agent/')), + isNot(anyElement(contains('/recipes/'))), + ); + }, + ); + + test( + 'recipes derive against installed behavior, not the authoring import', + () { + for (final spec in [defaultPreset, fortalPreset]) { + final output = PresetBuilder.forRepository( + Directory.current.absolute, + spec: spec, + ).derive(); + final items = + loadYaml(output.files['registry.yaml']!)['items'] as YamlMap; + for (final name in agentRecipes) { + final template = output.files['templates/recipes/$name.dart.tmpl']!; + expect( + template, + isNot(contains('package:remix_agent')), + reason: name, + ); + expect( + RegExp(r'(? mergePresetOutputs(left, right), throwsStateError); + }); + + test('preset merge rejects item and target collisions before writing', () { + PresetOutput fixture( + String name, + String target, + String source, + ) => PresetOutput( + files: { + 'registry.yaml': + 'schema: 1\nitems:\n $name:\n files:\n - source: $source\n target: "$target"\n', + source: 'fixture', + }, + sourceByTemplate: const {}, + ); + final base = fixture('one', '@ui/one.dart', 'templates/one'); + expect( + () => mergePresetOutputs( + base, + fixture('one', '@ui/two.dart', 'templates/two'), + ), + throwsStateError, + ); + expect( + () => mergePresetOutputs( + base, + fixture('two', '@ui/one.dart', 'templates/two'), + ), + throwsStateError, + ); + }); + + late Directory sandbox; + + setUp(() { + sandbox = Directory.systemTemp.createTempSync('fortal_preset_test_'); + }); + + tearDown(() { + if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); + }); + + test('every authored template round-trips and removes Fortal names', () { + final output = PresetBuilder.forRepository(Directory.current).derive(); + + expect(output.sourceByTemplate, isNotEmpty); + for (final entry in output.sourceByTemplate.entries) { + final template = output.files[entry.key]!; + final rendered = template + .replaceAll('{{typePrefix}}', 'Fortal') + .replaceAll('{{valuePrefix}}', 'fortal'); + + expect(rendered, entry.value, reason: entry.key); + expect(template, isNot(contains('Fortal')), reason: entry.key); + expect(template, isNot(contains('fortal')), reason: entry.key); + } + }); + + test('registry dependencies and package floors are inferred', () { + final output = PresetBuilder.forRepository(Directory.current).derive(); + final document = loadYaml(output.files['registry.yaml']!) as YamlMap; + final items = document['items'] as YamlMap; + + expect(_strings((items['button'] as YamlMap)['registryDependencies']), [ + 'theme', + 'base_button', + ]); + expect(_strings((items['data_table'] as YamlMap)['registryDependencies']), [ + 'theme', + 'checkbox', + 'icon_button', + 'select', + ]); + expect(_strings((items['sidebar'] as YamlMap)['registryDependencies']), [ + 'theme', + 'text', + 'toggle', + 'tooltip', + ]); + // sidebar_layout's `sidebar` field is typed `Widget`, not + // `FortalSidebar`, so its source never imports components/sidebar.dart + // and import inference alone would miss this dependency. It comes from + // the spec's composedRegistryDependencies instead, mirroring the same + // manual dependency the default preset's registry.yaml declares. + expect( + _strings((items['sidebar_layout'] as YamlMap)['registryDependencies']), + ['theme', 'sidebar'], + ); + expect( + (items['base_button'] as YamlMap).containsKey('dependencies'), + isFalse, + ); + expect( + (items['typography'] as YamlMap).containsKey('devDependencies'), + isFalse, + ); + expect( + ((items['chart'] as YamlMap)['dependencies'] as YamlMap).keys, + containsAll(['mix_annotations', 'mix_chart']), + ); + expect(_strings((items['button'] as YamlMap)['generated']), [ + '@ui/components/button.g.dart', + ]); + }); + + test('refuses Fortal path segments before reading registry metadata', () { + final builder = _emptyBuilder(sandbox); + _write( + builder.sourceRoot, + 'components/fortal_button.dart', + 'void recipe() {}\n', + ); + + expect( + builder.derive, + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf(contains('components/fortal_button.dart'), contains('segment')), + ), + ), + ); + }); + + test('refuses reserved template tokens before substitution', () { + final builder = _emptyBuilder(sandbox); + _write( + builder.sourceRoot, + 'components/button.dart', + '// {{reserved}}\nvoid recipe() {}\n', + ); + + expect( + builder.derive, + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf(contains('components/button.dart'), contains('"{{"')), + ), + ), + ); + }); + + test('refuses every package import outside the installed boundary', () { + for (final package in ['registry_source', 'mix', 'naked_ui']) { + final root = Directory(p.join(sandbox.path, package)); + final builder = _emptyBuilder(root); + _write( + builder.sourceRoot, + 'components/button.dart', + "import 'package:$package/example.dart';\n", + ); + + expect( + builder.derive, + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf( + contains('components/button.dart'), + contains('package:$package/'), + ), + ), + ), + reason: package, + ); + } + }); + + test('a second spec derives without any Fortal naming', () { + // Without this, "generalized" is unverified: every other test here runs + // the one spec whose values the builder used to hardcode. + final builder = _emptyBuilder(sandbox, spec: _acmePreset); + _write(builder.sourceRoot, 'core/core.dart', "export 'tokens.dart';\n"); + _write( + builder.sourceRoot, + 'core/tokens.dart', + "import 'package:remix/remix.dart';\nabstract class AcmeTokens {}\n", + ); + _write( + builder.sourceRoot, + 'widgets/dial.dart', + """import 'package:remix/remix.dart'; + +import '../core/core.dart'; + +void acmeDialStyle() {} +""", + ); + _writeDefaultRegistry(builder.defaultRegistryRoot); + + final output = builder.derive(); + final items = + (loadYaml(output.files['registry.yaml']!) as YamlMap)['items'] + as YamlMap; + + // The spec's directories, not Fortal's, decide layout and item names. + expect(items.keys, ['core', 'dial']); + expect(_strings((items['dial'] as YamlMap)['registryDependencies']), [ + 'core', + ]); + expect(_strings((items['dial'] as YamlMap)['exports']), [ + 'widgets/dial.dart', + ]); + // One directory per component, as Fortal's `templates/button/` is. + expect( + output.files['templates/dial/dial.dart.tmpl'], + contains('void {{valuePrefix}}DialStyle()'), + ); + expect( + output.files['templates/core/tokens.dart.tmpl'], + contains('abstract class {{typePrefix}}Tokens'), + ); + // `Fortal` is not a reserved word to this builder any more; `Acme` is. + expect( + output.files['templates/core/tokens.dart.tmpl'], + isNot(contains('Acme')), + ); + }); + + test('a second spec forbids importing its own source package', () { + final builder = _emptyBuilder(sandbox, spec: _acmePreset); + _write( + builder.sourceRoot, + 'widgets/dial.dart', + "import 'package:remix_acme/remix_acme.dart';\n", + ); + + expect( + builder.derive, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('package:remix_acme/'), + ), + ), + ); + }); + + test('a recipe rewrites the behavior import and word, then sorts', () { + final builder = _emptyBuilder(sandbox, spec: _acmeWithRecipes); + _writeAcmeSources(builder); + _write( + builder.sourceRoot, + 'recipes/dial_recipe.dart', + """import 'package:remix/remix.dart'; +import '../../bot/components/dial.dart'; + +import '../core/core.dart'; +import '../widgets/dial.dart'; + +final class AcmeBotDialRecipe { + const AcmeBotDialRecipe(this.style); + final BotDialStyler style; +} + +AcmeBotDialRecipe acmeBotDialRecipe() => + AcmeBotDialRecipe(BotDialStyler().merge(acmeDialStyle())); +""", + ); + final output = builder.derive(); + final template = output.files['templates/recipes/dial_recipe.dart.tmpl']!; + expect(template, """import 'package:remix/remix.dart'; + +import '../components/dial.dart'; +import '../core/core.dart'; +import '../widgets/dial.dart'; + +final class {{typePrefix}}BotDialRecipe { + const {{typePrefix}}BotDialRecipe(this.style); + final {{typePrefix}}DialStyler style; +} + +{{typePrefix}}BotDialRecipe {{valuePrefix}}BotDialRecipe() => + {{typePrefix}}BotDialRecipe({{typePrefix}}DialStyler().merge({{valuePrefix}}DialStyle())); +"""); + final items = loadYaml(output.files['registry.yaml']!)['items'] as YamlMap; + expect( + _strings((items['dial_recipe'] as YamlMap)['registryDependencies']), + ['core', 'dial'], + ); + expect( + (items['dial_recipe'] as YamlMap)['files'].first['target'], + '@ui/recipes/dial_recipe.dart', + ); + }); + + test( + 'behavior imports are refused outside recipes and beyond components', + () { + final builder = _emptyBuilder(sandbox, spec: _acmeWithRecipes); + _writeAcmeSources(builder); + _write( + builder.sourceRoot, + 'widgets/gauge.dart', + "import '../../bot/components/dial.dart';\n", + ); + _write( + builder.sourceRoot, + 'recipes/dial_recipe.dart', + "import '../../bot/support/glyph.dart';\n", + ); + expect( + builder.derive, + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf( + contains('widgets/gauge.dart: only recipes may import'), + contains( + 'recipes/dial_recipe.dart: recipes import behavior ' + 'components only', + ), + ), + ), + ), + ); + }, + ); + + test('recipes and declared recipe items must agree', () { + final builder = _emptyBuilder(sandbox, spec: _acmeWithRecipes); + _writeAcmeSources(builder); + expect( + builder.derive, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('recipes/dial_recipe.dart'), + ), + ), + ); + _write(builder.sourceRoot, 'recipes/dial_recipe.dart', ''); + _write(builder.sourceRoot, 'recipes/extra_recipe.dart', ''); + expect( + builder.derive, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('extra_recipe.dart is not a declared acme recipe'), + ), + ), + ); + }); + + test('a recipe importing something the preset never installs is refused', () { + final builder = _emptyBuilder(sandbox, spec: _acmeWithRecipes); + _writeAcmeSources(builder); + _write( + builder.sourceRoot, + 'recipes/dial_recipe.dart', + "import '../../bot/components/dial.dart';\n", + ); + // Derivation defers the behavior component to the merge; validation of + // the un-merged preset is where it surfaces. + final output = builder.derive(); + expect( + () => builder.validate(output), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('dial_recipe imports ../components/dial.dart'), + ), + ), + ); + }); + + test('check mode reports planted changed and stale output', () { + final builder = _fixtureBuilder(sandbox); + final output = builder.derive(); + builder.write(output); + expect(builder.drift(output), isEmpty); + + File( + p.join( + builder.outputRoot.path, + 'templates', + 'button', + 'button.dart.tmpl', + ), + ).writeAsStringSync('// planted drift\n'); + _write(builder.outputRoot, 'templates/stale.dart.tmpl', '// stale\n'); + + expect(builder.drift(output), [ + 'changed templates/button/button.dart.tmpl', + 'stale templates/stale.dart.tmpl', + ]); + }); +} + +/// A deliberately un-Fortal spec: different word, directories, and package. +const _acmePreset = PresetSpec( + name: 'acme', + sourceRoot: 'registry_source/acme', + sourcePackage: 'remix_acme', + typeWord: 'Acme', + valueWord: 'acme', + componentDirectory: 'widgets', + sharedItems: [ + SharedItemSpec( + name: 'core', + directory: 'core', + requiredFile: 'core/core.dart', + packages: {'remix'}, + exports: ['core/core.dart'], + ), + ], + copiedItems: [], + ignoredSourceFiles: {}, + floorPackages: {'remix'}, + detectedPackages: [], + composedRegistryDependencies: {}, +); + +/// [_acmePreset] styling a `remix_bot` behavior through one recipe. +const _acmeWithRecipes = PresetSpec( + name: 'acme', + sourceRoot: 'registry_source/acme', + sourcePackage: 'remix_acme', + typeWord: 'Acme', + valueWord: 'acme', + componentDirectory: 'widgets', + sharedItems: [ + SharedItemSpec( + name: 'core', + directory: 'core', + requiredFile: 'core/core.dart', + packages: {'remix'}, + exports: ['core/core.dart'], + ), + ], + copiedItems: [], + ignoredSourceFiles: {}, + floorPackages: {'remix'}, + detectedPackages: [], + composedRegistryDependencies: {}, + recipeItems: ['dial_recipe'], + behavior: BehaviorSpec( + directory: 'bot', + typeWord: 'Bot', + valueWord: 'bot', + componentDirectory: 'components', + ), +); + +void _writeAcmeSources(PresetBuilder builder) { + // The behavior the recipe styles, a sibling of the preset source. + _write( + Directory(p.join(builder.sourceRoot.parent.path, 'bot')), + 'components/dial.dart', + 'class BotDialStyler {}\n', + ); + _write( + Directory(p.join(builder.sourceRoot.parent.path, 'bot')), + 'support/glyph.dart', + '', + ); + _write(builder.sourceRoot, 'core/core.dart', "export 'tokens.dart';\n"); + _write( + builder.sourceRoot, + 'core/tokens.dart', + "import 'package:remix/remix.dart';\nabstract class AcmeTokens {}\n", + ); + _write( + builder.sourceRoot, + 'widgets/dial.dart', + "import 'package:remix/remix.dart';\n\nimport '../core/core.dart';\n\n" + 'void acmeDialStyle() {}\n', + ); + _writeDefaultRegistry(builder.defaultRegistryRoot); +} + +/// [fortalPreset] as the sandbox fixtures author it: theme and components +/// only, no recipes, so every refusal below is about the file under test. +const _fortalFixture = PresetSpec( + name: 'fortal', + sourceRoot: 'registry_source/fortal', + sourcePackage: 'registry_source', + typeWord: 'Fortal', + valueWord: 'fortal', + componentDirectory: 'components', + sharedItems: [ + SharedItemSpec( + name: 'theme', + directory: 'theme', + requiredFile: 'theme/theme.dart', + packages: {'remix'}, + exports: ['theme/theme.dart'], + ), + ], + copiedItems: [ + CopiedItemSpec( + name: 'icons', + templatePath: 'templates/icons/icons.dart.tmpl', + target: '@ui/icons.dart', + registryDependencies: ['theme'], + packages: {'remix_ui_icons'}, + exports: ['icons.dart'], + ), + ], + ignoredSourceFiles: {'icons.dart'}, + floorPackages: { + 'remix', + 'mix_annotations', + 'build_runner', + 'mix_generator', + 'mix_chart', + 'remix_ui_icons', + }, + detectedPackages: ['mix_chart', 'remix_ui_icons'], + composedRegistryDependencies: {}, +); + +PresetBuilder _emptyBuilder( + Directory root, { + PresetSpec spec = _fortalFixture, +}) => PresetBuilder( + spec: spec, + sourceRoot: Directory(p.join(root.path, 'source')), + defaultRegistryRoot: Directory(p.join(root.path, 'default')), + outputRoot: Directory(p.join(root.path, 'output')), +); + +PresetBuilder _fixtureBuilder(Directory root) { + final builder = _emptyBuilder(root); + _write(builder.sourceRoot, 'theme/theme.dart', "export 'tokens.dart';\n"); + _write( + builder.sourceRoot, + 'theme/tokens.dart', + "import 'package:remix/remix.dart';\nabstract class FortalTokens {}\n", + ); + _write( + builder.sourceRoot, + 'components/button.dart', + """import 'package:mix_annotations/mix_annotations.dart'; +import 'package:remix/remix.dart'; + +import '../theme/theme.dart'; + +part 'button.g.dart'; + +void fortalButtonStyle() {} +""", + ); + _writeDefaultRegistry(builder.defaultRegistryRoot); + return builder; +} + +void _writeDefaultRegistry(Directory root) { + _write(root, 'registry.yaml', '''schema: 1 +items: + theme: + dependencies: + remix: ^1.0.0 + files: + - source: templates/theme/theme.dart.tmpl + target: "@ui/theme/theme.dart" + button: + dependencies: + mix_annotations: ^2.0.0 + devDependencies: + build_runner: ^2.0.0 + mix_generator: ^2.0.0 + files: + - source: templates/button/button.dart.tmpl + target: "@ui/components/button.dart" + chart: + dependencies: + mix_chart: ^1.0.0 + files: + - source: templates/chart/chart.dart.tmpl + target: "@ui/components/chart.dart" + icons: + dependencies: + remix_ui_icons: ^1.0.0 + files: + - source: templates/icons/icons.dart.tmpl + target: "@ui/icons.dart" +'''); + _write( + root, + 'templates/icons/icons.dart.tmpl', + 'abstract final class {{typePrefix}}Icons {}\n', + ); +} + +void _write(Directory root, String relativePath, String source) { + final file = File(p.joinAll([root.path, ...p.posix.split(relativePath)])); + file.parent.createSync(recursive: true); + file.writeAsStringSync(source); +} + +List _strings(Object? value) => (value as YamlList).cast(); diff --git a/test/tool/check_open_code_test.dart b/test/tool/check_open_code_test.dart index dd34f1552..939405ecb 100644 --- a/test/tool/check_open_code_test.dart +++ b/test/tool/check_open_code_test.dart @@ -202,21 +202,24 @@ dependency_overrides: }); group('installed UI boundary', () { - test('accepts the exact registry output without build.yaml', () { - final app = Directory('${sandbox.path}/app'); - _writeInstalledUi(app); - - expect(checker.installedUiProblem(app), isNull); - }); + test( + 'accepts exact registry output and scoped Agent builder configuration', + () { + final app = Directory('${sandbox.path}/app'); + _writeInstalledUi(app); + + expect(checker.installedUiProblem(app), isNull); + }, + ); - test('rejects a generated consumer build.yaml', () { + test('rejects unexpected consumer builder configuration', () { final app = Directory('${sandbox.path}/app'); _writeInstalledUi(app); File('${app.path}/build.yaml').writeAsStringSync('targets: {}\n'); expect( checker.installedUiProblem(app), - contains('created a consumer build.yaml'), + contains('does not match the scoped Agent builder contract'), ); }); @@ -320,6 +323,22 @@ const _registryItems = [ 'toggle', 'toggle_group', 'tooltip', + 'activity', + 'answer', + 'composer', + 'execution', + 'message', + 'permission', + 'plan', + 'transcript', + 'activity_recipe', + 'answer_recipe', + 'composer_recipe', + 'execution_recipe', + 'message_recipe', + 'permission_recipe', + 'plan_recipe', + 'transcript_recipe', ]; /// Items with no generated adapter: layouts and other plain compositions @@ -332,15 +351,39 @@ void _writeInstalledUi(Directory app) { 'theme/tokens.dart', 'theme/theme_data.dart', 'theme/theme_scope.dart', + 'models/activity_item.dart', + 'models/plan_item.dart', + 'models/statuses.dart', + 'support/disclosure.dart', + 'support/functional_glyph.dart', + 'support/live_edge.dart', for (final item in _registryItems) ...(item == 'icons' ? const ['icons.dart'] + : item.endsWith('_recipe') + ? ['recipes/$item.dart'] : [ 'components/$item.dart', if (!_nonGeneratedRegistryItems.contains(item)) 'components/$item.g.dart', ]), ]; + app.createSync(recursive: true); + File('${app.path}/build.yaml').writeAsStringSync('''targets: + \$default: + builders: + mix_generator:spec_styler_generator: + enabled: true + generate_for: + - lib/ui/components/activity.dart + - lib/ui/components/answer.dart + - lib/ui/components/composer.dart + - lib/ui/components/execution.dart + - lib/ui/components/message.dart + - lib/ui/components/permission.dart + - lib/ui/components/plan.dart + - lib/ui/components/transcript.dart +'''); for (final relative in files) { final file = File('${app.path}/lib/ui/$relative'); file.parent.createSync(recursive: true); diff --git a/tool/build_fortal_preset.dart b/tool/build_fortal_preset.dart deleted file mode 100644 index 35d328b60..000000000 --- a/tool/build_fortal_preset.dart +++ /dev/null @@ -1,564 +0,0 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; - -/// Derives the application-owned Fortal registry from `remix_fortal` source. -/// -/// Run without arguments to synchronize committed output. Pass `--check` to -/// compare in memory and fail on drift without writing. -void main(List arguments) { - final check = arguments.contains('--check'); - final unknown = arguments.where((argument) => argument != '--check').toList(); - if (unknown.isNotEmpty || arguments.length != (check ? 1 : 0)) { - stderr.writeln('Usage: dart run tool/build_fortal_preset.dart [--check]'); - exitCode = 64; - return; - } - - final repositoryRoot = Directory.current.absolute; - final pubspec = File(p.join(repositoryRoot.path, 'pubspec.yaml')); - if (!pubspec.existsSync() || - !RegExp( - r'^name:\s*remix_workspace\s*$', - multiLine: true, - ).hasMatch(pubspec.readAsStringSync())) { - stderr.writeln('Run this tool from the Remix workspace root.'); - exitCode = 64; - return; - } - - try { - final builder = FortalPresetBuilder.forRepository(repositoryRoot); - final output = builder.derive(); - if (!check) { - builder.write(output); - stdout.writeln( - 'Wrote the Fortal preset: ${output.files.length - 1} templates and ' - 'registry.yaml.', - ); - return; - } - - final drift = builder.drift(output); - if (drift.isNotEmpty) { - stderr - ..writeln('The committed Fortal preset is stale:') - ..writeln(drift.map((entry) => ' - $entry').join('\n')) - ..writeln( - 'Run `dart run tool/build_fortal_preset.dart` and commit the result.', - ); - exitCode = 1; - return; - } - stdout.writeln('The committed Fortal preset matches authored source.'); - } on Object catch (error) { - stderr.writeln(error); - exitCode = 1; - } -} - -/// A deterministic snapshot of every file owned by the Fortal preset. -final class FortalPresetOutput { - const FortalPresetOutput({ - required this.files, - required this.sourceByTemplate, - }); - - /// Output paths relative to the preset root, including `registry.yaml`. - final Map files; - - /// Original authored source keyed by its derived template output path. - /// - /// This makes the substitution round trip directly testable without - /// exposing filesystem implementation details. - final Map sourceByTemplate; -} - -/// Builds one bundled registry tree from analyzer-checked Fortal Dart source. -final class FortalPresetBuilder { - const FortalPresetBuilder({ - required this.sourceRoot, - required this.defaultRegistryRoot, - required this.outputRoot, - }); - - factory FortalPresetBuilder.forRepository(Directory repositoryRoot) { - final registryRoot = Directory( - p.join( - repositoryRoot.path, - 'packages', - 'remix_cli', - 'lib', - 'src', - 'registry', - ), - ); - return FortalPresetBuilder( - sourceRoot: Directory( - p.join(repositoryRoot.path, 'packages', 'remix_fortal', 'lib', 'src'), - ), - defaultRegistryRoot: Directory(p.join(registryRoot.path, 'default')), - outputRoot: Directory(p.join(registryRoot.path, 'fortal')), - ); - } - - final Directory sourceRoot; - final Directory defaultRegistryRoot; - final Directory outputRoot; - - FortalPresetOutput derive() { - if (!sourceRoot.existsSync()) { - throw FormatException( - 'Fortal source root is missing: ${sourceRoot.path}', - ); - } - - final sources = _readSources(); - _validateSources(sources); - - final floors = _readDefaultFloors(); - final output = {}; - final sourceByTemplate = {}; - final items = {}; - - final themeSources = sources.entries - .where((entry) => entry.key.startsWith('theme/')) - .toList(); - if (themeSources.isEmpty || - !themeSources.any((entry) => entry.key == 'theme/theme.dart')) { - throw const FormatException( - 'Fortal source must contain theme/theme.dart and its theme files.', - ); - } - - final themeFiles = <_RegistryFileDraft>[]; - for (final entry in themeSources) { - final name = p.posix.basename(entry.key); - final templatePath = 'templates/theme/$name.tmpl'; - output[templatePath] = _templateFor(entry.key, entry.value); - sourceByTemplate[templatePath] = entry.value; - themeFiles.add( - _RegistryFileDraft(source: templatePath, target: '@ui/theme/$name'), - ); - } - items['theme'] = _RegistryItemDraft( - name: 'theme', - dependencies: {'remix': floors['remix']!}, - files: themeFiles, - exports: const ['theme/theme.dart'], - ); - - final defaultIcons = File( - p.join(defaultRegistryRoot.path, 'templates', 'icons', 'icons.dart.tmpl'), - ); - if (!defaultIcons.existsSync()) { - throw FormatException( - 'Default icons template is missing: ${defaultIcons.path}', - ); - } - const iconsTemplate = 'templates/icons/icons.dart.tmpl'; - output[iconsTemplate] = defaultIcons.readAsStringSync(); - items['icons'] = _RegistryItemDraft( - name: 'icons', - registryDependencies: const ['theme'], - dependencies: {'remix_ui_icons': floors['remix_ui_icons']!}, - files: const [ - _RegistryFileDraft(source: iconsTemplate, target: '@ui/icons.dart'), - ], - exports: const ['icons.dart'], - ); - - final componentNames = { - for (final path in sources.keys) - if (path.startsWith('components/')) - p.posix.basenameWithoutExtension(path), - }; - for (final entry in sources.entries.where( - (entry) => entry.key.startsWith('components/'), - )) { - final name = p.posix.basenameWithoutExtension(entry.key); - final templatePath = 'templates/$name/$name.dart.tmpl'; - final generated = _generatedPart(entry.value); - final imports = _imports(entry.value); - final registryDependencies = _registryDependencies( - sourcePath: entry.key, - imports: imports, - componentNames: componentNames, - ); - final dependencies = {}; - final devDependencies = {}; - if (generated != null) { - dependencies['mix_annotations'] = floors['mix_annotations']!; - devDependencies - ..['build_runner'] = floors['build_runner']! - ..['mix_generator'] = floors['mix_generator']!; - } - for (final package in const ['mix_chart', 'remix_ui_icons']) { - if (imports.any((uri) => uri.startsWith('package:$package/'))) { - dependencies[package] = floors[package]!; - } - } - - output[templatePath] = _templateFor(entry.key, entry.value); - sourceByTemplate[templatePath] = entry.value; - items[name] = _RegistryItemDraft( - name: name, - registryDependencies: registryDependencies, - dependencies: dependencies, - devDependencies: devDependencies, - files: [ - _RegistryFileDraft( - source: templatePath, - target: '@ui/components/$name.dart', - ), - ], - generated: generated == null ? const [] : ['@ui/components/$generated'], - exports: ['components/$name.dart'], - ); - } - - if (items.length != componentNames.length + 2) { - throw StateError('Fortal registry item names collided.'); - } - - output['registry.yaml'] = _renderRegistry(items); - return FortalPresetOutput( - files: Map.unmodifiable(_sortedMap(output)), - sourceByTemplate: Map.unmodifiable(_sortedMap(sourceByTemplate)), - ); - } - - /// Synchronizes only the files owned beneath [outputRoot]. - void write(FortalPresetOutput output) { - outputRoot.createSync(recursive: true); - final expected = output.files.keys.toSet(); - for (final file in _outputFiles()) { - final relative = _relative(file, outputRoot); - if (!expected.contains(relative)) file.deleteSync(); - } - for (final entry in output.files.entries) { - final file = File( - p.joinAll([outputRoot.path, ...p.posix.split(entry.key)]), - ); - if (file.existsSync() && file.readAsStringSync() == entry.value) continue; - file.parent.createSync(recursive: true); - file.writeAsStringSync(entry.value); - } - } - - /// Returns stable, human-readable differences without mutating output. - List drift(FortalPresetOutput output) { - final differences = []; - final actual = { - for (final file in _outputFiles()) _relative(file, outputRoot): file, - }; - for (final entry in output.files.entries) { - final file = actual.remove(entry.key); - if (file == null) { - differences.add('missing ${entry.key}'); - } else if (file.readAsStringSync() != entry.value) { - differences.add('changed ${entry.key}'); - } - } - for (final stale in actual.keys) { - differences.add('stale $stale'); - } - differences.sort(); - return differences; - } - - Map _readSources() { - final files = - sourceRoot - .listSync(recursive: true, followLinks: false) - .whereType() - .where( - (file) => - file.path.endsWith('.dart') && !file.path.endsWith('.g.dart'), - ) - .toList() - ..sort((left, right) => left.path.compareTo(right.path)); - return { - for (final file in files) - _relative(file, sourceRoot): file.readAsStringSync(), - }; - } - - void _validateSources(Map sources) { - final failures = []; - for (final entry in sources.entries) { - final path = entry.key; - final content = entry.value; - if (p.posix - .split(path) - .any((segment) => segment.toLowerCase().contains('fortal'))) { - failures.add('$path: a path segment contains "fortal"'); - } - if (content.contains('{{')) { - failures.add('$path: source contains the reserved template token "{{"'); - } - for (final match in _importPattern.allMatches(content)) { - final uri = match.group(1)!; - if (_forbiddenImportPrefixes.any(uri.startsWith)) { - failures.add('$path: forbidden installed-source import $uri'); - } - } - if (path != 'icons.dart' && - !path.startsWith('theme/') && - !path.startsWith('components/')) { - failures.add('$path: unsupported Fortal source placement'); - } - } - if (failures.isNotEmpty) { - failures.sort(); - throw FormatException( - 'Cannot derive the Fortal preset:\n${failures.map((failure) => ' - $failure').join('\n')}', - ); - } - } - - Map _readDefaultFloors() { - final registry = File(p.join(defaultRegistryRoot.path, 'registry.yaml')); - if (!registry.existsSync()) { - throw FormatException('Default registry is missing: ${registry.path}'); - } - final document = loadYaml(registry.readAsStringSync()); - if (document is! YamlMap || document['items'] is! YamlMap) { - throw FormatException('${registry.path} is not a registry map.'); - } - final constraints = >{}; - for (final item in (document['items'] as YamlMap).values) { - if (item is! YamlMap) continue; - for (final sectionName in const ['dependencies', 'devDependencies']) { - final section = item[sectionName]; - if (section is! YamlMap) continue; - for (final entry in section.entries) { - if (entry.key is String && entry.value is String) { - constraints - .putIfAbsent(entry.key as String, () => {}) - .add(entry.value as String); - } - } - } - } - - const required = { - 'remix', - 'mix_annotations', - 'build_runner', - 'mix_generator', - 'mix_chart', - 'remix_ui_icons', - }; - final floors = {}; - for (final package in required) { - final values = constraints[package]; - if (values == null || values.length != 1) { - throw FormatException( - 'Default registry must declare one $package constraint; found ' - '${values?.join(', ') ?? 'none'}.', - ); - } - floors[package] = values.single; - } - return floors; - } - - List _outputFiles() { - if (!outputRoot.existsSync()) return const []; - final files = - outputRoot - .listSync(recursive: true, followLinks: false) - .whereType() - .toList() - ..sort((left, right) => left.path.compareTo(right.path)); - return files; - } -} - -final class _RegistryItemDraft { - const _RegistryItemDraft({ - required this.name, - this.registryDependencies = const [], - this.dependencies = const {}, - this.devDependencies = const {}, - required this.files, - this.generated = const [], - required this.exports, - }); - - final String name; - final List registryDependencies; - final Map dependencies; - final Map devDependencies; - final List<_RegistryFileDraft> files; - final List generated; - final List exports; -} - -final class _RegistryFileDraft { - const _RegistryFileDraft({required this.source, required this.target}); - - final String source; - final String target; -} - -String _templateFor(String path, String source) { - final template = source - .replaceAll('Fortal', '{{typePrefix}}') - .replaceAll('fortal', '{{valuePrefix}}'); - final roundTrip = template - .replaceAll('{{typePrefix}}', 'Fortal') - .replaceAll('{{valuePrefix}}', 'fortal'); - if (roundTrip != source) { - throw StateError('$path did not survive the template round trip.'); - } - return template; -} - -List _imports(String source) => [ - for (final match in _importPattern.allMatches(source)) match.group(1)!, -]; - -/// Registry dependencies a component composes but never imports. -/// -/// [_registryDependencies] otherwise infers the graph from `import` -/// statements, which misses `sidebar_layout` -> `sidebar`: its `sidebar` -/// field is typed `Widget`, not `FortalSidebar`, so nothing imports -/// `components/sidebar.dart`. The default preset's hand-authored -/// registry.yaml declares the same dependency for the same reason. -const _uninferredRegistryDependencies = >{ - 'sidebar_layout': ['sidebar'], -}; - -List _registryDependencies({ - required String sourcePath, - required List imports, - required Set componentNames, -}) { - final dependencies = {}; - for (final uri in imports) { - if (uri.startsWith('package:') || uri.startsWith('dart:')) continue; - final resolved = p.posix.normalize( - p.posix.join(p.posix.dirname(sourcePath), uri), - ); - if (resolved.startsWith('theme/')) { - dependencies.add('theme'); - continue; - } - if (resolved.startsWith('components/')) { - final component = p.posix.basenameWithoutExtension(resolved); - if (component != p.posix.basenameWithoutExtension(sourcePath)) { - if (!componentNames.contains(component)) { - throw FormatException( - '$sourcePath imports missing component source $uri.', - ); - } - dependencies.add(component); - } - } - } - final name = p.posix.basenameWithoutExtension(sourcePath); - for (final component in _uninferredRegistryDependencies[name] ?? const []) { - if (!componentNames.contains(component)) { - throw FormatException( - '$sourcePath declares missing component dependency $component.', - ); - } - dependencies.add(component); - } - return [ - if (dependencies.remove('theme')) 'theme', - ...(dependencies.toList()..sort()), - ]; -} - -String? _generatedPart(String source) { - final matches = RegExp( - r'''^\s*part\s+['"]([^'"]+\.g\.dart)['"]\s*;''', - multiLine: true, - ).allMatches(source).toList(); - if (matches.length > 1) { - throw const FormatException( - 'A component declares multiple generated parts.', - ); - } - return matches.singleOrNull?.group(1); -} - -String _renderRegistry(Map items) { - final ordered = <_RegistryItemDraft>[ - items['theme']!, - items['icons']!, - ...items.entries - .where((entry) => entry.key != 'theme' && entry.key != 'icons') - .map((entry) => entry.value) - .toList() - ..sort((left, right) => left.name.compareTo(right.name)), - ]; - final buffer = StringBuffer() - ..writeln('# Generated by tool/build_fortal_preset.dart. Do not edit.') - ..writeln('schema: 1') - ..writeln('items:'); - for (final item in ordered) { - buffer.writeln(' ${item.name}:'); - _writeStringList(buffer, 'registryDependencies', item.registryDependencies); - _writeConstraintMap(buffer, 'dependencies', item.dependencies); - _writeConstraintMap(buffer, 'devDependencies', item.devDependencies); - buffer.writeln(' files:'); - for (final file in item.files) { - buffer - ..writeln(' - source: ${file.source}') - ..writeln(' target: "${file.target}"'); - } - _writeStringList(buffer, 'generated', item.generated, quote: true); - _writeStringList(buffer, 'exports', item.exports); - buffer.writeln(); - } - return '${buffer.toString().trimRight()}\n'; -} - -void _writeStringList( - StringBuffer buffer, - String name, - List values, { - bool quote = false, -}) { - if (values.isEmpty) return; - buffer.writeln(' $name:'); - for (final value in values) { - buffer.writeln(' - ${quote ? '"$value"' : value}'); - } -} - -void _writeConstraintMap( - StringBuffer buffer, - String name, - Map values, -) { - if (values.isEmpty) return; - buffer.writeln(' $name:'); - for (final key in values.keys.toList()..sort()) { - buffer.writeln(' $key: ${values[key]}'); - } -} - -Map _sortedMap(Map source) { - final keys = source.keys.toList()..sort(); - return {for (final key in keys) key: source[key]!}; -} - -String _relative(File file, Directory root) => - p.posix.joinAll(p.split(p.relative(file.path, from: root.path))); - -final _importPattern = RegExp( - r'''^\s*import\s+['"]([^'"]+)['"]''', - multiLine: true, -); - -const _forbiddenImportPrefixes = [ - 'package:remix_fortal/', - 'package:mix/', - 'package:naked_ui/', -]; diff --git a/tool/build_registry.dart b/tool/build_registry.dart new file mode 100644 index 000000000..9583d2c50 --- /dev/null +++ b/tool/build_registry.dart @@ -0,0 +1,1399 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import '../packages/remix_cli/lib/src/registry.dart'; + +/// Derives the bundled registry presets from analyzer-checked Dart source. +/// +/// Run without arguments to synchronize every committed preset. Pass `--check` +/// to compare in memory and fail on drift without writing, and `--preset` to +/// narrow the run to one preset. +void main(List arguments) { + final usage = + 'Usage: dart run tool/build_registry.dart ' + '[--preset ${presetSpecs.keys.join('|')}|all] [--check]'; + final check = arguments.contains('--check'); + final rest = arguments.where((argument) => argument != '--check').toList(); + var preset = 'all'; + if (rest.isNotEmpty) { + if (rest.first != '--preset' || rest.length != 2) { + stderr.writeln(usage); + exitCode = 64; + return; + } + preset = rest[1]; + } + if (preset != 'all' && !presetSpecs.containsKey(preset)) { + stderr.writeln(usage); + exitCode = 64; + return; + } + + final repositoryRoot = Directory.current.absolute; + final pubspec = File(p.join(repositoryRoot.path, 'pubspec.yaml')); + if (!pubspec.existsSync() || + !RegExp( + r'^name:\s*remix_workspace\s*$', + multiLine: true, + ).hasMatch(pubspec.readAsStringSync())) { + stderr.writeln('Run this tool from the Remix workspace root.'); + exitCode = 64; + return; + } + + final selected = preset == 'all' ? presetSpecs.keys : [preset]; + for (final name in selected) { + if (!_runPreset(repositoryRoot, name, check: check)) { + exitCode = 1; + return; + } + } +} + +/// Derives one preset and either writes or verifies its committed output. +/// +/// A preset is a list of specs rather than a single one because the writer +/// may merge a partially owned extension into the tree it owns. Returns false +/// once the preset is stale or underivable, having already reported why. +bool _runPreset(Directory repositoryRoot, String name, {required bool check}) { + try { + final builders = [ + for (final spec in presetSpecs[name]!) + PresetBuilder.forRepository(repositoryRoot, spec: spec), + ]; + final writer = builders.first; + var output = writer.derive(); + for (final builder in builders.skip(1)) { + output = mergePresetOutputs(output, builder.derive()); + } + if (!check) { + writer.write(output); + stdout.writeln( + 'Wrote the $name preset: ${output.files.length - 1} templates and ' + 'registry.yaml.', + ); + return true; + } + + final drift = writer.drift(output); + if (drift.isNotEmpty) { + stderr + ..writeln('The committed $name preset is stale:') + ..writeln(drift.map((entry) => ' - $entry').join('\n')) + ..writeln( + 'Run `dart run tool/build_registry.dart --preset $name` and commit ' + 'the result.', + ); + return false; + } + stdout.writeln('The committed $name preset matches authored source.'); + return true; + } on Object catch (error) { + stderr.writeln(error); + return false; + } +} + +/// One derivable source package, and everything that distinguishes it. +/// +/// The builder below turns analyzer-checked Dart source into a bundled +/// registry tree. It does not know which package it is reading, what word +/// stands in for the consumer's prefix, or which items exist outside the +/// component directory. Those live here, so a second source package is a new +/// [PresetSpec] rather than a second copy of the builder. +final class PresetSpec { + const PresetSpec({ + required this.name, + required this.sourceRoot, + required this.sourcePackage, + required this.typeWord, + required this.valueWord, + required this.componentDirectory, + required this.sharedItems, + required this.copiedItems, + this.fileItems = const [], + required this.ignoredSourceFiles, + required this.floorPackages, + required this.detectedPackages, + required this.composedRegistryDependencies, + this.recipeItems = const [], + this.behavior, + this.extensionDirectory, + }); + + /// Preset name, and the directory it occupies under the bundled registry. + final String name; + + /// Set on an extension: a second source package whose items derive into + /// this template subtree of another spec's preset. An extension never + /// writes; the preset's own spec merges and owns the whole tree. + final String? extensionDirectory; + + String get templateDirectory => extensionDirectory ?? 'templates'; + + /// Repository-relative directory of the authored source, e.g. + /// `registry_source/lib/src/fortal`. Everything under it derives. + final String sourceRoot; + + /// Name of the package [sourceRoot] belongs to. + /// + /// Installed source may never import it: an item ships the source itself. + final String sourcePackage; + + /// Identifier casing replaced by `{{typePrefix}}`, e.g. `Fortal`. + final String typeWord; + + /// Lowercase casing replaced by `{{valuePrefix}}`, e.g. `fortal`. + final String valueWord; + + /// Source directory holding one file per component item. + final String componentDirectory; + + /// Items assembled from a source directory that components may import. + final List sharedItems; + + /// Items copied verbatim from the default preset rather than derived. + final List copiedItems; + + /// Items derived from one authored file at the source root. + final List fileItems; + + /// Source files that are legal to author but own no registry item. + final Set ignoredSourceFiles; + + /// Packages whose constraint must be readable from the default registry. + final Set floorPackages; + + /// Packages declared on an item when its source imports them. + final List detectedPackages; + + /// Registry dependencies a component composes but never imports. + /// + /// Import inference misses `sidebar_layout` -> `sidebar`: its `sidebar` + /// field is typed `Widget`, not `FortalSidebar`, so nothing imports + /// `components/sidebar.dart`. The default preset's hand-authored + /// registry.yaml declares the same dependency for the same reason. + final Map> composedRegistryDependencies; + + /// Items derived from `recipes/.dart`: preset-specific stylings of + /// [behavior], composed from this preset's own components and theme. + final List recipeItems; + + /// The behavior the recipes style while authoring, and how its import lands + /// in installed source. Required once [recipeItems] is non-empty. + final BehaviorSpec? behavior; + + /// Source directories this preset reads, shared items first. + List get sourceDirectories => [ + for (final item in sharedItems) item.directory, + componentDirectory, + if (recipeItems.isNotEmpty) recipeDirectory, + ]; + + static const recipeDirectory = 'recipes'; + + /// Item name owning each shared source directory. + Map get itemsByDirectory => { + for (final item in sharedItems) item.directory: item.name, + }; + + /// Package imports that must never appear in installed source. + List get forbiddenImportPrefixes => [ + 'package:$sourcePackage/', + 'package:mix/', + 'package:naked_ui/', + ]; +} + +/// An item derived from every file in one source directory. +final class SharedItemSpec { + const SharedItemSpec({ + required this.name, + required this.directory, + required this.requiredFile, + required this.packages, + required this.exports, + this.registryDependencies = const [], + }); + + final String name; + + /// Source directory, also the installed target directory under `@ui/`. + final String directory; + + /// Source path that must exist, or the preset is not derivable. + final String requiredFile; + + final Set packages; + final List exports; + final List registryDependencies; +} + +/// An item taken verbatim from the default preset instead of from source. +final class CopiedItemSpec { + const CopiedItemSpec({ + required this.name, + required this.templatePath, + required this.target, + required this.registryDependencies, + required this.packages, + required this.exports, + }); + + final String name; + final String templatePath; + final String target; + final List registryDependencies; + final Set packages; + final List exports; +} + +/// An item derived from a single file at the source root, e.g. `icons.dart`. +final class FileItemSpec { + const FileItemSpec({ + required this.name, + required this.file, + required this.packages, + required this.registryDependencies, + required this.exports, + }); + + final String name; + + /// Source path relative to `lib/src`, also the installed path under `@ui/`. + final String file; + + final Set packages; + final List registryDependencies; + final List exports; +} + +/// Behavior a preset's recipes style: the sibling source directory it is +/// authored in, and the word that source is authored under. +/// +/// A recipe is analyzed against that source, so it names +/// `AgentComposerStyler` and imports `../../agent/components/composer.dart`. +/// The installed recipe sits beside the installed behavior instead, so the +/// derivation rewrites that import to `../components/` and the identifier +/// prefix `Agent` to the consumer prefix, before the preset's own word goes. +final class BehaviorSpec { + const BehaviorSpec({ + required this.directory, + required this.typeWord, + required this.valueWord, + required this.componentDirectory, + }); + + /// Directory beside the preset's [PresetSpec.sourceRoot], e.g. `agent`. + final String directory; + final String typeWord; + final String valueWord; + final String componentDirectory; + + /// The authoring import prefix that becomes `../` in installed source. + String get importPrefix => '../../$directory/'; +} + +/// Agent behavior as recipes reach it: `registry_source/lib/src/agent`. +const agentBehavior = BehaviorSpec( + directory: 'agent', + typeWord: 'Agent', + valueWord: 'agent', + componentDirectory: 'components', +); + +/// The eight Agent surfaces each preset styles. Dependencies are inferred +/// from each recipe's imports. +const agentRecipes = [ + 'activity_recipe', + 'answer_recipe', + 'composer_recipe', + 'execution_recipe', + 'message_recipe', + 'permission_recipe', + 'plan_recipe', + 'transcript_recipe', +]; + +/// The default preset as application-owned registry source. +/// +/// `Vanilla` is the authoring word: it stands in for the consumer prefix and +/// appears nowhere else in the source, so plain substitution is exact. +const defaultPreset = PresetSpec( + name: 'default', + sourceRoot: 'registry_source/lib/src/default', + sourcePackage: 'registry_source', + typeWord: 'Vanilla', + valueWord: 'vanilla', + componentDirectory: 'components', + sharedItems: [ + SharedItemSpec( + name: 'theme', + directory: 'theme', + requiredFile: 'theme/tokens.dart', + packages: {'remix'}, + exports: [ + 'theme/tokens.dart', + 'theme/theme_data.dart', + 'theme/theme_scope.dart', + ], + ), + ], + copiedItems: [], + fileItems: [ + FileItemSpec( + name: 'icons', + file: 'icons.dart', + packages: {'remix_ui_icons'}, + registryDependencies: ['theme'], + exports: ['icons.dart'], + ), + ], + ignoredSourceFiles: {}, + floorPackages: { + 'remix', + 'mix_annotations', + 'build_runner', + 'mix_generator', + 'mix_chart', + 'remix_ui_icons', + }, + detectedPackages: ['mix_chart', 'remix_ui_icons'], + // A layout, not a styled component: `sidebar_layout` composes an installed + // Sidebar through a `Widget`-typed field, so its source never imports + // components/sidebar.dart and import inference alone would miss it. + composedRegistryDependencies: { + 'sidebar_layout': ['sidebar'], + }, + recipeItems: agentRecipes, + behavior: agentBehavior, +); + +/// The Fortal design system as application-owned registry source. +const fortalPreset = PresetSpec( + name: 'fortal', + sourceRoot: 'registry_source/lib/src/fortal', + sourcePackage: 'registry_source', + typeWord: 'Fortal', + valueWord: 'fortal', + componentDirectory: 'components', + sharedItems: [ + SharedItemSpec( + name: 'theme', + directory: 'theme', + requiredFile: 'theme/theme.dart', + packages: {'remix'}, + exports: ['theme/theme.dart'], + ), + ], + copiedItems: [ + CopiedItemSpec( + name: 'icons', + templatePath: 'templates/icons/icons.dart.tmpl', + target: '@ui/icons.dart', + registryDependencies: ['theme'], + packages: {'remix_ui_icons'}, + exports: ['icons.dart'], + ), + ], + // Authored for the package's own use. The registry item copies the default + // preset's icons template instead, so this file owns no item. + ignoredSourceFiles: {'icons.dart'}, + floorPackages: { + 'remix', + 'mix_annotations', + 'build_runner', + 'mix_generator', + 'mix_chart', + 'remix_ui_icons', + }, + detectedPackages: ['mix_chart', 'remix_ui_icons'], + composedRegistryDependencies: { + 'sidebar_layout': ['sidebar'], + }, + recipeItems: agentRecipes, + behavior: agentBehavior, +); + +/// Agent behavior merged into the Fortal preset. The Fortal spec owns the +/// tree; this extension only derives into `templates/agent/`. +const fortalAgentExtension = PresetSpec( + name: 'fortal', + sourceRoot: 'registry_source/lib/src/agent', + sourcePackage: 'registry_source', + typeWord: 'Agent', + valueWord: 'agent', + componentDirectory: 'components', + extensionDirectory: 'templates/agent', + sharedItems: [ + SharedItemSpec( + name: 'models', + directory: 'models', + requiredFile: 'models/statuses.dart', + packages: {}, + exports: [ + 'models/activity_item.dart', + 'models/plan_item.dart', + 'models/statuses.dart', + ], + ), + SharedItemSpec( + name: 'support', + directory: 'support', + requiredFile: 'support/functional_glyph.dart', + packages: {}, + registryDependencies: ['theme'], + exports: [], + ), + ], + copiedItems: [], + ignoredSourceFiles: {}, + floorPackages: { + 'mix_annotations', + 'build_runner', + 'mix_generator', + 'remix_ui_icons', + }, + detectedPackages: ['remix_ui_icons'], + composedRegistryDependencies: {}, +); + +/// Agent behavior merged into the default preset, as above. +const defaultAgentExtension = PresetSpec( + name: 'default', + sourceRoot: 'registry_source/lib/src/agent', + sourcePackage: 'registry_source', + typeWord: 'Agent', + valueWord: 'agent', + componentDirectory: 'components', + extensionDirectory: 'templates/agent', + sharedItems: [ + SharedItemSpec( + name: 'models', + directory: 'models', + requiredFile: 'models/statuses.dart', + packages: {}, + exports: [ + 'models/activity_item.dart', + 'models/plan_item.dart', + 'models/statuses.dart', + ], + ), + SharedItemSpec( + name: 'support', + directory: 'support', + requiredFile: 'support/functional_glyph.dart', + packages: {}, + // The source imports Remix primitives, not the installed theme, but the + // existing theme item is the sole owner of the default Remix floor. + registryDependencies: ['theme'], + exports: [], + ), + ], + copiedItems: [], + ignoredSourceFiles: {}, + floorPackages: { + 'mix_annotations', + 'build_runner', + 'mix_generator', + 'remix_ui_icons', + }, + detectedPackages: ['remix_ui_icons'], + composedRegistryDependencies: {}, +); + +/// Every bundled preset and the specs that derive it, writer first. +/// +/// The first spec owns the preset. Any spec after it is an extension whose +/// items derive from a second source package into the same tree. +const presetSpecs = >{ + 'default': [defaultPreset, defaultAgentExtension], + 'fortal': [fortalPreset, fortalAgentExtension], +}; + +PresetOutput mergePresetOutputs(PresetOutput base, PresetOutput extension) { + final files = {...base.files}; + for (final entry in extension.files.entries) { + if (entry.key == 'registry.yaml') continue; + if (files.containsKey(entry.key)) { + throw StateError('Preset outputs collide at ${entry.key}.'); + } + files[entry.key] = entry.value; + } + final items = {}; + final targets = {}; + for (final output in [base, extension]) { + final document = loadYaml(output.files['registry.yaml']!) as YamlMap; + final entries = document['items'] as YamlMap?; + if (entries == null) continue; + for (final entry in entries.entries) { + final name = entry.key as String; + if (items.containsKey(name)) { + throw StateError('Preset items collide at $name.'); + } + final item = entry.value as YamlMap; + items[name] = item; + final sources = item['files'] as YamlList? ?? const []; + for (final source in sources) { + if (!files.containsKey(source['source'])) { + throw StateError( + 'Preset item $name references missing template ${source['source']}.', + ); + } + } + final paths = [ + for (final source in sources) source['target'] as String, + ...?item['generated'] as YamlList?, + ]; + for (final target in paths.cast()) { + final previous = targets[target]; + if (previous != null) { + throw StateError( + 'Preset targets collide at $target ($previous and $name).', + ); + } + targets[target] = name; + } + } + } + for (final entry in items.entries) { + for (final dependency + in entry.value['registryDependencies'] as YamlList? ?? const []) { + if (!items.containsKey(dependency)) { + throw StateError( + 'Preset item ${entry.key} has missing dependency $dependency.', + ); + } + } + } + String body(String yaml) => + yaml.substring(yaml.indexOf('items:') + 7).trimRight(); + files['registry.yaml'] = + '${base.files['registry.yaml']!.split('items:').first}items:\n${body(base.files['registry.yaml']!)}\n\n${body(extension.files['registry.yaml']!)}\n'; + return PresetOutput( + files: Map.unmodifiable(files), + sourceByTemplate: Map.unmodifiable({ + ...base.sourceByTemplate, + ...extension.sourceByTemplate, + }), + ); +} + +/// A deterministic snapshot of every file owned by one derived preset. +final class PresetOutput { + const PresetOutput({required this.files, required this.sourceByTemplate}); + + /// Output paths relative to the preset root. `registry.yaml` is an in-memory + /// metadata snapshot; partial-preset writers assert it instead of writing it. + final Map files; + + /// Original authored source keyed by its derived template output path. + /// + /// This makes the substitution round trip directly testable without + /// exposing filesystem implementation details. + final Map sourceByTemplate; +} + +/// Builds one bundled registry tree from analyzer-checked Dart source. +final class PresetBuilder { + const PresetBuilder({ + required this.spec, + required this.sourceRoot, + required this.defaultRegistryRoot, + required this.outputRoot, + }); + + factory PresetBuilder.forRepository( + Directory repositoryRoot, { + PresetSpec spec = fortalPreset, + }) { + final registryRoot = Directory( + p.join( + repositoryRoot.path, + 'packages', + 'remix_cli', + 'lib', + 'src', + 'registry', + ), + ); + return PresetBuilder( + spec: spec, + sourceRoot: Directory( + p.joinAll([repositoryRoot.path, ...p.posix.split(spec.sourceRoot)]), + ), + defaultRegistryRoot: Directory(p.join(registryRoot.path, 'default')), + outputRoot: Directory(p.join(registryRoot.path, spec.name)), + ); + } + + final PresetSpec spec; + final Directory sourceRoot; + final Directory defaultRegistryRoot; + final Directory outputRoot; + + PresetOutput derive() { + if (!sourceRoot.existsSync()) { + throw FormatException( + '${spec.name} source root is missing: ${sourceRoot.path}', + ); + } + + final sources = _readSources(); + _validateSources(sources); + + final floors = _readDefaultFloors(); + final output = {}; + final sourceByTemplate = {}; + final items = {}; + final componentPrefix = '${spec.componentDirectory}/'; + final componentNames = { + for (final path in sources.keys) + if (path.startsWith(componentPrefix)) + p.posix.basenameWithoutExtension(path), + }; + + for (final shared in spec.sharedItems) { + final sharedSources = sources.entries + .where((entry) => entry.key.startsWith('${shared.directory}/')) + .toList(); + if (!sharedSources.any((entry) => entry.key == shared.requiredFile)) { + throw FormatException( + '${spec.name} source must contain ${shared.requiredFile} and the ' + 'rest of ${shared.directory}/.', + ); + } + + final files = <_RegistryFileDraft>[]; + for (final entry in sharedSources) { + final name = p.posix.basename(entry.key); + final templatePath = + '${spec.templateDirectory}/${shared.directory}/$name.tmpl'; + output[templatePath] = _templateFor(entry.key, entry.value); + sourceByTemplate[templatePath] = entry.value; + files.add( + _RegistryFileDraft( + source: templatePath, + target: '@ui/${shared.directory}/$name', + ), + ); + } + items[shared.name] = _RegistryItemDraft( + name: shared.name, + registryDependencies: { + ...shared.registryDependencies, + for (final entry in sharedSources) + ..._registryDependencies( + sourcePath: entry.key, + imports: _imports(entry.value), + componentNames: componentNames, + ), + }.toList(), + dependencies: { + for (final package in shared.packages) package: floors[package]!, + for (final package in spec.detectedPackages) + if (sharedSources.any( + (entry) => _imports( + entry.value, + ).any((uri) => uri.startsWith('package:$package/')), + )) + package: floors[package]!, + }, + files: files, + exports: shared.exports, + ); + } + + for (final copied in spec.copiedItems) { + final template = File( + p.joinAll([ + defaultRegistryRoot.path, + ...p.posix.split(copied.templatePath), + ]), + ); + if (!template.existsSync()) { + throw FormatException( + 'Default ${copied.name} template is missing: ${template.path}', + ); + } + output[copied.templatePath] = template.readAsStringSync(); + items[copied.name] = _RegistryItemDraft( + name: copied.name, + registryDependencies: copied.registryDependencies, + dependencies: { + for (final package in copied.packages) package: floors[package]!, + }, + files: [ + _RegistryFileDraft( + source: copied.templatePath, + target: copied.target, + ), + ], + exports: copied.exports, + ); + } + + for (final item in spec.fileItems) { + final source = sources[item.file]; + if (source == null) { + throw FormatException('${spec.name} source must contain ${item.file}.'); + } + final name = p.posix.basename(item.file); + final templatePath = '${spec.templateDirectory}/${item.name}/$name.tmpl'; + final imports = _imports(source); + output[templatePath] = _templateFor(item.file, source); + sourceByTemplate[templatePath] = source; + items[item.name] = _RegistryItemDraft( + name: item.name, + registryDependencies: { + ...item.registryDependencies, + ..._registryDependencies( + sourcePath: item.file, + imports: imports, + componentNames: componentNames, + ), + }.toList(), + dependencies: { + for (final package in item.packages) package: floors[package]!, + for (final package in spec.detectedPackages) + if (imports.any((uri) => uri.startsWith('package:$package/'))) + package: floors[package]!, + }, + files: [ + _RegistryFileDraft(source: templatePath, target: '@ui/${item.file}'), + ], + exports: item.exports, + ); + } + + for (final entry in sources.entries.where( + (entry) => entry.key.startsWith(componentPrefix), + )) { + final name = p.posix.basenameWithoutExtension(entry.key); + final templatePath = '${spec.templateDirectory}/$name/$name.dart.tmpl'; + final generated = _generatedPart(entry.value); + final imports = _imports(entry.value); + final registryDependencies = _registryDependencies( + sourcePath: entry.key, + imports: imports, + componentNames: componentNames, + ); + final dependencies = {}; + final devDependencies = {}; + if (generated != null) { + dependencies['mix_annotations'] = floors['mix_annotations']!; + devDependencies + ..['build_runner'] = floors['build_runner']! + ..['mix_generator'] = floors['mix_generator']!; + } + for (final package in spec.detectedPackages) { + if (imports.any((uri) => uri.startsWith('package:$package/'))) { + dependencies[package] = floors[package]!; + } + } + + output[templatePath] = _templateFor(entry.key, entry.value); + sourceByTemplate[templatePath] = entry.value; + items[name] = _RegistryItemDraft( + name: name, + registryDependencies: registryDependencies, + dependencies: dependencies, + devDependencies: devDependencies, + files: [ + _RegistryFileDraft( + source: templatePath, + target: '@ui/$componentPrefix$name.dart', + ), + ], + generated: generated == null + ? const [] + : ['@ui/$componentPrefix$generated'], + exports: ['$componentPrefix$name.dart'], + ); + } + + final recipePrefix = '${PresetSpec.recipeDirectory}/'; + final recipeSources = sources.keys.where( + (path) => path.startsWith(recipePrefix), + ); + for (final path in recipeSources) { + final name = p.posix.basenameWithoutExtension(path); + if (!spec.recipeItems.contains(name)) { + throw FormatException('$path is not a declared ${spec.name} recipe.'); + } + } + for (final name in spec.recipeItems) { + final path = '$recipePrefix$name.dart'; + final authored = sources[path]; + if (authored == null) { + throw FormatException('${spec.name} source must contain $path.'); + } + final source = _recipeSource(path, authored); + final templatePath = '${spec.templateDirectory}/$path.tmpl'; + output[templatePath] = _templateFor(path, source); + sourceByTemplate[templatePath] = source; + items[name] = _RegistryItemDraft( + name: name, + registryDependencies: _registryDependencies( + sourcePath: path, + imports: _imports(source), + componentNames: componentNames, + // The behavior components live in the extension, so they are + // validated when the preset outputs merge rather than here. + allowForeignComponents: true, + ), + files: [_RegistryFileDraft(source: templatePath, target: '@ui/$path')], + exports: [path], + ); + } + + final expected = + componentNames.length + + spec.sharedItems.length + + spec.copiedItems.length + + spec.fileItems.length + + spec.recipeItems.length; + if (items.length != expected) { + throw StateError('${spec.name} registry item names collided.'); + } + + output['registry.yaml'] = _renderRegistry(items); + return PresetOutput( + files: Map.unmodifiable(_sortedMap(output)), + sourceByTemplate: Map.unmodifiable(_sortedMap(sourceByTemplate)), + ); + } + + /// Synchronizes the whole tree beneath [outputRoot] to [output]. + void write(PresetOutput output) { + _validateOutput(output); + outputRoot.createSync(recursive: true); + final expected = output.files.keys.toSet(); + for (final file in _outputFiles()) { + final relative = _relative(file, outputRoot); + if (!expected.contains(relative)) file.deleteSync(); + } + for (final entry in output.files.entries) { + final file = File( + p.joinAll([outputRoot.path, ...p.posix.split(entry.key)]), + ); + if (file.existsSync() && file.readAsStringSync() == entry.value) continue; + file.parent.createSync(recursive: true); + file.writeAsStringSync(entry.value); + } + } + + /// Returns stable, human-readable differences without mutating output. + List drift(PresetOutput output) { + _validateOutput(output); + final differences = []; + final actual = { + for (final file in _outputFiles()) _relative(file, outputRoot): file, + }; + for (final entry in output.files.entries) { + final file = actual.remove(entry.key); + if (file == null) { + differences.add('missing ${entry.key}'); + } else if (file.readAsStringSync() != entry.value) { + differences.add('changed ${entry.key}'); + } + } + for (final stale in actual.keys) { + differences.add('stale $stale'); + } + differences.sort(); + return differences; + } + + /// Reject the entire write before pruning, including links already on disk. + /// + /// Only a preset's own spec writes; an extension's output is merged into + /// it first, so the registry it validates is always the whole preset. + void _validateOutput(PresetOutput output) { + if (spec.extensionDirectory != null) { + throw StateError( + '${spec.name} extension output must be merged into the preset before ' + 'it is written or checked.', + ); + } + final registry = output.files['registry.yaml']; + if (registry == null) { + throw const FormatException('Missing derived registry metadata.'); + } + for (final path in output.files.keys) { + _validateRelativeOutput(path); + _rejectOutputLinks(path); + } + // Validate even when the derived output is empty. + _rejectOutputLinks('.'); + _outputFiles(); + validate(output); + } + + /// Holds a merged preset to the contract the installer enforces: no + /// dependency cycles or target collisions, and every relative import in an + /// installed recipe resolving to something the preset also installs. + void validate(PresetOutput output) { + final document = loadYaml(output.files['registry.yaml']!); + if (document is! YamlMap || document['items'] is! YamlMap) { + throw const FormatException('Registry must contain an items map.'); + } + RegistryCatalog.parse( + jsonEncode(document), + preset: spec.name, + rootUri: outputRoot.uri, + ); + final items = document['items'] as YamlMap; + final targets = { + for (final item in items.values) + for (final file in item['files'] as YamlList? ?? const []) + file['target'] as String, + }; + for (final entry in items.entries) { + for (final file in entry.value['files'] as YamlList? ?? const []) { + final source = file['source'] as String; + final target = file['target'] as String; + for (final uri in _imports(output.files[source] ?? '')) { + if (uri.startsWith('package:') || uri.startsWith('dart:')) continue; + final resolved = p.posix.normalize( + p.posix.join(p.posix.dirname(target), uri), + ); + if (!targets.contains(resolved)) { + throw FormatException( + '${entry.key} imports $uri, which nothing in the ${spec.name} ' + 'preset installs.', + ); + } + } + } + } + } + + void _validateRelativeOutput(String path) { + if (path.isEmpty || + path == '.' || + path.contains('\\') || + p.posix.isAbsolute(path) || + p.windows.isAbsolute(path) || + p.posix.normalize(path) != path || + p.posix.split(path).contains('..')) { + throw FormatException('Unsafe output path: $path'); + } + } + + void _rejectOutputLinks(String relative) { + var current = outputRoot.path; + for (final segment in ['', ...p.posix.split(relative)]) { + if (segment.isNotEmpty) current = p.join(current, segment); + if (FileSystemEntity.typeSync(current, followLinks: false) == + FileSystemEntityType.link) { + throw FormatException('Output path contains a symbolic link: $current'); + } + } + } + + Map _readSources() { + final files = + sourceRoot + .listSync(recursive: true, followLinks: false) + .whereType() + .where( + (file) => + file.path.endsWith('.dart') && !file.path.endsWith('.g.dart'), + ) + .toList() + ..sort((left, right) => left.path.compareTo(right.path)); + return { + for (final file in files) + _relative(file, sourceRoot): file.readAsStringSync(), + }; + } + + void _validateSources(Map sources) { + final failures = []; + for (final entry in sources.entries) { + final path = entry.key; + final content = entry.value; + if (p.posix + .split(path) + .any((segment) => segment.toLowerCase().contains(spec.valueWord))) { + failures.add('$path: a path segment contains "${spec.valueWord}"'); + } + if (content.contains('{{')) { + failures.add('$path: source contains the reserved template token "{{"'); + } + final behavior = spec.behavior; + final recipe = path.startsWith('${PresetSpec.recipeDirectory}/'); + for (final match in _directivePattern.allMatches(content)) { + final uri = match.group(2)!; + if (uri.contains(spec.typeWord) || uri.contains(spec.valueWord)) { + failures.add('$path: directive URI would be rewritten: $uri'); + } + if (!uri.startsWith('package:') && !uri.startsWith('dart:')) { + final resolved = p.posix.normalize( + p.posix.join(p.posix.dirname(path), uri), + ); + final generated = match.group(1) == 'part' && uri.endsWith('.g.dart'); + if (resolved.startsWith('../')) { + // Only a recipe may leave the preset, and only for the behavior + // components it styles. Anything else would install an import + // that points outside the application's own tree. + final components = + '${behavior?.importPrefix}${behavior?.componentDirectory}/'; + if (behavior == null || !recipe) { + failures.add('$path: only recipes may import $uri'); + } else if (!uri.startsWith(components) || + uri.contains('/', components.length)) { + failures.add( + '$path: recipes import behavior components only: $uri', + ); + } else if (!File( + p.joinAll([sourceRoot.path, ...p.posix.split(resolved)]), + ).existsSync()) { + failures.add('$path: missing behavior source $uri'); + } + } else if (!generated && !sources.containsKey(resolved)) { + failures.add('$path: missing relative source $uri'); + } + } + if (spec.forbiddenImportPrefixes.any(uri.startsWith)) { + failures.add('$path: forbidden installed-source import $uri'); + } + } + if (recipe && behavior == null) { + failures.add('$path: ${spec.name} declares no behavior to style'); + } + if (!spec.ignoredSourceFiles.contains(path) && + !spec.fileItems.any((item) => item.file == path) && + !spec.sourceDirectories.any( + (directory) => path.startsWith('$directory/'), + )) { + failures.add('$path: unsupported ${spec.name} source placement'); + } + } + if (failures.isNotEmpty) { + failures.sort(); + throw FormatException( + 'Cannot derive the ${spec.name} preset:\n' + '${failures.map((failure) => ' - $failure').join('\n')}', + ); + } + } + + Map _readDefaultFloors() { + final registry = File(p.join(defaultRegistryRoot.path, 'registry.yaml')); + if (!registry.existsSync()) { + throw FormatException('Default registry is missing: ${registry.path}'); + } + final document = loadYaml(registry.readAsStringSync()); + if (document is! YamlMap || document['items'] is! YamlMap) { + throw FormatException('${registry.path} is not a registry map.'); + } + final constraints = >{}; + for (final item in (document['items'] as YamlMap).values) { + if (item is! YamlMap) continue; + for (final sectionName in const ['dependencies', 'devDependencies']) { + final section = item[sectionName]; + if (section is! YamlMap) continue; + for (final entry in section.entries) { + if (entry.key is String && entry.value is String) { + constraints + .putIfAbsent(entry.key as String, () => {}) + .add(entry.value as String); + } + } + } + } + + final floors = {}; + for (final package in spec.floorPackages) { + final values = constraints[package]; + if (values == null || values.length != 1) { + throw FormatException( + 'Default registry must declare one $package constraint; found ' + '${values?.join(', ') ?? 'none'}.', + ); + } + floors[package] = values.single; + } + return floors; + } + + /// Rewrites an authored recipe into the form an application authored under + /// this preset's own word would hold, so [_templateFor] can take it from + /// there with its round trip intact. + /// + /// The behavior import becomes the relative path the installed behavior + /// lives at, and the behavior's identifier prefix becomes the preset word. + /// Only identifier-initial occurrences move: `FortalAgentComposerRecipe` + /// keeps its domain name, `AgentComposerStyler` becomes the installed + /// `FortalComposerStyler`. Directives are re-sorted afterwards because the + /// rewritten import changes group. + String _recipeSource(String path, String authored) { + final behavior = spec.behavior; + if (behavior == null) { + throw StateError('${spec.name} recipes need a behavior spec.'); + } + final rewritten = authored + .replaceAll(behavior.importPrefix, '../') + .replaceAllMapped( + RegExp('(? spec.typeWord, + ) + .replaceAllMapped( + RegExp('(? spec.valueWord, + ); + return _sortDirectives(path, rewritten); + } + + /// Swaps the preset's own naming for the consumer prefix placeholders. + /// + /// The round trip is asserted rather than assumed: a substitution that does + /// not reverse exactly means the source says the preset's name somewhere the + /// consumer's prefix does not belong. + String _templateFor(String path, String source) { + final template = source + .replaceAll(spec.typeWord, '{{typePrefix}}') + .replaceAll(spec.valueWord, '{{valuePrefix}}'); + final roundTrip = template + .replaceAll('{{typePrefix}}', spec.typeWord) + .replaceAll('{{valuePrefix}}', spec.valueWord); + if (roundTrip != source) { + throw StateError('$path did not survive the template round trip.'); + } + return template; + } + + /// Infers one item's registry dependencies from its relative imports. + List _registryDependencies({ + required String sourcePath, + required List imports, + required Set componentNames, + bool allowForeignComponents = false, + }) { + final owners = spec.itemsByDirectory; + final dependencies = {}; + for (final uri in imports) { + if (uri.startsWith('package:') || uri.startsWith('dart:')) continue; + final resolved = p.posix.normalize( + p.posix.join(p.posix.dirname(sourcePath), uri), + ); + final directory = p.posix.split(resolved).first; + final owner = owners[directory]; + if (owner != null) { + if (owner == owners[p.posix.split(sourcePath).first]) continue; + dependencies.add(owner); + continue; + } + if (directory != spec.componentDirectory) continue; + final component = p.posix.basenameWithoutExtension(resolved); + if (component == p.posix.basenameWithoutExtension(sourcePath)) continue; + if (!componentNames.contains(component) && !allowForeignComponents) { + throw FormatException( + '$sourcePath imports missing component source $uri.', + ); + } + dependencies.add(component); + } + final name = p.posix.basenameWithoutExtension(sourcePath); + for (final component + in spec.composedRegistryDependencies[name] ?? const []) { + if (!componentNames.contains(component)) { + throw FormatException( + '$sourcePath declares missing component dependency $component.', + ); + } + dependencies.add(component); + } + // Shared items lead, in spec order, so a graph reads foundation-first the + // way the hand-authored default registry does. + return [ + for (final shared in spec.sharedItems) + if (dependencies.remove(shared.name)) shared.name, + ...dependencies.toList()..sort(), + ]; + } + + /// Renders `registry.yaml`: shared, copied, and file items, then components. + String _renderRegistry(Map items) { + final leading = [ + for (final shared in spec.sharedItems) shared.name, + for (final copied in spec.copiedItems) copied.name, + for (final item in spec.fileItems) item.name, + ]; + final ordered = <_RegistryItemDraft>[ + for (final name in leading) items[name]!, + ...items.entries + .where((entry) => !leading.contains(entry.key)) + .map((entry) => entry.value) + .toList() + ..sort((left, right) => left.name.compareTo(right.name)), + ]; + final buffer = StringBuffer() + ..writeln('# Generated by tool/build_registry.dart. Do not edit.') + ..writeln('schema: 1') + ..writeln('items:'); + for (final item in ordered) { + buffer.writeln(' ${item.name}:'); + _writeStringList( + buffer, + 'registryDependencies', + item.registryDependencies, + ); + _writeConstraintMap(buffer, 'dependencies', item.dependencies); + _writeConstraintMap(buffer, 'devDependencies', item.devDependencies); + buffer.writeln(' files:'); + for (final file in item.files) { + buffer + ..writeln(' - source: ${file.source}') + ..writeln(' target: "${file.target}"'); + } + _writeStringList(buffer, 'generated', item.generated, quote: true); + _writeStringList(buffer, 'exports', item.exports); + buffer.writeln(); + } + return '${buffer.toString().trimRight()}\n'; + } + + List _outputFiles() { + _rejectOutputLinks('.'); + if (!outputRoot.existsSync()) return const []; + final entries = outputRoot.listSync(recursive: true, followLinks: false); + for (final link in entries.whereType()) { + throw FormatException('Output contains a symbolic link: ${link.path}'); + } + return entries.whereType().toList() + ..sort((left, right) => left.path.compareTo(right.path)); + } +} + +final class _RegistryItemDraft { + const _RegistryItemDraft({ + required this.name, + this.registryDependencies = const [], + this.dependencies = const {}, + this.devDependencies = const {}, + required this.files, + this.generated = const [], + required this.exports, + }); + + final String name; + final List registryDependencies; + final Map dependencies; + final Map devDependencies; + final List<_RegistryFileDraft> files; + final List generated; + final List exports; +} + +final class _RegistryFileDraft { + const _RegistryFileDraft({required this.source, required this.target}); + + final String source; + final String target; +} + +List _imports(String source) => [ + for (final match in _importPattern.allMatches(source)) match.group(1)!, +]; + +String? _generatedPart(String source) { + final matches = RegExp( + r'''^\s*part\s+['"]([^'"]+\.g\.dart)['"]\s*;''', + multiLine: true, + ).allMatches(source).toList(); + if (matches.length > 1) { + throw const FormatException( + 'A component declares multiple generated parts.', + ); + } + return matches.singleOrNull?.group(1); +} + +void _writeStringList( + StringBuffer buffer, + String name, + List values, { + bool quote = false, +}) { + if (values.isEmpty) return; + buffer.writeln(' $name:'); + for (final value in values) { + buffer.writeln(' - ${quote ? '"$value"' : value}'); + } +} + +void _writeConstraintMap( + StringBuffer buffer, + String name, + Map values, +) { + if (values.isEmpty) return; + buffer.writeln(' $name:'); + for (final key in values.keys.toList()..sort()) { + buffer.writeln(' $key: ${values[key]}'); + } +} + +Map _sortedMap(Map source) { + final keys = source.keys.toList()..sort(); + return {for (final key in keys) key: source[key]!}; +} + +String _relative(File file, Directory root) => + p.posix.joinAll(p.split(p.relative(file.path, from: root.path))); + +final _importPattern = RegExp( + r'''^\s*import\s+['"]([^'"]+)['"]''', + multiLine: true, +); + +/// Re-sorts a file's single-line import block into `dart:`, `package:`, and +/// relative groups, one blank line apart, the way `directives_ordering` reads +/// it. Anything else inside the block is refused rather than moved. +String _sortDirectives(String path, String source) { + final lines = source.split('\n'); + final indexes = [ + for (var i = 0; i < lines.length; i++) + if (lines[i].startsWith('import ')) i, + ]; + if (indexes.isEmpty) return source; + final block = lines.sublist(indexes.first, indexes.last + 1); + if (block.any( + (line) => !line.startsWith('import ') && line.trim().isNotEmpty, + )) { + throw FormatException('$path: imports must be single-line and contiguous.'); + } + String uri(String line) => _importPattern.firstMatch(line)!.group(1)!; + final imports = block.where((line) => line.startsWith('import ')).toList(); + final groups = [ + for (final test in [ + (String u) => u.startsWith('dart:'), + (String u) => u.startsWith('package:'), + (String u) => !u.startsWith('dart:') && !u.startsWith('package:'), + ]) + imports.where((line) => test(uri(line))).toList() + ..sort((a, b) => uri(a).compareTo(uri(b))), + ]; + final sorted = []; + for (final group in groups) { + if (group.isEmpty) continue; + if (sorted.isNotEmpty) sorted.add(''); + sorted.addAll(group); + } + return [ + ...lines.sublist(0, indexes.first), + ...sorted, + ...lines.sublist(indexes.last + 1), + ].join('\n'); +} + +final _directivePattern = RegExp( + r'''^\s*(import|export|part)\s+['"]([^'"]+)['"]''', + multiLine: true, +); diff --git a/tool/check_ci_coverage.dart b/tool/check_ci_coverage.dart index c2c9d30a5..2518efe64 100644 --- a/tool/check_ci_coverage.dart +++ b/tool/check_ci_coverage.dart @@ -102,7 +102,8 @@ void main() { final failures = []; final jobs = - (loadYaml(workflowFile.readAsStringSync()) as YamlMap)['jobs'] as YamlMap?; + (loadYaml(workflowFile.readAsStringSync()) as YamlMap)['jobs'] + as YamlMap?; if (jobs == null) { stderr.writeln('$_workflow declares no jobs.'); exitCode = 1; @@ -115,8 +116,7 @@ void main() { if (job is! YamlMap) return; final matrix = >{}; - final declared = - (job['strategy'] as YamlMap?)?['matrix'] as YamlMap?; + final declared = (job['strategy'] as YamlMap?)?['matrix'] as YamlMap?; declared?.forEach((key, value) { if (value is YamlList) { matrix['$key'] = value.map((entry) => '$entry').toList(); diff --git a/tool/check_dependency_constraints.dart b/tool/check_dependency_constraints.dart index b3c8e0f02..791d68664 100644 --- a/tool/check_dependency_constraints.dart +++ b/tool/check_dependency_constraints.dart @@ -96,6 +96,12 @@ void main() { } } + final registryConstraints = _checkRegistryConstraints( + workspaceRoot, + managed, + failures, + ); + if (failures.isNotEmpty) { stderr.writeln( 'Dependency constraint drift detected (${failures.length}):', @@ -115,6 +121,74 @@ void main() { stdout.writeln( '${managed.length} shared dependency constraints come from melos and ' - 'match in all ${members.length + 1} workspace pubspecs.', + 'match in all ${members.length + 1} workspace pubspecs, and in ' + '$registryConstraints bundled registry declarations.', ); } + +/// Registry files whose pub constraints a consumer inherits on `remix add`. +const _registryPaths = [ + 'packages/remix_cli/lib/src/registry/default/registry.yaml', + 'packages/remix_cli/lib/src/registry/fortal/registry.yaml', +]; + +/// `remix`, whose registry floor records the tested release rather than the +/// workspace constraint. +/// +/// `tool/check_version_alignment.dart` owns it and holds it equal to +/// `packages/remix`'s own version. Comparing it here too would give one value +/// two owners that disagree during a release. +const _versionAlignedPackages = {'remix'}; + +/// Holds the registry's own pub constraints to the melos-managed values. +/// +/// These are data, not pubspec dependencies, so `melos bootstrap` never +/// rewrites them and the pubspec walk above cannot see them. Nothing else +/// compares them to the workspace either: `build_registry.dart` requires +/// only that a package resolve to one distinct value *within* a registry, so +/// all 32 copies of a generator floor can agree with each other while having +/// drifted from the toolchain the templates are actually built against. +int _checkRegistryConstraints( + Directory workspaceRoot, + Map managed, + List failures, +) { + var compared = 0; + for (final relativePath in _registryPaths) { + final registry = File('${workspaceRoot.path}/$relativePath'); + if (!registry.existsSync()) { + failures.add('$relativePath is missing.'); + continue; + } + + final document = loadYaml(registry.readAsStringSync()); + final items = document is YamlMap ? document['items'] : null; + if (items is! YamlMap) { + failures.add('$relativePath is not in the expected shape.'); + continue; + } + + for (final MapEntry(key: item, value: definition) in items.entries) { + if (definition is! YamlMap) continue; + // The registry spells this section `devDependencies`, not the pubspec's + // `dev_dependencies`. + for (final section in const ['dependencies', 'devDependencies']) { + (definition[section] as YamlMap?)?.forEach((name, constraint) { + if (constraint == null || constraint is YamlMap) return; + if (_versionAlignedPackages.contains(name)) return; + final expected = managed[name as String]; + if (expected == null) return; + compared++; + if ('$constraint' != expected) { + failures.add( + '$relativePath item $item declares $name $constraint, but melos ' + 'manages $expected.', + ); + } + }); + } + } + } + + return compared; +} diff --git a/tool/check_material_independence.dart b/tool/check_material_independence.dart index 96f36e594..8a385c8c8 100644 --- a/tool/check_material_independence.dart +++ b/tool/check_material_independence.dart @@ -1,8 +1,8 @@ import 'dart:io'; -// Fortal stays here because these sources are copied into consumer apps even -// though the authoring package itself is no longer published. -const _consumerSourcePackages = ['remix', 'remix_fortal']; +// Repository-relative package directories whose source ships to applications, +// either installed from the registry or resolved as a hosted dependency. +const _consumerSourcePackages = ['packages/remix', 'registry_source']; final _forbiddenLibraryDirective = RegExp( r'''^\s*(?:import|export)\s+['"]package:(?:flutter/(?:material\.dart|src/material/[^'"]+)|material_ui/[^'"]+)['"]''', @@ -24,12 +24,12 @@ void main() { final failures = []; for (final package in _consumerSourcePackages) { - final packageDirectory = Directory('${workspace.path}/packages/$package'); + final packageDirectory = Directory('${workspace.path}/$package'); final libraryDirectory = Directory('${packageDirectory.path}/lib'); final pubspec = File('${packageDirectory.path}/pubspec.yaml'); if (!libraryDirectory.existsSync() || !pubspec.existsSync()) { - failures.add('packages/$package is missing its lib directory or pubspec'); + failures.add('$package is missing its lib directory or pubspec'); continue; } @@ -47,18 +47,16 @@ void main() { final manifest = pubspec.readAsStringSync(); if (_materialUiDependency.hasMatch(manifest)) { - failures.add('packages/$package/pubspec.yaml declares material_ui'); + failures.add('$package/pubspec.yaml declares material_ui'); } if (_materialFontFlag.hasMatch(manifest)) { - failures.add( - 'packages/$package/pubspec.yaml declares uses-material-design', - ); + failures.add('$package/pubspec.yaml declares uses-material-design'); } } if (failures.isEmpty) { stdout.writeln( - 'Remix and application-owned Fortal sources have no direct Material ' + 'Remix and application-owned Fortal/Agent sources have no direct Material ' 'usage.', ); return; diff --git a/tool/check_open_code.dart b/tool/check_open_code.dart index 05428ec0f..ea009387c 100644 --- a/tool/check_open_code.dart +++ b/tool/check_open_code.dart @@ -64,6 +64,34 @@ const _defaultRegistryItems = [ 'toggle', 'toggle_group', 'tooltip', + ..._agentRegistryItems, +]; + +const _agentComponentItems = [ + 'activity', + 'answer', + 'composer', + 'execution', + 'message', + 'permission', + 'plan', + 'transcript', +]; + +const _agentRecipeItems = [ + 'activity_recipe', + 'answer_recipe', + 'composer_recipe', + 'execution_recipe', + 'message_recipe', + 'permission_recipe', + 'plan_recipe', + 'transcript_recipe', +]; + +const _agentRegistryItems = [ + ..._agentComponentItems, + ..._agentRecipeItems, ]; /// Generated adapters compared byte-for-byte against a committed snapshot. @@ -125,6 +153,7 @@ const _fortalRegistryItems = [ 'toggle_group', 'tooltip', 'typography', + ..._agentRegistryItems, ]; const _defaultPreset = _PresetContract( @@ -134,6 +163,18 @@ const _defaultPreset = _PresetContract( themeFiles: ['tokens.dart', 'theme_data.dart', 'theme_scope.dart'], generatedSnapshots: _generatedSnapshots, nonGeneratedItems: {'sidebar_layout'}, + sharedItems: { + 'models': [ + 'models/activity_item.dart', + 'models/plan_item.dart', + 'models/statuses.dart', + ], + 'support': [ + 'support/disclosure.dart', + 'support/functional_glyph.dart', + 'support/live_edge.dart', + ], + }, ); const _fortalPreset = _PresetContract( @@ -159,6 +200,18 @@ const _fortalPreset = _PresetContract( 'sidebar_layout', 'typography', }, + sharedItems: { + 'models': [ + 'models/activity_item.dart', + 'models/plan_item.dart', + 'models/statuses.dart', + ], + 'support': [ + 'support/disclosure.dart', + 'support/functional_glyph.dart', + 'support/live_edge.dart', + ], + }, ); const _requiredRuntimeDependencies = [ @@ -168,7 +221,7 @@ const _requiredRuntimeDependencies = [ 'remix_ui_icons', ]; const _requiredDevDependencies = ['build_runner', 'mix_generator']; -const _forbiddenDependencies = ['mix', 'naked_ui', 'remix_fortal']; +const _forbiddenDependencies = ['mix', 'naked_ui', 'registry_source']; const _allowedImportPackages = [ 'flutter', 'remix', @@ -185,6 +238,7 @@ final class _PresetContract { required this.themeFiles, this.nonGeneratedItems = const {}, this.generatedSnapshots = const {}, + this.sharedItems = const {}, }); final String name; @@ -193,13 +247,17 @@ final class _PresetContract { final List themeFiles; final Set nonGeneratedItems; final Map generatedSnapshots; + final Map> sharedItems; List get installedUiFiles => [ 'ui.dart', for (final file in themeFiles) 'theme/$file', + for (final files in sharedItems.values) ...files, for (final item in registryItems) ...(item == 'icons' ? const ['icons.dart'] + : item.endsWith('_recipe') + ? ['recipes/$item.dart'] : [ 'components/$item.dart', if (!nonGeneratedItems.contains(item)) 'components/$item.g.dart', @@ -208,7 +266,9 @@ final class _PresetContract { List get generatedAppFiles => [ for (final item in registryItems) - if (item != 'icons' && !nonGeneratedItems.contains(item)) + if (item != 'icons' && + !item.endsWith('_recipe') && + !nonGeneratedItems.contains(item)) 'lib/ui/components/$item.g.dart', ]; } @@ -474,6 +534,14 @@ Future<_Failure?> _checkInTemporaryApp({ ); if (init != null) return _Failure('remix init failed in the fresh app'); + final independent = await _checkIndependentAgentItems( + sdk: sdk, + app: app, + preset: preset.name, + environment: environment, + ); + if (independent != null) return independent; + for (final item in preset.registryItems) { final add = await _runProcess( sdk.dart, @@ -618,6 +686,68 @@ Future<_Failure?> _checkInTemporaryApp({ ); } +/// Each surface must compile with only its own dependency closure. The full +/// gallery installs every item and would otherwise mask an omitted dependency. +Future<_Failure?> _checkIndependentAgentItems({ + required _Toolchain sdk, + required Directory app, + required String preset, + required Map environment, +}) async { + final pubspec = File('${app.path}/pubspec.yaml').readAsStringSync(); + final override = File('${app.path}/pubspec_overrides.yaml'); + for (final item in _agentRegistryItems) { + final isolated = Directory('${app.parent.path}/independent_$item') + ..createSync(); + File('${isolated.path}/pubspec.yaml').writeAsStringSync(pubspec); + if (override.existsSync()) { + override.copySync('${isolated.path}/pubspec_overrides.yaml'); + } + Directory('${isolated.path}/lib').createSync(); + File( + '${isolated.path}/lib/main.dart', + ).writeAsStringSync('void main() {}\n'); + for (final command in [ + ['pub', 'get'], + [ + 'run', + 'remix_cli:remix', + 'init', + '--prefix', + 'Solo', + '--preset', + preset, + ], + ['run', 'remix_cli:remix', 'add', item], + ]) { + final failure = await _runProcess( + sdk.dart, + command, + workingDirectory: isolated.path, + environment: environment, + ); + if (failure != null) + return _Failure('Independent $item install failed: ${failure.message}'); + } + final ui = Directory('${isolated.path}/lib/ui'); + final files = ui + .listSync(recursive: true) + .whereType() + .map( + (file) => + file.path.substring(ui.path.length + 1).replaceAll('\\', '/'), + ) + .toSet(); + final problems = _installedReferenceProblems(ui, files); + if (problems.isNotEmpty) + return _Failure('Independent $item: ${problems.join('; ')}'); + _step( + 'Independent $item installed, generated, and analyzed with its own dependencies.', + ); + } + return null; +} + void _writeCheckoutOverride(Directory app, Directory remixSource) { File('${app.path}/pubspec_overrides.yaml').writeAsStringSync(''' # Created in a guarded temporary app by tool/check_open_code.dart. @@ -764,13 +894,17 @@ _Failure? _verifyRegistryCoverage( return _Failure('registry.yaml is not in the expected shape.'); } - // `theme` is every component's registry dependency, so the CLI installs it - // on the first `add` rather than as an item of its own. + // Foundations arrive through dependency closure. Their exact files are + // still asserted by the installed inventory, not exempted from coverage. final bundled = { for (final key in (document['items'] as YamlMap).keys) - if (key is String && key != 'theme') key, + if (key is String) key, + }; + final installed = { + 'theme', + ...preset.sharedItems.keys, + ...preset.registryItems, }; - final installed = preset.registryItems.toSet(); final problems = [ for (final item in bundled.difference(installed)) 'registry.yaml has $item, which this check never installs', @@ -955,8 +1089,28 @@ bool _isFlutterSdkDeclaration(Object? declaration) => declaration is YamlMap && declaration['sdk'] == 'flutter'; _Failure? _verifyInstalledUi(Directory app, _PresetContract preset) { - if (File('${app.path}/build.yaml').existsSync()) { - return _Failure('remix_cli created a consumer build.yaml'); + final buildConfig = File('${app.path}/build.yaml'); + final expected = { + 'targets': { + r'$default': { + 'builders': { + 'mix_generator:spec_styler_generator': { + 'enabled': true, + 'generate_for': [ + for (final item in _agentComponentItems) + 'lib/ui/components/$item.dart', + ]..sort(), + }, + }, + }, + }, + }; + if (!buildConfig.existsSync() || + _canonicalYaml(loadYaml(buildConfig.readAsStringSync())) != + _canonicalYaml(expected)) { + return _Failure( + 'consumer build.yaml does not match the scoped Agent builder contract', + ); } final uiRoot = Directory('${app.path}/lib/ui'); if (!uiRoot.existsSync()) { @@ -970,13 +1124,13 @@ _Failure? _verifyInstalledUi(Directory app, _PresetContract preset) { .map((file) => _relativePath(uiRoot, file)) .toList() ..sort(); - final expected = [...preset.installedUiFiles]..sort(); + final expectedFiles = [...preset.installedUiFiles]..sort(); final problems = []; - for (final relative in expected) { + for (final relative in expectedFiles) { if (!found.contains(relative)) problems.add('installed UI lacks $relative'); } for (final relative in found) { - if (!expected.contains(relative)) { + if (!expectedFiles.contains(relative)) { problems.add('installed UI has an unexpected file: $relative'); } } @@ -1325,3 +1479,12 @@ final class _Failure { final String message; final int exitCode; } + +String _canonicalYaml(Object? value) { + if (value is Map) { + final keys = value.keys.cast().toList()..sort(); + return '{${keys.map((key) => '${jsonEncode(key)}:${_canonicalYaml(value[key])}').join(',')}}'; + } + if (value is List) return '[${value.map(_canonicalYaml).join(',')}]'; + return jsonEncode(value); +} diff --git a/tool/check_open_code_dogfood.dart b/tool/check_open_code_dogfood.dart index d8d90f80e..6203d654d 100644 --- a/tool/check_open_code_dogfood.dart +++ b/tool/check_open_code_dogfood.dart @@ -4,9 +4,10 @@ /// dart run tool/check_open_code_dogfood.dart /// ``` /// -/// Playground expects the full default registry; the Agent example expects -/// its Composer dependencies and the catalog Button. Check expected items even when their files -/// are missing. The CLI owns config parsing, template rendering, and diffing. +/// Playground expects the full default registry; demo and dashboard list the +/// Fortal items they install explicitly, since only the default manifest is +/// read here. +/// Check expected items even when their files are missing. The CLI owns config parsing, template rendering, and diffing. /// /// Application-owned source may be customized. Each deliberate edit belongs in /// [_customized]; an entry that matches the template again is also an error. @@ -26,16 +27,119 @@ const _customized = { /// Expected items per consumer; null means the entire default registry. const _consumers = ?>{ 'apps/playground': null, - 'packages/remix_agent/example': [ + // The Fortal review catalog: every non-Agent Fortal item. + 'apps/demo': [ 'theme', + 'icons', + 'accordion', + 'avatar', + 'badge', + 'base_button', + 'button', + 'callout', 'card', - 'textfield', + 'chart', + 'checkbox', + 'code', + 'data_list', + 'data_table', + 'dialog', + 'disclosure', + 'divider', + 'heading', 'icon_button', + 'kbd', + 'link', + 'menu', + 'popover', + 'progress', + 'radio', + 'segmented_control', + 'select', + 'sidebar', + 'sidebar_layout', + 'skeleton', + 'slider', + 'spinner', + 'switch', + 'tabs', + 'text', + 'textfield', + 'toast', + 'toggle', + 'toggle_group', + 'tooltip', + 'typography', + ], + // Every Fortal item, Agent surfaces and recipes included. + 'apps/dashboard': [ + 'theme', + 'icons', + 'accordion', + 'activity_recipe', + 'answer_recipe', + 'avatar', + 'badge', + 'base_button', 'button', + 'callout', + 'card', + 'chart', + 'checkbox', + 'code', + 'composer_recipe', + 'data_list', + 'data_table', + 'dialog', + 'disclosure', + 'divider', + 'execution_recipe', + 'heading', + 'icon_button', + 'kbd', + 'link', + 'menu', + 'message_recipe', + 'permission_recipe', + 'plan_recipe', + 'popover', + 'progress', + 'radio', + 'segmented_control', + 'select', + 'sidebar', + 'sidebar_layout', + 'skeleton', + 'slider', + 'spinner', + 'switch', + 'tabs', + 'text', + 'textfield', + 'toast', + 'toggle', + 'toggle_group', + 'tooltip', + 'transcript_recipe', + 'typography', + 'models', + 'support', + 'activity', + 'answer', + 'composer', + 'execution', + 'message', + 'permission', + 'plan', + 'transcript', ], }; /// What `remix add --diff` prints when the installed source is up to date. +/// +/// The CLI prints a preamble and then exactly one verdict: this line, or a +/// `git diff`. Both are recognized below so that a third shape can be reported +/// as itself rather than silently read as divergence. const _clean = 'No authored-source differences.'; Future main(List arguments) async { @@ -97,7 +201,24 @@ Future _run(Directory root) async { continue; } - final edited = !(result.stdout as String).contains(_clean); + // Classifying on the sentinel alone means any reword of it reports all + // 40 items as diverged and sends the reader to `--overwrite`, which + // cannot fix a change in the CLI's own output. Recognize both verdicts + // instead, and name the case where neither or both appear. + final lines = (result.stdout as String).split('\n'); + final clean = lines.any((line) => line.trimRight() == _clean); + final diffed = lines.any((line) => line.startsWith('diff --git ')); + if (clean == diffed) { + problems.add( + '$label: `remix add $key --diff` printed ' + '${clean ? 'both a clean verdict and a diff' : 'no recognizable verdict'}' + '. The CLI output contract moved; update `_clean` in this check to ' + 'match `installer.dart`.', + ); + continue; + } + + final edited = diffed; final reason = _customized[label]; if (!edited && reason != null) { problems.add( @@ -117,8 +238,32 @@ Future _run(Directory root) async { for (final item in unknown) { problems.add('$item is listed as customized but is not an expected item.'); } + problems.addAll(_sourcePackageImports(root)); if (problems.isEmpty) return null; return 'a consumer and the registry disagree:\n' '${problems.map((problem) => ' - $problem').join('\n')}'; } + +/// Applications consume installed source only. Outside their `lib/ui/`, no +/// file may reach the authoring packages the registry derives from. +Iterable _sourcePackageImports(Directory root) sync* { + final forbidden = RegExp( + r"""^\s*(?:import|export)\s+['"]package:(registry_source)/""", + multiLine: true, + ); + for (final consumer in _consumers.keys) { + final lib = Directory('${root.path}/$consumer/lib'); + if (!lib.existsSync()) continue; + for (final file in lib.listSync(recursive: true).whereType()) { + if (!file.path.endsWith('.dart')) continue; + final relative = file.path.substring(root.path.length + 1); + if (relative.startsWith('$consumer/lib/ui/')) continue; + final match = forbidden.firstMatch(file.readAsStringSync()); + if (match != null) { + yield '$relative imports package:${match.group(1)}; applications ' + 'consume installed source only.'; + } + } + } +} diff --git a/tool/check_toolchain.dart b/tool/check_toolchain.dart index f355398ef..c200368df 100644 --- a/tool/check_toolchain.dart +++ b/tool/check_toolchain.dart @@ -29,7 +29,6 @@ const _pureDartFloor = {'sdk': '>=3.12.0 <4.0.0'}; const _consumerFloorPackages = { 'packages/naked_ui', 'packages/remix', - 'packages/remix_fortal', 'packages/remix_ui_icons', }; diff --git a/tool/generate_fortal_catalog.dart b/tool/generate_fortal_catalog.dart index 174b259f3..4916cd05f 100644 --- a/tool/generate_fortal_catalog.dart +++ b/tool/generate_fortal_catalog.dart @@ -33,7 +33,7 @@ void main(List arguments) { return; } - final packageRoot = Directory('${workspaceRoot.path}/packages/remix_fortal'); + final packageRoot = Directory('${workspaceRoot.path}/registry_source'); final manifestFile = File( '${packageRoot.path}/reference/radix_themes_3_3_0/manifest.json', ); @@ -228,7 +228,7 @@ void _writeFamilySections( ? 'Radix `$radix`' : 'Fortal extension (no Radix counterpart)'; buffer - ..writeln('$origin · recipe `packages/remix_fortal/$recipePath`') + ..writeln('$origin · recipe `registry_source/$recipePath`') ..writeln(); if (enums.isNotEmpty) { @@ -354,7 +354,7 @@ String _recipePath(String id) { 'text_field' || 'text_area' => 'textfield', _ => id, }; - return 'lib/src/components/$recipeName.dart'; + return 'lib/src/fortal/components/$recipeName.dart'; } String _otherDefaults(Map family, Map enums) { diff --git a/tool/validate_docs.dart b/tool/validate_docs.dart index 911cdf858..5f8b5ee54 100644 --- a/tool/validate_docs.dart +++ b/tool/validate_docs.dart @@ -130,7 +130,7 @@ final _iconButtonInvocation = RegExp( r'\b(?:RemixIconButton|FortalIconButton)(?:\.[A-Za-z0-9_]+)?\s*\(', ); final _remixImport = RegExp( - r'''import\s+['"]package:(?:remix|remix_fortal)/(?:remix|remix_fortal)\.dart['"]\s*;''', + r'''import\s+['"]package:remix/remix\.dart['"]\s*;''', ); final _applicationOwnedFortalImport = RegExp( r'''import\s+['"]ui/ui\.dart['"]\s*;''', @@ -143,14 +143,14 @@ const _exampleSourceDirectories = [ 'apps/demo/lib', 'apps/playground/lib', 'packages/remix/example', - 'packages/remix_fortal/example', + 'registry_source/example', ]; // Package library sources aren't examples, but the same retired-API sweep // applies: doc comments quote call sites and drift the same way prose does. const _packageLibraryDirectories = [ 'packages/remix/lib', - 'packages/remix_fortal/lib', + 'registry_source/lib', ]; // Test code is part of the canonical-styler contract too. Mix still exposes @@ -162,7 +162,7 @@ const _testSourceDirectories = [ 'apps/demo/test', 'apps/playground/test', 'packages/remix/test', - 'packages/remix_fortal/test', + 'registry_source/test', ]; const _publishedSkillDirectories = [ @@ -172,7 +172,7 @@ const _publishedSkillDirectories = [ const _consumerDocumentationFiles = [ 'README.md', 'packages/remix/README.md', - 'packages/remix_fortal/README.md', + 'registry_source/docs/fortal/README.md', 'packages/remix_cli/README.md', 'open_code/README.md', 'open_code/CLEAN_SHEET.md', @@ -361,11 +361,11 @@ Future main() async { final file = File('${tempRoot.path}/snippet_$index.dart'); // Fortal docs show the barrel an initialized application owns. The // temporary validation directory has no application package, so map only - // that import to the analyzer-checked authoring package. The Fortal + // that import to the analyzer-checked authoring source. The Fortal // derivation round trip separately proves that the prefixed APIs match. final validationSource = snippet.source.replaceAll( _applicationOwnedFortalImport, - "import 'package:remix_fortal/remix_fortal.dart';", + "import 'package:registry_source/fortal.dart';", ); file.writeAsStringSync( '// Generated temporarily by tool/validate_docs.dart.\n' @@ -848,9 +848,8 @@ _extractAnalyzableSnippets( if (_remixApiReference.hasMatch(snippet)) { failures.add( '$relativePath Dart example ${index + 1} uses Remix or Fortal APIs ' - 'but imports neither package:remix/remix.dart, ' - 'package:remix_fortal/remix_fortal.dart, nor the application-owned ' - 'ui/ui.dart barrel.', + 'but imports neither package:remix/remix.dart nor the ' + 'application-owned ui/ui.dart barrel.', ); } continue;