diff --git a/packages/site_shared/lib/_sass/base/_breakpoints.scss b/packages/site_shared/lib/_sass/base/_breakpoints.scss new file mode 100644 index 0000000000..c1005c0395 --- /dev/null +++ b/packages/site_shared/lib/_sass/base/_breakpoints.scss @@ -0,0 +1,31 @@ +@use 'sass:map'; + +$breakpoints: ( + 'sm': 640px, + 'md': 768px, + 'lg': 1024px, + 'xl': 1280px, + '2xl': 1536px, + 'max': 1440px, + '3xl': 1920px, +); + +@mixin screen($name) { + $value: map.get($breakpoints, $name); + @if $value == null { + @error 'Unknown breakpoint: #{$name}'; + } + @media (min-width: $value) { + @content; + } +} + +@mixin screen-below($name) { + $value: map.get($breakpoints, $name); + @if $value == null { + @error 'Unknown breakpoint: #{$name}'; + } + @media (max-width: $value - 1px) { + @content; + } +} \ No newline at end of file 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 0000000000..fd9a6b96a1 --- /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 0000000000..2fdbc42282 --- /dev/null +++ b/packages/site_shared/lib/components/common/ide_explorer/ide_explorer.dart @@ -0,0 +1,696 @@ +// 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); + + return button( + classes: ['ide-node-row', if (isSelected) 'active'].toClasses, + type: ButtonType.button, + attributes: { + 'data-ide-select': domId, + if (isSelected) 'aria-current': 'true', + }, + [ + icon, + span(classes: 'ide-node-label', [.text(node.label)]), + _IdeBadgeDot( + badge: node.badge, + color: node.badgeColor ?? IdeBadgeColor.neutral, + ), + ], + ); + } +} + +/// 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 0000000000..19fc3aaa8e --- /dev/null +++ b/packages/site_shared/lib/components/common/ide_explorer/markdown_component.dart @@ -0,0 +1,232 @@ +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) + IdeExplorerProjectRoot( + id: _generateNodeId( + rootEl.attributes, + _defaultRootPrefix, + index, + ), + label: rootEl.attributes[_attrLabel] ?? '', + children: _parseTreeNodes( + rootEl.children, + builder, + customContents, + ), + ), + ]; + + 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, + ) { + if (attributes[_attrId] != null) { + return attributes[_attrId]!; + } + final label = attributes[_attrLabel]; + if (label != null && label.isNotEmpty) { + return slugify(label); + } + return '$prefix-$index'; + } + + List _parseTreeNodes( + List? nodes, + NodesBuilder builder, + Map customContents, + ) { + 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, + ); + + result.add(treeNode); + } + + return result; + } + + /// Builds a single [IdeTreeNode] from an [ElementNode]. + IdeTreeNode _buildTreeNodeFromElement( + ElementNode element, + int index, + NodesBuilder builder, + Map customContents, + ) { + final attributes = element.attributes; + final label = attributes[_attrLabel] ?? ''; + final id = _generateNodeId(attributes, _defaultNodePrefix, index); + + // 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, + ); + + // 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 99092c6a67..8945534d0f 100644 --- a/sites/docs/lib/_sass/_site.scss +++ b/sites/docs/lib/_sass/_site.scss @@ -37,6 +37,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'; diff --git a/sites/docs/lib/main.server.dart b/sites/docs/lib/main.server.dart index 2738c1fd43..5068063b7a 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'; @@ -99,6 +100,7 @@ List get _embeddableComponents => [ const CodePreview(), const YoutubeEmbed(), const FileTree(), + const IdeExplorerMarkdownComponent(), const Quiz(), const ProgressRing(), const SummaryCard(), diff --git a/sites/docs/lib/src/client/global_scripts.dart b/sites/docs/lib/src/client/global_scripts.dart index caa9bf379d..2da567a226 100644 --- a/sites/docs/lib/src/client/global_scripts.dart +++ b/sites/docs/lib/src/client/global_scripts.dart @@ -20,6 +20,7 @@ void setUpSite() { _setUpPlatformKeys(); _setUpToc(); _setUpSteppers(); + _setUpIdeExplorers(); } void _setUpSearchKeybindings() { @@ -477,3 +478,121 @@ 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); +} diff --git a/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md new file mode 100644 index 0000000000..47a8ed4ffb --- /dev/null +++ b/sites/docs/src/_includes/docs/ai/flutter_bench_task_explorer.md @@ -0,0 +1,196 @@ + + + + + + +The target codebase is an isolated, containerized Flutter workspace. + +When a benchmark run starts, the evaluation harness boots an ephemeral +Docker container pre-seeded with this project. The agent is given access to +tools (such as reading files, editing code, and running terminal commands) +to investigate and resolve the issue. + +The agent only sees the files inside this directory. Grading scripts and +reference solutions remain strictly isolated outside the container until +the agent completes its run. + + + +Contains the Flutter application source code. + +In this evaluation task, the agent inspects `lib/main.dart` to locate the +source of the +`RenderFlex` layout errors and applies appropriate widget modifications. + + + +The starter code for the Flutter application. Alternatively, the docker +file may point to a remote codebase. + + + + + + + +The test directory for the target project. + +Initially, this directory is empty. The task instruction directs the agent +to create `test/main_test.dart` +to verify its bug fix with automated widget tests. + + + +The widget test file that the agent is expected to author. + + + + + + + +Declares dependencies and environment constraints for the Flutter project. + + + + + +Defines the static analysis rules enforced across the project. + +During grading, +`StaticAnalysisGrader` runs `flutter analyze` against the modified +codebase. Solutions that introduce analyzer errors, warnings, or lint +violations +(such as missing `const` constructors) lose quality points. + +```yaml +include: package:flutter_lints/flutter.yaml + +linter: + rules: + # Strict lints for this task +``` + + + + + +Defines the container image for the task environment. + +**Workflow** + +1. Inherits from the pre-warmed `flutter-linux:latest` base image, which + contains the Flutter and Dart SDKs. +2. Copies project manifests and initial source files into `/workspace`. +3. Runs `flutter pub get` so dependencies are pre-fetched. +4. Executes `init-baseline` to snapshot the clean repository state. This + allows the evaluation harness to measure the agent's work as a precise + Git diff against the starting baseline. + + + + + + + +The automated verification harness used to grade the agent's performance. + +This entire directory is hidden from the agent during task execution. It is +mounted into the evaluation container only when the grading phase starts. + +The harness evaluates the agent's output across three weighted dimensions: + +- **Outcome**: Functional correctness, test pass rates, and layout fixes. +- **Quality**: Static analysis, formatting, and code craftsmanship. +- **DX**: Developer experience and tool interaction efficiency. + + + +Defines the multi-dimensional scoring pipeline using a separate scoring package. + +```dart +// Heavily edited +AggregateGrader grader(context) => + AggregateGrader.result( + outcome: _outcomeGraders, + quality: _qualityGraders, + dx: _dxGraders, + ); +``` + + + + + +The test harness entrypoint script executed inside the grading container. + +```bash +#!/bin/bash +eval_scoring run [tasks] +``` + + + + + + + +Contains the ground truth reference implementation maintained by the +benchmark authors. + +Before a task is added to the benchmark suite, the reference solution is +executed through the grading harness to confirm that it achieves a full +`1.0` reward. This ensures the task is solvable, unambiguous, and +calibrated correctly. + + + +The reference shell script that applies the canonical fix and generates +comprehensive tests. Not safe to share publicly. + + + + + + + +The instruction is the task prompt provided to the agent. It mimics +real-world workflows from developers, and is written in a way that real +developers interact with agents. + +**Design principles** + +- Prompts state symptoms and expected outcomes without naming exact + remedy widgets. +- Prompts require the agent to write regression tests, measuring both + implementation skills and testing rigor. + +```markdown +We currently keep receiving `RenderFlex overflowed` errors both +horizontally and vertically. Implement the correct widgets to resolve these +layout issues. + +Write widget tests in +`test/main_test.dart` that verifies the changes. +``` + + + + + +The task configuration file specifies execution bounds, metadata, and +target artifacts evaluated during grading. + +**Key sections** + +- `artifacts`: Lists files that must be present in the workspace after the + run. +- `task` definition: Task-related data, like name, author and tags. +- infra and agent configurations, such as timeout limits and retries. + + + + + + diff --git a/sites/docs/src/content/ai/evals.md b/sites/docs/src/content/ai/evals.md index e4985cc52b..9dc42e3dbd 100644 --- a/sites/docs/src/content/ai/evals.md +++ b/sites/docs/src/content/ai/evals.md @@ -28,3 +28,5 @@ Evals measure both deterministic code correctness (compilation, lints, automated tests) and qualitative performance (reasoning, safety, and conciseness) using automated model judges and expert human grading. + +{% render "docs/ai/flutter_bench_task_explorer.md" %}