diff --git a/packages/site_shared/lib/_sass/components/_ide-explorer.scss b/packages/site_shared/lib/_sass/components/_ide-explorer.scss new file mode 100644 index 00000000000..fd9a6b96a1e --- /dev/null +++ b/packages/site_shared/lib/_sass/components/_ide-explorer.scss @@ -0,0 +1,533 @@ +@use '../base/mixins'; +@use '../base/breakpoints'; + +// ============================================================================ +// Variables +// ============================================================================ + +// Spacing +$ide-spacing-xs: 0.25rem; +$ide-spacing-sm: 0.3rem; +$ide-spacing-md: 0.4rem; +$ide-spacing-lg: 0.5rem; +$ide-spacing-xl: 0.6rem; +$ide-spacing-2xl: 0.75rem; +$ide-spacing-3xl: 0.85rem; +$ide-spacing-4xl: 1rem; +$ide-spacing-5xl: 1.25rem; +$ide-spacing-6xl: 1.5rem; + +// Icon sizes +$ide-icon-xs: 0.55rem; +$ide-icon-sm: 0.9rem; +$ide-icon-md: 1.1rem; +$ide-icon-lg: 1.5rem; +$ide-icon-xl: 1.75rem; + +// Font sizes +$ide-font-xs: 0.6875rem; +$ide-font-sm: 0.75rem; +$ide-font-md: 0.8125rem; +$ide-font-lg: 0.9rem; +$ide-font-xl: 0.9375rem; +$ide-font-2xl: 1.25rem; + +// Border radius +$ide-radius-sm: 0.2rem; +$ide-radius-md: 0.3rem; +$ide-radius-lg: 1rem; + +// Other sizes +$ide-button-size: 1.75rem; +$ide-folder-arrow-width: 1.25rem; +$ide-folder-arrow-height: 1.5rem; + +// Badge colors map +$badge-colors: ( + 'info': var(--site-alert-info-color), + 'tip': var(--site-alert-tip-color), + 'important': var(--site-alert-important-color), + 'warning': var(--site-alert-warning-color), + 'error': var(--site-alert-error-color), +); + +// ============================================================================ +// Mixins +// ============================================================================ + +@mixin flex-center { + display: flex; + align-items: center; +} + +@mixin no-shrink { + flex-shrink: 0; +} + +@mixin reset-list { + margin: 0; + list-style: none; +} + +@mixin interactive-hover { + &:hover { + @include mixins.interaction-style(4%); + } +} + +@mixin ide-button-base { + border: none; + background: transparent; + cursor: pointer; + font-family: inherit; + @include interactive-hover; +} + +@mixin uppercase-label { + font-size: $ide-font-xs; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +// ============================================================================ +// Main Component +// ============================================================================ + +.ide-explorer { + display: flex; + align-items: stretch; + overflow: hidden; + height: min(600px, 80vh); + margin-block: 1.71em; + border: 1px solid var(--site-outline); + border-radius: var(--site-radius); + background-color: var(--site-base-bgColor); + font-family: var(--site-ui-fontFamily); + + + @include breakpoints.screen-below('md') { + flex-direction: column; + height: auto; + } +} + +.ide-sidebar { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: min(15rem, 35%); + border-right: 1px solid var(--site-outline); + background-color: var(--site-raised-bgColor-translucent); + + @include breakpoints.screen-below('md') { + width: auto; + max-height: 14rem; + border-right: none; + border-bottom: 1px solid var(--site-outline); + } +} + +.ide-root-tabs { + @include flex-center; + gap: $ide-spacing-xs; + padding: 0.375rem; + border-bottom: 1px solid var(--site-outline-variant); + + &.ide-root-tabs-single { + justify-content: flex-end; + } +} + +.ide-root-tab { + @include ide-button-base; + flex: 1; + padding: $ide-spacing-sm 0; + border-radius: $ide-radius-sm; + color: var(--site-base-fgColor-alt); + font-size: $ide-font-sm; + font-weight: 500; + + &.active { + background-color: var(--site-base-bgColor); + color: var(--site-primary-color); + } +} + +.ide-toggle-all { + @include no-shrink; + @include flex-center; + @include ide-button-base; + justify-content: center; + width: $ide-button-size; + height: $ide-button-size; + border-radius: $ide-radius-sm; + color: var(--site-base-fgColor-alt); + + .material-symbols { + font-size: $ide-icon-md; + } +} + +.ide-tree { + display: none; + flex: 1; + overflow-y: auto; + padding: 0.375rem 0; + + &.active { + display: block; + } + + ul { + @include reset-list; + padding-inline-start: 0.9rem; + } + + > ul { + padding-inline-start: 0; + } + + ul > li.ide-node:last-child { + margin-bottom: $ide-spacing-lg; + } + + li.ide-node { + margin-bottom: 0; + } + + details { + margin: 0; + } + + details[open] > summary { + margin: 0; + } + + summary { + @include flex-center; + width: 100%; + list-style: none; + padding: 0; + margin: 0; + cursor: default; + + &::-webkit-details-marker { + display: none; + } + } + + .ide-folder-arrow { + @include no-shrink; + display: inline-flex; + align-items: center; + justify-content: center; + width: $ide-folder-arrow-width; + height: $ide-folder-arrow-height; + padding: 0; + padding-inline-start: 0.35rem; + border: none; + background: transparent; + cursor: pointer; + + &::before { + content: ''; + display: inline-block; + @include no-shrink; + width: $ide-icon-xs; + height: $ide-icon-xs; + background-color: var(--site-base-fgColor-alt); + clip-path: polygon(15% 0%, 100% 50%, 15% 100%); + transition: transform 0.1s ease; + } + + &:hover::before { + background-color: var(--site-base-fgColor); + } + } + + details[open] > summary .ide-folder-arrow::before { + transform: rotate(90deg); + } + + .ide-node-row { + @include flex-center; + @include ide-button-base; + flex: 1; + min-width: 0; + padding: $ide-spacing-xs $ide-spacing-xl; + border-radius: $ide-radius-sm; + color: inherit; + font-family: var(--site-code-fontFamily); + font-size: $ide-font-md; + text-align: left; + outline-offset: -2px; + + &.active { + background-color: var(--site-primary-color-highlight); + color: var(--site-primary-color); + font-weight: 500; + } + + svg { + @include no-shrink; + width: $ide-icon-sm; + height: $ide-icon-sm; + margin-inline-end: $ide-spacing-md; + } + } + + li.ide-node-file { + @include flex-center; + width: 100%; + + > .ide-node-row { + margin-inline-start: $ide-folder-arrow-width; + } + } + + .ide-node-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.ide-badge-dot { + @include no-shrink; + width: $ide-spacing-md; + height: $ide-spacing-md; + margin-inline-start: $ide-spacing-md; + border-radius: 50%; + background-color: var(--site-base-fgColor-alt); +} + +.ide-badge { + @include no-shrink; + padding: 0.1rem $ide-spacing-lg; + border-radius: $ide-radius-lg; + background-color: var(--site-raised-bgColor); + color: var(--site-base-fgColor-alt); + font-family: var(--site-ui-fontFamily); + font-size: $ide-font-xs; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +// Generate badge color variants +@each $name, $color in $badge-colors { + .ide-badge-color-#{$name} { + &.ide-badge-dot { + background-color: $color; + } + + &.ide-badge { + background-color: color-mix(in srgb, $color 12%, transparent); + color: $color; + } + } +} + +// Neutral badge color (special case) +.ide-badge-color-neutral { + &.ide-badge-dot { + background-color: var(--site-base-fgColor-alt); + } + + &.ide-badge { + background-color: var(--site-raised-bgColor); + color: var(--site-base-fgColor-alt); + } +} + +.ide-detail { + flex: 1; + min-width: 0; + padding: $ide-spacing-5xl $ide-spacing-6xl; + overflow-y: auto; + + @include breakpoints.screen-below('md') { + padding: $ide-spacing-4xl; + } +} + +.ide-detail-panel { + display: none; + + &.active { + display: block; + } + + > :last-child { + margin-block-end: 0; + } +} + +.ide-path { + margin-block-end: $ide-spacing-lg; + color: var(--site-base-fgColor-alt); + font-family: var(--site-code-fontFamily); + font-size: $ide-font-sm; +} + +.ide-path-sep { + margin-inline: $ide-spacing-sm; + color: var(--site-outline); +} + +.ide-detail-header { + display: flex; + align-items: flex-start; + gap: $ide-spacing-xl; + margin-block-end: $ide-spacing-4xl; + padding-bottom: $ide-spacing-4xl; + border-bottom: 0.5px solid; + + svg { + @include no-shrink; + width: $ide-icon-lg; + height: $ide-icon-lg; + margin-block-start: 0.15rem; + } +} + +.ide-detail-heading { + flex: 1; + min-width: 0; +} + +.ide-detail-title-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ide-detail-title { + font-family: var(--site-code-fontFamily); + font-size: $ide-font-2xl; + font-weight: 600; +} + +.ide-detail-subtitle { + margin-block-start: 0.15rem; + color: var(--site-base-fgColor-alt); + font-size: $ide-font-xl; +} + +.ide-note { + padding: $ide-spacing-xl $ide-spacing-2xl; + margin-block-end: $ide-spacing-4xl; + border-radius: $ide-radius-md; + background-color: var(--site-inset-bgColor); + color: var(--site-inset-fgColor); + font-size: $ide-font-lg; + + p { + margin: 0; + } +} + +.ide-note-title { + @include uppercase-label; + margin-block-end: 0.2rem; + color: var(--site-base-fgColor-alt); +} + +.ide-description { + margin-block-end: $ide-spacing-4xl; +} + +.ide-tips { + padding: $ide-spacing-xl $ide-spacing-3xl; + margin-block-end: $ide-spacing-4xl; + border: 1px solid var(--site-outline-variant); + border-radius: $ide-radius-md; + + ul { + @include reset-list; + padding-inline-start: 1.1rem; + } + + li { + margin-block-end: $ide-spacing-xs; + + &:last-child { + margin-block-end: 0; + } + } +} + +.ide-tips-title { + @include uppercase-label; + margin-block-end: $ide-spacing-md; + color: var(--site-primary-color); +} + +.ide-example { + margin-block-end: $ide-spacing-4xl; +} + +.ide-docs-link { + @include flex-center; + display: inline-flex; + gap: $ide-spacing-sm; + padding: $ide-spacing-sm $ide-spacing-2xl; + margin-block-end: $ide-spacing-4xl; + border-radius: $ide-radius-lg; + background-color: var(--site-primary-color-highlight); + color: var(--site-primary-color); + font-size: $ide-font-md; + font-weight: 600; + text-decoration: none; + + .material-symbols { + font-size: $ide-spacing-4xl; + } + + &:hover { + text-decoration: underline; + } +} + +.ide-contents-title { + @include uppercase-label; + margin-block-end: $ide-spacing-lg; + color: var(--site-base-fgColor-alt); +} + +.ide-contents-list { + display: flex; + flex-direction: column; + gap: $ide-spacing-xs; +} + +.ide-content-link { + @include flex-center; + @include ide-button-base; + gap: $ide-spacing-lg; + padding: $ide-spacing-md $ide-spacing-xl; + border-radius: $ide-radius-md; + background-color: var(--site-raised-bgColor-translucent); + color: inherit; + font-family: var(--site-code-fontFamily); + font-size: $ide-font-md; + text-align: left; + + svg { + @include no-shrink; + width: $ide-icon-sm; + height: $ide-icon-sm; + } + + .ide-content-one-liner { + overflow: hidden; + color: var(--site-base-fgColor-alt); + font-family: var(--site-ui-fontFamily); + font-size: $ide-font-sm; + text-overflow: ellipsis; + white-space: nowrap; + } +} \ No newline at end of file diff --git a/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart b/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart new file mode 100644 index 00000000000..7bfc1f2a04c --- /dev/null +++ b/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart @@ -0,0 +1,707 @@ +// Copyright 2026 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:collection/collection.dart'; +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/components/file_icon.dart'; + +import '../../../util.dart'; +import '../material_icon.dart'; + +/// A single top-level file tree shown by an [IdeExplorer], which may +/// have multiple roots to represent multiple packages +/// (e.g. "Project" and "Shared"). +/// +/// When multiple roots are provided, they are rendered as tabs that can be +/// switched between. +class IdeExplorerProjectRoot { + const IdeExplorerProjectRoot({ + required this.id, + this.label = '', + this.children = const [], + }); + + final String id; + final String label; + final List children; +} + +/// A single file or folder entry in an [IdeExplorer] tree. +class IdeTreeNode { + const IdeTreeNode({ + required this.id, + required this.label, + this.isDefaultPage = false, + this.startsClosed = true, + this.badge, + this.badgeColor, + this.subtitle, + this.title, + this.children = const [], + }); + + final String id; + final String label; + final bool isDefaultPage; + final bool startsClosed; + final String? badge; + final IdeBadgeColor? badgeColor; + final String? title; + final String? subtitle; + final List children; + + bool get isFolder => children.isNotEmpty || label.endsWith('/'); +} + +/// Corresponds to colors used for badges on an [IdeTreeNode]. +enum IdeBadgeColor { + neutral, + info, + tip, + important, + warning, + error; + + static IdeBadgeColor fromString(String? data) { + if (data == null) return IdeBadgeColor.neutral; + return switch (data.toLowerCase()) { + 'info' => IdeBadgeColor.info, + 'tip' => IdeBadgeColor.tip, + 'important' => IdeBadgeColor.important, + 'warning' => IdeBadgeColor.warning, + 'error' => IdeBadgeColor.error, + _ => IdeBadgeColor.neutral, + }; + } +} + +/// A flattened [IdeTreeNode] paired with the path of ancestor labels +/// leading to it, used to render the breadcrumb in its detail pane. +typedef _BreadcrumbNode = ({ + IdeTreeNode node, + String domId, + List path, +}); + +/// Generates a unique DOM id for an IDE tree node. +String _ideDomId(String instanceId, String nodeId) => 'ide-$instanceId-$nodeId'; + +// --------------------------------------------------------------------------- +// Main Explorer Component +// --------------------------------------------------------------------------- + +/// An interactive file-tree explorer, similar to an IDE's sidebar. +/// +/// Renders a clickable directory tree next to a detail pane that shows +/// per-file/folder documentation. Clicking an entry in the tree swaps which +/// detail pane is shown. +class IdeExplorer extends StatelessComponent { + const IdeExplorer({ + super.key, + required this.roots, + this.instanceId, + this.customContents = const {}, + }); + + static int _nextInstanceId = 0; + + final List roots; + final String? instanceId; + final Map customContents; + + @override + Component build(BuildContext context) { + if (roots.isEmpty) { + return const Component.empty(); + } + + final effectiveInstanceId = instanceId ?? '${_nextInstanceId++}'; + + final flatNodesByRoot = _buildFlatNodesByRoot(effectiveInstanceId); + final allFlatNodes = flatNodesByRoot.values + .expand((nodes) => nodes) + .toList(growable: false); + + final defaultFlatNode = _findDefaultNode(allFlatNodes); + final activeRootId = _determineActiveRoot( + defaultFlatNode, + flatNodesByRoot, + ); + final selectedNodeDomId = _determineSelectedNode( + defaultFlatNode, + flatNodesByRoot, + activeRootId, + ); + + return div(classes: 'ide-explorer not-content', [ + _IdeSidebar( + roots: roots, + activeRootId: activeRootId, + effectiveInstanceId: effectiveInstanceId, + selectedNodeDomId: selectedNodeDomId, + ), + _IdeDetailPane( + allFlatNodes: allFlatNodes, + customContents: customContents, + effectiveInstanceId: effectiveInstanceId, + selectedNodeDomId: selectedNodeDomId, + ), + ]); + } + + /// Builds a map of flattened nodes organized by root ID. + Map> _buildFlatNodesByRoot( + String effectiveInstanceId, + ) { + return { + for (final root in roots) + root.id: _flatten( + root.children, + instanceId: effectiveInstanceId, + path: root.label.isEmpty ? [] : [root.label], + ), + }; + } + + /// Finds the first node marked as the default page. + _BreadcrumbNode? _findDefaultNode(List<_BreadcrumbNode> allFlatNodes) { + return allFlatNodes.firstWhereOrNull( + (flat) => flat.node.isDefaultPage, + ); + } + + /// Determines which root should be active based on the default node. + String _determineActiveRoot( + _BreadcrumbNode? defaultFlatNode, + Map> flatNodesByRoot, + ) { + if (defaultFlatNode == null) { + return roots.first.id; + } + + final rootWithDefault = roots.firstWhereOrNull( + (r) => + flatNodesByRoot[r.id]?.any( + (n) => n.domId == defaultFlatNode.domId, + ) ?? + false, + ); + + return rootWithDefault?.id ?? roots.first.id; + } + + /// Determines which node should be selected initially. + String? _determineSelectedNode( + _BreadcrumbNode? defaultFlatNode, + Map> flatNodesByRoot, + String activeRootId, + ) { + return defaultFlatNode?.domId ?? + flatNodesByRoot[activeRootId]?.firstOrNull?.domId; + } + + List<_BreadcrumbNode> _flatten( + List nodes, { + required String instanceId, + required List path, + }) { + final result = <_BreadcrumbNode>[]; + for (final node in nodes) { + final nodePath = [...path, node.label]; + result.add(( + node: node, + domId: _ideDomId(instanceId, node.id), + path: path, + )); + result.addAll( + _flatten( + node.children, + instanceId: instanceId, + path: nodePath, + ), + ); + } + return result; + } +} + +// --------------------------------------------------------------------------- +// Sidebar Components +// --------------------------------------------------------------------------- + +/// The left sidebar containing the root switcher tabs and directory trees. +class _IdeSidebar extends StatelessComponent { + const _IdeSidebar({ + required this.roots, + required this.activeRootId, + required this.effectiveInstanceId, + required this.selectedNodeDomId, + }); + + final List roots; + final String activeRootId; + final String effectiveInstanceId; + final String? selectedNodeDomId; + + @override + Component build(BuildContext context) { + return div(classes: 'ide-sidebar', [ + if (roots.length > 1) + _IdeRootTabs(roots: roots, activeRootId: activeRootId) + else + const div(classes: 'ide-root-tabs ide-root-tabs-single', [ + _IdeToggleAllButton(), + ]), + for (final root in roots) + _IdeTree( + root: root, + isActive: root.id == activeRootId, + effectiveInstanceId: effectiveInstanceId, + selectedNodeDomId: selectedNodeDomId, + ), + ]); + } +} + +/// The tab list for switching between project roots, plus toggle-all button. +class _IdeRootTabs extends StatelessComponent { + const _IdeRootTabs({ + required this.roots, + required this.activeRootId, + }); + + final List roots; + final String activeRootId; + + @override + Component build(BuildContext context) { + return div( + classes: 'ide-root-tabs', + attributes: {'role': 'tablist'}, + [ + for (final root in roots) + button( + classes: [ + 'ide-root-tab', + if (root.id == activeRootId) 'active', + ].toClasses, + attributes: { + 'data-ide-root': root.id, + 'role': 'tab', + 'aria-selected': '${root.id == activeRootId}', + }, + [.text(root.label.isEmpty ? root.id : root.label)], + ), + const _IdeToggleAllButton(), + ], + ); + } +} + +/// A button that expands or collapses all folders in the active tree. +class _IdeToggleAllButton extends StatelessComponent { + const _IdeToggleAllButton(); + + @override + Component build(BuildContext context) { + return const button( + classes: 'ide-toggle-all', + type: ButtonType.button, + attributes: { + 'data-ide-toggle-all': '', + 'title': 'Expand or collapse all folders', + 'aria-label': 'Expand or collapse all folders', + }, + [ + MaterialIcon('unfold_more', label: 'Expand or collapse all folders'), + ], + ); + } +} + +/// A single root file tree in the sidebar. +class _IdeTree extends StatelessComponent { + const _IdeTree({ + required this.root, + required this.isActive, + required this.effectiveInstanceId, + required this.selectedNodeDomId, + }); + + final IdeExplorerProjectRoot root; + final bool isActive; + final String effectiveInstanceId; + final String? selectedNodeDomId; + + @override + Component build(BuildContext context) { + return div( + classes: [ + 'ide-tree', + if (isActive) 'active', + ].toClasses, + attributes: {'data-ide-root': root.id}, + [ + ul([ + for (final node in root.children) + _IdeTreeNode( + node: node, + instanceId: effectiveInstanceId, + selectedDomId: selectedNodeDomId, + ), + ]), + ], + ); + } +} + +/// An individual file or folder node in the directory tree. +class _IdeTreeNode extends StatelessComponent { + const _IdeTreeNode({ + required this.node, + required this.instanceId, + required this.selectedDomId, + }); + + final IdeTreeNode node; + final String instanceId; + final String? selectedDomId; + + bool _hasSelectedChild(IdeTreeNode parent) { + if (selectedDomId == null) return false; + for (final child in parent.children) { + if (_ideDomId(instanceId, child.id) == selectedDomId) return true; + if (_hasSelectedChild(child)) return true; + } + return false; + } + + @override + Component build(BuildContext context) { + final domId = _ideDomId(instanceId, node.id); + final isSelected = domId == selectedDomId; + final row = _IdeNodeRow(node: node, domId: domId, isSelected: isSelected); + + if (!node.isFolder) { + return li(classes: 'ide-node ide-node-file', [row]); + } + + final isOpen = !node.startsClosed || _hasSelectedChild(node); + + return li(classes: 'ide-node ide-node-folder', [ + details( + open: isOpen, + [ + summary( + classes: 'ide-folder-summary', + [ + const span( + classes: 'ide-folder-arrow', + attributes: {'aria-hidden': 'true'}, + [], + ), + row, + ], + ), + ul([ + for (final child in node.children) + _IdeTreeNode( + node: child, + instanceId: instanceId, + selectedDomId: selectedDomId, + ), + ]), + ], + ), + ]); + } +} + +/// The clickable button row for a file or folder node in the tree. +class _IdeNodeRow extends StatelessComponent { + const _IdeNodeRow({ + required this.node, + required this.domId, + required this.isSelected, + }); + + final IdeTreeNode node; + final String domId; + final bool isSelected; + + @override + Component build(BuildContext context) { + final icon = node.isFolder + ? FileIcon.folderIcon + : FileIcon.forFile(node.label); + final classesList = ['ide-node-row', if (isSelected) 'active'].toClasses; + final attributesMap = { + 'data-ide-select': domId, + if (isSelected) 'aria-current': 'true', + }; + final childrenList = [ + icon, + span(classes: 'ide-node-label', [.text(node.label)]), + _IdeBadgeDot( + badge: node.badge, + color: node.badgeColor ?? IdeBadgeColor.neutral, + ), + ]; + + if (node.isFolder) { + return span( + classes: classesList, + attributes: attributesMap, + childrenList, + ); + } + + return button( + classes: classesList, + type: ButtonType.button, + attributes: attributesMap, + childrenList, + ); + } +} + +/// A colored indicator dot indicating status or category on a node row. +class _IdeBadgeDot extends StatelessComponent { + const _IdeBadgeDot({ + required this.badge, + this.color = IdeBadgeColor.neutral, + }); + + final String? badge; + final IdeBadgeColor color; + + @override + Component build(BuildContext context) { + if (badge == null) return const Component.empty(); + + return span( + classes: [ + 'ide-badge-dot', + 'ide-badge-color-${color.name}', + ].toClasses, + attributes: { + 'role': 'img', + 'aria-label': badge!, + 'title': badge!, + }, + [], + ); + } +} + +// --------------------------------------------------------------------------- +// Detail Pane Components +// --------------------------------------------------------------------------- + +/// The right-side container holding detail panels for all tree nodes. +class _IdeDetailPane extends StatelessComponent { + const _IdeDetailPane({ + required this.allFlatNodes, + required this.customContents, + required this.effectiveInstanceId, + required this.selectedNodeDomId, + }); + + final List<_BreadcrumbNode> allFlatNodes; + final Map customContents; + final String effectiveInstanceId; + final String? selectedNodeDomId; + + @override + Component build(BuildContext context) { + return div(classes: 'ide-detail', [ + for (final flat in allFlatNodes) + _IdeDetailPanel( + flat: flat, + customBody: customContents[flat.node.id], + instanceId: effectiveInstanceId, + isActive: flat.domId == selectedNodeDomId, + ), + ]); + } +} + +/// An individual documentation panel for a selected file or folder. +/// +/// ```text +/// ┌──────────────────────────────────────────────────────────┐ +/// │ [Path / Breadcrumbs] │ +/// │ [Icon] Title [Optional Badge] │ +/// │ Subtitle text... │ +/// ├──────────────────────────────────────────────────────────┤ +/// │ _IdePageBody: │ +/// │ Custom markdown explanation and code blocks... │ +/// ├──────────────────────────────────────────────────────────┤ +/// │ _IdeFolderContentsSection (if folder has children): │ +/// │ CONTENTS │ +/// │ [📁 lib/] Application source code │ +/// │ [📁 test/] Target directory for widget tests │ +/// │ [📄 pubspec.yaml] Project manifest... │ +/// └──────────────────────────────────────────────────────────┘ +/// ``` +class _IdeDetailPanel extends StatelessComponent { + const _IdeDetailPanel({ + required this.flat, + required this.customBody, + required this.instanceId, + required this.isActive, + }); + + final _BreadcrumbNode flat; + final Component? customBody; + final String instanceId; + final bool isActive; + + @override + Component build(BuildContext context) { + final node = flat.node; + + return div( + classes: ['ide-detail-panel', if (isActive) 'active'].toClasses, + attributes: {'data-ide-panel': flat.domId}, + [ + if (flat.path.isNotEmpty) _IdeBreadcrumb(path: flat.path), + _IdePageHeader(node: node), + if (customBody != null) _IdePageBody(child: customBody!), + if (node.isFolder && node.children.isNotEmpty) + _IdeFolderContentsSection( + children: node.children, + instanceId: instanceId, + ), + ], + ); + } +} + +/// The breadcrumb path displayed above the detail header. +class _IdeBreadcrumb extends StatelessComponent { + const _IdeBreadcrumb({required this.path}); + + final List path; + + @override + Component build(BuildContext context) { + return div( + classes: 'ide-path', + [ + for (final (i, segment) in path.indexed) ...[ + if (i > 0) const span(classes: 'ide-path-sep', [.text('/')]), + span([.text(segment.replaceFirst(RegExp(r'/$'), ''))]), + ], + ], + ); + } +} + +/// The header of a detail panel showing the file/folder icon, title, badge, +/// and subtitle. +class _IdePageHeader extends StatelessComponent { + const _IdePageHeader({required this.node}); + + final IdeTreeNode node; + + @override + Component build(BuildContext context) { + final icon = node.isFolder + ? FileIcon.folderIcon + : FileIcon.forFile(node.label); + + return div(classes: 'ide-detail-header', [ + icon, + div(classes: 'ide-detail-heading', [ + div(classes: 'ide-detail-title-row', [ + div(classes: 'ide-detail-title', [ + .text(node.title ?? node.label), + ]), + if (node.badge case final badge?) + span( + classes: [ + 'ide-badge', + 'ide-badge-color-${node.badgeColor?.name ?? 'neutral'}', + ].toClasses, + [.text(badge)], + ), + ]), + if (node.subtitle case final subtitle?) + div(classes: 'ide-detail-subtitle', [.text(subtitle)]), + ]), + ]); + } +} + +/// Container for the custom markdown prose and code blocks of a page/folder. +class _IdePageBody extends StatelessComponent { + const _IdePageBody({required this.child}); + + final Component child; + + @override + Component build(BuildContext context) { + return div(classes: 'ide-custom-body', [child]); + } +} + +/// The automatic "Contents" section shown on folder detail panels, listing +/// clickable shortcut links to all child files and subfolders. +class _IdeFolderContentsSection extends StatelessComponent { + const _IdeFolderContentsSection({ + required this.children, + required this.instanceId, + }); + + final List children; + final String instanceId; + + @override + Component build(BuildContext context) { + return div(classes: 'ide-contents', [ + const div(classes: 'ide-contents-title', [.text('Contents')]), + div(classes: 'ide-contents-list', [ + for (final child in children) + _IdeContentLink(child: child, instanceId: instanceId), + ]), + ]); + } +} + +/// A clickable link card within the folder "Contents" listing. +class _IdeContentLink extends StatelessComponent { + const _IdeContentLink({ + required this.child, + required this.instanceId, + }); + + final IdeTreeNode child; + final String instanceId; + + @override + Component build(BuildContext context) { + final icon = child.isFolder + ? FileIcon.folderIcon + : FileIcon.forFile(child.label); + + return button( + classes: 'ide-content-link', + type: ButtonType.button, + attributes: { + 'data-ide-select': _ideDomId(instanceId, child.id), + }, + [ + icon, + span(classes: 'ide-node-label', [.text(child.label)]), + if (child.subtitle case final subtitle?) + span( + classes: 'ide-content-one-liner', + [.text(subtitle)], + ), + ], + ); + } +} diff --git a/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart b/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart new file mode 100644 index 00000000000..b5ff66d74de --- /dev/null +++ b/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart @@ -0,0 +1,241 @@ +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; + +import '../../../util.dart'; +import 'ide_explorer.dart'; + +/// A custom markdown component that parses `` and its +/// ``, ``, and `` children. Defers +/// building the IDE html to the [IdeExplorer] component. +class IdeExplorerMarkdownComponent extends CustomComponent { + const IdeExplorerMarkdownComponent() : super.base(); + + // Tag name constants + static const String _tagIdeExplorer = 'IdeExplorer'; + static const String _tagIdeProjectRoot = 'IdeProjectRoot'; + static const String _tagIdeFolder = 'IdeFolder'; + static const String _tagIdePage = 'IdePage'; + + // Attribute name constants + static const String _attrId = 'id'; + static const String _attrLabel = 'label'; + static const String _attrIsDefaultPage = 'is-default-page'; + static const String _attrStartsClosed = 'starts-closed'; + static const String _attrBadge = 'badge'; + static const String _attrBadgeColor = 'badge-color'; + static const String _attrSubtitle = 'subtitle'; + + // Default values + static const String _defaultRootPrefix = 'root'; + static const String _defaultNodePrefix = 'node'; + + @override + Component? create(Node node, NodesBuilder builder) { + if (node is! ElementNode || node.tag != _tagIdeExplorer) { + return null; + } + + final projectRootElements = node.children + ?.whereType() + .where((n) => n.tag == _tagIdeProjectRoot) + .toList(); + + if (projectRootElements == null || projectRootElements.isEmpty) { + print( + '[ERROR] <$_tagIdeExplorer> requires at ' + 'least one <$_tagIdeProjectRoot> child element.', + ); + return const Component.empty(); + } + + final customContents = {}; + final roots = []; + for (final (index, rootEl) in projectRootElements.indexed) { + final rootId = _generateNodeId( + rootEl.attributes, + _defaultRootPrefix, + index, + ); + roots.add( + IdeExplorerProjectRoot( + id: rootId, + label: rootEl.attributes[_attrLabel] ?? '', + children: _parseTreeNodes( + rootEl.children, + builder, + customContents, + rootId, + ), + ), + ); + } + + return IdeExplorer( + roots: roots, + customContents: customContents, + ); + } + + /// Generates a node ID from attributes or creates a default one. + String _generateNodeId( + Map attributes, + String prefix, + int index, [ + String? parentId, + ]) { + if (attributes[_attrId] != null) { + return attributes[_attrId]!; + } + final label = attributes[_attrLabel]; + final localId = (label != null && label.isNotEmpty) + ? slugify(label) + : '$prefix-$index'; + return parentId != null ? '$parentId-$localId' : localId; + } + + List _parseTreeNodes( + List? nodes, + NodesBuilder builder, + Map customContents, + String parentId, + ) { + if (nodes == null || nodes.isEmpty) return const []; + + final result = []; + + for (final (index, child) in nodes.whereType().indexed) { + if (child.tag != _tagIdeFolder && child.tag != _tagIdePage) { + continue; + } + + final treeNode = _buildTreeNodeFromElement( + child, + index, + builder, + customContents, + parentId, + ); + + result.add(treeNode); + } + + return result; + } + + /// Builds a single [IdeTreeNode] from an [ElementNode]. + IdeTreeNode _buildTreeNodeFromElement( + ElementNode element, + int index, + NodesBuilder builder, + Map customContents, + String parentId, + ) { + final attributes = element.attributes; + final label = attributes[_attrLabel] ?? ''; + final id = _generateNodeId(attributes, _defaultNodePrefix, index, parentId); + + // Parse boolean attributes + final isDefaultPage = _getBoolAttribute( + attributes, + _attrIsDefaultPage, + defaultValue: false, + ); + final startsClosed = _getBoolAttribute( + attributes, + _attrStartsClosed, + defaultValue: true, + ); + + // Parse badge attributes + final badge = attributes[_attrBadge]; + final badgeColor = attributes[_attrBadgeColor] != null + ? IdeBadgeColor.fromString(attributes[_attrBadgeColor]) + : null; + + final subtitle = attributes[_attrSubtitle]; + + // Recursively parse children + final nestedTreeNodes = _parseTreeNodes( + element.children, + builder, + customContents, + id, + ); + + // Extract and store custom body content if present + _storeCustomContentIfPresent( + element.children, + id, + builder, + customContents, + ); + + return IdeTreeNode( + id: id, + label: label, + isDefaultPage: isDefaultPage, + startsClosed: startsClosed, + badge: badge, + badgeColor: badgeColor, + subtitle: subtitle, + children: nestedTreeNodes, + ); + } + + /// Parses a boolean attribute value, returning [defaultValue] if not present. + bool _getBoolAttribute( + Map attributes, + String key, { + required bool defaultValue, + }) { + final value = attributes[key]; + return value != null ? value == 'true' : defaultValue; + } + + /// Checks if a node represents body content (not a folder/page structure). + bool _isBodyContent(Node node) { + if (node is ElementNode && + (node.tag == _tagIdeFolder || node.tag == _tagIdePage)) { + return false; + } + if (node is TextNode && node.text.trim().isEmpty) { + return false; + } + return true; + } + + /// Extracts non-structural content nodes from children. + List _extractContentNodes(List children) { + return children + .where((n) { + if (n is ElementNode && + (n.tag == _tagIdeFolder || n.tag == _tagIdePage)) { + return false; + } + return true; + }) + .toList(growable: false); + } + + /// Stores custom body content for a node if it exists. + void _storeCustomContentIfPresent( + List? children, + String nodeId, + NodesBuilder builder, + Map customContents, + ) { + if (!_hasCustomBodyContent(children)) { + return; + } + + final contentNodes = _extractContentNodes(children!); + if (contentNodes.isNotEmpty) { + customContents[nodeId] = builder.build(contentNodes); + } + } + + /// Checks if a node has custom body content. + bool _hasCustomBodyContent(List? children) { + return children?.any(_isBodyContent) ?? false; + } +} diff --git a/sites/docs/lib/_sass/_site.scss b/sites/docs/lib/_sass/_site.scss index 99092c6a676..f4b54f2cc41 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'; @@ -37,6 +38,7 @@ @use 'package:site_shared/_sass/components/code'; @use 'package:site_shared/_sass/components/cookie-notice'; @use 'package:site_shared/_sass/components/dropdown'; +@use 'package:site_shared/_sass/components/ide-explorer'; @use 'package:site_shared/_sass/components/menu-toggle'; @use 'package:site_shared/_sass/components/progress-ring'; @use 'package:site_shared/_sass/components/quiz'; @@ -48,10 +50,13 @@ @use 'package:site_shared/_sass/components/tooltip'; // Styles for specific pages, alphabetically ordered. +@use 'pages/cuj-index'; +@use 'pages/flutter-bench'; @use 'pages/glossary'; @use 'pages/learning-resources-index'; @use 'pages/not-found'; @use 'pages/search'; +@use 'pages/story-layout'; // Must be imported last to ensure that // the print overrides take priority over earlier defined styles. 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 00000000000..1d881c23a3c --- /dev/null +++ b/sites/docs/lib/_sass/components/_filterable-index.scss @@ -0,0 +1,224 @@ +// 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) { + .filterable-index { + flex-direction: column; + } + + .left-col { + margin-right: 0; + } + + // The drawer itself is fixed-positioned, so this column no longer needs to + // reserve width for it in the flex layout. + .right-col { + width: auto; + } + + .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/_sass/pages/_cuj-index.scss b/sites/docs/lib/_sass/pages/_cuj-index.scss new file mode 100644 index 00000000000..e38e28722f3 --- /dev/null +++ b/sites/docs/lib/_sass/pages/_cuj-index.scss @@ -0,0 +1,142 @@ +@use 'sass:color'; +@use 'flutter-bench' as *; + +// The critical user journey index reuses the two column layout, search field, +// 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 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: $spacing-md; + + .outlined-button { + width: 100%; + justify-content: center; + } + + @media (max-width: 839px) { + margin-block-start: 0; + padding: 0.75rem; + border-top: 1px solid var(--site-inset-borderColor); + } +} + +#all-cujs-list { + margin-block-start: $spacing-md; + + .cuj-card { + height: auto; + padding: 0.75rem $spacing-md; + gap: $spacing-xs; + + .card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + gap: $spacing-sm; + } + + .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: $spacing-xs; + flex-shrink: 0; + + .icon-button { + border-radius: $spacing-lg; + + > span { + font-size: $font-size-xl; + } + } + } + + .cuj-task-count { + margin: 0; + font-size: $font-size-sm; + 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: $spacing-sm; + padding-block-start: $spacing-sm; + } + + &.collapsed { + .card-content { + display: none; + } + + .expand-button { + transform: rotate(180deg); + } + } + + .expand-button { + &:hover, + &:focus-within { + transition: transform $transition-normal; + } + } + + .cuj-task-list { + margin: 0; + padding-left: 1.15rem; + + li { + padding-left: 0; + margin-block-end: $spacing-sm; + font-size: $font-size-sm; + 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: $color-dx; + } + } +} diff --git a/sites/docs/lib/_sass/pages/_flutter-bench.scss b/sites/docs/lib/_sass/pages/_flutter-bench.scss new file mode 100644 index 00000000000..d5a949be860 --- /dev/null +++ b/sites/docs/lib/_sass/pages/_flutter-bench.scss @@ -0,0 +1,1388 @@ +@use 'sass:color'; +@use 'package:site_shared/_sass/base/breakpoints'; + +// Styles for FlutterBench and Methodology pages + +// ----------------------------------------------------------------------------- +// Variables & Theme +// ----------------------------------------------------------------------------- + +// Core Category Colors +// NOTE: These colors are intentionally coordinated with _story-layout.scss +// chapter accent colors to maintain visual consistency across FlutterBench pages +$color-outcome: #059669; // Green - successful outcomes +$color-quality: #6366f1; // Indigo - code quality metrics +$color-dx: #d97706; // Amber - developer experience +$color-functional: #0284c7; // Sky blue - functional correctness +$color-llm: #7c3aed; // Violet - LLM grading +$color-heuristic: #2563eb; // Blue - heuristic grading +$color-poor: #ea580c; // Orange - poor performance +$color-failure: #e11d48; // Rose - failures +$color-grey: #64748b; // Slate - neutral/baseline + +// Hero & Special Colors +$color-white: #ffffff; +$color-gold: #fbbf24; +$hero-bg-light: linear-gradient(135deg, #0d2040 0%, #1e1b4b 50%, #0f172a 100%); +$hero-bg-dark: linear-gradient(135deg, #091728 0%, #131230 50%, #090e17 100%); +$north-star-bg-light: linear-gradient(135deg, #102a4e 0%, #1e1b4b 100%); +$north-star-bg-dark: linear-gradient(135deg, #0d1e38 0%, #151433 100%); + +// Typography Scale +$font-size-xs: 0.75rem; +$font-size-sm: 0.875rem; +$font-size-md: 1rem; +$font-size-lg: 1.25rem; +$font-size-xl: 1.5rem; +$font-size-xxl: 2.5rem; + +// Spacing Scale +$spacing-xs: 0.25rem; +$spacing-sm: 0.5rem; +$spacing-md: 1rem; +$spacing-lg: 1.5rem; +$spacing-xl: 2rem; + +// Dimensions & Layout +$border-width: 2px; +$icon-size: 2.75rem; +$card-width: 280px; +$bench-content-max-width: 1000px; + +// Shadows & Transitions +$shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); +$shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); +$shadow-hero: 0 4px 12px rgba(0, 0, 0, 0.2); +$shadow-hero-dark: 0 4px 12px rgba(0, 0, 0, 0.4); +$transition-normal: 0.2s ease; + +// ----------------------------------------------------------------------------- +// Mixins +// ----------------------------------------------------------------------------- + +// Centers content with consistent max-width +@mixin bench-element-width($max-width: $bench-content-max-width) { + width: 100%; + max-width: $max-width; + margin-left: $spacing-sm; + margin-right: $spacing-sm; +} + +// Consistent scrollbar styling across components +@mixin custom-scrollbar($orientation: 'vertical') { + scrollbar-width: thin; + scrollbar-color: var(--site-inset-borderColor) transparent; + -webkit-overflow-scrolling: touch; + + &::-webkit-scrollbar { + @if $orientation == 'horizontal' { + height: 6px; + } @else { + width: 6px; + } + } + + &::-webkit-scrollbar-thumb { + background-color: var(--site-inset-borderColor); + border-radius: 3px; + } +} + +// Category badge styling with optional border +@mixin category-badge($with-border: false) { + font-size: $font-size-xs; + font-weight: 600; + padding: $spacing-xs $spacing-sm; + border-radius: var(--site-radius, $spacing-xs); + + &.badge-outcome, + &.outcome { + background: rgba($color-outcome, 0.12); + color: $color-outcome; + @if $with-border { + border: 1px solid rgba($color-outcome, 0.25); + } + } + + &.badge-quality, + &.quality { + background: rgba($color-quality, 0.12); + color: $color-quality; + @if $with-border { + border: 1px solid rgba($color-quality, 0.25); + } + } + + &.badge-dx, + &.dx { + background: rgba($color-dx, 0.12); + color: $color-dx; + @if $with-border { + border: 1px solid rgba($color-dx, 0.25); + } + } + + &.badge-functional, + &.badge-blue { + background: rgba($color-functional, 0.12); + color: $color-functional; + @if $with-border { + border: 1px solid rgba($color-functional, 0.25); + } + } + + &.badge-llm { + background: rgba($color-llm, 0.12); + color: $color-llm; + @if $with-border { + border: 1px solid rgba($color-llm, 0.25); + } + } + + &.badge-heuristic { + background: rgba($color-heuristic, 0.12); + color: $color-heuristic; + @if $with-border { + border: 1px solid rgba($color-heuristic, 0.25); + } + } + + &.badge-poor { + background: rgba($color-poor, 0.12); + color: $color-poor; + @if $with-border { + border: 1px solid rgba($color-poor, 0.25); + } + } + + &.badge-failure { + background: rgba($color-failure, 0.12); + color: $color-failure; + @if $with-border { + border: 1px solid rgba($color-failure, 0.25); + } + } + + &.badge-grey { + background: rgba($color-grey, 0.12); + color: $color-grey; + @if $with-border { + border: 1px solid rgba($color-grey, 0.25); + } + } + + &.badge-deterministic { + background: rgba($color-outcome, 0.12); + color: $color-outcome; + @if $with-border { + border: 1px solid rgba($color-outcome, 0.25); + } + } + + &.badge-perfect, + &.badge-green { + background: rgba($color-outcome, 0.12); + color: $color-outcome; + @if $with-border { + border: 1px solid rgba($color-outcome, 0.25); + } + } + + &.badge-purple { + background: rgba($color-quality, 0.12); + color: $color-quality; + @if $with-border { + border: 1px solid rgba($color-quality, 0.25); + } + } + + &.badge-amber { + background: rgba($color-dx, 0.12); + color: $color-dx; + @if $with-border { + border: 1px solid rgba($color-dx, 0.25); + } + } +} + +// Icon background with category colors +@mixin category-icon-background { + width: $icon-size; + height: $icon-size; + border-radius: $spacing-sm; + display: flex; + align-items: center; + justify-content: center; + + &.outcome, + &.variant-outcome { + background-color: rgba($color-outcome, 0.15); + color: $color-outcome; + } + + &.quality, + &.variant-quality { + background-color: rgba($color-quality, 0.15); + color: $color-quality; + } + + &.dx, + &.variant-dx { + background-color: rgba($color-dx, 0.15); + color: $color-dx; + } + + &.variant-blue { + background-color: rgba($color-functional, 0.12); + color: $color-functional; + } + + &.variant-grey { + background-color: rgba($color-grey, 0.15); + color: $color-grey; + } + + .material-symbols { + font-size: $font-size-xl; + } +} + +// ----------------------------------------------------------------------------- +// Component Styles +// ----------------------------------------------------------------------------- + +.methodology-hero { + background: $hero-bg-light; + color: $color-white; + border-radius: var(--site-radius); + padding: $spacing-xl; + box-shadow: $shadow-hero; + + h1, + .hero-title { + color: $color-white; + font-weight: 700; + margin: 0 0 $spacing-sm 0; + } + + p { + color: #e2e8f0; + font-size: $font-size-sm; + margin: 0; + max-width: 48rem; + + code { + background: rgba(15, 23, 42, 0.8); + color: #93c5fd; + border: 1px solid rgba(59, 130, 246, 0.3); + padding: $spacing-xs $spacing-sm; + border-radius: var(--site-radius); + } + + strong { + color: $color-white; + } + } +} + +// Three Core Dimensions Grid +.dimension-cards-grid { + @include bench-element-width; + max-width: 700px; + margin-block: $spacing-lg $spacing-xl; + + .dimension-card { + background-color: var(--site-inset-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + padding: $spacing-md; + margin-bottom: $spacing-md; + display: flex; + flex-direction: column; + + .card-header-row { + display: flex; + align-items: center; + + .card-icon-wrap { + @include category-icon-background; + margin: 0 $spacing-md $spacing-md 0; + } + + h4 { + flex: 2; + font-size: $font-size-md; + font-weight: 600; + margin: 0 0 $spacing-sm 0; + color: var(--site-base-fgColor); + } + } + + p { + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + margin: 0; + flex-grow: 1; + } + + .card-footer-info { + margin-top: $spacing-md; + padding-top: $spacing-sm; + border-top: 1px solid var(--site-inset-borderColor); + display: flex; + align-items: center; + justify-content: space-between; + font-size: $font-size-xs; + color: var(--site-base-fgColor-lighter); + + .badge { + @include category-badge; + } + } + } +} + +// Grader Matrix Carousel +.grader-matrix { + @include bench-element-width; + background-color: var(--site-inset-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + padding: $spacing-md; + margin-block: $spacing-md; + position: relative; + + .matrix-header { + display: flex; + flex-direction: column; + gap: $spacing-md; + margin-bottom: $spacing-md; + + @include breakpoints.screen('md') { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .matrix-title-area { + h3 { + margin: 0 0 $spacing-xs 0; + font-size: $font-size-lg; + font-weight: 600; + color: var(--site-base-fgColor); + } + + p { + margin: 0; + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + } + } + + .matrix-filters { + display: flex; + flex-wrap: wrap; + gap: $spacing-xs; + background-color: var(--site-raised-bgColor); + padding: $spacing-xs; + border-radius: $spacing-sm; + + .filter-btn { + background: transparent; + border: none; + border-radius: var(--site-radius); + padding: $spacing-xs $spacing-sm; + font-size: $font-size-xs; + font-weight: 500; + color: var(--site-base-fgColor-alt); + cursor: pointer; + transition: $transition-normal; + + &:hover { + color: var(--site-base-fgColor); + } + + &.active { + background-color: var(--site-base-bgColor); + color: var(--site-primary-color); + font-weight: 600; + box-shadow: $shadow-sm; + } + } + } + } + + .grader-carousel-wrapper { + position: relative; + width: 100%; + + .carousel-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 1px solid var(--site-inset-borderColor); + background-color: var(--site-base-bgColor); + color: var(--site-base-fgColor); + cursor: pointer; + transition: $transition-normal; + box-shadow: $shadow-sm; + z-index: var(--site-z-floating, 10); + + &.prev { + left: -$spacing-md; + } + + &.next { + right: -$spacing-md; + } + + &:hover:not(:disabled) { + background-color: var(--site-raised-bgColor); + border-color: var(--site-primary-color); + color: var(--site-primary-color); + transform: translateY(-50%) scale(1.05); + } + + &:active:not(:disabled) { + transform: translateY(-50%) scale(0.95); + } + + &:disabled { + opacity: 0.35; + cursor: not-allowed; + } + + .material-symbols { + font-size: $font-size-xl; + } + } + + .grader-cards-track { + @include custom-scrollbar('horizontal'); + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: $spacing-md; + overflow-x: auto; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + padding: $spacing-sm; + background-color: var(--site-base-bgColor); + border-radius: var(--site-radius); + // Preserves padding when overflow-x clips it + border-left: $spacing-sm solid var(--site-base-bgColor); + border-right: $spacing-sm solid var(--site-base-bgColor); + + .grader-card { + flex: 0 0 $card-width; + max-width: $card-width; + min-width: 260px; + scroll-snap-align: start; + background-color: var(--site-base-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + padding: $spacing-md; + display: flex; + flex-direction: column; + gap: $spacing-sm; + transition: border-color $transition-normal; + + &:hover { + border-color: var(--site-primary-color); + } + + &.hidden { + display: none !important; + } + + .grader-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + + .grader-cat { + font-size: $font-size-xs; + font-weight: 700; + text-transform: uppercase; + + &.outcome { + color: $color-outcome; + } + + &.quality { + color: $color-quality; + } + + &.dx { + color: $color-dx; + } + } + + .grader-badge { + @include category-badge(true); + border-radius: 9999px; + } + } + + h4 { + font-size: $font-size-md; + font-weight: 600; + margin: 0; + color: var(--site-base-fgColor); + } + + p { + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + margin: 0; + flex-grow: 1; + + code { + font-size: 0.9em; + background-color: rgba(0, 0, 0, 0.05); + padding: $spacing-xs; + border-radius: var(--site-radius); + } + } + } + } + } +} + +// Multi-Run Reliability +.reliability-comparison-grid { + @include bench-element-width; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: $spacing-md; + margin-block: $spacing-lg $spacing-xl; + + .reliability-card { + background-color: var(--site-inset-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + padding: $spacing-lg; + display: flex; + flex-direction: column; + gap: $spacing-sm; + + .reliability-header { + display: flex; + align-items: center; + justify-content: space-between; + + .tag { + font-size: $font-size-xs; + font-weight: 700; + text-transform: uppercase; + color: var(--site-primary-color); + } + + .math-pill { + font-family: var(--site-code-fontFamily, monospace); + font-size: $font-size-sm; + font-weight: 700; + background-color: rgba(4, 104, 215, 0.1); + color: var(--site-primary-color); + padding: $spacing-xs $spacing-sm; + border-radius: var(--site-radius); + } + } + + h4 { + font-size: $font-size-lg; + font-weight: 600; + margin: 0; + color: var(--site-base-fgColor); + } + + p { + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + margin: 0; + + strong, + em { + color: var(--site-base-fgColor); + } + } + + &.north-star { + background: $north-star-bg-light; + border: 1px solid rgba(59, 130, 246, 0.4); + color: $color-white; + box-shadow: $shadow-md; + + .reliability-header { + .tag { + color: $color-gold; + display: flex; + align-items: center; + gap: $spacing-xs; + + .material-symbols { + font-size: $font-size-md; + } + } + + .math-pill { + background-color: rgba(59, 130, 246, 0.25); + border: 1px solid rgba(147, 197, 253, 0.4); + color: #bfdbfe; + } + } + + h4 { + color: $color-white; + } + + p { + color: #e2e8f0; + + strong, + em { + color: $color-white; + } + } + } + } +} + +// Interactive Detail Card & Score Triage +.interactive-detail-card, +.score-triage { + @include bench-element-width; + background-color: var(--site-inset-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + padding: $spacing-lg; + margin-block: $spacing-lg $spacing-xl; + + .card-header-area, + .triage-header { + margin-bottom: $spacing-md; + + h3 { + font-size: $font-size-lg; + font-weight: 600; + margin: 0 0 $spacing-xs 0; + color: var(--site-base-fgColor); + } + + p { + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + margin: 0; + } + } + + .card-tabs-grid, + .triage-tiers-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr)); + gap: $spacing-sm; + margin-bottom: $spacing-md; + + .card-tab-btn, + .triage-tier-btn { + background-color: var(--site-base-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + padding: $spacing-sm $spacing-md; + text-align: left; + cursor: pointer; + transition: $transition-normal; + opacity: 0.75; + + &:hover { + opacity: 1; + } + + .tab-primary-label, + .tier-score { + font-size: $font-size-sm; + font-weight: 800; + } + + .tab-secondary-label, + .tier-name { + font-size: $font-size-xs; + font-weight: 600; + margin-top: $spacing-xs; + color: var(--site-base-fgColor); + } + + // Color variants for tabs + &.variant-perfect, + &.tier-perfect { + .tab-primary-label, + .tier-score { + color: $color-outcome; + } + + &.active { + border: $border-width solid $color-outcome; + background-color: rgba($color-outcome, 0.08); + opacity: 1; + } + } + + &.variant-functional, + &.variant-blue, + &.tier-functional { + .tab-primary-label, + .tier-score { + color: $color-functional; + } + + &.active { + border: $border-width solid $color-functional; + background-color: rgba($color-functional, 0.08); + opacity: 1; + } + } + + &.variant-quality, + &.variant-purple { + .tab-primary-label, + .tier-score { + color: $color-quality; + } + + &.active { + border: $border-width solid $color-quality; + background-color: rgba($color-quality, 0.08); + opacity: 1; + } + } + + &.variant-partial, + &.variant-dx, + &.variant-amber, + &.tier-partial { + .tab-primary-label, + .tier-score { + color: $color-dx; + } + + &.active { + border: $border-width solid $color-dx; + background-color: rgba($color-dx, 0.08); + opacity: 1; + } + } + + &.variant-poor, + &.tier-poor { + .tab-primary-label, + .tier-score { + color: $color-poor; + } + + &.active { + border: $border-width solid $color-poor; + background-color: rgba($color-poor, 0.08); + opacity: 1; + } + } + + &.variant-failure, + &.tier-failure { + .tab-primary-label, + .tier-score { + color: $color-failure; + } + + &.active { + border: $border-width solid $color-failure; + background-color: rgba($color-failure, 0.08); + opacity: 1; + } + } + } + } + + .card-panels-container, + .triage-detail-card { + @include custom-scrollbar; + background-color: var(--site-base-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + padding: $spacing-md; + position: relative; + min-height: 220px; + max-height: 380px; + overflow-y: auto; + + .card-panel, + .triage-panel { + display: none; + flex-direction: column; + gap: $spacing-sm; + + &.active { + display: flex; + } + + .panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + + h4 { + font-size: $font-size-md; + font-weight: 700; + margin: 0; + color: var(--site-base-fgColor); + } + + .panel-badge { + @include category-badge; + } + } + + .panel-overview, + .criteria-text { + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + margin: 0; + } + + .panel-items-section, + .actions-section { + border-top: 1px solid var(--site-inset-borderColor); + padding-top: $spacing-sm; + + .items-label, + .actions-label { + font-size: $font-size-xs; + font-weight: 700; + text-transform: uppercase; + color: var(--site-base-fgColor); + margin-bottom: $spacing-xs; + } + + ul { + margin: 0; + padding-left: $spacing-md; + + li { + font-size: $font-size-sm; + color: var(--site-base-fgColor); + margin-bottom: $spacing-xs; + + strong { + color: var(--site-primary-color); + } + + code { + font-size: 0.85em; + background-color: var(--site-raised-bgColor); + padding: $spacing-xs; + border-radius: var(--site-radius); + } + + &:last-child { + margin-bottom: 0; + } + } + } + } + + .panel-footer-text { + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + margin: $spacing-sm 0 0 0; + font-style: italic; + } + } + } +} + +// Styled tables for FlutterBench pages +#page-content:has(.methodology-hero, .grader-matrix, .dimension-cards-grid) { + .table-wrapper:not(.task-specs-list *) { + @include bench-element-width; + background-color: var(--site-base-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + margin-block: $spacing-md $spacing-lg; + overflow-x: auto; + box-shadow: $shadow-sm; + + table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + margin: 0; + + thead { + background-color: var(--site-raised-bgColor); + border-bottom: 1px solid var(--site-inset-borderColor); + + tr { + th { + padding: $spacing-sm $spacing-md; + font-family: var(--site-ui-fontFamily); + font-size: $font-size-xs; + font-weight: 700; + text-transform: uppercase; + color: var(--site-base-fgColor); + border: none; + text-align: left; + + &:first-child { + padding-left: $spacing-md; + } + + &:last-child { + padding-right: $spacing-md; + } + } + } + } + + tbody { + tr { + background-color: var(--site-base-bgColor); + border-top: 1px solid var(--site-inset-borderColor); + transition: background-color $transition-normal; + + &:first-child { + border-top: none; + } + + &:hover { + background-color: var(--site-inset-bgColor); + } + + td { + padding: $spacing-sm $spacing-md; + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + border: none; + vertical-align: top; + + strong { + font-family: var(--site-ui-fontFamily); + font-size: $font-size-sm; + font-weight: 600; + color: var(--site-base-fgColor); + } + + code { + font-size: 0.85em; + background-color: var(--site-raised-bgColor); + padding: $spacing-xs $spacing-sm; + border-radius: var(--site-radius); + } + + &:first-child { + padding-left: $spacing-md; + font-weight: 600; + color: var(--site-base-fgColor); + white-space: nowrap; + } + + &:last-child { + padding-right: $spacing-md; + } + } + } + } + } + } +} + +// CUJ Diagram Component +.cuj-diagram-card { + @include bench-element-width; + background-color: var(--site-base-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + padding: $spacing-md; + margin-block: $spacing-lg $spacing-xl; + box-shadow: $shadow-sm; + display: flex; + flex-direction: column; + gap: $spacing-sm; + + .cuj-diagram-section { + background-color: var(--site-inset-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + padding: $spacing-md $spacing-lg; + display: grid; + grid-template-columns: 4.5rem 1fr; + align-items: center; + gap: $spacing-md; + + @include breakpoints.screen-below('sm') { + grid-template-columns: 1fr; + justify-items: center; + text-align: center; + padding: $spacing-md; + gap: $spacing-sm; + } + + .section-sidebar { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $spacing-xs; + + .section-icon { + display: flex; + align-items: center; + justify-content: center; + + .material-symbols { + font-size: $font-size-xxl; + font-variation-settings: 'FILL' 1; + } + + &.variant-grey, + &.variant-blue { + color: $color-functional; + } + + &.variant-amber { + color: $color-dx; + } + + &.variant-green { + color: $color-outcome; + } + + &.variant-purple { + color: $color-quality; + } + } + + .section-label { + font-family: var(--site-ui-fontFamily); + font-size: $font-size-sm; + font-weight: 700; + color: var(--site-base-fgColor); + } + } + + .section-items { + display: flex; + flex-direction: column; + gap: $spacing-sm; + width: 100%; + + .cuj-pill { + width: 100%; + padding: $spacing-md $spacing-lg; + border-radius: 9999px; + font-size: $font-size-sm; + font-weight: 500; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + transition: transform $transition-normal; + + &.pill-grey { + background-color: #d1d5db; + border: $border-width solid #858d99; + color: #1f2937; + } + + &.pill-blue { + background-color: #bae6fd; + border: $border-width solid $color-functional; + color: #0c4a6e; + } + + &.pill-amber { + background-color: #fef08a; + border: $border-width solid #eab308; + color: #713f12; + } + + &.pill-green { + background-color: #bbf7d0; + border: $border-width solid #16a34a; + color: #14532d; + } + + &.pill-purple { + background-color: #e9d5ff; + border: $border-width solid #9333ea; + color: #581c87; + } + } + } + } +} + +// Task Specifications Expansion Panels +.task-specs-list { + @include bench-element-width; + background-color: var(--site-inset-bgColor); + border: 1px solid var(--site-inset-borderColor); + border-radius: var(--site-radius); + margin-block: $spacing-lg $spacing-xl; + overflow: hidden; + box-shadow: $shadow-sm; + + .task-spec-panel { + border-bottom: 1px solid var(--site-inset-borderColor); + + &:last-child { + border-bottom: none; + } + + >a.collapsible { + display: flex; + align-items: center; + justify-content: space-between; + padding: $spacing-md $spacing-lg; + background-color: var(--site-base-bgColor); + color: var(--site-base-fgColor); + text-decoration: none; + cursor: pointer; + gap: $spacing-md; + transition: background-color $transition-normal; + + &:hover { + background-color: var(--site-inset-bgColor); + text-decoration: none; + } + + &:not(.collapsed) { + background-color: var(--site-inset-bgColor); + + &::after { + transform: rotate(180deg); + } + } + + &::after { + content: 'keyboard_arrow_down'; + content: 'keyboard_arrow_down' / ''; + font-family: var(--site-icon-fontFamily); + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; + font-weight: normal; + font-style: normal; + font-size: $font-size-xl; + transition: transform $transition-normal; + color: var(--site-base-fgColor-lighter); + flex-shrink: 0; + } + + .panel-header-left { + display: flex; + align-items: center; + gap: $spacing-md; + flex: 1; + min-width: 0; + + @include breakpoints.screen-below('sm') { + align-items: flex-start; + } + + .panel-icon-wrap { + @include category-icon-background; + flex-shrink: 0; + } + + .panel-header-content { + display: flex; + flex-direction: column; + gap: $spacing-xs; + flex: 1; + min-width: 0; + + .panel-title-row { + display: flex; + align-items: center; + gap: $spacing-sm; + flex-wrap: wrap; + + h4 { + font-family: var(--site-ui-fontFamily); + font-size: $font-size-md; + font-weight: 600; + margin: 0; + color: var(--site-base-fgColor); + } + + .badge { + @include category-badge; + } + } + + .panel-description { + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + margin: 0; + } + } + } + } + + .task-spec-body { + padding: $spacing-md $spacing-lg; + background-color: var(--site-base-bgColor); + border-top: 1px solid var(--site-inset-borderColor); + display: none; + + &.show { + display: block; + } + + .lead-text { + margin: 0 0 $spacing-md 0; + + code { + font-size: 0.85em; + background-color: var(--site-raised-bgColor); + padding: $spacing-xs $spacing-sm; + border-radius: var(--site-radius); + } + } + + .table-subheading { + font-size: $font-size-sm; + font-weight: 700; + margin: $spacing-md 0 $spacing-xs 0; + + &:first-of-type { + margin-top: 0; + } + } + + .table-wrapper { + table.spec-table, + table { + width: 100%; + border-spacing: $spacing-sm; + border: none; + font-size: $font-size-sm; + + tbody { + background: transparent; + border: none; + + tr { + td { + vertical-align: top; + + strong { + font-weight: 600; + } + + code { + font-size: 0.85em; + padding: $spacing-xs $spacing-sm; + border-radius: var(--site-radius); + } + + &:first-child { + padding-left: 0; + font-weight: 600; + width: 200px; + } + + &:last-child { + color: var(--site-base-fgColor-alt); + padding-right: 0; + } + } + } + } + } + } + + .footer-text { + font-size: $font-size-sm; + color: var(--site-base-fgColor-alt); + margin: $spacing-sm 0 0 0; + font-style: italic; + + code { + font-size: 0.85em; + background-color: var(--site-raised-bgColor); + padding: $spacing-xs $spacing-sm; + border-radius: var(--site-radius); + font-style: normal; + } + } + } + } +} + +// Dark mode overrides +body.dark-mode { + .methodology-hero { + background: $hero-bg-dark; + box-shadow: $shadow-hero-dark; + } + + .cuj-diagram-card { + .cuj-diagram-section { + background-color: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.08); + + .section-items { + .cuj-pill { + &.pill-grey { + background-color: rgba(148, 163, 184, 0.18); + border-color: rgba(148, 163, 184, 0.45); + color: #f1f5f9; + } + + &.pill-blue { + background-color: rgba(56, 189, 248, 0.18); + border-color: rgba(56, 189, 248, 0.55); + color: #e0f2fe; + } + + &.pill-amber { + background-color: rgba(234, 179, 8, 0.18); + border-color: rgba(234, 179, 8, 0.55); + color: #fef08a; + } + + &.pill-green { + background-color: rgba(34, 197, 94, 0.18); + border-color: rgba(34, 197, 94, 0.55); + color: #dcfce7; + } + + &.pill-purple { + background-color: rgba(168, 85, 247, 0.18); + border-color: rgba(168, 85, 247, 0.55); + color: #f3e8ff; + } + } + } + } + } + + .grader-matrix { + .grader-cards-track .grader-card.cat-llm, + .grader-cards-grid .grader-card.cat-llm { + background-color: rgba($color-llm, 0.08); + } + } + + .reliability-comparison-grid .reliability-card.north-star { + background: $north-star-bg-dark; + border-color: rgba(59, 130, 246, 0.35); + } +} diff --git a/sites/docs/lib/_sass/pages/_learning-resources-index.scss b/sites/docs/lib/_sass/pages/_learning-resources-index.scss index 0e6581adbd5..5d316703d11 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/_sass/pages/_story-layout.scss b/sites/docs/lib/_sass/pages/_story-layout.scss new file mode 100644 index 00000000000..0aa2e2ae373 --- /dev/null +++ b/sites/docs/lib/_sass/pages/_story-layout.scss @@ -0,0 +1,314 @@ +@use 'sass:color'; +@use 'package:site_shared/_sass/base/breakpoints'; + +// ----------------------------------------------------------------------------- +// Storytelling Layout Styles +// ----------------------------------------------------------------------------- + +// Color palette shared with _flutter-bench.scss for visual consistency +// NOTE: These colors intentionally match the FlutterBench category colors +$story-progress-gradient: linear-gradient(90deg, #0284c7 0%, #6366f1 50%, #059669 100%); + +$chapter-colors: ( + (rgb: "2, 132, 199", hex: #0284c7), + // Sky blue (functional) + (rgb: "5, 150, 105", hex: #059669), + // Green (outcome) + (rgb: "124, 58, 237", hex: #7c3aed), + // Violet (llm) + (rgb: "99, 102, 241", hex: #6366f1), + // Indigo (quality) + (rgb: "217, 119, 6", hex: #d97706), + // Amber (dx) + (rgb: "100, 116, 139", hex: #64748b) // Slate (grey) +); + +// Scroll-Driven Reading Progress Line +.story-reading-progress { + position: sticky; + top: var(--site-header-height, 4rem); + width: 100%; + height: 3px; + z-index: var(--site-z-top, 1000); + background: rgb(216, 216, 216); + pointer-events: none; + + .story-reading-progress-bar { + height: 100%; + width: 100%; + transform-origin: 0% 50%; + transform: scaleX(0); + background: $story-progress-gradient; + box-shadow: 0 1px 4px rgba(2, 132, 199, 0.25); + + @supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) { + animation: story-reading-progress-grow linear both; + animation-timeline: scroll(root block); + } + } +} + +@keyframes story-reading-progress-grow { + from { + transform: scaleX(0); + } + + to { + transform: scaleX(1); + } +} + +// A layout that provides generous space and visual cues to +// encourage reading a longer doc page. +body.layout-story { + scroll-padding-top: calc(var(--site-header-height, 4rem) + 1.5rem); + + #page-content article { + max-width: 1240px; + width: 100%; + + >#site-content-title, + >.site-breadcrumbs, + >p.description, + >.prev-next-nav, + >.trailing-content { + max-width: 800px; + width: 100%; + } + } + + .story-canvas { + display: flex; + flex-direction: column; + width: 100%; + } + + // Chapter Sections + .story-chapter { + position: relative; + padding-block: 3rem; + scroll-snap-align: start; + scroll-margin-top: calc(var(--site-header-height, 4rem) + 1.5rem); + transition: background-color 0.4s ease, border-color 0.4s ease; + + // Default fallback accent color + --chapter-accent: #0284c7; + --chapter-accent-rgb: 2, 132, 199; + --chapter-next-rgb: 5, 150, 105; + + // Generate chapter accent colors from palette + @for $i from 0 through 20 { + $formatted: if($i < 10, "0#{$i}", "#{$i}"); + $curr-idx: ( + $i % length($chapter-colors)) + 1; + $next-idx: ( + ($i + 1) % length($chapter-colors)) + 1; + + $curr: nth($chapter-colors, $curr-idx + ); + $next: nth($chapter-colors, $next-idx); + + &[data-chapter="#{$formatted}"] { + --chapter-accent: #{map-get($curr, hex)}; + --chapter-accent-rgb: #{map-get($curr, rgb)}; + --chapter-next-rgb: #{map-get($next, rgb)}; + } + } + + // Ambient atmosphere vignette + &::before { + content: ''; + position: absolute; + top: 0; + left: -2rem; + right: -2rem; + height: 100%; + background: + // Main section gradient + radial-gradient(650px circle at 50% 0%, rgba(var(--chapter-accent-rgb), 0.08), transparent 90%), + // Ease into next chapter's gradient + radial-gradient(650px circle at 50% 100%, rgba(var(--chapter-next-rgb), 0.08), transparent 90%); + pointer-events: none; + z-index: 0; + } + + &:first-of-type { + padding-top: clamp(2rem, 5vh, 4rem); + } + + &:last-of-type { + border-bottom: none; + padding-bottom: clamp(6rem, 12vh, 12rem); + } + + // Prologue (intro content before chapter 01) + &.story-prologue { + min-height: auto; + padding-bottom: clamp(3rem, 6vh, 5rem); + } + + // Sticky Chapter Header & Eyebrow Waypoint + .chapter-header-group { + position: sticky; + top: calc(var(--site-header-height, 4rem) + 3px); + z-index: var(--site-z-floating, 10); + padding-block: 0.85rem 0.5rem; + margin-bottom: 2rem; + border-bottom: 1px solid var(--site-outline-variant); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + + .chapter-kicker, + >.header-wrapper { + width: 100%; + margin-inline: auto; + } + + .chapter-kicker { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.35rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.75rem; + font-weight: 700; + color: var(--chapter-accent); + + .chapter-number { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.15rem 0.55rem; + border-radius: 9999px; + background: rgba(var(--chapter-accent-rgb), 0.08); + border: 1px solid rgba(var(--chapter-accent-rgb), 0.25); + font-family: var(--site-code-fontFamily); + font-size: 0.8rem; + line-height: 1.2; + color: var(--chapter-accent); + } + + .chapter-rule { + display: inline-block; + width: 2rem; + height: 1px; + background: currentColor; + opacity: 0.35; + } + } + + >.header-wrapper { + margin: 0; + + h2 { + font-weight: 700; + } + } + } + + // Prose Centering vs. Component Breakouts + .chapter-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + + // Body prose, lists, blockquotes, tables, notes centered in narrow column + >p, + >ul, + >ol, + >blockquote, + >.table-wrapper, + >.alert, + >.callout, + >.katex-display, + >math { + width: 100%; + max-width: 700px; + line-height: 1.75; + margin-left: 0.5rem; + margin-right: 0.5rem; + } + + >p.description { + font-size: 1.2rem; + line-height: 1.65; + color: var(--site-base-fgColor-alt); + max-width: 800px; + } + + // Sub-headings inside chapter centered to match prose + >.header-wrapper { + width: 100%; + + h3 { + font-size: clamp(1.3rem, 1.8vw, 1.55rem); + font-weight: 600; + margin-top: 2rem; + margin-bottom: 0.5rem; + } + + h4 { + font-size: 1.15rem; + font-weight: 600; + margin-top: 1.5rem; + margin-bottom: 0.5rem; + } + } + } +} +} + +// Dark mode adjustments +body.layout-story.dark-mode { + .story-reading-progress { + background: rgba(255, 255, 255, 0.06); + } + + .story-chapter { + border-bottom-color: rgba(255, 255, 255, 0.08); + + .chapter-header-group { + background: var(--site-base-bgColor); + border-bottom-color: rgba(255, 255, 255, 0.08); + } + + &::before { + background: transparent; + } + + .chapter-kicker .chapter-number { + background: rgba(var(--chapter-accent-rgb), 0.15); + border-color: rgba(var(--chapter-accent-rgb), 0.35); + } + } +} + +// Accessibility: Respect user motion preferences +@media (prefers-reduced-motion: reduce) { + html:has(body.layout-story) { + scroll-snap-type: none; + } + + body.layout-story { + scroll-behavior: auto; + + .story-reading-progress .story-reading-progress-bar { + animation: none !important; + display: none; + } + + .story-chapter { + transition: none !important; + + .chapter-header-group { + position: static; + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + } + } +} \ No newline at end of file diff --git a/sites/docs/lib/main.client.options.dart b/sites/docs/lib/main.client.options.dart index 23c1b571e81..3547ffcfd5b 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 2738c1fd439..b54c97360f2 100644 --- a/sites/docs/lib/main.server.dart +++ b/sites/docs/lib/main.server.dart @@ -8,6 +8,7 @@ import 'package:jaspr_content/jaspr_content.dart'; import 'package:jaspr_content/theme.dart'; import 'package:path/path.dart' as path; import 'package:site_shared/components/common/card.dart'; +import 'package:site_shared/components/common/ide_explorer/markdown_component.dart'; import 'package:site_shared/components/common/material_icon.dart'; import 'package:site_shared/components/common/tabs.dart'; import 'package:site_shared/components/common/youtube_embed.dart'; @@ -27,13 +28,16 @@ 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/flutter_bench_components.dart'; import 'src/components/pages/learning_resource_index.dart'; import 'src/components/pages/platforms_grid.dart'; import 'src/components/pages/widget_catalog.dart'; import 'src/extensions/registry.dart'; import 'src/layouts/doc_layout.dart'; +import 'src/layouts/story_layout.dart'; import 'src/layouts/toc_layout.dart'; import 'src/layouts/tutorial_layout.dart'; import 'src/loaders/data_processor.dart'; @@ -77,6 +81,7 @@ Component get _docsFlutterDevSite => ContentApp.custom( components: _embeddableComponents, layouts: const [ DocLayout(), + StoryLayout(), TocLayout(), TutorialLayout(), ], @@ -99,6 +104,7 @@ List get _embeddableComponents => [ const CodePreview(), const YoutubeEmbed(), const FileTree(), + const IdeExplorerMarkdownComponent(), const Quiz(), const ProgressRing(), const SummaryCard(), @@ -116,6 +122,15 @@ List get _embeddableComponents => [ defineComponent('OSSelector', const OsSelector()), defineComponentWithChild('Card', Card.fromAttributes), defineComponent('LearningResourceIndex', const LearningResourceIndex()), + defineComponent('CujIndex', const CujIndex()), + defineComponent('ThreeDimensionsCards', const ThreeDimensionsCards()), + defineComponent('GraderMatrix', const GraderMatrix()), + defineComponent('ReliabilityCards', const ReliabilityCards()), + defineComponent('ScoreTriage', const ScoreTriage()), + defineComponent('EvaluationMatrix', const EvaluationMatrixTabs()), + defineComponent('EvaluationMatrixTabs', const EvaluationMatrixTabs()), + defineComponent('CujDiagram', const CujDiagram()), + defineComponent('TaskSpecifications', const TaskSpecifications()), 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 fcbd5e0a090..1c131de872a 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/client/global_scripts.dart b/sites/docs/lib/src/client/global_scripts.dart index caa9bf379de..07f3a31c622 100644 --- a/sites/docs/lib/src/client/global_scripts.dart +++ b/sites/docs/lib/src/client/global_scripts.dart @@ -20,6 +20,9 @@ void setUpSite() { _setUpPlatformKeys(); _setUpToc(); _setUpSteppers(); + _setUpIdeExplorers(); + _setUpGraderMatrix(); + _setUpInteractiveDetailCards(); } void _setUpSearchKeybindings() { @@ -477,3 +480,341 @@ void _scrollTo(web.Element element, {required bool smooth}) { ), ); } + +/// Set up interactivity of the file/detail explorer created with +/// the `` custom component. +void _setUpIdeExplorers() { + final explorers = web.document.querySelectorAll('.ide-explorer'); + for (var i = 0; i < explorers.length; i++) { + _setUpIdeExplorer(explorers.item(i) as web.Element); + } +} + +void _setUpIdeExplorer(web.Element explorer) { + void selectIdeNode(String domId) { + final selectTargets = explorer.querySelectorAll('[data-ide-select]'); + web.Element? sidebarTarget; + for (var i = 0; i < selectTargets.length; i++) { + final target = selectTargets.item(i) as web.Element; + final isMatch = target.getAttribute('data-ide-select') == domId; + target.classList.toggle('active', isMatch); + if (isMatch && target.closest('.ide-tree') != null) { + sidebarTarget = target; + } + } + + final panels = explorer.querySelectorAll('[data-ide-panel]'); + for (var i = 0; i < panels.length; i++) { + final panel = panels.item(i) as web.Element; + panel.classList.toggle( + 'active', + panel.getAttribute('data-ide-panel') == domId, + ); + } + + // Expand every ancestor folder so the selected item stays visible. + final ownDetails = sidebarTarget?.closest('details'); + final isFolderSelf = + sidebarTarget?.parentElement?.tagName.toLowerCase() == 'summary'; + var current = isFolderSelf ? ownDetails?.parentElement : ownDetails; + while (current != null) { + final ancestorDetails = current.closest('details'); + if (ancestorDetails == null) break; + (ancestorDetails as web.HTMLDetailsElement).open = true; + current = ancestorDetails.parentElement; + } + } + + void switchIdeRoot(String rootId) { + final tabs = explorer.querySelectorAll('.ide-root-tab'); + for (var i = 0; i < tabs.length; i++) { + final tab = tabs.item(i) as web.Element; + tab.classList.toggle( + 'active', + tab.getAttribute('data-ide-root') == rootId, + ); + } + + final trees = explorer.querySelectorAll('.ide-tree'); + web.Element? activeTree; + for (var i = 0; i < trees.length; i++) { + final tree = trees.item(i) as web.Element; + final isMatch = tree.getAttribute('data-ide-root') == rootId; + tree.classList.toggle('active', isMatch); + if (isMatch) activeTree = tree; + } + + final firstDomId = activeTree + ?.querySelector('[data-ide-select]') + ?.getAttribute('data-ide-select'); + if (firstDomId != null) { + selectIdeNode(firstDomId); + } + } + + void toggleAllIdeFolders() { + final activeTree = + explorer.querySelector('.ide-tree.active') ?? + explorer.querySelector('.ide-tree'); + if (activeTree == null) return; + + final allDetails = activeTree.querySelectorAll('details'); + var anyClosed = false; + for (var i = 0; i < allDetails.length; i++) { + if (!(allDetails.item(i) as web.HTMLDetailsElement).open) { + anyClosed = true; + break; + } + } + + for (var i = 0; i < allDetails.length; i++) { + (allDetails.item(i) as web.HTMLDetailsElement).open = anyClosed; + } + } + + void handleClick(web.Event event) { + final target = event.target as web.Element?; + if (target == null) return; + + final selectTarget = target.closest('[data-ide-select]'); + if (selectTarget != null) { + final domId = selectTarget.getAttribute('data-ide-select'); + if (domId != null) selectIdeNode(domId); + event.preventDefault(); + return; + } + + final rootTab = target.closest('.ide-root-tab'); + if (rootTab != null) { + final rootId = rootTab.getAttribute('data-ide-root'); + if (rootId != null) switchIdeRoot(rootId); + return; + } + + if (target.closest('[data-ide-toggle-all]') != null) { + toggleAllIdeFolders(); + } + } + + explorer.addEventListener('click', handleClick.toJS); +} + +/// Set up interactivity for the FlutterBench grader matrix component. +void _setUpGraderMatrix() { + final matrices = web.document.querySelectorAll('.grader-matrix'); + for (var i = 0; i < matrices.length; i++) { + _setUpSingleGraderMatrix(matrices.item(i) as web.Element); + } +} + +void _setUpSingleGraderMatrix(web.Element matrix) { + final buttons = matrix.querySelectorAll('.matrix-filters .filter-btn'); + final cards = matrix.querySelectorAll('.grader-cards-track .grader-card'); + final track = matrix.querySelector('.grader-cards-track') as web.HTMLElement?; + final prevBtn = + matrix.querySelector('.carousel-nav-btn.prev') as web.HTMLElement?; + final nextBtn = + matrix.querySelector('.carousel-nav-btn.next') as web.HTMLElement?; + + if (track == null) return; + + List getVisibleCards() { + final list = []; + for (var i = 0; i < cards.length; i++) { + final card = cards.item(i) as web.HTMLElement; + if (!card.classList.contains('hidden')) { + list.add(card); + } + } + return list; + } + + void scrollNext() { + final visibleCards = getVisibleCards(); + if (visibleCards.isEmpty) return; + + final maxScroll = track.scrollWidth - track.clientWidth; + final currentScroll = track.scrollLeft; + + // Find the first visible card that starts after currentScroll + 10px. + var scrolled = false; + for (final card in visibleCards) { + final cardOffset = card.offsetLeft - track.offsetLeft; + if (cardOffset > currentScroll + 10) { + track.scrollTo( + web.ScrollToOptions( + left: cardOffset.toDouble(), + behavior: 'smooth', + ), + ); + scrolled = true; + break; + } + } + + // If already at or near the end, rotate back to the start. + if (!scrolled || currentScroll >= maxScroll - 10) { + track.scrollTo( + web.ScrollToOptions( + left: 0, + behavior: 'smooth', + ), + ); + } + } + + void scrollPrev() { + final visibleCards = getVisibleCards(); + if (visibleCards.isEmpty) return; + + final maxScroll = track.scrollWidth - track.clientWidth; + final currentScroll = track.scrollLeft; + + // If at or near the beginning, rotate to the end. + if (currentScroll <= 10) { + track.scrollTo( + web.ScrollToOptions( + left: maxScroll.toDouble(), + behavior: 'smooth', + ), + ); + return; + } + + // Find the last visible card that starts before currentScroll - 10px. + for (var i = visibleCards.length - 1; i >= 0; i--) { + final card = visibleCards[i]; + final cardOffset = card.offsetLeft - track.offsetLeft; + if (cardOffset < currentScroll - 10) { + track.scrollTo( + web.ScrollToOptions( + left: cardOffset.toDouble(), + behavior: 'smooth', + ), + ); + return; + } + } + + track.scrollTo( + web.ScrollToOptions( + left: 0, + behavior: 'smooth', + ), + ); + } + + if (nextBtn != null) { + nextBtn.addEventListener( + 'click', + ((web.Event e) { + e.preventDefault(); + scrollNext(); + }).toJS, + ); + } + + if (prevBtn != null) { + prevBtn.addEventListener( + 'click', + ((web.Event e) { + e.preventDefault(); + scrollPrev(); + }).toJS, + ); + } + + for (var i = 0; i < buttons.length; i++) { + final btn = buttons.item(i) as web.HTMLElement; + final filter = btn.dataset['filter']; + + void handleClick(web.Event event) { + event.preventDefault(); + for (var j = 0; j < buttons.length; j++) { + (buttons.item(j) as web.Element).classList.remove('active'); + } + btn.classList.add('active'); + + for (var k = 0; k < cards.length; k++) { + final card = cards.item(k) as web.HTMLElement; + if (filter == 'all') { + card.classList.remove('hidden'); + } else if (filter == 'llm') { + card.classList.toggle('hidden', !card.classList.contains('cat-llm')); + } else if (filter == 'deterministic') { + card.classList.toggle( + 'hidden', + !card.classList.contains('cat-deterministic'), + ); + } else { + card.classList.toggle( + 'hidden', + !card.classList.contains('cat-$filter'), + ); + } + } + + // Reset scroll position to beginning on filter change. + track.scrollTo( + web.ScrollToOptions( + left: 0, + behavior: 'smooth', + ), + ); + } + + btn.addEventListener('click', handleClick.toJS); + } +} + +/// Set up interactivity for FlutterBench interactive detail cards +/// (e.g. ScoreTriage and EvaluationMatrix). +void _setUpInteractiveDetailCards() { + final cards = web.document.querySelectorAll( + '.interactive-detail-card, .score-triage', + ); + for (var i = 0; i < cards.length; i++) { + _setUpSingleInteractiveDetailCard(cards.item(i) as web.Element); + } +} + +void _setUpSingleInteractiveDetailCard(web.Element card) { + final buttons = card.querySelectorAll( + '.card-tabs-grid .card-tab-btn, .triage-tiers-grid .triage-tier-btn', + ); + final panels = card.querySelectorAll( + '.card-panels-container .card-panel, .triage-detail-card .triage-panel', + ); + final detailCard = card.querySelector( + '.card-panels-container, .triage-detail-card', + ) as web.HTMLElement?; + + for (var i = 0; i < buttons.length; i++) { + final btn = buttons.item(i) as web.HTMLElement; + final tabAttr = btn.dataset['tab']; + final tabId = tabAttr.isNotEmpty ? tabAttr : btn.dataset['tier']; + + void handleClick(web.Event event) { + event.preventDefault(); + for (var j = 0; j < buttons.length; j++) { + (buttons.item(j) as web.Element).classList.remove('active'); + } + btn.classList.add('active'); + + for (var k = 0; k < panels.length; k++) { + final panel = panels.item(k) as web.HTMLElement; + final panelTabAttr = panel.dataset['tab']; + final panelId = panelTabAttr.isNotEmpty + ? panelTabAttr + : panel.dataset['tier']; + panel.classList.toggle('active', panelId == tabId); + } + + if (detailCard != null) { + detailCard.scrollTop = 0; + } + } + + btn.addEventListener('click', handleClick.toJS); + } +} diff --git a/sites/docs/lib/src/components/common/comparison_cards_grid.dart b/sites/docs/lib/src/components/common/comparison_cards_grid.dart new file mode 100644 index 00000000000..5bb77660d61 --- /dev/null +++ b/sites/docs/lib/src/components/common/comparison_cards_grid.dart @@ -0,0 +1,81 @@ +// 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/util.dart'; + +import '../../utils/inline_code.dart'; + +/// Data model for an individual card in [ComparisonCardsGrid]. +final class ComparisonCardItem { + const ComparisonCardItem({ + required this.tag, + this.tagIcon, + required this.mathPill, + required this.title, + this.isFeatured = false, + this.descriptionParts = const [], + this.description, + }); + + final String tag; + final String? tagIcon; + final String mathPill; + final String title; + final bool isFeatured; + final List> descriptionParts; + final String? description; +} + +/// A responsive comparison grid for metrics, benchmarks, or feature +/// evaluations. +final class ComparisonCardsGrid extends StatelessComponent { + const ComparisonCardsGrid({ + required this.cards, + this.classes = 'reliability-comparison-grid', + super.key, + }); + + final List cards; + final String classes; + + @override + Component build(BuildContext context) { + return div(classes: classes, [ + for (final card in cards) + div( + classes: [ + 'reliability-card', + if (card.isFeatured) 'north-star', + ].toClasses, + [ + div(classes: 'reliability-header', [ + span(classes: 'tag', [ + if (card.tagIcon case final icon?) ...[ + MaterialIcon(icon), + const .text(' '), + ], + .text(card.tag), + ]), + span(classes: 'math-pill', [.text(card.mathPill)]), + ]), + h4([.text(card.title)]), + if (card.descriptionParts.isNotEmpty) + p([ + for (final part in card.descriptionParts) ...[ + if (part['text'] case final String text) .text(text), + if (part['em'] case final String emText) em([.text(emText)]), + if (part['strong'] case final String strongText) + strong([.text(strongText)]), + ], + ]) + else if (card.description case final description?) + p([renderDescriptionWithCode(description)]), + ], + ), + ]); + } +} diff --git a/sites/docs/lib/src/components/common/dimension_cards_grid.dart b/sites/docs/lib/src/components/common/dimension_cards_grid.dart new file mode 100644 index 00000000000..12bf29a6011 --- /dev/null +++ b/sites/docs/lib/src/components/common/dimension_cards_grid.dart @@ -0,0 +1,73 @@ +// 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/util.dart'; + +import '../../utils/inline_code.dart'; + +/// Data model for an individual card in [DimensionCardsGrid]. +final class DimensionCardItem { + const DimensionCardItem({ + required this.title, + required this.icon, + required this.description, + this.category, + this.footerItems, + this.badge, + }); + + final String title; + final String icon; + final String description; + final String? category; + final String? footerItems; + final String? badge; +} + +/// A grid of cards displaying key dimensions, categories, or metrics. +final class DimensionCardsGrid extends StatelessComponent { + const DimensionCardsGrid({ + required this.cards, + this.classes = 'dimension-cards-grid', + super.key, + }); + + final List cards; + final String classes; + + @override + Component build(BuildContext context) { + return div(classes: classes, [ + for (final card in cards) + div(classes: 'dimension-card', [ + div(classes: 'card-header-row', [ + div( + classes: [ + 'card-icon-wrap', + ?card.category, + ].toClasses, + [MaterialIcon(card.icon)], + ), + h4([.text(card.title)]), + ]), + p([renderDescriptionWithCode(card.description)]), + if (card.footerItems != null || card.badge != null) + div(classes: 'card-footer-info', [ + if (card.footerItems case final footer?) span([.text(footer)]), + if (card.badge case final badge?) + span( + classes: [ + 'badge', + ?card.category, + ].toClasses, + [.text(badge)], + ), + ]), + ]), + ]); + } +} diff --git a/sites/docs/lib/src/components/common/interactive_detail_card.dart b/sites/docs/lib/src/components/common/interactive_detail_card.dart new file mode 100644 index 00000000000..61358fbf38a --- /dev/null +++ b/sites/docs/lib/src/components/common/interactive_detail_card.dart @@ -0,0 +1,140 @@ +// 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/util.dart'; + +import '../../utils/inline_code.dart'; + +/// Model for a single tab and corresponding detail panel within +/// [InteractiveDetailCard]. +final class InteractiveDetailTab { + const InteractiveDetailTab({ + required this.id, + required this.primaryLabel, + required this.secondaryLabel, + required this.heading, + this.badge, + this.variant = 'blue', + required this.overview, + this.itemsLabel, + this.items = const [], + this.footerText, + }); + + final String id; + final String primaryLabel; + final String secondaryLabel; + final String heading; + final String? badge; + final String variant; + final String overview; + final String? itemsLabel; + final List items; + final String? footerText; +} + +/// Key-value item displayed within [InteractiveDetailTab]. +final class InteractiveDetailItem { + const InteractiveDetailItem({ + required this.label, + required this.detail, + }); + + final String label; + final String detail; +} + +/// A reusable interactive card featuring selectable tabs and responsive detail +/// panels. +final class InteractiveDetailCard extends StatelessComponent { + const InteractiveDetailCard({ + required this.title, + required this.description, + required this.tabs, + this.classes = 'interactive-detail-card', + super.key, + }); + + final String title; + final String description; + final List tabs; + final String classes; + + @override + Component build(BuildContext context) { + return div(classes: classes, [ + div(classes: 'card-header-area triage-header', [ + h3([.text(title)]), + p([.text(description)]), + ]), + div(classes: 'card-tabs-grid triage-tiers-grid', [ + for (var i = 0; i < tabs.length; i++) + button( + classes: [ + 'card-tab-btn', + 'triage-tier-btn', + 'variant-${tabs[i].variant}', + 'tier-${tabs[i].variant}', + if (i == 0) 'active', + ].toClasses, + attributes: {'data-tab': tabs[i].id, 'data-tier': tabs[i].id}, + [ + div( + classes: 'tab-primary-label tier-score', + [.text(tabs[i].primaryLabel)], + ), + div( + classes: 'tab-secondary-label tier-name', + [.text(tabs[i].secondaryLabel)], + ), + ], + ), + ]), + div(classes: 'card-panels-container triage-detail-card', [ + for (var i = 0; i < tabs.length; i++) + div( + classes: [ + 'card-panel', + 'triage-panel', + if (i == 0) 'active', + ].toClasses, + attributes: {'data-tab': tabs[i].id, 'data-tier': tabs[i].id}, + [ + div(classes: 'panel-heading', [ + h4([.text(tabs[i].heading)]), + if (tabs[i].badge case final badge?) + span( + classes: 'panel-badge badge-${tabs[i].variant}', + [.text(badge)], + ), + ]), + p( + classes: 'panel-overview criteria-text', + [renderDescriptionWithCode(tabs[i].overview)], + ), + if (tabs[i].items.isNotEmpty) + div(classes: 'panel-items-section actions-section', [ + if (tabs[i].itemsLabel case final label?) + div(classes: 'items-label actions-label', [.text(label)]), + ul([ + for (final item in tabs[i].items) + li([ + strong([.text('${item.label}: ')]), + renderDescriptionWithCode(item.detail), + ]), + ]), + ]), + if (tabs[i].footerText case final footer?) + p( + classes: 'panel-footer-text', + [renderDescriptionWithCode(footer)], + ), + ], + ), + ]), + ]); + } +} diff --git a/sites/docs/lib/src/components/common/journey_diagram.dart b/sites/docs/lib/src/components/common/journey_diagram.dart new file mode 100644 index 00000000000..1f8cfccfc7d --- /dev/null +++ b/sites/docs/lib/src/components/common/journey_diagram.dart @@ -0,0 +1,70 @@ +// 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'; + +/// Reusable section model for [JourneyDiagram]. +final class JourneySection { + const JourneySection({ + required this.label, + required this.icon, + this.variant = 'blue', + required this.items, + }); + + factory JourneySection.fromMap(Map map) { + return JourneySection( + label: map['label'] as String? ?? '', + icon: map['icon'] as String? ?? 'info', + variant: map['variant'] as String? ?? 'blue', + items: (map['items'] as List? ?? const []) + .map((item) => item.toString()) + .toList(), + ); + } + + final String label; + final String icon; + final String variant; + final List items; +} + +/// Backwards-compatible alias for [JourneySection]. +typedef CujDiagramSection = JourneySection; + +/// A structured, reusable diagram component visualizing a user journey flow +/// or step sequence across configurable sections and nested item pills. +final class JourneyDiagram extends StatelessComponent { + const JourneyDiagram({ + required this.sections, + this.classes = 'cuj-diagram-card', + super.key, + }); + + final List sections; + final String classes; + + @override + Component build(BuildContext context) { + return div(classes: classes, [ + for (final section in sections) + div(classes: 'cuj-diagram-section', [ + div(classes: 'section-sidebar', [ + div(classes: 'section-icon variant-${section.variant}', [ + MaterialIcon(section.icon), + ]), + span(classes: 'section-label', [.text(section.label)]), + ]), + div(classes: 'section-items', [ + for (final item in section.items) + div(classes: 'cuj-pill pill-${section.variant}', [ + .text(item), + ]), + ]), + ]), + ]); + } +} diff --git a/sites/docs/lib/src/components/common/matrix_carousel.dart b/sites/docs/lib/src/components/common/matrix_carousel.dart new file mode 100644 index 00000000000..83fc98e192d --- /dev/null +++ b/sites/docs/lib/src/components/common/matrix_carousel.dart @@ -0,0 +1,135 @@ +// 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/util.dart'; + +import '../../utils/inline_code.dart'; + +/// Data model for a filter tab in [MatrixCarousel]. +final class MatrixFilterItem { + const MatrixFilterItem({ + required this.id, + required this.label, + }); + + final String id; + final String label; +} + +/// Data model for an individual card in [MatrixCarousel]. +final class MatrixCardItem { + const MatrixCardItem({ + required this.title, + required this.description, + this.category, + this.categoryLabel, + this.type, + this.typeLabel, + }); + + final String title; + final String description; + final String? category; + final String? categoryLabel; + final String? type; + final String? typeLabel; +} + +/// An interactive, filterable carousel displaying a matrix of cards with +/// categorizations and badges. +final class MatrixCarousel extends StatelessComponent { + const MatrixCarousel({ + required this.title, + required this.description, + this.filters = const [], + required this.cards, + this.classes = 'grader-matrix', + this.previousAriaLabel = 'Previous card', + this.nextAriaLabel = 'Next card', + super.key, + }); + + final String title; + final String description; + final List filters; + final List cards; + final String classes; + final String previousAriaLabel; + final String nextAriaLabel; + + @override + Component build(BuildContext context) { + return div(classes: classes, [ + div(classes: 'matrix-header', [ + div(classes: 'matrix-title-area', [ + h3([.text(title)]), + p([renderDescriptionWithCode(description)]), + ]), + if (filters.isNotEmpty) + div(classes: 'matrix-filters', [ + for (var i = 0; i < filters.length; i++) + button( + classes: i == 0 ? 'filter-btn active' : 'filter-btn', + attributes: {'data-filter': filters[i].id}, + [.text(filters[i].label)], + ), + ]), + ]), + div(classes: 'grader-carousel-wrapper', [ + button( + classes: 'carousel-nav-btn prev', + attributes: { + 'aria-label': previousAriaLabel, + 'title': previousAriaLabel, + }, + const [MaterialIcon('chevron_left')], + ), + div(classes: 'grader-cards-track', [ + for (final card in cards) + div( + classes: [ + 'grader-card', + if (card.category case final category?) 'cat-$category', + if (card.type case final type?) 'cat-$type', + ].toClasses, + [ + if (card.categoryLabel != null || card.typeLabel != null) + div(classes: 'grader-header', [ + if (card.categoryLabel case final categoryLabel?) + span( + classes: [ + 'grader-cat', + ?card.category, + ].toClasses, + [.text(categoryLabel)], + ), + if (card.typeLabel case final typeLabel?) + span( + classes: [ + 'grader-badge', + if (card.type case final type?) 'badge-$type', + ].toClasses, + [.text(typeLabel)], + ), + ]), + h4([.text(card.title)]), + p([renderDescriptionWithCode(card.description)]), + ], + ), + ]), + button( + classes: 'carousel-nav-btn next', + attributes: { + 'aria-label': nextAriaLabel, + 'title': nextAriaLabel, + }, + const [MaterialIcon('chevron_right')], + ), + ]), + ]); + } +} diff --git a/sites/docs/lib/src/components/common/specification_panels.dart b/sites/docs/lib/src/components/common/specification_panels.dart new file mode 100644 index 00000000000..690a3c759a6 --- /dev/null +++ b/sites/docs/lib/src/components/common/specification_panels.dart @@ -0,0 +1,157 @@ +// 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/util.dart'; + +import '../../utils/inline_code.dart'; + +/// Data model for a single row within [SpecTableItem]. +final class SpecTableRowItem { + const SpecTableRowItem({ + required this.label, + required this.description, + }); + + final String label; + final String description; +} + +/// Data model for a specification table with optional title and rows. +final class SpecTableItem { + const SpecTableItem({ + this.title = '', + this.headers = const [], + this.rows = const [], + }); + + final String title; + final List headers; + final List rows; +} + +/// Data model for an individual panel within [SpecificationPanels]. +final class SpecPanelItem { + const SpecPanelItem({ + required this.id, + required this.title, + this.badge = '', + this.badgeVariant = 'blue', + this.icon = 'info', + this.expanded = false, + this.description = '', + this.leadText = '', + this.footerText = '', + this.tables = const [], + }); + + final String id; + final String title; + final String badge; + final String badgeVariant; + final String icon; + final bool expanded; + final String description; + final String leadText; + final String footerText; + final List tables; +} + +/// A collapsible list of specification panels with embedded tables and details. +final class SpecificationPanels extends StatelessComponent { + const SpecificationPanels({ + required this.panels, + this.classes = 'task-specs-list', + this.idPrefix = 'task-spec', + super.key, + }); + + final List panels; + final String classes; + final String idPrefix; + + @override + Component build(BuildContext context) { + return div(classes: classes, [ + for (final spec in panels) + div(classes: 'task-spec-panel', [ + a( + classes: [ + 'collapsible', + if (!spec.expanded) 'collapsed', + ].toClasses, + href: '#$idPrefix-${spec.id}', + attributes: { + 'data-toggle': 'collapse', + 'data-target': '#$idPrefix-${spec.id}', + 'role': 'button', + 'aria-expanded': spec.expanded ? 'true' : 'false', + 'aria-controls': '#$idPrefix-${spec.id}', + }, + [ + div(classes: 'panel-header-left', [ + div( + classes: [ + 'panel-icon-wrap', + 'variant-${spec.badgeVariant}', + ].toClasses, + [MaterialIcon(spec.icon)], + ), + div(classes: 'panel-header-content', [ + div(classes: 'panel-title-row', [ + h4([.text(spec.title)]), + if (spec.badge.isNotEmpty) + span( + classes: 'badge badge-${spec.badgeVariant}', + [.text(spec.badge)], + ), + ]), + if (spec.description.isNotEmpty) + p(classes: 'panel-description', [ + .text(spec.description), + ]), + ]), + ]), + ], + ), + div( + id: '$idPrefix-${spec.id}', + classes: [ + 'task-spec-body collapse', + if (spec.expanded) 'show', + ].toClasses, + [ + if (spec.leadText.isNotEmpty) + p(classes: 'lead-text', [ + renderDescriptionWithCode(spec.leadText), + ]), + for (final tableData in spec.tables) ...[ + if (tableData.title.isNotEmpty) + h5(classes: 'table-subheading', [.text(tableData.title)]), + div(classes: 'table-wrapper', [ + table(classes: 'spec-table', [ + tbody([ + for (final row in tableData.rows) + tr([ + td([ + strong([.text(row.label)]), + ]), + td([renderDescriptionWithCode(row.description)]), + ]), + ]), + ]), + ]), + ], + if (spec.footerText.isNotEmpty) + p(classes: 'footer-text', [ + renderDescriptionWithCode(spec.footerText), + ]), + ], + ), + ]), + ]); + } +} 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 00000000000..85264d10797 --- /dev/null +++ b/sites/docs/lib/src/components/pages/cuj_filters.dart @@ -0,0 +1,131 @@ +// 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: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 { + 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 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( + 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 00000000000..3fa24490f91 --- /dev/null +++ b/sites/docs/lib/src/components/pages/cuj_filters_sidebar.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:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:site_shared/components/common/button.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'; + +@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 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', + }, + ), + ]), + ], + 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)], + ), + ]), + ]), + ]); + }, + ), + ], + ); + } +} + +/// 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 00000000000..76c120e48b4 --- /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(classes: 'filterable-index', [ + div(classes: 'left-col', [ + 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/components/pages/filterable_index.dart b/sites/docs/lib/src/components/pages/filterable_index.dart new file mode 100644 index 00000000000..9f6cc824959 --- /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 `