From db3c040e134ba0c683110cb599678fd2e18fd5a1 Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 6 Aug 2026 12:11:16 -0700 Subject: [PATCH 1/6] add CUJ --- sites/docs/src/data/cujs.yaml | 1040 +++++++++++++++++++++++++++++++++ 1 file changed, 1040 insertions(+) create mode 100644 sites/docs/src/data/cujs.yaml diff --git a/sites/docs/src/data/cujs.yaml b/sites/docs/src/data/cujs.yaml new file mode 100644 index 0000000000..97caab6c35 --- /dev/null +++ b/sites/docs/src/data/cujs.yaml @@ -0,0 +1,1040 @@ +- id: 0 + goal: Evaluate and select the technical stack, folder structure, state management, + and routing architecture for a project + persona: The Tech Lead / Architect + tasks: + - id: 1 + name: research-existing-options-available-architecture + task: Evaluate available architectural patterns, routing libraries, and state + management frameworks, documenting the rationale for the selected technology + stack. + - id: 2 + name: use-workspace-monorepo-repo-structure + task: Configure a multi-package Dart workspace or monorepo repository structure + to separate core domain logic from application UI features. +- id: 1 + goal: Enforce consistent code formatting, linting, and architectural standards + persona: The Tech Lead / Architect + tasks: + - id: 3 + name: compose-analysis-options-style-guide + task: Author comprehensive static analysis rules in "analysis_options.yaml" + and document architectural standards in a project style guide. + - id: 4 + name: ensure-codebase-uses-only-selected + task: Enforce that the codebase adheres strictly to documented architectural + decisions and state management patterns, avoiding unapproved approaches. +- id: 2 + goal: Manage dependency risks and audit third-party packages + persona: The Tech Lead / Architect + tasks: + - id: 5 + name: audit-third-party-pub-dev + task: Audit third-party pub.dev packages for license compliance, maintenance + activity, and security vulnerabilities before adoption. + - id: 6 + name: ensure-dependencies-are-installed-cli + task: Add project dependencies using official command-line package managers + rather than manually modifying configuration files. +- id: 3 + goal: Establish repository governance, branching conventions, code review standards, + and CI quality gates + persona: The Tech Lead / Architect + tasks: + - id: 7 + name: establish-repository-governance-standardize-branching + task: Establish repository governance policies to standardize branching models, + enforce peer code reviews, and automate CI quality gates. +- id: 4 + goal: Develop custom Dart CLI developer utilities and automation tools + persona: The App Developer + tasks: + - id: 8 + name: write-dart-cli-tool-generate + task: Develop a standalone Dart command-line utility that parses database schema + specifications and generates required boilerplate data access code. + - id: 9 + name: write-cli-tool-optimise-csv + task: Develop a Dart command-line utility to automate the parsing, validation, + and compression of CSV datasets and application resources. +- id: 5 + goal: Optimize application release builds for minimal bundle size + persona: The Tech Lead / Architect + tasks: + - id: 10 + name: analyze-size-analyze-size-devtools + task: Analyze application bundle composition and asset weight using command-line + size analysis tools and Flutter DevTools. + - id: 11 + name: enable-tree-shaking-obfuscation-split + task: Configure production build flags to enable code tree shaking, symbol obfuscation, + and split debug information. + - id: 12 + name: audit-compress-codebase-assets + task: Audit application resources to remove unused assets and compress images + and fonts for reduced download size. +- id: 6 + goal: Maintain accurate, up-to-date repository documentation and README guides + persona: The Tech Lead / Architect + tasks: + - id: 13 + name: review-update-readme-other-documentation + task: Audit and update repository documentation, including README guides and + architectural overviews, to align with recent codebase modifications. +- id: 7 + goal: Make an application accessible to all users + persona: The App Developer + tasks: + - id: 14 + name: evaluate-how-app-accessibility-is + task: Audit the application using Flutter DevTools and automated accessibility + inspection tools to identify compliance gaps. + - id: 15 + name: modify-app-add-semantic-labels + task: Refactor UI widgets to include descriptive Semantics properties and screen + reader labels for visually impaired users. + - id: 16 + name: modify-app-make-tappable-areas + task: Enforce minimum interactive touch target dimensions across all interactive + components to meet mobile accessibility standards. + - id: 17 + name: remove-fixed-text-scaler + task: Refactor text components to support dynamic system font scaling and remove + hardcoded text scale restrictions. + - id: 18 + name: add-high-contrast-color-themes + task: Implement high-contrast visual themes and color palettes to support users + with visual impairments. +- id: 8 + goal: Achieve comprehensive test coverage with unit, widget, and integration test + suites + persona: The App Developer + tasks: + - id: 19 + name: check-existing-test-coverage-percentage + task: Analyze current test coverage to identify untested code sections and + determine which parts of the application require additional test coverage. + - id: 20 + name: add-app-benchmarking-uses-binding + task: Implement automated performance benchmarking using binding.traceAction + to measure frame timing and verify that the 90th percentile execution duration + remains below defined latency thresholds. + - id: 21 + name: add-flutter-integration-tests-mobile + task: Develop end-to-end integration test suites using "package:integration_test" + to validate complete user journeys across mobile and web environments. +- id: 9 + goal: Diagnose and resolve layout overflow errors in UI component trees + persona: The App Developer + tasks: + - id: 22 + name: find-real-cause-ui-overflow + task: Diagnose and identify the root cause of layout overflow errors in the + UI component tree. + - id: 23 + name: fix-overflow-bug-with-proper-widgets + task: Refactor the layout using flexible scrolling or bounding widgets to resolve + the overflow error. + - id: 24 + name: write-widget-tests-edge-cases + task: Implement automated widget tests covering boundary conditions and large + data values to prevent regression of layout overflows. +- id: 10 + goal: Implement a structured routing and navigation system + persona: The App Developer + tasks: + - id: 25 + name: set-up-go-router-named + task: Configure declarative application routing using "package:go_router", implementing + named routes and dynamic URL path parameters. + - id: 26 + name: set-up-go-router-builder + task: Integrate "package:go_router_builder" and code generation to manage type-safe + route navigation and arguments. + - id: 27 + name: implement-deep-linking-trigger-deep + task: Configure platform-specific deep linking schemas and verify that external + links navigate correctly to target application screens. + - id: 28 + name: guard-routes-based-auth-state + task: Implement redirection guards within the routing configuration to restrict + access to authenticated user sessions. + - id: 29 + name: use-navigator-v1-route-does + task: Implement imperative navigation using standard Navigator 1.0 APIs for + simple internal modal dialogs and screen transitions. +- id: 11 + goal: Add a new UI screen to an existing application following established design + and architectural patterns + persona: The App Developer + tasks: + - id: 30 + name: add-new-screen-design-system + task: Add a new UI screen to the application that integrates with the existing + design system, routing architecture, and standard page structure. +- id: 12 + goal: Design responsive UI layouts that reflow cleanly across all window sizes and + device orientations + persona: The App Developer + tasks: + - id: 31 + name: define-central-breakpoints-m3-window + task: Define layout breakpoints based on Material Design 3 window size classes, + such as using compact layouts for widths under 600 logical pixels. + - id: 32 + name: use-mediaquery-sizeof-window-sizing + task: Refactor responsive sizing logic to use MediaQuery.sizeOf for global window + dimensions and LayoutBuilder for local widget constraint sizing, removing + hardcoded device-type checks. + - id: 33 + name: apply-safearea-notches-insets + task: Wrap visual layouts in SafeArea widgets to prevent content from obscuring + system status bars, display notches, and physical screen bezels. + - id: 34 + name: don-t-portrait-lock-support + task: Configure the application to support both portrait and landscape orientations, + verifying smooth UI reflow during device rotation. + - id: 35 + name: cap-content-width-large-windows + task: Constrain maximum content width on wide desktop or tablet displays using + BoxConstraints or by dynamically transitioning from ListView to GridView layouts. + - id: 36 + name: handle-foldable-letterboxing-support-all + task: Optimize layouts for foldable devices and letterboxed display modes across + various screen postures and orientations. +- id: 13 + goal: Optimize application rendering and memory performance + persona: The App Developer + tasks: + - id: 37 + name: use-devtools-profile-rendering-performance + task: Profile application frame rendering times and rasterization metrics using + Flutter DevTools. + - id: 38 + name: hunt-down-memory-leaks + task: Diagnose and resolve application memory leaks and retained object graphs + using memory profiling tools. + - id: 39 + name: add-renderrepaintboundary-s-widget-tree + task: Refactor the widget hierarchy by inserting RenderRepaintBoundary widgets + around frequently animating components to isolate repaint regions. +- id: 14 + goal: Implement state restoration to preserve user state across application restarts + persona: The App Developer + tasks: + - id: 40 + name: add-state-restoration-functionality-app + task: Implement Flutter state restoration APIs using RestorationManager and + RestorationBucket to preserve interface navigation and scroll states across + process terminations. + - id: 41 + name: add-hydrated-versions-state-management + task: Integrate persistent state management libraries, such as "package:hydrated_bloc", + to automatically serialize and restore application state across application + restarts. +- id: 15 + goal: Implement offline-first data caching and synchronization + persona: The App Developer + tasks: + - id: 42 + name: add-local-caching-solution-be + task: Implement an offline-first repository pattern that caches remote server + data locally and synchronizes pending mutations when network connectivity + is restored. +- id: 16 + goal: Build interactive widget preview catalogs and isolated design system showcases + persona: The App Developer + tasks: + - id: 43 + name: create-interactive-website-every-widget + task: Develop a standalone interactive web catalog showcasing every UI component + and visual state available within the component library. + - id: 44 + name: add-widget-previews-for-components + task: Implement isolated widget preview configurations using the official Flutter + widget previewer tool for all UI components in the application. +- id: 17 + goal: Implement a customizable, reusable UI design system + persona: The App Developer + tasks: + - id: 45 + name: create-totally-custom-design-system + task: Build an independent UI design system from scratch without relying on + standard Material or Cupertino widget libraries. + - id: 46 + name: customise-material-design-system-fit + task: Customize and extend Material Design widgets and styling tokens to implement + a proprietary visual design system. + - id: 47 + name: customise-cupertino-design-system-fit + task: Customize and extend Cupertino widgets to implement a proprietary iOS-styled + visual design system. +- id: 18 + goal: Implement a consistent visual design theme and styling across an application + persona: The App Developer + tasks: + - id: 48 + name: create-theme-data-from-design-document + task: Implement application ThemeData configurations derived from specifications + in a design document. + - id: 49 + name: add-dark-mode-support + task: Implement dark mode theming and color scheme switching. + - id: 50 + name: change-dropdown-popup-buttons-styling + task: Customize visual styling for dropdown menus, popup dialogs, and interactive + buttons by extending central ThemeData configurations. +- id: 19 + goal: Implement custom gesture detection and pointer interactions + persona: The App Developer + tasks: + - id: 51 + name: create-custom-widget-detects-hover + task: Implement a custom interactive component that combines MouseRegion for + hover detection with GestureDetector or InkWell for touch and pointer interactions. +- id: 20 + goal: Implement custom widget animation states and transitions + persona: The App Developer + tasks: + - id: 52 + name: create-widget-uses-animationcontrollers-animate + task: Develop an explicit animated component using AnimationController to manage + custom state transitions and tween animations. + - id: 53 + name: add-tests-confirm-animation-logic + task: Implement automated widget tests to verify that animation state machines + and value transitions execute correctly. + - id: 54 + name: replace-static-widget-animated-version + task: Refactor static UI components to use implicit animation widgets, such + as AnimatedContainer and AnimatedOpacity, for smooth state transitions. + - id: 55 + name: use-hero-transition-animations-between + task: Implement shared element routing transitions across navigation boundaries + using Hero animation widgets. +- id: 21 + goal: Integrate rich animated graphics and shaders into an application + persona: The App Developer + tasks: + - id: 56 + name: use-rive-lottie-or-some + task: Integrate animation libraries, such as "package:rive" or "package:lottie", + to render rich vector animations within the application. + - id: 57 + name: use-shaders-animate-things-app + task: Implement FragmentShaders using GLSL shader programs to render custom + GPU-accelerated visual effects and animations. +- id: 22 + goal: Integrate interactive data visualization and charting libraries + persona: The App Developer + tasks: + - id: 58 + name: find-list-available-libraries-charts + task: Research available charting libraries on pub.dev and evaluate which packages + support the required chart types and features for the use case. + - id: 59 + name: install-chart-library-supports-bar + task: Install a third-party charting package that supports interactive bar charts + using official command-line tools ("flutter pub add") rather than manually + editing configuration files. + - id: 60 + name: implement-library-application-show-chart + task: Integrate the charting library into the application dashboard to render + interactive data visualizations, implementing automated widget tests to verify + chart rendering. +- id: 23 + goal: Configure package dependency overrides using Git repositories or local filesystem + paths + persona: The App Developer + tasks: + - id: 61 + name: add-dependency-override-git + task: Configure package dependency overrides in "pubspec.yaml" to target a specific + remote Git repository and subdirectory path, verifying that all automated + tests pass. + - id: 62 + name: add-dependency-override-local + task: Configure package dependency overrides in "pubspec.yaml" to link against + a local filesystem package path, verifying that all automated tests pass. +- id: 24 + goal: Build an application that renders Material UI on Android and Cupertino UI + on iOS + persona: The App Developer + tasks: + - id: 63 + name: create-new-app-android-ios + task: Create a new cross-platform Flutter application targeting both Android + and iOS. + - id: 64 + name: add-adaptive-material-cupertino-layouts + task: Implement navigation layouts that adaptively render Material Design components + on Android and Cupertino components on iOS. +- id: 25 + goal: Implement performant scrolling layouts for long-form content + persona: The App Developer + tasks: + - id: 65 + name: identify-overflow-refactor-layout-use + task: Diagnose vertical layout overflow errors and refactor the component hierarchy + to use SingleChildScrollView. + - id: 66 + name: migrate-customscrollview-slivers-more-complex + task: Refactor standard scroll views to use CustomScrollView and Sliver components + for advanced scrolling effects and header animations. + - id: 67 + name: have-long-list-items-variate + task: Implement programmatic scroll-to-index functionality for variable-height + item lists using scroll controllers or item alignment libraries. +- id: 26 + goal: Build intuitive, validated user forms with polished input UX + persona: The App Developer + tasks: + - id: 68 + name: auto-focus-first-invalid-field + task: Implement form validation logic that automatically transfers focus to + the first invalid input field when a user submits an incomplete form. + - id: 69 + name: add-floating-label-behavior-textfields + task: Configure text input fields with floating label behavior and error messaging + using InputDecoration properties. +- id: 27 + goal: Build an application that communicates with a REST API + persona: The App Developer + tasks: + - id: 70 + name: fetch-parse-json-http-or + task: Implement network calls to fetch and parse JSON payloads from a REST API + using "package:http" or "package:dio". + - id: 71 + name: handle-errors-timeouts-loading-states + task: Implement robust error handling, network request timeout management, and + UI loading state indicators. + - id: 72 + name: get-rid-ui-jank-due + task: Offload JSON serialization and deserialization to background worker isolates + to prevent main thread stutter and UI frame drops. + - id: 73 + name: if-api-has-spec-use + task: Generate type-safe API client code and data models automatically from + an OpenAPI specification using code generation tools. +- id: 28 + goal: Implement and evaluate state management architectures + persona: The App Developer + tasks: + - id: 74 + name: use-setstate-manage-state + task: Implement application state management using standard StatefulWidget and + setState mechanisms. + - id: 75 + name: use-provider-manage-state + task: Implement application state management using "package:provider" for dependency + injection and reactive updates. + - id: 76 + name: use-riverpod-manage-state-maybe + task: Implement application state management using "package:riverpod", optionally + incorporating "package:flutter_hooks" and code generation. + - id: 77 + name: use-bloc-cubit-manage-state + task: Implement application state management using the Business Logic Component + (BLoC) and Cubit patterns from "package:flutter_bloc". + - id: 78 + name: use-hooks-manage-state + task: Implement application state management using "package:flutter_hooks" to + manage widget lifecycle and local state with composable hook functions. + - id: 79 + name: use-rxdart-manage-state + task: Implement stream-based application state management using reactive programming + primitives from "package:rxdart". +- id: 29 + goal: Implement local data persistence in an application + persona: The App Developer + tasks: + - id: 80 + name: set-up-shared-preferences-package + task: Implement local persistence for simple key-value data and user preferences + using "package:shared_preferences". + - id: 81 + name: set-up-sqlite-sqflite-or + task: Implement a structured local relational database using "package:sqflite" + or "package:drift". + - id: 82 + name: set-up-secure-storage-store + task: Implement encrypted local storage for sensitive user data and authentication + tokens using "package:flutter_secure_storage". + - id: 83 + name: use-path-provider-locate-application + task: Integrate "package:path_provider" to locate platform-specific filesystem + directories for application documents and temporary files. +- id: 30 + goal: Offload CPU-intensive computation to background isolates to prevent UI freezing + persona: The App Developer + tasks: + - id: 84 + name: offload-cpu-intensive-work-isolate + task: Offload computationally expensive synchronous operations to background + worker isolates using Isolate.run. + - id: 85 + name: use-compute-one-shot-tasks + task: Execute one-shot background computations using the top-level compute function + to prevent UI thread blocking. + - id: 86 + name: set-up-long-lived-isolate + task: Implement a long-lived background isolate communicating via ReceivePort + and SendPort message passing to handle continuous asynchronous processing. +- id: 31 + goal: Adopt code generation tools to reduce boilerplate for models and immutable + data classes + persona: The App Developer + tasks: + - id: 87 + name: set-up-build-runner-json + task: Configure "package:build_runner" and "package:json_serializable" to generate + type-safe JSON serialization code for data models. + - id: 88 + name: use-freezed-immutable-data-classes + task: Integrate "package:freezed" to generate immutable data classes, union + types, and value equality boilerplate. + - id: 89 + name: use-build-verify-ci-cd + task: Configure automated CI/CD pipelines using "package:build_verify" to ensure + generated source code is synchronized with existing data models. +- id: 32 + goal: Diagnose and resolve native platform interop bugs and channel communication + errors + persona: The Plugin Developer + tasks: + - id: 90 + name: analyze-codebase-try-find-cause + task: Analyze native platform channel implementations and Dart bindings to diagnose + the root cause of platform communication failures. + - id: 91 + name: add-debug-logs-perform-test + task: Instrument platform interop channels with diagnostic logging and execute + test runs to isolate native execution errors. + - id: 92 + name: fix-issue + task: Refactor native host code and Dart channel handlers to resolve platform + interop exceptions and restore reliable communication. +- id: 33 + goal: Design a unified, well-documented cross-platform API surface for plugin consumers + persona: The Plugin Developer + tasks: + - id: 93 + name: design-intuitive-well-documented-unified + task: Design a unified, documented Dart API that abstracts native iOS and Android + implementation differences for plugin consumers. + - id: 94 + name: encapsulate-platform-interface-code + task: Structure the plugin package to ensure public APIs encapsulate and hide + internal platform interface implementations. + - id: 95 + name: generate-html-api-documentation + task: Generate HTML API documentation from inline dartdoc comments using command-line + tools to verify public API presentation. +- id: 34 + goal: Implement automated native platform test suites (XCTest, Espresso, JUnit) + to prevent OS upgrade regressions + persona: The Plugin Developer + tasks: + - id: 96 + name: write-automated-tests-validate-both + task: Implement automated test suites validating Dart logic and native implementations + using XCTest for iOS and JUnit or Espresso for Android. +- id: 35 + goal: Maintain high-quality published packages with rigorous semantic versioning, + detailed changelogs, and responsiveness to SDK updates + persona: The Plugin Developer + tasks: + - id: 97 + name: manage-versions-write-changelogs-get + task: Manage semantic versioning, maintain changelogs, achieve high pub.dev + quality scores, and publish package releases compatible with current Flutter + SDK versions. +- id: 36 + goal: Adopt optimal native interop mechanisms (FFI, Pigeon, JS Interop) based on + performance and platform requirements + persona: The Plugin Developer + tasks: + - id: 98 + name: migrate-pigeon-based-platform-channels + task: Migrate Pigeon-based platform channels to Foreign Function Interface (FFI) + bindings for performance-critical or synchronous native calls. + - id: 99 + name: replace-manual-platform-channel-boilerplate + task: Replace manual platform channel boilerplate with type-safe message passing + code generated by "package:pigeon". + - id: 100 + name: create-type-safe-bindings-between + task: Create type-safe bindings between Dart and JavaScript using "dart:js_interop" + and extension types to integrate with browser APIs and external JavaScript + libraries. + - id: 101 + name: check-all-resources-used-native + task: Ensure native memory allocations (malloc, calloc, FFI structs, OpenGL + handles, file descriptors) are properly released using NativeFinalizer and + the Finalizable interface. +- id: 37 + goal: Extend an existing federated plugin architecture to support a new target platform + persona: The Plugin Developer + tasks: + - id: 102 + name: add-implementation-new-platform-app + task: Implement native platform support for an additional operating system within + an existing plugin, verifying that all application-facing integration tests + pass. + - id: 103 + name: test-new-plugin-through-app + task: Verify the new platform implementation by running automated test suites + against the application-facing package. + - id: 104 + name: map-c-language-types-integers + task: Map C language data types (integers, structs, and pointers) to "dart:ffi" + types, utilizing AbiSpecificInteger for platform-dependent type sizing. + - id: 105 + name: refactor-plugin-split-it-into + task: Refactor a monolithic plugin into a federated architecture consisting + of separate application-facing, platform interface, and platform implementation + packages. +- id: 38 + goal: Implement automated cross-platform integration tests for plugins using modern + testing frameworks + persona: The Plugin Developer + tasks: + - id: 106 + name: use-patrol-package-be-able + task: Implement automated cross-platform integration tests using "package:patrol" + to verify plugin functionality across native environments. + - id: 107 + name: write-ci-cd-pipeline-test + task: Configure an automated CI/CD pipeline to execute plugin integration tests + on every pull request and prior to release publication. + - id: 108 + name: write-integration-tests-has-100% + task: Develop comprehensive integration test suites that achieve full test coverage + of native platform plugin functionality. +- id: 39 + goal: Implement user authentication flows and UI + persona: The Full Stack Developer + tasks: + - id: 109 + name: plan-auth-provider-design-system + task: Define the architectural requirements, user experience flows, and edge-case + handling for application authentication. + - id: 110 + name: code-auth-flow + task: Implement secure user authentication and registration workflows connecting + the frontend UI to the authentication service. + - id: 111 + name: test-all-auth-flows + task: Implement automated unit, widget, and integration test suites to verify + all authentication and session management workflows. +- id: 40 + goal: Integrate push notification services into an application + persona: The Full Stack Developer + tasks: + - id: 112 + name: add-firebase-cloud-messaging + task: Integrate "package:firebase_messaging" to enable push notifications across + mobile and web platforms. + - id: 113 + name: handle-foreground-background-terminated-states + task: Implement notification event listeners and handlers for foreground, background, + and terminated application lifecycle states. +- id: 41 + goal: Integrate third-party authentication providers into an application + persona: The Full Stack Developer + tasks: + - id: 114 + name: add-google-sign + task: Integrate "package:google_sign_in" to enable single sign-on authentication. + - id: 115 + name: handle-sign-sign-out-flows + task: Implement complete authentication state machines managing sign-in, sign-out, + session persistence, and OAuth token refresh workflows. +- id: 42 + goal: Integrate cloud file storage into an application + persona: The Full Stack Developer + tasks: + - id: 116 + name: add-firebase-storage + task: Integrate "package:firebase_storage" into the application to enable cloud + storage capabilities. + - id: 117 + name: implement-upload-download-delete + task: Implement user flows and repository methods to upload, download, and delete + cloud storage files. +- id: 43 + goal: Integrate crash reporting, error tracking, and production telemetry + persona: The Full Stack Developer + tasks: + - id: 118 + name: add-crashlytics + task: Integrate Firebase Crashlytics ("package:firebase_crashlytics") to capture + and monitor real-time fatal exception reports. + - id: 119 + name: add-custom-log-events-non + task: Implement custom error logging and non-fatal exception tracking to record + application telemetry and diagnostic metadata. +- id: 44 + goal: Integrate AWS Amplify authentication, cloud storage, and backend APIs into + an application + persona: The Full Stack Developer + tasks: + - id: 120 + name: integrate-amplify-auth-+-storage + task: Integrate AWS Amplify authentication, cloud storage, and API services + into the application architecture. +- id: 45 + goal: Integrate Supabase authentication, database services, and real-time subscriptions + into an application + persona: The Full Stack Developer + tasks: + - id: 121 + name: integrate-supabase-auth-+-database + task: Integrate Supabase authentication, relational database services, and real-time + data subscriptions into the application architecture. +- id: 46 + goal: Build a full-stack Dart web server backend with shared data models between + frontend and backend + persona: The Full Stack Developer + tasks: + - id: 122 + name: create-web-server-shelf + task: Develop a backend web server and HTTP API routing layer using "package:shelf". + - id: 123 + name: create-cloud-function-upload-firebase + task: Implement server-side logic or Google Cloud Functions to process file + uploads and store metadata in Firebase. + - id: 124 + name: decide-best-repo-structure-according + task: Design and implement a shared monorepo workspace structure to allow seamless + data model reuse between Dart frontend and backend services. +- id: 47 + goal: Implement API versioning and backward compatibility checks between frontend + applications and backend services + persona: The Full Stack Developer + tasks: + - id: 125 + name: create-ci-cd-pipeline-confirm + task: Configure automated CI/CD deployment pipelines to verify that the target + backend API version is active prior to releasing client applications. + - id: 126 + name: create-screen-app-app-version + task: Implement a dedicated application deprecation screen that informs users + when their client version is no longer supported by backend API services. +- id: 48 + goal: Develop Google Cloud Functions in Dart using the Genkit SDK + persona: The Full Stack Developer + tasks: + - id: 127 + name: write-google-cloud-function-dart + task: Develop and deploy serverless Google Cloud Functions written in Dart using + the Genkit framework ("package:genkit"). +- id: 49 + goal: Add internationalization (i18n) and localization (l10n) support to an application + persona: The App Developer + tasks: + - id: 128 + name: add-required-languages-locales-app + task: Configure supported languages and regional locales within the application + localization settings. + - id: 129 + name: confirm-default-flutter-ways-i18n + task: Verify that the codebase implements standard Flutter internationalization + practices using ARB files and generated localization delegates. +- id: 50 + goal: Embed interactive Flutter applications and widgets within existing HTML or + React web pages + persona: The Hybrid (Native + Flutter) Developer + tasks: + - id: 130 + name: create-website-jaspr + task: Build a server-rendered or static website in Dart using the Jaspr web + framework ("package:jaspr"). + - id: 131 + name: add-flutter-app-react-app + task: Embed a compiled Flutter web application as an interactive component within + an existing React web application. + - id: 132 + name: add-many-flutter-widgets-across + task: Embed multiple interactive Flutter widgets across a standard HTML web + page, utilizing Flutter multi-view mode to optimize rendering performance + and resource consumption. +- id: 51 + goal: Integrate Flutter modules into existing native Android and iOS applications + using Add-to-app + persona: The Hybrid (Native + Flutter) Developer + tasks: + - id: 133 + name: add-flutter-engine-view-android + task: Integrate a cached FlutterEngine and FlutterActivity into an existing + native Android application using Flutter Add-to-app workflows. + - id: 134 + name: add-flutter-engine-view-ios + task: Integrate a cached FlutterEngine and FlutterViewController into an existing + native iOS application using Flutter Add-to-app workflows. +- id: 52 + goal: Implement seamless cross-layer navigation and state synchronization between + native host apps and embedded Flutter modules + persona: The Hybrid (Native + Flutter) Developer + tasks: + - id: 135 + name: cache-pre-warm-flutter-engine + task: Configure native host applications to pre-warm and cache the FlutterEngine + during application startup to eliminate initialization latency. + - id: 136 + name: manage-complex-navigation-stacks-where + task: Implement bidirectional navigation stacks where users transition between + native Swift screens and embedded Flutter modules, ensuring native gesture + back-swipes behave naturally. + - id: 137 + name: securely-pass-active-user-session + task: Synchronize active session tokens, visual theme preferences, and user + state from the host native application into embedded Flutter modules to provide + a seamless user experience. +- id: 53 + goal: Build platform-specific home screen widgets (for iOS WidgetKit and Android) + that share data with the host application + persona: The Hybrid (Native + Flutter) Developer + tasks: + - id: 138 + name: set-up-home-screen-widget + task: Scaffold and configure native home screen widget extensions for iOS using + WidgetKit and for Android using AppWidgets. + - id: 139 + name: share-data-between-flutter-app + task: Implement shared local storage using App Groups on iOS and SharedPreferences + on Android to synchronize data between the Flutter application and native + widgets. + - id: 140 + name: update-widget-data-flutter + task: Trigger programmatic background updates and timeline reloads for native + home screen widgets directly from Dart application logic. +- id: 54 + goal: Identify and refactor architectural anti-patterns in the codebase + persona: The App Developer + tasks: + - id: 141 + name: analyze-codebase-anti-patterns + task: Analyze the codebase to identify and refactor architectural anti-patterns, + such as building complex widget trees inside helper methods rather than separate + widget classes. +- id: 55 + goal: Audit and migrate codebases away from deprecated frameworks, libraries, and + SDK APIs + persona: The Tech Lead / Architect + tasks: + - id: 142 + name: analyze-codebase-deprecated-api + task: Analyze the codebase to identify and migrate deprecated API usage, including + Material Design 2 components, direct window references in "dart:ui", and legacy + ThemeData styling properties. +- id: 56 + goal: Automate multi-flavor application build, configuration, and distribution pipelines + persona: The Tech Lead / Architect + tasks: + - id: 143 + name: config-flavors-app-different-naming + task: Configure multi-flavor build schemes across iOS and Android to support + distinct application names, bundle identifiers, and launcher icons for staging + and production environments. + - id: 144 + name: write-ci-cd-pipeline-deploy + task: Implement an automated CI/CD distribution pipeline to build and deploy + application binaries to internal testing tracks or distribution services. +- id: 57 + goal: Upgrade and migrate legacy Flutter applications to the latest SDK version + persona: The Tech Lead / Architect + tasks: + - id: 145 + name: audit-and-upgrade-flutter-sdk + task: Audit the local Flutter SDK installation and upgrade safely using version + management tools ("fvm") or system package managers. + - id: 146 + name: run-dart-fix-migration-tool + task: Execute "dart fix" to update deprecated syntax and resolve breaking API + changes across the codebase. +- id: 58 + goal: Extend an existing application to support an additional target platform + persona: The Tech Lead / Architect + tasks: + - id: 147 + name: analyze-repo-check-which-features + task: Audit existing codebase capabilities and third-party plugins to determine + feature compatibility with the target platform. + - id: 148 + name: verify-proper-command-is-used + task: Execute official platform scaffolding commands to generate target platform + projects and verify dependency compatibility. +- id: 59 + goal: Implement new application features utilizing modern language capabilities + and defensive coding practices + persona: The App Developer + tasks: + - id: 149 + name: verify-assert-calls-all-passed + task: Enforce defensive coding by adding runtime assert statements to validate + constructor and function parameter boundaries. + - id: 150 + name: use-record-structure-type-function + task: Refactor function signatures to return structured, type-safe multiple + values using modern Dart Record types. +- id: 60 + goal: Implement custom canvas drawing and custom painters for specialized UI components + persona: The App Developer + tasks: + - id: 151 + name: create-widget-displays-custom-pattern + task: Implement a custom component utilizing CustomPaint and Canvas primitives + to render specialized graphics above or below child widget layers. +- id: 61 + goal: Design and develop a new cross-platform plugin from scratch, selecting the + appropriate native interop mechanism + persona: The Plugin Developer + tasks: + - id: 152 + name: investigate-should-ffi-or-methodchannels + task: Evaluate whether Foreign Function Interface (FFI) bindings or asynchronous + MethodChannels provide the optimal architectural foundation for a new cross-platform + plugin. +- id: 62 + goal: Set up and configure a complete cross-platform Flutter development environment + persona: The App Developer + tasks: + - id: 153 + name: install-configure-xcode-command-line + task: Install and configure Xcode, command-line tools, and CocoaPods on a macOS + development environment to compile for all supported Flutter target platforms. + - id: 154 + name: sets-up-windows-environment-flutter + task: Set up and configure a Windows development environment for Flutter with + necessary dependencies to compile for all supported non-Apple target platforms. + - id: 155 + name: sets-up-linux-environment-flutter + task: Set up and configure a Linux development environment for Flutter with + necessary dependencies to compile for all supported non-Apple target platforms. +- id: 63 + goal: Build a responsive Flutter Web frontend for an Enterprise Resource Planning + (ERP) system + persona: The App Developer + tasks: + - id: 156 + name: create-flutter-web-app-has + task: Develop a responsive Flutter web frontend that dynamically adapts between + desktop browser layouts and mobile web layouts. + - id: 157 + name: use-proper-url-path-strategy + task: Configure the web URL routing strategy (hash-based or path-based) according + to target web hosting platform requirements. + - id: 158 + name: use-wasm-if-possible + task: Configure the web build pipeline to compile to WebAssembly (Wasm) for + high-performance browser execution. + - id: 159 + name: use-pwa-web-app-if + task: Configure Progressive Web App (PWA) manifest and service worker features + to enable offline support and desktop installation. + - id: 160 + name: use-package:web-dart:js-interop-interact + task: Implement browser API integrations and JavaScript interop using "package:web" + and modern Dart type-safe JS interop mechanisms. +- id: 64 + goal: Build adaptive UI layouts that dynamically adjust to platform conventions + and input methods (touch, mouse, keyboard, stylus) + persona: The App Developer + tasks: + - id: 161 + name: switch-nav-window-size:-bottom + task: Implement adaptive navigation that transitions between a bottom navigation + bar on compact screens and a side NavigationRail on expanded displays, sharing + routing destinations. + - id: 162 + name: target-android-tier-3-mouse + task: Optimize the interface for mouse and stylus input by utilizing Material + Design 3 components with built-in hover and focus states. + - id: 163 + name: scroll-wheel-custom-scrollables-listener + task: Refactor custom scrollable components using Listener widgets to support + mouse scroll wheel and trackpad navigation. + - id: 164 + name: tab-traversal-+-visible-focus + task: Implement keyboard tab navigation and visible focus highlights on custom + interactive components using FocusableActionDetector and FocusTraversalGroup. + - id: 165 + name: keyboard-shortcuts-shortcuts-actions-disable + task: Configure application-wide keyboard shortcuts using Shortcuts and Actions + widgets, ensuring shortcuts are disabled during text input. + - id: 166 + name: visualdensity-switched-input-mode-hit + task: Adjust widget VisualDensity dynamically based on active input mode to + optimize touch target sizes versus mouse precision sizing. +- id: 65 + goal: Migrate application architecture between state management solutions + persona: The App Developer + tasks: + - id: 167 + name: replace-setstate-riverpod + task: Refactor the codebase to use "package:riverpod" rather than StatefulWidgets. + - id: 168 + name: replace-setstate-provider + task: Refactor the codebase to use "package:provider" rather than StatefulWidgets. + - id: 169 + name: replace-inheritedwidget-provider + task: Refactor the codebase to use "package:provider" rather than custom InheritedWidgets. + - id: 170 + name: replace-provider-riverpod + task: Migrate existing state management from "package:provider" to "package:riverpod". + - id: 171 + name: replace-provider-bloc + task: Migrate existing state management from "package:provider" to the BLoC + ("package:flutter_bloc") architecture. + - id: 172 + name: replace-setstate-rxdart + task: Refactor the codebase to manage reactive state using "package:rxdart" + rather than StatefulWidgets. +- id: 66 + goal: Diagnose, debug, and resolve runtime exceptions and network defects + persona: The App Developer + tasks: + - id: 173 + name: reproduce-reported-defect-failing-test + task: Reproduce a reported defect in a failing test, then trace the root cause + using the Dart debugger and Flutter DevTools. + - id: 174 + name: fix-common-runtime-exceptions + task: Diagnose and resolve common runtime exceptions (null errors, late init + failures, RangeErrors, invalid setState calls). + - id: 175 + name: diagnose-fix-failed-network-request + task: Diagnose and resolve failed HTTP requests (non-200 status codes, timeouts, + JSON deserialization failures). +- id: 67 + goal: Refactor application code to improve modularity, component reusability, and + architectural maintainability + persona: The App Developer + tasks: + - id: 176 + name: extract-repeated-widget-code-into + task: Extract repeated widget trees into reusable components and consolidate + shared colors, spacing, and text styles into central theme constants. + - id: 177 + name: split-large-dart-class-into + task: Refactor large Dart classes into smaller units, separating business logic + from widget presentation. + - id: 178 + name: extract-shared-ui-logic-into-mixins + task: Extract shared UI behavior and state logic into reusable Dart mixins. +- id: 68 + goal: Call native platform APIs directly using MethodChannel and EventChannel implementations + on Android and iOS + persona: The App Developer + tasks: + - id: 179 + name: call-one-shot-native-method + task: Implement one-shot communication between Dart and native platforms via + MethodChannel (e.g., reading battery level or triggering haptic feedback), + writing handlers in Kotlin for Android and Swift for iOS. + - id: 180 + name: stream-continuous-native-events-into + task: Stream continuous native events into Dart via EventChannel (e.g., sensor + data or network connectivity state). From 0b7a4f3342c5d55dbcb82f01908fe4c6b6f5570a Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 6 Aug 2026 13:39:41 -0700 Subject: [PATCH 2/6] add CUJ page --- sites/docs/lib/_sass/_site.scss | 1 + sites/docs/lib/_sass/pages/_cuj-index.scss | 159 ++++++++++++++++++ sites/docs/lib/main.client.options.dart | 12 ++ sites/docs/lib/main.server.dart | 2 + sites/docs/lib/main.server.options.dart | 11 ++ .../lib/src/components/pages/cuj_filters.dart | 154 +++++++++++++++++ .../components/pages/cuj_filters_sidebar.dart | 141 ++++++++++++++++ .../lib/src/components/pages/cuj_index.dart | 103 ++++++++++++ sites/docs/lib/src/models/cuj_model.dart | 127 ++++++++++++++ sites/docs/src/content/ai/evals/cujs.md | 33 ++++ .../content/ai/{evals.md => evals/index.md} | 0 11 files changed, 743 insertions(+) create mode 100644 sites/docs/lib/_sass/pages/_cuj-index.scss create mode 100644 sites/docs/lib/src/components/pages/cuj_filters.dart create mode 100644 sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart create mode 100644 sites/docs/lib/src/components/pages/cuj_index.dart create mode 100644 sites/docs/lib/src/models/cuj_model.dart create mode 100644 sites/docs/src/content/ai/evals/cujs.md rename sites/docs/src/content/ai/{evals.md => evals/index.md} (100%) diff --git a/sites/docs/lib/_sass/_site.scss b/sites/docs/lib/_sass/_site.scss index 99092c6a67..05f2c2c056 100644 --- a/sites/docs/lib/_sass/_site.scss +++ b/sites/docs/lib/_sass/_site.scss @@ -48,6 +48,7 @@ @use 'package:site_shared/_sass/components/tooltip'; // Styles for specific pages, alphabetically ordered. +@use 'pages/cuj-index'; @use 'pages/glossary'; @use 'pages/learning-resources-index'; @use 'pages/not-found'; diff --git a/sites/docs/lib/_sass/pages/_cuj-index.scss b/sites/docs/lib/_sass/pages/_cuj-index.scss new file mode 100644 index 0000000000..0a7fc88ae3 --- /dev/null +++ b/sites/docs/lib/_sass/pages/_cuj-index.scss @@ -0,0 +1,159 @@ +@use 'sass:color'; + +// The critical user journey index reuses the two column layout, search field, +// and filter sidebar of the learning resources index, so the ids it shares +// with that page are styled in `_learning-resources-index.scss`. +// +// The journeys themselves render as full-width expandable cards, following +// the glossary in `_glossary.scss`. Expanding and collapsing is wired up +// by the `_setUpExpandableCards` global script. + +// The feedback button sits below the filter card in the sidebar. +.cuj-feedback { + margin-block-start: 1rem; + display: flex; + justify-content: center; + + .outlined-button { + width: 100%; + justify-content: center; + } +} + +// Below this width the filter card becomes an off-canvas drawer, so the +// feedback button has to slide in with it or it stays off screen. +// Mirrors the `#resource-filter-group-wrapper` rules in +// `_learning-resources-index.scss`. +@media (max-width: 839px) { + .cuj-feedback { + position: fixed; + right: -220px; + bottom: 0; + width: 220px; + margin-block-start: 0; + padding: 0.75rem; + background-color: var(--site-inset-bgColor); + border-top: 1px solid var(--site-inset-borderColor); + transition: right 0.3s ease-in-out; + z-index: 1001; + } + + #open-filter-toggle:checked ~ .cuj-feedback { + right: 0; + } +} + +#all-cujs-list { + margin-block-start: 1rem; + + .cuj-card { + height: auto; + padding: 0.75rem 1rem; + gap: 0.25rem; + + .card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + } + + .cuj-card-heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; + // Allow long goals to wrap instead of widening the flex item. + min-width: 0; + } + + .card-title { + display: block; + margin: 0; + font-family: var(--site-ui-fontFamily); + font-size: 1.1rem; + font-weight: 500; + line-height: 1.35; + text-wrap: pretty; + } + + .card-header-buttons { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + + .icon-button { + border-radius: 1.5rem; + + > span { + font-size: 1.5rem; + } + } + } + + .cuj-task-count { + margin: 0; + font-size: 0.9rem; + color: var(--site-base-fgColor-lighter); + } + + // The shared card styles lay `.card-content` out as a centered row. + // Journeys need a plain block so the task list stacks below the divider. + .card-content { + display: block; + border-top: 0.05rem solid var(--site-inset-borderColor); + margin-block-start: 0.5rem; + padding-block-start: 0.5rem; + } + + &.collapsed { + .card-content { + display: none; + } + + .expand-button { + transform: rotate(180deg); + } + } + + .expand-button { + &:hover, + &:focus-within { + transition: transform .25s ease-out; + } + } + + .cuj-task-list { + margin: 0; + padding-left: 1.15rem; + + li { + padding-left: 0; + margin-block-end: 0.5rem; + font-size: 0.9rem; + line-height: 1.45; + color: var(--site-base-fgColor-alt); + + &:last-child { + margin-block-end: 0; + } + } + } + + // Two additional persona colors, following the `.pill-sm` modifiers. + // TODO(parlough): Consider if these colors can be shared and if + // compatible with dark mode. + .pill-sm.magenta { + color: color.scale(#FBD9EF, $lightness: 10%); + background: #9A2E6E; + } + + .pill-sm.amber { + color: color.scale(#FCEBC8, $lightness: 10%); + background: #8A5300; + } + } +} diff --git a/sites/docs/lib/main.client.options.dart b/sites/docs/lib/main.client.options.dart index 23c1b571e8..3547ffcfd5 100644 --- a/sites/docs/lib/main.client.options.dart +++ b/sites/docs/lib/main.client.options.dart @@ -14,6 +14,10 @@ import 'package:docs_flutter_dev_site/src/components/layout/client/pagenav.dart' deferred as _pagenav; import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' deferred as _archive_table; +import 'package:docs_flutter_dev_site/src/components/pages/cuj_filters.dart' + deferred as _cuj_filters; +import 'package:docs_flutter_dev_site/src/components/pages/cuj_filters_sidebar.dart' + deferred as _cuj_filters_sidebar; import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' deferred as _glossary_search_section; import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' @@ -96,6 +100,14 @@ ClientOptions get defaultClientOptions => ClientOptions( ), loader: _archive_table.loadLibrary, ), + 'cuj_filters': ClientLoader( + (p) => _cuj_filters.CujFilters(), + loader: _cuj_filters.loadLibrary, + ), + 'cuj_filters_sidebar': ClientLoader( + (p) => _cuj_filters_sidebar.CujFiltersSidebar(), + loader: _cuj_filters_sidebar.loadLibrary, + ), 'glossary_search_section': ClientLoader( (p) => _glossary_search_section.GlossarySearchSection(), loader: _glossary_search_section.loadLibrary, diff --git a/sites/docs/lib/main.server.dart b/sites/docs/lib/main.server.dart index 2738c1fd43..26b5373eb5 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -27,6 +27,7 @@ import 'src/components/common/code_preview.dart'; import 'src/components/common/dash_image.dart'; import 'src/components/pages/architecture_recommendations.dart'; import 'src/components/pages/archive_table.dart'; +import 'src/components/pages/cuj_index.dart'; import 'src/components/pages/devtools_release_notes_index.dart'; import 'src/components/pages/expansion_list.dart'; import 'src/components/pages/learning_resource_index.dart'; @@ -116,6 +117,7 @@ List get _embeddableComponents => [ defineComponent('OSSelector', const OsSelector()), defineComponentWithChild('Card', Card.fromAttributes), defineComponent('LearningResourceIndex', const LearningResourceIndex()), + defineComponent('CujIndex', const CujIndex()), defineComponentWithAttrs('ArchiveTable', ArchiveTable.fromAttributes), defineComponentWithAttrs( 'DownloadLatestButton', diff --git a/sites/docs/lib/main.server.options.dart b/sites/docs/lib/main.server.options.dart index fcbd5e0a09..1c131de872 100644 --- a/sites/docs/lib/main.server.options.dart +++ b/sites/docs/lib/main.server.options.dart @@ -13,6 +13,10 @@ import 'package:docs_flutter_dev_site/src/components/layout/client/pagenav.dart' as _pagenav; import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' as _archive_table; +import 'package:docs_flutter_dev_site/src/components/pages/cuj_filters.dart' + as _cuj_filters; +import 'package:docs_flutter_dev_site/src/components/pages/cuj_filters_sidebar.dart' + as _cuj_filters_sidebar; import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' as _glossary_search_section; import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' @@ -80,6 +84,13 @@ ServerOptions get defaultServerOptions => ServerOptions( 'archive_table', params: __archive_tableArchiveTable, ), + _cuj_filters.CujFilters: ClientTarget<_cuj_filters.CujFilters>( + 'cuj_filters', + ), + _cuj_filters_sidebar.CujFiltersSidebar: + ClientTarget<_cuj_filters_sidebar.CujFiltersSidebar>( + 'cuj_filters_sidebar', + ), _glossary_search_section.GlossarySearchSection: ClientTarget<_glossary_search_section.GlossarySearchSection>( 'glossary_search_section', diff --git a/sites/docs/lib/src/components/pages/cuj_filters.dart b/sites/docs/lib/src/components/pages/cuj_filters.dart new file mode 100644 index 0000000000..73506f35d8 --- /dev/null +++ b/sites/docs/lib/src/components/pages/cuj_filters.dart @@ -0,0 +1,154 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:site_shared/components/common/material_icon.dart'; +import 'package:site_shared/components/common/search.dart'; +import 'package:site_shared/components/utils/global_event_listener.dart'; +import 'package:universal_web/web.dart' as web; + +import '../../models/cuj_model.dart'; +import 'cuj_filters_sidebar.dart'; + +@client +class CujFilters extends StatefulComponent { + const CujFilters({super.key}); + + @override + State createState() => _CujFiltersState(); +} + +class _CujFiltersState extends State { + String searchQuery = ''; + + CujFiltersNotifier get filters => CujFiltersSidebar.filters; + + final List cujs = []; + int filteredCujCount = 0; + + @override + void initState() { + super.initState(); + + if (kIsWeb) { + filters.addListener(setFilters); + + final cujList = web.document.getElementById('all-cujs-list'); + if (cujList == null) { + return; + } + + recreateCujs(cujList.querySelectorAll('.card')); + } + } + + void recreateCujs(web.NodeList cujCards) { + for (var i = 0; i < cujCards.length; i++) { + final element = cujCards.item(i) as web.Element; + cujs.add(Cuj.fromElement(element)); + } + filteredCujCount = cujs.length; + } + + /// Update the filter state and re-evaluate which journeys to show. + /// + /// Use like the `setState` method by passing a callback that updates + /// the relevant state variables. + void setFilters([void Function()? callback]) { + setState(callback ?? () {}); + + final cujsToShow = filters.filterCujs(cujs, searchQuery); + filteredCujCount = cujsToShow.length; + for (final cuj in cujs) { + final element = + web.document.getElementById(cuj.elementId) as web.HTMLElement?; + if (element == null) { + continue; + } + + if (cujsToShow.contains(cuj)) { + element.classList.remove('hidden'); + } else { + element.classList.add('hidden'); + } + } + } + + @override + void dispose() { + if (kIsWeb) { + filters.removeListener(setFilters); + } + super.dispose(); + } + + @override + Component build(BuildContext context) { + return div(id: 'resource-search-group', classes: 'chip-filters-group', [ + SearchBar( + placeholder: 'Try "testing" or "architecture"...', + label: 'Search critical user journeys by goal, persona, and task', + value: searchQuery, + id: 'resource-search', + onInput: (value) { + setFilters(() { + searchQuery = value; + }); + }, + trailing: GlobalEventListener( + onClick: (event) { + final target = event.target as web.Element?; + // If clicking outside the filters or toggle, close the filters. + if (target?.closest('#resource-filter-group-wrapper') == null && + target?.closest('.show-filters-button') == null) { + final toggle = + web.document.getElementById('open-filter-toggle') + as web.HTMLInputElement?; + toggle?.checked = false; + } + }, + button( + classes: 'icon-button show-filters-button', + onClick: () { + final toggle = + web.document.getElementById('open-filter-toggle') + as web.HTMLInputElement?; + toggle?.checked = !toggle.checked; + }, + [ + const MaterialIcon('filter_list'), + ], + ), + ), + ), + div(classes: 'label-row', [ + label( + attributes: {'for': 'resource-search'}, + [ + const .text('Showing '), + span([.text('$filteredCujCount')]), + const .text(' / '), + span([.text('${cujs.length}')]), + ], + ), + button( + attributes: { + if (searchQuery.isEmpty && filters.selectedPersonas.isEmpty) + 'disabled': 'true', + }, + onClick: () { + // No setState needed, since resetting filters will trigger it. + searchQuery = ''; + filters.reset(); + }, + [ + const MaterialIcon('close_small'), + const span([.text('Clear filters')]), + ], + ), + ]), + ]); + } +} diff --git a/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart b/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart new file mode 100644 index 0000000000..fde6c5ebdd --- /dev/null +++ b/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart @@ -0,0 +1,141 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:site_shared/components/common/button.dart'; +import 'package:site_shared/components/common/material_icon.dart'; + +import '../../models/cuj_model.dart'; +import 'cuj_filters.dart'; + +// TODO(ewindmill): Replace with the real feedback destination once it exists. +const _feedbackUrl = 'https://github.com/flutter/evals/issues'; + +@client +class CujFiltersSidebar extends StatelessComponent { + const CujFiltersSidebar({super.key}); + + /// The filter state for the critical user journey list. + /// + /// This is static so that [CujFilters] can access it, + /// since both client components don't share a common ancestor. + static CujFiltersNotifier filters = CujFiltersNotifier(); + + @override + Component build(BuildContext context) { + return div(classes: 'right-col', [ + const input( + type: InputType.checkbox, + id: 'open-filter-toggle', + attributes: {'hidden': 'true'}, + ), + div(id: 'resource-filter-group-wrapper', [ + div(id: 'resource-filter-group', [ + const div(classes: 'filter-header', [ + label( + attributes: {'for': 'open-filter-toggle', 'aria-hidden': 'true'}, + classes: 'close-icon', + [MaterialIcon('close')], + ), + ]), + const div(classes: 'table-title', [.text('Filter by')]), + ListenableBuilder( + listenable: filters, + builder: (context) { + return div(classes: 'table-content', [ + const h4([.text('Persona')]), + ul([ + for (final persona in CujPersona.values) + li([ + input( + type: InputType.checkbox, + attributes: { + 'role': 'checkbox', + 'name': 'filter-${persona.name}', + }, + id: 'filter-${persona.name}', + checked: filters.selectedPersonas.contains(persona), + onChange: (checked) { + filters.setPersona(persona, checked as bool); + }, + ), + label( + attributes: {'for': 'filter-${persona.name}'}, + [.text(persona.label)], + ), + ]), + ]), + ]); + }, + ), + ]), + ]), + const div(classes: 'cuj-feedback', [ + Button( + href: _feedbackUrl, + content: 'Feedback', + icon: 'feedback', + style: ButtonStyle.outlined, + title: 'Leave feedback or suggest new CUJs', + attributes: { + 'target': '_blank', + 'rel': 'noopener', + }, + ), + ]), + ]); + } +} + +/// Notifier to manage the state of the critical user journey filters. +class CujFiltersNotifier extends ChangeNotifier { + Set selectedPersonas = {}; + + void setPersona(CujPersona persona, bool isSelected) { + if (isSelected) { + selectedPersonas.add(persona); + } else { + selectedPersonas.remove(persona); + } + notifyListeners(); + } + + void reset() { + selectedPersonas.clear(); + notifyListeners(); + } + + Set filterCujs(List cujs, String searchQuery) { + searchQuery = searchQuery.trim().toLowerCase(); + + if (searchQuery.isEmpty && selectedPersonas.isEmpty) { + // No filters applied, return all journeys. + return cujs.toSet(); + } + + final cujsToShow = {}; + + for (final cuj in cujs) { + final matchesPersona = + selectedPersonas.isEmpty || selectedPersonas.contains(cuj.persona); + if (!matchesPersona) { + continue; + } + + final matchesSearchQuery = + searchQuery.isEmpty || + cuj.goal.toLowerCase().contains(searchQuery) || + (cuj.persona?.label.toLowerCase().contains(searchQuery) ?? false) || + cuj.tasks.any((t) => t.task.toLowerCase().contains(searchQuery)); + if (!matchesSearchQuery) { + continue; + } + + cujsToShow.add(cuj); + } + + return cujsToShow; + } +} diff --git a/sites/docs/lib/src/components/pages/cuj_index.dart b/sites/docs/lib/src/components/pages/cuj_index.dart new file mode 100644 index 0000000000..008158e2e9 --- /dev/null +++ b/sites/docs/lib/src/components/pages/cuj_index.dart @@ -0,0 +1,103 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; +import 'package:site_shared/components/common/button.dart'; +import 'package:site_shared/components/common/card.dart'; + +import '../../models/cuj_model.dart'; +import 'cuj_filters.dart'; +import 'cuj_filters_sidebar.dart'; + +final class CujIndex extends StatelessComponent { + const CujIndex({super.key}); + + @override + Component build(BuildContext context) { + final cujData = context.page.data['cujs'] as List?; + + final cujs = [ + for (final cuj in cujData ?? const []) + Cuj.fromMap(cuj as Map), + ]; + + return div(id: 'resource-index-content', [ + div(classes: 'left-col', id: 'resource-index-main-content', [ + const CujFilters(), + div(classes: 'card-list', id: 'all-cujs-list', [ + for (final cuj in cujs) _CujCard(cuj), + ]), + ]), + const CujFiltersSidebar(), + ]); + } +} + +final class _CujCard extends StatelessComponent { + const _CujCard(this.cuj); + + final Cuj cuj; + + @override + Component build(BuildContext context) { + final cardId = cuj.elementId; + final taskCount = cuj.tasks.length; + + // Expanding and collapsing is handled for every `.expandable-card` + // by the `_setUpExpandableCards` global script. + return Card.expandable( + id: cardId, + outlined: true, + additionalClasses: 'cuj-card', + initiallyExpanded: false, + attributes: { + 'data-persona': cuj.persona?.name ?? '', + 'data-goal': cuj.goal, + 'data-tasks': cuj.taskData, + }, + header: [ + div(classes: 'cuj-card-heading', [ + if (cuj.persona case final persona?) + span(classes: 'pill-sm ${persona.pillClass}', [ + .text(persona.label), + ]), + h2(classes: 'card-title', [.text(cuj.goal)]), + ]), + div(classes: 'card-header-buttons', [ + Button( + href: '#$cardId', + icon: 'tag', + classes: const ['share-button'], + title: 'Link to journey', + attributes: { + 'aria-label': 'Link to the "${cuj.goal}" journey', + }, + ), + Button( + icon: 'keyboard_arrow_up', + classes: const ['expand-button'], + title: 'Expand or collapse tasks', + attributes: { + 'aria-expanded': 'false', + 'aria-controls': '$cardId-content', + 'aria-label': 'Expand or collapse the tasks for "${cuj.goal}"', + }, + ), + ]), + ], + collapsedContent: [ + p(classes: 'cuj-task-count', [ + .text(taskCount == 1 ? '1 task' : '$taskCount tasks'), + ]), + ], + expandedContent: [ + ul(classes: 'cuj-task-list', [ + for (final task in cuj.tasks) li([.text(task.task)]), + ]), + ], + ); + } +} diff --git a/sites/docs/lib/src/models/cuj_model.dart b/sites/docs/lib/src/models/cuj_model.dart new file mode 100644 index 0000000000..8f015c26c9 --- /dev/null +++ b/sites/docs/lib/src/models/cuj_model.dart @@ -0,0 +1,127 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:universal_web/web.dart' as web; + +/// Separator used to join task descriptions into a single `data-tasks` +/// attribute, so they can be recovered on the client. +const _taskSeparator = '|'; + +/// Prefix used for the DOM id of each journey's card element. +const _elementIdPrefix = 'cuj-'; + +final class Cuj { + Cuj({ + required this.id, + required this.goal, + required this.persona, + required this.tasks, + }); + + /// Creates a [Cuj] from a Map, used on the server + /// when parsing the yaml data files. + factory Cuj.fromMap(Map map) { + return Cuj( + id: '${map['id']}', + goal: map['goal'] as String, + persona: CujPersona.fromDataValue(map['persona'] as String?), + tasks: [ + for (final task in (map['tasks'] as List?) ?? const []) + CujTask.fromMap(task as Map), + ], + ); + } + + /// Creates a [Cuj] from a DOM Element, used on the client + /// for recreating and filtering existing journeys. + factory Cuj.fromElement(web.Element element) { + final dataPersona = element.getAttribute('data-persona') ?? ''; + final dataGoal = element.getAttribute('data-goal') ?? ''; + final dataTasks = element.getAttribute('data-tasks') ?? ''; + + return Cuj( + id: element.id.replaceFirst(_elementIdPrefix, ''), + goal: dataGoal, + persona: CujPersona.fromName(dataPersona), + tasks: [ + for (final task in dataTasks.split(_taskSeparator)) + if (task.trim().isNotEmpty) CujTask(id: '', name: '', task: task), + ], + ); + } + + final String id; + final String goal; + final CujPersona? persona; + final List tasks; + + /// The identifier of the card element that renders this journey. + String get elementId => '$_elementIdPrefix$id'; + + /// The task descriptions, joined for storage in a `data-tasks` attribute. + String get taskData => tasks.map((task) => task.task).join(_taskSeparator); +} + +final class CujTask { + CujTask({required this.id, required this.name, required this.task}); + + factory CujTask.fromMap(Map map) { + return CujTask( + id: '${map['id']}', + name: map['name'] as String? ?? '', + task: map['task'] as String? ?? '', + ); + } + + final String id; + final String name; + final String task; +} + +/// The developer personas a critical user journey can belong to. +/// +/// [dataValue] must match the `persona` values used in `src/data/cujs.yaml`. +enum CujPersona { + appDeveloper('App developer', 'The App Developer', 'flutter-blue'), + techLead('Tech lead / architect', 'The Tech Lead / Architect', 'purple'), + pluginDeveloper('Plugin developer', 'The Plugin Developer', 'teal'), + fullStackDeveloper( + 'Full-stack developer', + 'The Full Stack Developer', + 'magenta', + ), + hybridDeveloper( + 'Hybrid (native + Flutter) developer', + 'The Hybrid (Native + Flutter) Developer', + 'amber', + ); + + const CujPersona(this.label, this.dataValue, this.pillClass); + + /// Looks up a persona by the value used in the yaml data file. + static CujPersona? fromDataValue(String? dataValue) { + for (final persona in values) { + if (persona.dataValue == dataValue) { + return persona; + } + } + return null; + } + + /// Looks up a persona by its enum name, as stored in the DOM. + static CujPersona? fromName(String name) { + for (final persona in values) { + if (persona.name == name) { + return persona; + } + } + return null; + } + + final String label; + final String dataValue; + + /// The `.pill-sm` color modifier used for this persona's badge. + final String pillClass; +} diff --git a/sites/docs/src/content/ai/evals/cujs.md b/sites/docs/src/content/ai/evals/cujs.md new file mode 100644 index 0000000000..0e53d3c8d0 --- /dev/null +++ b/sites/docs/src/content/ai/evals/cujs.md @@ -0,0 +1,33 @@ +--- +title: Flutter critical user journeys +shortTitle: Flutter CUJs +sidenav: ai +description: > + Browse the catalog of canonical Flutter and Dart critical user journeys + (CUJs). FlutterBench evals measures these CUJs. +bodyClass: wide-site-content +showToc: false +--- + + +A _[critical user journey][]_ (CUJ) is a goal a developer sets out to +accomplish, like "make an application accessible to all users" or +"diagnose and resolve layout overflow errors" and it's broken down into the +concrete tasks they take to get there. + +Product teams at Google treat CUJs as a source of truth: they're how teams align +on priorities, shape roadmaps, measure product health and more. And recently, we +started using CUJs for our agent [evaluation][] tasks. The FlutterBench prompts +are derived from the tasks below. + +The catalog below is the Flutter team's CUJs. It's a claim about what matters in +Flutter development. If the way you build Flutter apps isn't represented here, +the list is incomplete, and we encourage [your feedback][]. + + + +[evaluation]: /ai/evals + +[critical user journeys]: https://static.googleusercontent.com/media/sre.google/en//static/pdf/art-of-slos-handbook-a4.pdf + +[your feedback]: todo/link/to/form diff --git a/sites/docs/src/content/ai/evals.md b/sites/docs/src/content/ai/evals/index.md similarity index 100% rename from sites/docs/src/content/ai/evals.md rename to sites/docs/src/content/ai/evals/index.md From 9cea3b4840caec3aa84df9d1bd3ace959492d3ce Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 6 Aug 2026 14:21:35 -0700 Subject: [PATCH 3/6] refactor shared SCSS --- sites/docs/lib/_sass/_site.scss | 1 + sites/docs/lib/_sass/pages/_cuj-index.scss | 28 +-- .../pages/_learning-resources-index.scss | 233 +----------------- .../lib/src/components/pages/cuj_filters.dart | 97 +++----- .../components/pages/cuj_filters_sidebar.dart | 100 ++++---- .../lib/src/components/pages/cuj_index.dart | 4 +- .../pages/learning_resource_filters.dart | 101 +++----- .../learning_resource_filters_sidebar.dart | 130 +++++----- .../pages/learning_resource_index.dart | 4 +- 9 files changed, 190 insertions(+), 508 deletions(-) diff --git a/sites/docs/lib/_sass/_site.scss b/sites/docs/lib/_sass/_site.scss index 05f2c2c056..d29bb9a7b2 100644 --- a/sites/docs/lib/_sass/_site.scss +++ b/sites/docs/lib/_sass/_site.scss @@ -14,6 +14,7 @@ @use 'components/content'; @use 'components/expansion-list'; @use 'components/filter-search'; +@use 'components/filterable-index'; @use 'components/footer'; @use 'components/header'; @use 'components/icons'; diff --git a/sites/docs/lib/_sass/pages/_cuj-index.scss b/sites/docs/lib/_sass/pages/_cuj-index.scss index 0a7fc88ae3..9a07f49a1e 100644 --- a/sites/docs/lib/_sass/pages/_cuj-index.scss +++ b/sites/docs/lib/_sass/pages/_cuj-index.scss @@ -1,45 +1,27 @@ @use 'sass:color'; // The critical user journey index reuses the two column layout, search field, -// and filter sidebar of the learning resources index, so the ids it shares -// with that page are styled in `_learning-resources-index.scss`. +// and filter sidebar of the learning resources index, which are styled in +// `_filterable-index.scss`. // // The journeys themselves render as full-width expandable cards, following // the glossary in `_glossary.scss`. Expanding and collapsing is wired up // by the `_setUpExpandableCards` global script. -// The feedback button sits below the filter card in the sidebar. +// The feedback button is the sidebar footer, so it sits below the filter card +// and slides in with it when the sidebar becomes a drawer. .cuj-feedback { margin-block-start: 1rem; - display: flex; - justify-content: center; .outlined-button { width: 100%; justify-content: center; } -} -// Below this width the filter card becomes an off-canvas drawer, so the -// feedback button has to slide in with it or it stays off screen. -// Mirrors the `#resource-filter-group-wrapper` rules in -// `_learning-resources-index.scss`. -@media (max-width: 839px) { - .cuj-feedback { - position: fixed; - right: -220px; - bottom: 0; - width: 220px; + @media (max-width: 839px) { margin-block-start: 0; padding: 0.75rem; - background-color: var(--site-inset-bgColor); border-top: 1px solid var(--site-inset-borderColor); - transition: right 0.3s ease-in-out; - z-index: 1001; - } - - #open-filter-toggle:checked ~ .cuj-feedback { - right: 0; } } diff --git a/sites/docs/lib/_sass/pages/_learning-resources-index.scss b/sites/docs/lib/_sass/pages/_learning-resources-index.scss index 0e6581adbd..5d316703d1 100644 --- a/sites/docs/lib/_sass/pages/_learning-resources-index.scss +++ b/sites/docs/lib/_sass/pages/_learning-resources-index.scss @@ -1,235 +1,8 @@ @use 'package:site_shared/_sass/base/mixins'; -#resource-filter-group-wrapper { - border: 1px solid var(--site-inset-borderColor); - background-color: var(--site-inset-bgColor); - border-radius: var(--site-radius); - overflow: hidden; - position: sticky; - top: calc(var(--site-header-height) + 1rem); -} - -.filter-header { - display: none; - position: absolute; - top: 0; - right: 0; - padding: 0.75rem 1rem; - z-index: 10; -} - -.close-icon { - cursor: pointer; - padding: 0.25rem; - border-radius: 4px; - background: none; - border: none; - color: var(--site-base-fgColor-alt); - transition: all 0.2s ease; - position: absolute; - right: 1rem; - top: 75%; - transform: translateY(-50%); - - &:hover { - background-color: rgba(0, 0, 0, 0.1); - color: var(--site-primary-color); - } - - .material-symbols { - font-size: 20px; - } -} - -// Mobile screen customizations. -@media (max-width: 839px) { - .filter-header { - display: block; - } - - #resource-filter-group-wrapper { - position: fixed; - top: var(--site-header-height); - bottom: 0; - right: -220px; - width: 220px; - border-bottom: none; - height: 100%; - border-radius: 0; - transition: right 0.3s ease-in-out; - z-index: 1000; - } - - #open-filter-toggle:not(:checked)+#resource-filter-group-wrapper { - right: -220px; - } - - #open-filter-toggle:checked+#resource-filter-group-wrapper { - right: 0; - } -} - -//Desktop screens -@media (min-width: 840px) { - #resource-filter-group { - position: static !important; - right: auto !important; - } - - .filter-header { - display: none !important; - } -} - -#resource-index-content { - display: flex; - flex-direction: row; - - .left-col { - margin-right: 1rem; - flex: 2; - } - - .right-col { - width: 220px; - - @media (max-width: 840px) { - position: fixed; - top: var(--site-header-height); - bottom: 0; - right: -15rem; - box-shadow: 0 6px 18px 0 rgba(0, 0, 0, 0.2); - border-radius: 0.4rem; - width: 220px; - - @keyframes slidein { - 0% { - right: -10rem; - } - - 100% { - right: 0; - } - } - - &.show { - animation-duration: 500ms; - animation-delay: 200ms; - animation-name: slidein; - animation-iteration-count: 1; - animation-timing-function: ease; - animation-fill-mode: forwards; - } - } - } -} - -#resource-filter-group { - .table-title { - text-align: center; - color: var(--site-base-fgColor-alt); - background-color: var(--site-raised-bgColor); - font-family: var(--site-ui-fontFamily); - font-weight: 600; - font-size: .925rem; - padding: .5rem; - border-bottom: 1px solid var(--site-inset-borderColor); - } - - .table-content { - padding: 1rem; - } - - ul { - padding-left: 0; - margin-bottom: .5rem; - - li { - list-style: none; - padding-left: 0; - padding-bottom: .25rem; - display: flex; - align-items: center; - - label { - padding-left: .35rem; - font-size: .9rem; - } - } - } - - h4 { - margin: 0 0 .5rem; - padding: 0; - } - - button { - color: var(--site-primary-color); - margin: 0 0 2rem; - padding: .5rem 0 1rem 1rem; - - &:hover { - color: var(--site-onPrimary-color); - } - } - - @media (max-width: 840px) { - border-bottom: none; - height: 100%; - border-radius: 0; - - .table-title { - background-color: var(--site-raised-bgColor-translucent); - } - } - - .hidden { - display: none; - } -} - -#resource-search-group { - display: flex; - flex-direction: column; - - button.show-filters-button { - @media (min-width: 840px) { - display: none; - } - } - - .label-row { - display: flex; - justify-content: space-between; - font-size: .925rem; - - label { - font-family: var(--site-ui-fontFamily); - color: var(--site-base-fgColor-lighter); - - padding: .25rem 1rem 0 0; - margin: 0; - text-align: end; - } - - button { - padding: .25rem; - color: var(--site-primary-color); - display: flex; - align-items: center; - - &:hover { - color: var(--site-onPrimary-color-light); - } - - &:disabled, - &[disabled] { - color: var(--site-inset-bgColor-translucent); - cursor: default; - } - } - } -} +// The learning resources index shares its two column layout, search field, +// and filter sidebar with the critical user journey index, so those styles +// live in `_filterable-index.scss`. #all-resources-grid { margin-block-start: 1rem; diff --git a/sites/docs/lib/src/components/pages/cuj_filters.dart b/sites/docs/lib/src/components/pages/cuj_filters.dart index 73506f35d8..85264d1079 100644 --- a/sites/docs/lib/src/components/pages/cuj_filters.dart +++ b/sites/docs/lib/src/components/pages/cuj_filters.dart @@ -5,12 +5,14 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:site_shared/components/common/material_icon.dart'; -import 'package:site_shared/components/common/search.dart'; -import 'package:site_shared/components/utils/global_event_listener.dart'; import 'package:universal_web/web.dart' as web; import '../../models/cuj_model.dart'; import 'cuj_filters_sidebar.dart'; +import 'filterable_index.dart'; + +/// The id of the search field, so its result count can label it. +const _searchId = 'cuj-search'; @client class CujFilters extends StatefulComponent { @@ -86,69 +88,44 @@ class _CujFiltersState extends State { @override Component build(BuildContext context) { - return div(id: 'resource-search-group', classes: 'chip-filters-group', [ - SearchBar( - placeholder: 'Try "testing" or "architecture"...', - label: 'Search critical user journeys by goal, persona, and task', - value: searchQuery, - id: 'resource-search', - onInput: (value) { - setFilters(() { - searchQuery = value; - }); - }, - trailing: GlobalEventListener( - onClick: (event) { - final target = event.target as web.Element?; - // If clicking outside the filters or toggle, close the filters. - if (target?.closest('#resource-filter-group-wrapper') == null && - target?.closest('.show-filters-button') == null) { - final toggle = - web.document.getElementById('open-filter-toggle') - as web.HTMLInputElement?; - toggle?.checked = false; - } - }, + return FilterSearchGroup( + searchId: _searchId, + placeholder: 'Try "testing" or "architecture"...', + label: 'Search critical user journeys by goal, persona, and task', + value: searchQuery, + onInput: (value) { + setFilters(() { + searchQuery = value; + }); + }, + children: [ + div(classes: 'label-row', [ + label( + attributes: {'for': _searchId}, + [ + const .text('Showing '), + span([.text('$filteredCujCount')]), + const .text(' / '), + span([.text('${cujs.length}')]), + ], + ), button( - classes: 'icon-button show-filters-button', + attributes: { + if (searchQuery.isEmpty && filters.selectedPersonas.isEmpty) + 'disabled': 'true', + }, onClick: () { - final toggle = - web.document.getElementById('open-filter-toggle') - as web.HTMLInputElement?; - toggle?.checked = !toggle.checked; + // No setState needed, since resetting filters will trigger it. + searchQuery = ''; + filters.reset(); }, [ - const MaterialIcon('filter_list'), + const MaterialIcon('close_small'), + const span([.text('Clear filters')]), ], ), - ), - ), - div(classes: 'label-row', [ - label( - attributes: {'for': 'resource-search'}, - [ - const .text('Showing '), - span([.text('$filteredCujCount')]), - const .text(' / '), - span([.text('${cujs.length}')]), - ], - ), - button( - attributes: { - if (searchQuery.isEmpty && filters.selectedPersonas.isEmpty) - 'disabled': 'true', - }, - onClick: () { - // No setState needed, since resetting filters will trigger it. - searchQuery = ''; - filters.reset(); - }, - [ - const MaterialIcon('close_small'), - const span([.text('Clear filters')]), - ], - ), - ]), - ]); + ]), + ], + ); } } diff --git a/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart b/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart index fde6c5ebdd..3fa24490f9 100644 --- a/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart +++ b/sites/docs/lib/src/components/pages/cuj_filters_sidebar.dart @@ -5,10 +5,10 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:site_shared/components/common/button.dart'; -import 'package:site_shared/components/common/material_icon.dart'; import '../../models/cuj_model.dart'; import 'cuj_filters.dart'; +import 'filterable_index.dart'; // TODO(ewindmill): Replace with the real feedback destination once it exists. const _feedbackUrl = 'https://github.com/flutter/evals/issues'; @@ -25,67 +25,53 @@ class CujFiltersSidebar extends StatelessComponent { @override Component build(BuildContext context) { - return div(classes: 'right-col', [ - const input( - type: InputType.checkbox, - id: 'open-filter-toggle', - attributes: {'hidden': 'true'}, - ), - div(id: 'resource-filter-group-wrapper', [ - div(id: 'resource-filter-group', [ - const div(classes: 'filter-header', [ - label( - attributes: {'for': 'open-filter-toggle', 'aria-hidden': 'true'}, - classes: 'close-icon', - [MaterialIcon('close')], - ), - ]), - const div(classes: 'table-title', [.text('Filter by')]), - ListenableBuilder( - listenable: filters, - builder: (context) { - return div(classes: 'table-content', [ - const h4([.text('Persona')]), - ul([ - for (final persona in CujPersona.values) - li([ - input( - type: InputType.checkbox, - attributes: { - 'role': 'checkbox', - 'name': 'filter-${persona.name}', - }, - id: 'filter-${persona.name}', - checked: filters.selectedPersonas.contains(persona), - onChange: (checked) { - filters.setPersona(persona, checked as bool); - }, - ), - label( - attributes: {'for': 'filter-${persona.name}'}, - [.text(persona.label)], - ), - ]), - ]), - ]); + return FiltersSidebar( + footer: const [ + div(classes: 'cuj-feedback', [ + Button( + href: _feedbackUrl, + content: 'Feedback', + style: ButtonStyle.outlined, + title: 'Leave feedback or suggest new CUJs', + attributes: { + 'target': '_blank', + 'rel': 'noopener', }, ), ]), - ]), - const div(classes: 'cuj-feedback', [ - Button( - href: _feedbackUrl, - content: 'Feedback', - icon: 'feedback', - style: ButtonStyle.outlined, - title: 'Leave feedback or suggest new CUJs', - attributes: { - 'target': '_blank', - 'rel': 'noopener', + ], + children: [ + ListenableBuilder( + listenable: filters, + builder: (context) { + return div(classes: 'table-content', [ + const h4([.text('Persona')]), + ul([ + for (final persona in CujPersona.values) + li([ + input( + type: InputType.checkbox, + attributes: { + 'role': 'checkbox', + 'name': 'cuj-filter-${persona.name}', + }, + id: 'cuj-filter-${persona.name}', + checked: filters.selectedPersonas.contains(persona), + onChange: (checked) { + filters.setPersona(persona, checked as bool); + }, + ), + label( + attributes: {'for': 'cuj-filter-${persona.name}'}, + [.text(persona.label)], + ), + ]), + ]), + ]); }, ), - ]), - ]); + ], + ); } } diff --git a/sites/docs/lib/src/components/pages/cuj_index.dart b/sites/docs/lib/src/components/pages/cuj_index.dart index 008158e2e9..76c120e48b 100644 --- a/sites/docs/lib/src/components/pages/cuj_index.dart +++ b/sites/docs/lib/src/components/pages/cuj_index.dart @@ -24,8 +24,8 @@ final class CujIndex extends StatelessComponent { Cuj.fromMap(cuj as Map), ]; - return div(id: 'resource-index-content', [ - div(classes: 'left-col', id: 'resource-index-main-content', [ + return div(classes: 'filterable-index', [ + div(classes: 'left-col', [ const CujFilters(), div(classes: 'card-list', id: 'all-cujs-list', [ for (final cuj in cujs) _CujCard(cuj), diff --git a/sites/docs/lib/src/components/pages/learning_resource_filters.dart b/sites/docs/lib/src/components/pages/learning_resource_filters.dart index c3e90ddf9c..f05c0de5ef 100644 --- a/sites/docs/lib/src/components/pages/learning_resource_filters.dart +++ b/sites/docs/lib/src/components/pages/learning_resource_filters.dart @@ -8,14 +8,16 @@ import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:site_shared/analytics.dart'; import 'package:site_shared/components/common/material_icon.dart'; -import 'package:site_shared/components/common/search.dart'; -import 'package:site_shared/components/utils/global_event_listener.dart'; import 'package:universal_web/js_interop.dart'; import 'package:universal_web/web.dart' as web; import '../../models/learning_resource_model.dart'; +import 'filterable_index.dart'; import 'learning_resource_filters_sidebar.dart'; +/// The id of the search field, so its result count can label it. +const _searchId = 'learning-resource-search'; + @client class LearningResourceFilters extends StatefulComponent { const LearningResourceFilters({super.key}); @@ -121,71 +123,46 @@ class _LearningResourceFiltersState extends State { @override Component build(BuildContext context) { - return div(id: 'resource-search-group', classes: 'chip-filters-group', [ - SearchBar( - placeholder: 'Try "button" or "networking"...', - label: 'Search learning resources by name and category', - value: searchQuery, - id: 'resource-search', - onInput: (value) { - setFilters(() { - searchQuery = value; - }); - }, - trailing: GlobalEventListener( - onClick: (event) { - final target = event.target as web.Element?; - // If clicking outside the filters or toggle, close the filters. - if (target?.closest('#resource-filter-group-wrapper') == null && - target?.closest('.show-filters-button') == null) { - final toggle = - web.document.getElementById('open-filter-toggle') - as web.HTMLInputElement?; - toggle?.checked = false; - } - }, + return FilterSearchGroup( + searchId: _searchId, + placeholder: 'Try "button" or "networking"...', + label: 'Search learning resources by name and category', + value: searchQuery, + onInput: (value) { + setFilters(() { + searchQuery = value; + }); + }, + children: [ + div(classes: 'label-row', [ + label( + attributes: {'for': _searchId}, + [ + const .text('Showing '), + span([.text('$filteredResourcesCount')]), + const .text(' / '), + span([.text('${resources.length}')]), + ], + ), button( - classes: 'icon-button show-filters-button', + attributes: { + if (searchQuery.isEmpty && + filters.selectedTags.isEmpty && + filters.selectedTypes.isEmpty) + 'disabled': 'true', + }, onClick: () { - final toggle = - web.document.getElementById('open-filter-toggle') - as web.HTMLInputElement?; - toggle?.checked = !toggle.checked; + // No setState needed, since resetting filters will trigger it. + searchQuery = ''; + filters.reset(); }, [ - const MaterialIcon('filter_list'), + const MaterialIcon('close_small'), + const span([.text('Clear filters')]), ], ), - ), - ), - div(classes: 'label-row', [ - label( - attributes: {'for': 'resource-search'}, - [ - const .text('Showing '), - span([.text('$filteredResourcesCount')]), - const .text(' / '), - span([.text('${resources.length}')]), - ], - ), - button( - attributes: { - if (searchQuery.isEmpty && - filters.selectedTags.isEmpty && - filters.selectedTypes.isEmpty) - 'disabled': 'true', - }, - onClick: () { - // No setState needed, since resetting filters will trigger it. - searchQuery = ''; - filters.reset(); - }, - [ - const MaterialIcon('close_small'), - const span([.text('Clear filters')]), - ], - ), - ]), - ]); + ]), + ], + ); } } diff --git a/sites/docs/lib/src/components/pages/learning_resource_filters_sidebar.dart b/sites/docs/lib/src/components/pages/learning_resource_filters_sidebar.dart index cf51219300..4f14e5c8d9 100644 --- a/sites/docs/lib/src/components/pages/learning_resource_filters_sidebar.dart +++ b/sites/docs/lib/src/components/pages/learning_resource_filters_sidebar.dart @@ -9,6 +9,7 @@ import 'package:site_shared/components/common/material_icon.dart'; import 'package:site_shared/util.dart'; import '../../models/learning_resource_model.dart'; +import 'filterable_index.dart'; import 'learning_resource_filters.dart'; @client @@ -23,89 +24,74 @@ class LearningResourceFiltersSidebar extends StatelessComponent { @override Component build(BuildContext context) { - return div(classes: 'right-col', [ - const input( - type: InputType.checkbox, - id: 'open-filter-toggle', - attributes: {'hidden': 'true'}, - ), - div(id: 'resource-filter-group-wrapper', [ - div(id: 'resource-filter-group', [ - const div(classes: 'filter-header', [ - label( - attributes: {'for': 'open-filter-toggle', 'aria-hidden': 'true'}, - classes: 'close-icon', - [MaterialIcon('close')], - ), - ]), - const div(classes: 'table-title', [.text('Filter by')]), - ListenableBuilder( - listenable: filters, - builder: (context) { - return div(classes: 'table-content', [ - const h4([.text('Subject')]), - ul(classes: filters.tagsExpanded ? '' : 'collapsed', [ - for (final (index, tag) in LearningResourceTag.values.indexed) - li( - classes: [ - if (!filters.tagsExpanded && index > 3) 'hidden', - ].toClasses, - [ - input( - type: InputType.checkbox, - attributes: { - 'role': 'checkbox', - 'name': 'filter-${tag.name}', - }, - id: 'filter-${tag.name}', - checked: filters.selectedTags.contains(tag), - onChange: (checked) { - filters.setTag(tag, checked as bool); - }, - ), - label( - attributes: {'for': 'filter-${tag.name}'}, - [.text(tag.label)], - ), - ], - ), - ]), - button(onClick: filters.toggleTagsExpanded, [ - span(classes: 'label', [ - .text(filters.tagsExpanded ? 'Less' : 'More'), - ]), - MaterialIcon( - filters.tagsExpanded ? 'expand_less' : 'expand_more', - ), - ]), - const h4([.text('Type')]), - ul([ - for (final type in LearningResourceType.values) - li([ + return FiltersSidebar( + children: [ + ListenableBuilder( + listenable: filters, + builder: (context) { + return div(classes: 'table-content', [ + const h4([.text('Subject')]), + ul(classes: filters.tagsExpanded ? '' : 'collapsed', [ + for (final (index, tag) in LearningResourceTag.values.indexed) + li( + classes: [ + if (!filters.tagsExpanded && index > 3) 'hidden', + ].toClasses, + [ input( type: InputType.checkbox, attributes: { 'role': 'checkbox', - 'name': 'filter-${type.name}', + 'name': 'resource-filter-${tag.name}', }, - id: 'filter-${type.name}', - checked: filters.selectedTypes.contains(type), + id: 'resource-filter-${tag.name}', + checked: filters.selectedTags.contains(tag), onChange: (checked) { - filters.setType(type, checked as bool); + filters.setTag(tag, checked as bool); }, ), label( - attributes: {'for': 'filter-${type.name}'}, - [.text(type.label)], + attributes: {'for': 'resource-filter-${tag.name}'}, + [.text(tag.label)], ), - ]), + ], + ), + ]), + button(onClick: filters.toggleTagsExpanded, [ + span(classes: 'label', [ + .text(filters.tagsExpanded ? 'Less' : 'More'), ]), - ]); - }, - ), - ]), - ]), - ]); + MaterialIcon( + filters.tagsExpanded ? 'expand_less' : 'expand_more', + ), + ]), + const h4([.text('Type')]), + ul([ + for (final type in LearningResourceType.values) + li([ + input( + type: InputType.checkbox, + attributes: { + 'role': 'checkbox', + 'name': 'resource-filter-${type.name}', + }, + id: 'resource-filter-${type.name}', + checked: filters.selectedTypes.contains(type), + onChange: (checked) { + filters.setType(type, checked as bool); + }, + ), + label( + attributes: {'for': 'resource-filter-${type.name}'}, + [.text(type.label)], + ), + ]), + ]), + ]); + }, + ), + ], + ); } } diff --git a/sites/docs/lib/src/components/pages/learning_resource_index.dart b/sites/docs/lib/src/components/pages/learning_resource_index.dart index 3141659ba0..140c25ea03 100644 --- a/sites/docs/lib/src/components/pages/learning_resource_index.dart +++ b/sites/docs/lib/src/components/pages/learning_resource_index.dart @@ -30,8 +30,8 @@ final class LearningResourceIndex extends StatelessComponent { } } - return div(id: 'resource-index-content', [ - div(classes: 'left-col', id: 'resource-index-main-content', [ + return div(classes: 'filterable-index', [ + div(classes: 'left-col', [ const LearningResourceFilters(), section(classes: 'card-grid', id: 'all-resources-grid', [ for (final item in learningResources) _ResourceCard(item), From 8c43d7d360759acafaba779f84fff1429b275a9d Mon Sep 17 00:00:00 2001 From: Eric Windmill Date: Thu, 6 Aug 2026 14:22:17 -0700 Subject: [PATCH 4/6] add shared filter component --- .../_sass/components/_filterable-index.scss | 210 ++++++++++++++++++ .../components/pages/filterable_index.dart | 153 +++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 sites/docs/lib/_sass/components/_filterable-index.scss create mode 100644 sites/docs/lib/src/components/pages/filterable_index.dart diff --git a/sites/docs/lib/_sass/components/_filterable-index.scss b/sites/docs/lib/_sass/components/_filterable-index.scss new file mode 100644 index 0000000000..8aeeca88d8 --- /dev/null +++ b/sites/docs/lib/_sass/components/_filterable-index.scss @@ -0,0 +1,210 @@ +// Layout and controls shared by the filterable index pages: the learning +// resources index and the critical user journey index. +// +// The markup comes from the `FiltersSidebar` and `FilterSearchGroup` +// components in `filterable_index.dart`, so everything here is keyed off +// classes. Each page styles its own results list separately, keyed off the +// id of that list. + +$mobile-breakpoint: 839px; +$sidebar-width: 220px; + +.filterable-index { + display: flex; + flex-direction: row; + + .left-col { + margin-right: 1rem; + flex: 2; + } + + .right-col { + width: $sidebar-width; + } +} + +// The sidebar follows the page as it scrolls on wide screens, and becomes an +// off-canvas drawer on narrow ones. Everything within it, including any +// page-specific footer, slides in and out as a single unit. +.filter-sidebar { + position: sticky; + top: calc(var(--site-header-height) + 1rem); +} + +.filter-group-wrapper { + border: 1px solid var(--site-inset-borderColor); + background-color: var(--site-inset-bgColor); + border-radius: var(--site-radius); + overflow: hidden; +} + +// Only shown while the sidebar is a drawer, to close it again. +.filter-header { + display: none; + position: absolute; + top: 0; + right: 0; + padding: 0.75rem 1rem; + z-index: 10; +} + +.close-icon { + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + background: none; + border: none; + color: var(--site-base-fgColor-alt); + transition: all 0.2s ease; + position: absolute; + right: 1rem; + top: 75%; + transform: translateY(-50%); + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + color: var(--site-primary-color); + } + + .material-symbols { + font-size: 20px; + } +} + +.filter-group { + .table-title { + text-align: center; + color: var(--site-base-fgColor-alt); + background-color: var(--site-raised-bgColor); + font-family: var(--site-ui-fontFamily); + font-weight: 600; + font-size: .925rem; + padding: .5rem; + border-bottom: 1px solid var(--site-inset-borderColor); + } + + .table-content { + padding: 1rem; + } + + ul { + padding-left: 0; + margin-bottom: .5rem; + + li { + list-style: none; + padding-left: 0; + padding-bottom: .25rem; + display: flex; + align-items: center; + + label { + padding-left: .35rem; + font-size: .9rem; + } + } + } + + h4 { + margin: 0 0 .5rem; + padding: 0; + } + + button { + color: var(--site-primary-color); + margin: 0 0 2rem; + padding: .5rem 0 1rem 1rem; + + &:hover { + color: var(--site-onPrimary-color); + } + } + + .hidden { + display: none; + } +} + +.filter-search-group { + display: flex; + flex-direction: column; + + button.show-filters-button { + @media (min-width: $mobile-breakpoint + 1) { + display: none; + } + } + + .label-row { + display: flex; + justify-content: space-between; + font-size: .925rem; + + label { + font-family: var(--site-ui-fontFamily); + color: var(--site-base-fgColor-lighter); + + padding: .25rem 1rem 0 0; + margin: 0; + text-align: end; + } + + button { + padding: .25rem; + color: var(--site-primary-color); + display: flex; + align-items: center; + + &:hover { + color: var(--site-onPrimary-color-light); + } + + &:disabled, + &[disabled] { + color: var(--site-inset-bgColor-translucent); + cursor: default; + } + } + } +} + +// On narrow screens the sidebar becomes a drawer that slides in from the +// right. Opening and closing is driven entirely by the hidden +// `#open-filter-toggle` checkbox that precedes it. +@media (max-width: $mobile-breakpoint) { + .filter-header { + display: block; + } + + .filter-sidebar { + position: fixed; + top: var(--site-header-height); + bottom: 0; + right: -$sidebar-width; + width: $sidebar-width; + display: flex; + flex-direction: column; + background-color: var(--site-inset-bgColor); + border-left: 1px solid var(--site-inset-borderColor); + transition: right 0.3s ease-in-out; + z-index: 1000; + } + + #open-filter-toggle:checked + .filter-sidebar { + right: 0; + } + + // Fill the drawer rather than floating within it as a card, + // and scroll on its own so any footer stays in view. + .filter-group-wrapper { + flex: 1; + min-height: 0; + overflow-y: auto; + border: none; + border-radius: 0; + } + + .filter-group .table-title { + background-color: var(--site-raised-bgColor-translucent); + } +} diff --git a/sites/docs/lib/src/components/pages/filterable_index.dart b/sites/docs/lib/src/components/pages/filterable_index.dart new file mode 100644 index 0000000000..9f6cc82495 --- /dev/null +++ b/sites/docs/lib/src/components/pages/filterable_index.dart @@ -0,0 +1,153 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// The shell components shared by the filterable index pages: +/// the learning resources index and the critical user journey index. +/// +/// The pages provide their own filter controls and results list, +/// but the surrounding layout, search field, and sidebar are the same, +/// and are styled by `_filterable-index.scss`. +library; + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:site_shared/components/common/material_icon.dart'; +import 'package:site_shared/components/common/search.dart'; +import 'package:site_shared/components/utils/global_event_listener.dart'; +import 'package:universal_web/web.dart' as web; + +/// The id of the hidden checkbox that opens and closes the filter sidebar +/// while it is a drawer on narrow screens. +/// +/// The drawer is CSS-only: `_filterable-index.scss` keys off the checkbox's +/// `:checked` state, and the close button within the sidebar is a `